diff --git a/.changeset/10164-legacy-webhook-cleartext-refusal.md b/.changeset/10164-legacy-webhook-cleartext-refusal.md deleted file mode 100644 index 64751928b83..00000000000 --- a/.changeset/10164-legacy-webhook-cleartext-refusal.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@objectstack/plugin-webhooks': minor ---- - -fix(plugin-webhooks): a webhook credential stored as cleartext inside `sys_webhook.definition_json` is refused, at the delivery path and at the write door (#10164) - -Clause-②: no (narrowing) - -**BREAKING** — shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by -this banner and the ADR-0087 disposition below, never by the level). - -**Webhooks that still carry the legacy cleartext shape STOP DELIVERING.** A -`sys_webhook` row whose signing secret or custom header map exists only as a -`secret` / `headers` key inside `definition_json` — with nothing stored in the -encrypted `signing_secret` / `headers_secret` column — used to be delivered from -that cleartext with a `warn`. It is now refused, dated `2026-09-23`: - -- **Delivery path.** The subscription is PARKED, the same fail-closed shape as an - encrypted credential that cannot be recovered: nothing is sent, and every - matching record change is recorded in `sys_http_delivery` as a `dead` row with - 0 attempts, no signature and no headers. Its `error` names the refusal as - `[VALIDATION_ERROR/400]`, and the drop is reported once at `error`, with - `code: 'VALIDATION_ERROR'`, `status: 400`, `field: 'definition_json'` and the - refused `keys` in the log meta. -- **Write door** (when `WebhookOutboxPlugin` is mounted, the standard mount). A - `sys_webhook` insert or update whose `definition_json` carries a `secret` or - `headers` key, whatever its value, is refused before anything is stored - (`VALIDATION_ERROR` / `400`, with `object`, `field` and `keys` on the error). - That covers a raw `PATCH /api/v1/data/sys_webhook`. It also covers a Setup-form - save that echoes back a legacy blob unchanged. Omitting `definition_json`, or - writing one without those keys, is unaffected. The plugin binds this refusal - itself, and it is not exported from the package entry: a host that composes - `AutoEnqueuer` on its own still gets the delivery-path refusal above, but not - this write-door refusal. - -**Fix.** Both remedies need a registered `CryptoProvider` -(`engine.setCryptoProvider` — `LocalCryptoProvider` in dev, KMS/Vault in -production), because writing a `secret`-typed column is itself refused without -one. With a provider registered, either: - -- restart, and the boot sweep `migrateLegacyWebhookSecrets` moves both values into - their encrypted columns and strips them from `definition_json` in one update. The - subscription re-arms at the next refresh. -- or re-author the webhook yourself. Write the key into `signing_secret` and the - header map into `headers_secret` (a JSON object of string values), then remove - both keys from `definition_json`. - -There is no transition path for a deployment that runs with no `CryptoProvider`. - -Unchanged: authoring. `defineWebhook({ secret, headers })` is written exactly as -before, and the boot materializer still routes each value to its encrypted column. -A row whose encrypted columns are set delivers exactly as before. - - diff --git a/.changeset/12271-published-entry-no-auto-transpile.md b/.changeset/12271-published-entry-no-auto-transpile.md deleted file mode 100644 index 2834af43752..00000000000 --- a/.changeset/12271-published-entry-no-auto-transpile.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`bin/run.js` — the entry `os` / `objectstack` names — resolves its commands from `dist/` whatever an ambient `NODE_ENV` says, so an exported `NODE_ENV=development` no longer kills the CLI in a project whose tsconfig maps a package to TypeScript source (#12271). - -`@oclif/core` skips its TypeScript path lookup only when `isProd()` — a negated `['development', 'test'].includes(NODE_ENV)`. Under either value it resolved the CLI's **own** command modules from `src/` and registered tsx on the way, and tsx honours the tsconfig of the **current working directory**. An application that maps a CommonJS workspace package to its TypeScript source for *type* resolution — `"@objectstack/formula": ["../../packages/formula/src/index.ts"]` — therefore steered this CLI's *runtime* module graph into `.ts` files, after which Node's CommonJS resolver walked their extensionless siblings and found nothing: - -``` -[MODULE_NOT_FOUND] import() failed to load …/packages/cli/src/commands/doctor.ts: -Cannot find module './registry' -``` - -Measured at two example apps with `NODE_ENV` as the only variable: `os compile`, `os dev --compile --fresh`, `os serve --dev` and `os start` each exited 1 on that signature under `development`, and each compiled or booted cleanly under `production`. The app with no `paths` block was the only one unaffected. - -- **The fix is one declaration**: `settings.enableAutoTranspile = false`, checked by oclif ahead of `isProd()`. `bin/run.js` is the built entry and `bin/run-dev.js` is the source entry — a division `check:cli-test-child-env` already enforced on every test that spawns the CLI; the entry simply never asserted it about itself. -- ⛔ **Not a child-environment scrub.** `os serve --dev` and `os start` are top-level processes with no parent to scrub, and the casualty was the CLI's own command table rather than the user's config, so no per-spawn `NODE_ENV` handling could reach it. -- **`NODE_ENV=development objectstack start` works again** — the debugging mode `os start` has advertised in a comment all along, and did not deliver. -- ⚠️ **What it costs, measured**: the only thing oclif keeps its TypeScript lookup alive for in production is a **linked** plugin, so a `plugins link`ed TypeScript plugin would no longer be auto-transpiled through the published entry. That path is not reachable today — `@oclif/plugin-plugins` sits in `devDependencies` and oclif's core-plugin loader only matches names under `dependencies`, so `os plugins` is not a registered command (`os --help` lists 34 topics and none is `plugins`), which is what `content/docs/plugins/index.mdx` already documents. On an unbuilt checkout the entry now answers oclif's `command not found` under `development`/`test` exactly as it already did with `NODE_ENV` unset. diff --git a/.changeset/13272-liveness-cloud-citations-verifiedat-anchors.md b/.changeset/13272-liveness-cloud-citations-verifiedat-anchors.md deleted file mode 100644 index f6b78fb4a25..00000000000 --- a/.changeset/13272-liveness-cloud-citations-verifiedat-anchors.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`liveness/agent.json`, `liveness/skill.json` and `liveness/action.json` — the 21 cloud citations these ledgers rest on now carry the date they were read and the symbol they were read at, and the two claims that reading falsified are corrected in the prose (#13272). - -The ledgers ship inside this package, so the pointers an upgrading reader follows are these. Until now they named a package root and nothing else: `cloud: packages/service-ai/src/agent-runtime.ts`, with no date and — after #13309 repointed them off a path that existed in neither repository — still no evidence that anybody had opened the file. Every row was re-read in a cloud checkout at cloud `@cb8ee7ff60c097cc21a584fe9caf8ef4391cc0e8` and now carries `verifiedAt: 2026-09-15`, `evidenceScope: "cross-repo"`, and a `#symbol` anchor on the consuming function. - -- **A symbol instead of a line, because a line rots in range.** Three of the cited line numbers had already drifted onto unrelated prose (`agent-runtime.ts:264`, `agent-access.ts:50`, `action-tools.ts:535`) while every mechanical check kept passing. A symbol moves with the consumer and goes red when the consumer is renamed or deleted. -- **The framework half is now gate-checked.** `packages/mcp/src/skill-prompts.ts#projectSkillPrompt` is a repo-local anchor in five skill rows — the `;` before it ends the `cloud` realm's scope — so `check:liveness` resolves it against the file on every run, where the old parenthesised `(projectSkillPrompt)` was prose no check read. Cloud anchors are counted, never resolved, which is why the date on them is load-bearing. -- **Two ledger assertions were false and are repaired.** `agent.role` was noted as *"persona → system prompt."*: it reaches `AgentSummary` through `listAgents` and nothing else — `buildSystemMessages` never reads it. `agent.planning` was cited at `agent-runtime.ts`, which does not read the key at all; its three readers are `routes/agent-routes.ts`, `routes/assistant-routes.ts` and `eval/eval-runner.ts`. -- **One row is deliberately left unstamped.** `agent.tools` was falsified by the same read — zero consumers in cloud, and this package's own `AgentSchema` already declares the key `retiredKey(...)`. Its verdict is a liveness re-grade rather than a stamping decision, filed separately as #18304; a `verifiedAt` there would certify the wrong thing. - -No verdict moved and no schema changed: this is the evidence layer of the ledger, and `check:liveness` reports the same 505 repo-local paths resolving as before with five more anchors now checked. diff --git a/.changeset/14361-adr-0024-identity-citations.md b/.changeset/14361-adr-0024-identity-citations.md deleted file mode 100644 index 2bec46ed1f3..00000000000 --- a/.changeset/14361-adr-0024-identity-citations.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -'@objectstack/plugin-auth': patch -'@objectstack/platform-objects': patch -'@objectstack/spec': patch -'@objectstack/core': patch -'@objectstack/cli': patch ---- - -docs(identity): re-point the cloud-identity `ADR-0024` citations at the records that decide them (#14361) - -From this repository's point of view `ADR-0024` names two unrelated decisions. -`docs/adr/0024-mcp-connectors.md` is *MCP Servers as Connectors* — an open, -vendor-neutral tool protocol, with a Decision section numbered §1–§5 and no -D-lettered clauses at all. The identity surface's citations mean something else -entirely: the identity-and-access decision taken in `objectstack-ai/cloud` as -its own ADR-0024, whose open mechanism half has been mirrored into this repo -since 2026-09-07 as -[ADR-0135](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0135-identity-and-access-architecture.md). -A reader following one of those citations landed on a real page about the wrong -subject, which is worse than a dangling id: a plausible-looking record invites -belief rather than a second question. - -79 citation lines were read one at a time and re-pointed. 73 mean a clause -ADR-0135 restates and now name it with its letter — D4 (source-of-truth marking, -managed vs env-native), D5.2 (the break-glass last-administrator invariant), D6 -(SSO per production environment, including the opt-in DNS domain-verification -clause this tree spelled `ADR-0024 ②`) and D9 (environment users and -organization membership). 6 mean a clause ADR-0135 deliberately leaves in the -cloud record and now carry the anchors gate's cross-repo qualifier -`cloud ADR-0024`: `V1` (the SSO default-role provisioning, the roadmap and -commercial framing) and `§7` (the `ai_seat` synthesis, which ADR-0135 does not -restate). - -What actually reaches a consumer of these packages: - -- `@objectstack/plugin-auth` — the **operator-facing break-glass refusal - detail** now reads `break-glass invariant, ADR-0135 D5.2 — an environment must - always keep at least one administrator who can sign in`. The condition that - raises it, its status, its error code and the rest of its wording are - unchanged; only the ADR number moves. ⚠️ A deployment that greps that message - for the literal `ADR-0024` should grep for `ADR-0135`. The guard's - registration log line moves the same way. -- `@objectstack/platform-objects` — `sys_sso_provider`'s `domain_verified` field - help text, its `protection.reason`, and the matching leaf in all four shipped - locale bundles (`en`, `es-ES`, `ja-JP`, `zh-CN`). -- `@objectstack/spec` — the doc comment above `AuthConfigSchema`'s - `ssoDomainVerification`, published both in `dist/` and as - `src/system/auth-config.zod.ts`. -- `@objectstack/core`, `@objectstack/cli` — doc comments only, published in - `dist/`; no runtime string and no behaviour. - -No behaviour moves. No schema accepts or refuses anything it did not accept or -refuse before, no security or permission semantics are touched, and no ADR -record is written or edited. Bare `ADR-0024` still resolves exactly as it did: -the 15 citations that mean the local MCP-connectors record are byte-identical to -`main`, and `check:adr-anchors` reports the same resolving-citation totals before -and after. Historical archives are deliberately untouched — 36 CHANGELOG lines -across seven packages, and the 22 lines under `docs/adr/`, which is a governed -surface this change does not enter. diff --git a/.changeset/14361-adr-0071-identity-citations.md b/.changeset/14361-adr-0071-identity-citations.md deleted file mode 100644 index 2ac8a5cff80..00000000000 --- a/.changeset/14361-adr-0071-identity-citations.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/plugin-auth': patch -'@objectstack/platform-objects': patch -'@objectstack/spec': patch ---- - -docs(identity): re-point the SCIM/identity `ADR-0071` citations at the records that mean them (#14361) - -From this repository's point of view `ADR-0071` named two unrelated decisions, -and only one of them had a record here. `docs/adr/0071-dataset-semantic-layer-depth.md` -is *Dataset semantic-layer depth — multi-hop joins*. The identity and SCIM -citations mean something else entirely: the enterprise-identity decision taken in -`objectstack-ai/cloud`, whose open mechanism half has been mirrored into this -repo since 2026-09-07 as -[ADR-0134](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0134-env-side-scim-provisioning.md). -So a reader following one of those citations landed on a real page about the -wrong subject — worse than a dangling id, because a plausible-looking record -invites belief rather than a second question. - -44 identity-meaning citations now name the record that holds the decision they -describe. 43 of them read `ADR-0134` (the open mechanism half: effective SCIM -forces the better-auth `admin` plugin on, `active:false` lands as a ban plus -session revocation, the SCIM 2.0 Service Provider mounts in the environment, and -the seven stable `sys_scim_*` models). One reads `cloud ADR-0071` — the -"paid Identity lifecycle" note in `auth-manager.ts`, which names the commercial -half that deliberately stays in the cloud record. - -What actually reaches a consumer of these packages: - -- `@objectstack/plugin-auth` — the **operator-facing construction-time refusal** - raised when SCIM is effective beside an explicit `plugins.admin: false` now - cites ADR-0134 instead of ADR-0071. The condition that triggers the refusal, - its wording otherwise, and the two documented ways out are unchanged; only the - ADR number in the sentence moves. ⚠️ A deployment that greps that message for - the literal `ADR-0071` should grep for `ADR-0134`. -- `@objectstack/spec` — the `admin` flag's `.describe()` text (shipped both as - `src/system/auth-config.zod.ts` and in the generated `json-schema/` bundle), - and therefore the generated `content/docs/references/system/auth-config.mdx` - reference page app authors read. -- `@objectstack/platform-objects` — the `protection.reason` strings on the eight - `sys_scim_*` identity objects and on `sys_user`. - -No behaviour moves. No schema accepts or refuses anything it did not accept or -refuse before, no security or permission semantics are touched, and no ADR -record is written or edited. Bare `ADR-0071` still resolves exactly as it did: -the 22 dataset-meaning citations are byte-identical to `main` and -`check:adr-anchors` reports the same 35477 resolving citations before and after. -Historical archives — the six package CHANGELOGs — are deliberately untouched. diff --git a/.changeset/14512-multi-package-artifact-single-copy.md b/.changeset/14512-multi-package-artifact-single-copy.md deleted file mode 100644 index f39429209a2..00000000000 --- a/.changeset/14512-multi-package-artifact-single-copy.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`composeStacks(…, { manifest: 'preserve' })` emits each definition **once**, in the body of the package that owns it: a multi-package release artifact no longer carries a flattened copy of its collections at the top level (#14512, ADR-0130 D4's 2026-09-22 addendum). - -Measured on `examples/app-multi-package` (two packages, three definitions): `dist/objectstack.json` goes from 8,223 to 5,328 bytes, −35%, and the ratio does not improve with size — it was one extra copy of everything a package owns. The artifact's own `manifest`, `packages`, `plugins`, `devPlugins`, `devLogins`, `api`, `server`, `i18n`, `runtimeModule`, `onEnable` and `devHint` are untouched at the top level; the 35 package-owned collections are what moves. - -**BREAKING** for anything that read a compiled multi-package artifact's top-level collections directly. Every reader the platform ships was converted first — the ruling's order was readers first, emitter last — and this lands only with #15004's acceptance probe green on both shapes, which boots a two-package collection zoo through all five load boundaries and fails if any subsystem sees an empty collection. - -- **No authored metadata changes, and no artifact on disk is invalidated.** The authoring shape is identical: N `defineStack` packages plus a project config composing them with `manifest: 'preserve'`. ADR-0130 D4's read-both rule is untouched — `packages` present ⇒ iterate it, `packages` absent ⇒ `manifest` as a single-element list — so an artifact built before this change, which carries both halves, loads exactly as it did. -- **A single-package artifact keeps today's shape byte for byte** (ADR-0130 D7): the emitter strips nothing below two package entries. -- **The flattened half is dropped only where it is a COPY — three conditions, and a composition failing any one of them keeps today's additive shape**: (1) two or more package entries; (2) every input's collections attributed to a body — an input declaring no `manifest` has no package that could own its collections, and an input already carrying `packages` contributes those entries untouched; (3) the bodies reproduce the flattened collections item for item. Condition 3 is what `objectConflict: 'merge'` / `'override'` and a standalone action bound onto a SIBLING package's object fail: composition RECONCILES those into a top-level definition no body carries, so the flattened half is not a copy of anything and stays. Nothing here is refused — a composition that was legal before stays legal. -- **Why the copy goes rather than being compressed**: one definition was serialized twice with nothing keeping the copies equal. Where they differ today the flattened one is the reconciled copy and the platform's reader prefers it deterministically, which is exactly why this change removes the second copy only where the first one is redundant. One measured consequence on the compiled path: a seed dataset declared once reached the runtime's shared seed registry twice, and now reaches it once. - -Clause-②: yes (narrowing) - - diff --git a/.changeset/14640-rest-api-liveness-ledger.md b/.changeset/14640-rest-api-liveness-ledger.md deleted file mode 100644 index 7b72d1dbaf3..00000000000 --- a/.changeset/14640-rest-api-liveness-ledger.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`liveness/rest_api.json` — `RestApiConfigSchema` (the `api` sub-object of `RestServerConfig`) is now a governed liveness type. Every key it declares carries a status, the evidence that settles it and the producer that populates it: 12 `live`, 14 `dead` across 26 classified properties (#14640). - -The ledgers ship inside this package (`files[]` includes `liveness`), so this is one new file in the tarball plus two changed ones — `liveness/README.md`'s index row and heading, and the generated `liveness/state-counts.md`. Nothing else moves: no schema accepts or refuses anything it did not before, no export changes, no runtime behaviour changes, and no CLI author warning is added (no entry is marked `authorWarn` — a `RestServerConfig` is not part of a stack, so the lint that emits those warnings could never reach one). - -- **Why it was ungoverned, and why that reason has expired.** #14369 enrolled four of the five `RestServerConfig` sub-objects and deliberately left this one out: the `api` block's consumption seam was then still validate-only, so a census would have recorded a half that was about to move. That half has moved — `RestServer.normalizeConfig` now builds the `api` block from `parseDeclaredApiConfig`'s output rather than discarding it, and the change is released, not in flight. The fence was re-tested before anything here was written; it no longer holds, so the sub-object is measured on the settled seam. The stale sentence is corrected in the gate source and in the four README rows that repeated it. -- **The ledger is `rest_api.json`, never `api.json`.** That name was already taken, by a different `api`: `ApiEndpointSchema`, the registered `api` metadata type, with real consumers in the matcher, executor, policy chain and mapping layer. One spelling, two unrelated meanings inside this package — filing here would have published one file's measurement under the other's name. The gate's own override paragraph now carries that fence too. -- **Twelve keys are `live`.** `version`, `basePath` and `apiPath` become the prefix of every route the server mounts, through `getApiBasePath` — read via a whole-block destructure rather than a property access, which is why the census for the dead keys swept destructuring shapes with their own control instead of relying on a property-access pattern. The eight `enable*` switches each gate a mount, and most of them also the discovery document's capability block, so mount and advertisement move together. `projectResolution` decides whether the unscoped legacy routes mount alongside the scoped ones. -- **Fourteen are `dead`**: the `requireAuth` tombstone, and the two declared containers `documentation` and `responseFormat`, which `normalizeConfig` copies into `this.config.api` and nothing reads back. So `api.responseFormat.envelope: false` unwraps no response, and `api.documentation.title` retitles no served OpenAPI document — that document's `info` block is written by this package's own `build-openapi.ts` and passed through untouched by the REST layer. -- **`documentation` is drilled**, including its nested `contact` and `license` objects, so the change adds no row to the undrilled-container baseline. Those five leaf verdicts rest on a structural argument rather than a spelling sweep, which is stated in each row: `name` / `url` / `email` are too generic to grep, but the container that holds them is unreachable from outside `RestServer` — the field is `private` and `NormalizedRestServerConfig` is module-local with no `export` — so nothing can read a member of an object nothing reads. -- **The two dead containers deliberately do not share one verdict.** This file records status; it decides nothing. The enforce-or-remove call per key (ADR-0049) is a follow-up on the human floor, and the two differ: `documentation`'s members are OpenAPI `info` fields whose enforce route collides with a recorded ownership decision, while `responseFormat`'s enforce route would mean making the response envelope configurable — a larger claim. - -For an embedder, the practical read: every key that changes what this server mounts or advertises is marked `live` and evidenced; `documentation` and `responseFormat` are accepted, validated and normalized, and change nothing. diff --git a/.changeset/14656-declared-capability-absence-warn-once.md b/.changeset/14656-declared-capability-absence-warn-once.md deleted file mode 100644 index 5e87614f8b2..00000000000 --- a/.changeset/14656-declared-capability-absence-warn-once.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/types": patch -"@objectstack/runtime": patch ---- - -A **declared capability absence** — a 5xx answered because the deployment did not install an optional service — is now reported **once per route per process at `warn`**, naming the missing service, instead of one `error` line per request. Every other 5xx keeps the per-request `error` line #14310 shipped. - -Measured before the change, on a stock showcase boot: `GET /api/v1/ai/*` (the cloud-only AI service's declared `501 NOT_IMPLEMENTED`) printed one `error`-level line per request, and Studio opens it unprompted. A deployment that is working exactly as configured was training the channel built to mean "an operator must look" into noise — which is the failure mode `--log-level`-watching operators learn as "skim the errors". - -- **What counts as an absence** is the envelope the door composed: a producer-declared 5xx (`declaresServerFault` — the repo's existing declared-5xx predicate) whose ADR-0112 `code` is `NOT_IMPLEMENTED` or `SERVICE_UNAVAILABLE`. Nothing is invented to recognise one; the code the producer already declared *is* the declaration. -- **The predicate is applied inside the shared funnel** (`logServerFault`, `@objectstack/types`), not at each door, so `sendError`'s nested-envelope exit and the runtime dispatcher read one answer by construction. A door cannot opt in, opt out, or drift. -- **The dedupe key is (route, process).** A restart reports again, and a second, different route reports on its own — deliberately not a global "first N", which is the shape that hides the second route. A door that supplies no route coordinates is demoted to `warn` but never suppressed: an un-keyed bucket is that same hiding shape. -- **A thrown 5xx keeps its `error` line even when it declared `501`.** The thrown exit hands the funnel the throw and no envelope `code`, so it is not recognised as an absence — fail-loud for the half that carries a stack. - -⛔ **No wire byte moves.** Status, `code`, `message` and body shape are unchanged at both doors; this changes a log level and a count. The response bytes are pinned in `packages/runtime/src/declared-capability-absence-warn-once.test.ts`, and that block runs green on the pre-change tree too, which is what makes it a before/after measurement rather than a claim. - -Operators who were alerting on `[5xx]` at `error` level for an uninstalled optional service will now see one `warn` line per route per process instead. The line says so in its own text: `(declared capability absence — reported once per route per process)`. diff --git a/.changeset/15052-search-fields-docblock-icontains.md b/.changeset/15052-search-fields-docblock-icontains.md deleted file mode 100644 index c68553cba15..00000000000 --- a/.changeset/15052-search-fields-docblock-icontains.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`search-fields.ts`'s module docblock says `$search` expands to an `$or` of `$icontains`, the operator the engine actually emits - -The docblock's ENGINE bullet claimed `@objectstack/objectql`'s -`expandSearchToFilter` expands a `$search` term into an `$or` of **`$contains`** -clauses. It has compiled to `$icontains` since objectstack#7641: -`packages/objectql/src/search-filter.ts:23` carries the ruling verbatim — *"The -case-insensitive operator is `$icontains`, NOT `$contains`. `$contains` is -contractually case-SENSITIVE (#4706 Q2 = A)"* — and both return paths of -`fieldClausesForTerm` (`:109`, `:111`) emit `$icontains`. - -**Why the distinction is worth a clause rather than a word swap.** `$contains` -is contractually case-SENSITIVE, so a reader who trusted the old sentence built -an ingress gate, a test or a driver **stricter** than the platform is — a false -refusal, not a leak. The corrected bullet now says that in one clause, so the -next reader of this module does not have to reconstruct it from two other -packages. - -⛔ No behaviour changes. This is a module docblock; the engine has been right -since #7641 and no accept set, authorable key or published behaviour moves. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/spec`'s published `files[]` ships `dist`, and -this TSDoc is emitted into `dist/data/index.d.ts` and `dist/data/index.d.mts` — -measured on the built artifact, with the old spelling absent from all 216 built -files afterwards and the docblock's own neighbouring sentence present at 2 as -the lit control. `src/data/search-fields.ts` is not a `.zod.ts`, so it is not -shipped as source; the emitted declarations are the whole of its published -reach, and they change. - -The sibling INGRESS sentence two lines below — `@objectstack/metadata-protocol` -`findData` refusing a `$searchFields` override the resolved set does not admit -(#4254) — was measured on the same tip and is unchanged: `findData` still calls -`assertSearchFieldsAreSearchable`, which resolves through this module's own -`resolveSearchFieldResolution` rather than re-implementing the rule. diff --git a/.changeset/15110-retired-element-node-refusal.md b/.changeset/15110-retired-element-node-refusal.md deleted file mode 100644 index 76387aa72b2..00000000000 --- a/.changeset/15110-retired-element-node-refusal.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): `element:filter` and `element:form` are refused BY NAME at the node, and the typo suggester stops renaming authors into retired types (#15110) - -Two halves of one vocabulary defect, and only one of them is a narrowing. - -**BREAKING** — a bare `element:filter` / `element:form` component node no longer -parses. Both elements were retired whole at element grain (ADR-0049 -enforce-or-remove): no renderer for either ever shipped in objectui, framework -or cloud. Every authorable key became a `retiredKey` tombstone at the time, but -the node itself kept parsing, and each schema's own docblock recorded that as a -limitation rather than an intention: - -> A bare node with empty `properties` parses clean (the open `type` union -> accepts any string, so a node-level refusal is not expressible here) - -It is expressible one level up. Both names join -`RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses them with -a located prescription — the same door already built for `user:profile`. - -``` -FROM PageComponentSchema.safeParse({ type: 'element:filter' }) - -> { success: true } // nothing renders it; the console - // drew the unknown-type panel - -TO PageComponentSchema.safeParse({ type: 'element:filter' }) - -> { success: false, - issues: [{ code: 'custom', path: ['type'], - params: { retiredComponentType: 'element:filter' }, - message: '`element:filter` was removed in @objectstack/spec 17 …' }] } -``` - -**The prescription is not new prose.** Each node message is the element-grain -TAIL of that element's own `retiredKey` tombstones with the `property ` -clause dropped, so the node door and the props door carry one text — pinned -byte-for-byte in `component.test.ts`. An author who writes `element:filter` is -told to delete the component and use a view's `userFilters` quick-filter bar or -the list toolbar's filter builder; an author who writes `element:form` is sent -to the object-bound `object-form` block. - -**What does NOT change.** The rows stay in `ComponentPropsMap` — deleting one -would demote a loud retirement to a silent skip on every reader that dispatches -on it — so both rows keep refusing each retired key with its own per-key -prescription, and `isKnownComponentType` still answers `true` for both. The open -string arm is untouched: `object-grid`, `mcp:connect-agent`, `custom.widget` and -every live `element:*` member parse exactly as before. The two D2 conversions -still strip the keys and still leave the node; what changes is that the node -they leave is now refused by name instead of sitting inert, and their prose says -so. - -**The other half is a plain bug fix, no accept set involved.** -`KNOWN_COMPONENT_TYPE_CANDIDATES` — the typo-suggestion pool behind the -`component-type-unknown` authoring rule — was derived from every known type, -retired ones included. Measured through the rule: - -``` -FROM type: 'element:fitler' -> hint: "Rename `element:fitler` → `element:filter`." -TO type: 'element:fitler' -> hint: "Use a declared component type from the standard - vocabulary, or … give it its own namespace …" -``` - -The tool was renaming an author INTO a retired element — a rename the parser -refuses. The pool is now the known set minus whatever the vocabulary retired, -derived from the retirement map rather than restated beside it, so a type -retired tomorrow leaves the pool the day it lands. Live spellings are -unaffected: `global:serch` still proposes `global:search`, `record:detials` -still proposes `record:details`, `element:butotn` still proposes -`element:button`. - -Also corrected: the vocabulary docblock described the `ComponentPropsMap` row -set as a superset of the enum by "exactly" the string-arm registrations plus the -two tombstoned elements — one member short since `user:profile` joined it. - - diff --git a/.changeset/15117-action-engine-delete-id-array.md b/.changeset/15117-action-engine-delete-id-array.md deleted file mode 100644 index 1e504d9e4e5..00000000000 --- a/.changeset/15117-action-engine-delete-id-array.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `ActionEngineFacade.delete` declares the id ARRAY the runtime has always accepted, and says which convention is the contract (#15117) - -`delete(object, id: string)` declared one id. The runtime facade -(`buildActionEngineFacade` in `packages/runtime`) has accepted `string | string[]` -all along — normalising the argument and issuing one `ql.delete` per id — and -described that in a comment as a tolerance two handler suites happened to cause. -The declaration was simply behind the behaviour, and the one first-party suite on -the array form could only reach it by hand-rolling a private copy of the -interface (a copy that had already drifted on `find`). - -The slot is now `delete(object: string, idOrIds: string | string[])`, and the -member's doc comment states the contract instead of leaving it to be inferred -from a runtime comment two packages away: - -- **Both spellings are contract.** One row is `delete(object, id)`; a set is - `delete(object, ids)` — a handler holding a list does not have to unroll it - into a loop to stay on the contract. -- **The array form is a convenience over the same per-row path** — not a bulk or - atomic delete. There is no transaction around the set: a failure part-way - leaves the ids before it deleted. An empty array deletes nothing and resolves. - -Nothing is removed and nothing narrows: every existing single-id call still -type-checks, and no runtime behaviour changes — this release makes the published -type describe what was already being served. That makes it non-breaking, not a -patch: widening a published parameter is a purely additive widening of a public -surface, which takes at least `minor` whatever the commit type says. Handler authors who copied the -facade into a local context type to reach the array form can delete the copy and -annotate with `ActionHandlerContext` / `ActionHandler` from `@objectstack/spec/ui`. diff --git a/.changeset/15124-action-engine-facade-find-query-envelope.md b/.changeset/15124-action-engine-facade-find-query-envelope.md deleted file mode 100644 index ef93af3ad41..00000000000 --- a/.changeset/15124-action-engine-facade-find-query-envelope.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/runtime': minor ---- - -**BREAKING for action handlers** — `ActionEngineFacade.find` takes the engine's query ENVELOPE; the bare-filter parameter shape is withdrawn (#15124) - -Clause-②: yes (narrowing) - -`ctx.engine.find(object, query)` now takes `EngineQueryOptions` — the same -options bag `IDataEngine.find` and ObjectQL's own `engine.find` take, named by -identity rather than restated. **One platform, one query shape.** - -### Migration — FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `ctx.engine.find('task', { status: 'open' })` | `ctx.engine.find('task', { where: { status: 'open' } })` | -| `ctx.engine.find('task', { amount: { $gt: 100 } })` | `ctx.engine.find('task', { where: { amount: { $gt: 100 } } })` | -| `ctx.engine.find('task', {})` | unchanged — an empty envelope is still the unfiltered read | - -The rewrite is lossless and mechanical: the filter moves under `where`, verbatim. -`tsc --noEmit` over your handlers finds every unmigrated call — see below. - -### Why the shape was withdrawn rather than the bar closed - -Until now this parameter was the `where` HALF of a query while every other -`find` on the platform took the whole envelope, and the runtime wrapped what it -was given. That made the most natural spelling the wrong one, silently: an -author who passed the engine's own envelope reached the engine as -`{ where: { where: … } }` — a filter on a field named `where` — which matches no -row and resolves to `[]` with **no error at all**. A handler that made the -mistake ran to completion over zero rows for as long as it shipped, and its own -hand-written test double, written to the same belief, passed every assertion. -Because an empty `{}` skipped the wrap, one unfiltered read kept working under -either belief, so a dead handler looked partially alive. - -Refusing `where` at the top level instead — intersecting the old parameter with -`{ where?: never }` — was rejected: it asserts a vocabulary fact the spec -declares nowhere, reserving the field name `where` across every customer's data -model to buy one parameter's compile-time check. Aligning the parameter removes -the ambiguity at its root and reserves nothing. - -### What the new declaration refuses, measured - -If your handler is typed with the published `ActionHandlerContext`, a bare filter -no longer type-checks on **either** path you can reach it by: - -- an object literal (`{ status: 'completed' }`) fails the excess-property check — - a field name is not an envelope key; -- a filter held in a `FilterCondition` variable fails **TS2559** — every envelope - key is optional, so a bag of field names has no property in common with it. - -The envelope's own keys are typed too: `where: 'a = b'`, `fields: 'id,subject'` -and `limit: '50'` are each refused. - -**If your handler is NOT typed with it** — a handler in an `objectstack.config.js` -/ `.mjs`, one annotated with your own copy of the context type, or a `(ctx: any)` -handler — nothing above reaches you, so the facade refuses the withdrawn shape at -**runtime** instead, before the engine, with the same prescription: - -``` -find('task') was given a key 'status' the query envelope does not carry. -ctx.engine.find(object, query) takes the engine QUERY ENVELOPE, not a bare -filter — move the filter under `where`: find(object, { where: { … } }). -Envelope keys: context, cursor, distinct, expand, fields, limit, offset, -orderBy, search, searchFields, top, where. -``` - -⚠️ **That refusal matters most for a filter whose value is `null`.** The engine's -own unknown-option check exempts a `null` value, because on an option bag a -`null` is a withdrawal. On a filter it is the "rows with no X" idiom, so -`{ deleted_at: null }` would have been dropped unexecuted and the read would have -widened to **every row** — including the ones you were excluding — with no error -at all. It is refused instead. - -### What this opens - -`fields`, `orderBy`, `limit`, `offset` and `expand` are reachable from an action -handler for the first time — under the old parameter there was nowhere to carry -them. A caller-supplied `context` is **ignored**: this facade is trusted and -context-less by design, and the runtime stamps its own elevated -`ExecutionContext` last. Do not write one — it reads as authorization and is -none. - -### Checking a migrated handler - -Do not settle for "it still resolves". A handler that had been passing the -envelope was returning `[]` on **every** call, so a suite written against the -mistake passes and the row count is the only witness. Re-run each migrated -handler against seeded data and assert it returns the rows its filter selects. - - diff --git a/.changeset/15130-entry-prose-scanned-as-source.md b/.changeset/15130-entry-prose-scanned-as-source.md deleted file mode 100644 index 7723a22a87d..00000000000 --- a/.changeset/15130-entry-prose-scanned-as-source.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`src/migrations/entries/README.md` — the ADR-0087 entry authoring rules now record that an entry's prose is scanned as source **twice**, and that the rule is never to spell a shape a live textual ratchet matches (#15130). - -This file ships inside the package (`files[]` carries `README.md`, which matches at every depth — measured with `npm pack --dry-run`: 277 files, this one among them), so the rules an entry author reads are these. - -- **The mechanism is the counter-intuitive part.** Every string an entry declares — `surface`, `replacement`, `reason`, `acceptanceCriteria` — is concatenated verbatim into the generated `src/migrations/registry.ts`, which is ordinary `.ts`. A repo-wide textual scan therefore reads the same sentence once in the entry file and once in the registry. The tree's one code/prose separator masks **comments** and leaves **string literals** intact by design, so a quoted example is code to every scan built on it, and prose in this package can turn **another package's** test red. -- **The rule is the broad one, and the parenthesis is its instance.** A rule worded as "quote a retired call site without its parentheses" would make counter-examples of entries that spell a parenthesised call and are green — they go unmatched only because no live ratchet enumerates *those* methods, which is a fact about today's ratchets rather than a licence. What an author controls is not spelling a shape some ratchet matches; the guidance is to name the surface rather than spell a call of it. - -No schema, no export and no authorable key moves; the three existing rules in that section are unchanged and no entry was edited. diff --git a/.changeset/15141-cluster-doc-pointer-site-urls.md b/.changeset/15141-cluster-doc-pointer-site-urls.md deleted file mode 100644 index a930ebd4c0c..00000000000 --- a/.changeset/15141-cluster-doc-pointer-site-urls.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`EventMetadata.cluster` and `ServiceMetadata.cluster` cite the live docs page by SITE URL, not a dead filename - -Both `.describe()` strings pointed at `cluster-semantics.mdx`, a page that is no -longer in the tree — `apps/docs/redirects.mjs` has redirected -`/docs/concepts/cluster-semantics` to `/docs/kernel/cluster` since the page was -folded in. The section numbers still resolved, so nothing was broken for a -reader following a link; what was broken is retrieval by filename, which finds -nothing. - -These two strings are the published half. `gen:docs` copies them into -`content/docs/references/kernel/events-core.mdx` and `service-registry.mdx`, and -they also ship as JSON Schema `description` values under `packages/spec/json-schema/` -and as string literals in `packages/spec/dist/`. So the citation had to become -something a SITE reader can follow: - -``` -- See cluster-semantics.mdx §4. (a file that does not exist) -+ See /docs/kernel/cluster §4. (the address the redirect already resolves to) -``` - -⛔ Deliberately NOT the in-repo house style. Source comments elsewhere in the -tree cite `` `content/docs/kernel/cluster.mdx` §N `` — a repo path, correct for a -reader who has the repo checked out. Copying that convention into a `.describe()` -would tell a docs-site reader to open a `content/docs/...` file they do not -have, which is the same class of unfollowable reference pointed the other way. -There is no in-repo precedent to copy either way: these are the only two -`.describe()` strings in `packages/spec/src` that cite a docs page at all. - -The site URL is also redirect-independent — it is the redirect's own target, so -the reference survives the redirect being retired. - -No accept set moves and no authorable key is added or removed: the schemas, -their parse behaviour and their exported types are byte-identical apart from -these two description strings. The two regenerated reference pages carry the -same one-line change on three rows. diff --git a/.changeset/15178-translation-bundle-split-settings-platform-only.md b/.changeset/15178-translation-bundle-split-settings-platform-only.md deleted file mode 100644 index db820897410..00000000000 --- a/.changeset/15178-translation-bundle-split-settings-platform-only.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-settings': minor ---- - -**BREAKING for per-app translation bundles** — the translation bundle type splits in two: `settings` is a PLATFORM group and a per-app bundle may no longer declare it (#15178) - -Clause-②: yes - -`TranslationDataSchema` served two different bundles at once — the per-app one an -application authors (`stack.translations`, `defineTranslationBundle`) and the -code-authored bundles the platform packages ship. It now names the **per-app** -bundle entry and declares ten groups; the new `PlatformTranslationDataSchema` / -`PlatformTranslationBundleSchema` (types `PlatformTranslationData` / -`PlatformTranslationBundle`) carry the eleven-group platform face, `settings` -included. - -### Migration — FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `defineTranslationBundle({ 'zh-CN': { settings: { mail: { title: '邮件投递' } } } })` | delete the `settings` group — there is no per-app replacement key | -| `defineStack({ translations: [{ 'zh-CN': { settings: … } }] })` | delete the `settings` group from the bundle entry | -| `const b: TranslationBundle = { en: { settings: … } }` — a PLATFORM package's own bundle | `const b: PlatformTranslationBundle = { en: { settings: … } }` | -| `const d: TranslationData = { settings: … }` — a PLATFORM package's own locale entry | `const d: PlatformTranslationData = { settings: … }` | - -**The one-line fix for an application: delete the `settings` group.** Settings copy -is not application-authorable at all — `settings` is keyed by -`SettingsManifest.namespace` and only platform code declares a manifest, so the -only namespaces a per-app entry could ever address were the platform's own. -`settingsCommon` is **not** affected — the Settings UI shell strings (the source -badges, under `settingsCommon.sourceLabels`) stay on the per-app face; only the -per-namespace manifest copy under `settings` leaves. -Run `os migrate meta --from 17` to list the mechanical edits for existing -sources; apply them by hand. - -### What the deletion changes, which is not nothing - -⚠️ This is **not** a lossless delete, and the record says so rather than claiming -the house phrase. Both bundles load into ONE served tree — `AppPlugin`'s -`loadTranslations` and every platform plugin's `kernel:ready` contribution both -call `II18nService.loadTranslations`, which deep-merges — and the -`resolveSettings*` family and the console's settings labels read that merged -tree. So an app-authored `settings` branch did resolve. - -**It was a gap filler, not an override.** The app's bundles are loaded in -`AppPlugin`'s own `start()` (kernel Phase 2); the platform's settings -translations arrive from `SettingsServicePlugin`'s `kernel:ready` hook (Phase -3); `deepMerge` gives the **later** source the leaf. So the platform won every -key both bundles defined, and a per-app entry rendered **only where the platform -bundle carried no string for that key and locale** — the platform ships `en`, -`zh-CN`, `ja-JP` and `es-ES`. - -**What to expect after upgrading.** Where the platform already carried the -string, nothing changes on screen — that value was the one being served all -along. Where your entry was filling a gap, that Settings screen now renders the -**manifest's own literal, which is English** (the `?? fallback` every -`resolveSettings*` helper ends in). Those are the screens to re-read. If a -platform string is wrong or missing for your locale, correct it in the platform -bundle (`@objectstack/service-settings`'s `settingsBuiltinTranslations`) — do -not re-add the app-side copy, which the platform overwrites on every boot -wherever it has its own value. - -No deprecation window: the per-app door refuses the key by name from this major, -and the rejection carries the prescription above. - -### Unchanged - -The registered `translation` metadata type (`TranslationItemSchema`) is not -changed by THIS entry — this ruling covers the file-authored bundle. (Superseded -in the same release: #19620 narrows the item door too; see its own changeset.) `GET -/api/v1/i18n/translations/:locale` still declares it on its response, because the -served document is the merged tree; `GetTranslationsResponseSchema` is typed -against the platform face for exactly that reason. - -Ruling batch #132 item 2 letter ② (2026-09-13) — 「同意」. The card's original -"removal" disposition is struck: `settings` is a live platform key. - - diff --git a/.changeset/15184-list-view-field-order-composition.md b/.changeset/15184-list-view-field-order-composition.md deleted file mode 100644 index 6074260b69c..00000000000 --- a/.changeset/15184-list-view-field-order-composition.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`ListViewSchema` declares the `columns` x `hiddenFields` x `fieldOrder` composition instead of leaving it to be inferred from a renderer (#15184) - -The three keys that together build a list view's field list now say how they compose, in their own `.describe()` text — the string that ships into `json-schema/` and into the generated `content/docs/references/ui/view.mdx`: - -- `columns` is the **projection**: the candidate set and the baseline order, and neither other key can add a field it omits. An **empty** `columns` declares no projection, so neither other key applies: which columns show is left to the renderer (objectui's `ListView` grid derives the object's default columns). -- `hiddenFields` **subtracts** from that projection, before any ordering runs. A name `columns` never projected subtracts nothing. -- `fieldOrder` **orders what survives** and never adds a field. A surviving column absent from `fieldOrder` sorts **last**, after every listed one, keeping its `columns`-relative order; a name listed there that did not survive orders nothing. - -**Why this is a declaration and not a precedence rule.** `fieldOrder` was proposed for retirement as a second spelling of `columns` with no contract deciding who wins. They never compete: one selects, the other sorts. Maintainer decision batch #115 (2026-09-11) kept the key and ruled the composition into the contract, which is what this change lands. - -⛔ **No accept set moves.** No key is added, removed, narrowed or widened; no parse verdict changes; the four `@objectstack/lint` list-view validators are untouched. What changes is the published description of three keys that were already there, plus one ledger row's evidence. - -**`packages/spec/liveness/view.json` — the `/props/list/children/fieldOrder` row is re-cited**, `verifiedAt: 2026-09-21`. The ledger ships in this package's `files[]`, so the pointers an upgrading reader follows are these, and both halves of the 2026-08-10 citation had rotted: its first path (`objectui packages/react/src/spec-bridge/bridges/list-view.ts`) no longer exists, and its second had drifted in range through three sets of line numbers. The row now anchors both pointers on symbols, splits the relay rung out into `producer`, and names the measurement it was taken at. - -The declaration and the accept set are held together by `packages/spec/src/ui/view-field-order-composition.pin.test.ts`: it reads the three descriptions off the live schema and parses a document carrying all three keys through the page-list, object-views, `defineView` and registered-metadata doors, asserting the arrays come back verbatim — the spec declares the composition, it does not perform it. diff --git a/.changeset/15292-dev-plugin-malformed-stack-posture.md b/.changeset/15292-dev-plugin-malformed-stack-posture.md deleted file mode 100644 index 5a4a3475ac5..00000000000 --- a/.changeset/15292-dev-plugin-malformed-stack-posture.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/plugin-dev": patch ---- - -`DevPlugin`'s boot posture on a malformed stack is now written down: dev boot tolerates and reports; refusing belongs to the production doors, which are not uniform about it (#15292). - -Clause-②: no - -No behaviour changes. `DevPlugin` already degraded a stack the platform would reject, and the posture — ruled, not invented here — is that it should: the contract refuses at the production door, while the developer's inner loop tolerates incomplete input and never hides it. Metadata that is incomplete halfway through an edit is the normal state of a project under active development, so refusing at dev boot would charge the cost to the only user group this plugin exists for, for a consistency the production doors already provide. What was missing was the written posture and one load-bearing correction to it. - -- **The two branches are not one defect handled two ways.** `new AppPlugin(stack)` reads `manifest.id` / `manifest.name` and nothing else, so a malformed `packages[]` passes the constructor untouched and is refused one branch later: `AppPlugin.init()`'s LAST statement hands the bundle to the `manifest` service, whose `register()` calls `resolveArtifactPackageOrder` unguarded, and `DevPlugin`'s child-`init()` loop degrades that refusal to an `error` line. The lazy `collections` getter is not on that path at all — it is not read during `init()`, and its first read is in `AppPlugin.start()`, where it reaches the same refusal on the same bytes. Both in-file comments that named the constructor as the stack's parse door (*"a malformed stack throws HERE"*, and §3b's *"twenty lines above, `new AppPlugin(...)` parses the SAME object"*) overclaim for that reason, and both are corrected in this PR. -- **The two malformations are exact complements, measured with a lit control.** An app payload with no `manifest.id` / `manifest.name` throws from the constructor (a bare `Error`, no ADR-0112 `code` / `status`) and is invisible to the package-list parse; a `packages[]` entry that is not a package entry (ADR-0130 D4) is invisible to the constructor and refused by the parse as `INVALID_ARTIFACT_PACKAGE_ENTRY` / `422`. A stack carrying neither is silent on both. So a clean boot past one branch is no evidence about the other — which is why the division is now documented rather than left to be re-derived. -- **Tolerating is not hiding.** The posture's second half is that a boot which skipped something is never byte-identical to a healthy one: a silent degrade lets an author, or a coding agent, read "it started" as "I wrote it correctly". -- **The production doors are not uniform, and every carrier now says so.** A malformed `packages[]` fails `ObjectStackDefinitionSchema` — `packages: z.array(ArtifactPackageSchema)`, the SAME entry schema the runtime parse uses — and both `os validate` and `os build` parse the lowered stack against it and exit 1 (`validate.ts` step 2; `compile.ts` step 3). `lowerCallables` passes a non-`{ manifest: object }` entry through untouched, so the verdict transfers to what the CLI actually parses. An app payload with no `manifest.id` parses green at BOTH: `os validate` reports it only as the structural advisory *"Missing manifest.id — required for deployment"*, which fails only under `--strict` (both exit faces read one `warnings` list — the `--json` ternary and the text face's `if (flags.strict)` block), and `os compile` "never computes them at all" in its own words, so `os build` is silent on it. The flat "`os validate` / build / publish refuse" overstated BOTH doors for that half, and `publish` is simply not a door this card measured, so it is no longer claimed. -- **What ships**: the `DevPlugin` docblock (which reaches the published `dist/*.d.ts`), the two in-file comments named above, and `content/docs/plugins/packages.mdx`, plus a test pinning the posture, its division and the init-time path the refusal actually takes. The wording of the malformed-metadata diagnostic itself is deliberately not pinned — that text is a sibling change. diff --git a/.changeset/15293-non-array-packages-refusal.md b/.changeset/15293-non-array-packages-refusal.md deleted file mode 100644 index 7c948792c29..00000000000 --- a/.changeset/15293-non-array-packages-refusal.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -A release artifact whose `packages` is present but is not an array (`{}`, `0`, `'x'`) is now refused by the runtime's collection reader too, as `INVALID_ARTIFACT_PACKAGES` (ADR-0112, `status: 422`) (#15293). - -Clause-②: no - -`packages` is declared as an array of package entries (`ObjectStackDefinitionSchema.packages: z.array(ArtifactPackageSchema).optional()`), and the rule is now written down once, beside `AssembledPackageBodySchema` in `@objectstack/spec`: an absent `packages` means a single-package artifact, and any other non-array value is malformed and refused. `resolveArtifactPackageOrder` in `@objectstack/core` already refused it, and so did the i18n detector in `@objectstack/plugin-dev` and the default-permission-set reader in `@objectstack/plugin-security`. - -- **What changes**: `AppPlugin` reads its collections in `start()`, and `start()` now raises the same refusal `init()` already raised through the kernel's `manifest` service. Under `os dev`, `DevPlugin`'s child-`start()` loop logs it on its `error` line, where before the app started on its top-level collections alone. `createStandaloneStack` now refuses such an artifact while it builds the stack. Before, the refusal came later, when the app registered with the `manifest` service. `loadArtifactBundle`'s runtime-module merge reports it through its existing `warn` line and skips the merge, as it already does for a malformed `packages[]` entry. `resolveProjectDatabaseUrl` no longer reads a default datasource out of such an artifact: it declines, as it already does for any artifact it cannot read, and moves on to the next rung (the unified default database). The boot that loads the artifact then refuses it. -- **What does not change**: an absent `packages` still returns the caller's own object by identity. A well-formed `packages[]` resolves exactly as before. `packages: null` is not absent: it is malformed, and it is refused the same way (#19926). -- **Fix**: remove the `packages` key for a single-package artifact, or make it an array of `{ manifest: … }` entries. diff --git a/.changeset/15295-serve-observability-mirror-comment.md b/.changeset/15295-serve-observability-mirror-comment.md deleted file mode 100644 index 7a55384552c..00000000000 --- a/.changeset/15295-serve-observability-mirror-comment.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`serve.ts`'s observability knob block points at the cloud mirror in the house style, keeps the sync duty, and names the package that owns the list (#15295) - -The block above `buildServeObservability()` instructed the reader to *"keep the -two in sync"* with `apps/cloud/server/observability.ts` — a path that has not -existed in this repository since `apps/cloud` moved to `objectstack-ai/cloud` -(`git ls-tree origin/main -- apps/` returns exactly `apps/docs`, the positive -control that makes that a reading rather than a broken query). A reader was -being sent to a file they cannot open, with no hint that it lives in another -repository. - -**The duty is live, so it stays.** The cloud file still exists and still reads -these names as `process.env` lookups (measured on `objectstack-ai/cloud` and -recorded on #15295, with that file's own `process.env` hit count as the firing -control) — for every knob in the block except `OS_OTLP_FLUSH_MS`, which was -added on this side after that measurement and is therefore unverified rather -than mirrored. The comment states that boundary rather than a bare count, so a -reader counting six entries under a claim about five cannot be misled about -which of them the reading covers. Deleting the clause would have dropped a real -obligation whose failure mode is quiet: the two exporters drift and the cloud -host stops reading the variables an operator set. - -Three things change, all inside one comment block: - -- the path is re-spelled in this repo's settled style for a cloud-repo - reference — ``(`apps/cloud/server/observability.ts`, cloud repo)``, the form - at `packages/services/service-cluster/src/multi-node-gate-mount.ts:9`; -- the duty is narrowed to what its own words say — **names, not defaults**. - `OS_OBS_SERVICE_NAME` defaults to `objectstack` here and to - `objectstack-cloud` there *deliberately*, because two deployments are two - services; a future reader "tidying" that into one value would merge both - deployments into a single telemetry series. The comment now says so, which is - the point of writing it down rather than leaving it to be rediscovered; -- the canonical home for the variable list is named as - `@objectstack/observability` — the package **both** consumers already import - — instead of two consumers pointing at each other. That mutual pointing is - the decay mechanism itself, and it is still one-sided today: the cloud file - carries no reciprocal sentence, so nobody renaming a name over there is - prompted to come back here. - -⛔ No behaviour changes, and no observability code path was touched. No env var -is added, removed or renamed; no default moves. - -**This ships, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/cli`'s published `files[]` is -`["dist","README.md","CHANGELOG.md"]`, and this package builds with plain `tsc` -(no `removeComments`), so the block is emitted verbatim into the tarball — -measured on the rebuilt artifact: the new clause is present in -`dist/commands/serve.js` (1 occurrence, and the knob-list line as control -resolves to that one file), the old spelling is absent from all of `dist`, and -`dist/commands/serve.d.ts` carries 0 of it because the block sits above a -non-exported helper. So the published JS bytes move while the declaration -surface does not. diff --git a/.changeset/15385-metadata-service-load-many-keyed.md b/.changeset/15385-metadata-service-load-many-keyed.md deleted file mode 100644 index 2b4dd087585..00000000000 --- a/.changeset/15385-metadata-service-load-many-keyed.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`IMetadataService` declares `loadManyKeyed?` — the keyed plural loader read now sits on the contract beside its two declared siblings `loadMany?` and `loadDiagnosed?` (#15385). - -Clause-②: yes - -A verb family lives whole on the contract. `MetadataManager.loadManyKeyed(type)` shipped as a public member with no declaration on the interface its siblings are declared on, so the one cross-package caller — the ObjectQL governance audit — narrowed the service slot with a **local structural type** written beside the call site. That local type is deleted in the same change and the call site reads the contract. - -The vocabulary is not new: `loadManyKeyed`, and the `{ name, data }` item shape it answers with, are already published on `MetadataLoader`, which declares the same member as optional over its own loader-local options type. What this adds is the member's place on `IMetadataService`. - -```ts -loadManyKeyed?( - type: string, - options?: Record, -): Promise>; -``` - -**What it is for.** The key is a fact about the **store** — `register()`'s own `name` argument — and it travels *beside* `data`, never folded into it, so `data` stays byte-identical to what the unkeyed plural read would return and no consumer ever sees a synthesised `name`. An item whose stored body has no top-level `name` is legal and deliberate (an org customization container's identity is the object it targets), and such an item has no identity at all in a plural read keyed by `data.name` — it is dropped, silently. That is why this is a second member rather than a widened return type on the existing one. - -**What moves for consumers.** Nothing breaks. The member is **optional**, like `loadMany?` and `loadDiagnosed?` beside it, so every existing `IMetadataService` implementation still satisfies the contract unchanged and the `typeof … === 'function'` probe stays the way a caller asks for it. What changes is that a caller no longer has to declare the shape itself to stay typed: intersecting the slot with a hand-written structural type was the only way to reach the member without erasing the lookup to `any`, and that workaround is now unnecessary. `MetadataManager`, which already implements the member, needs no edit. - -This is the position `loadDiagnosed` was in before #4127 batch 4 declared it, and it is resolved the same way. Ruled in decision batch #123 item 5 (2026-09-12), maintainer verbatim: 「同意」. - -`content/docs/kernel/contracts/metadata-service.mdx` gains the member in the same change. diff --git a/.changeset/15429-decision-edge-branching-first-match.md b/.changeset/15429-decision-edge-branching-first-match.md deleted file mode 100644 index e0f19ecd4e7..00000000000 --- a/.changeset/15429-decision-edge-branching-first-match.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-automation": minor -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor -"@objectstack/metadata-core": patch ---- - -feat(automation)!: an edge-branched `decision` is exclusive — the first out-edge whose condition holds, in declaration order, wins; `mode: 'inclusive'` takes every one (#15429) - - - -Clause-②: yes - -**BREAKING** — the run-time semantics of a shipped node type change. A `decision` node that -declares no `config.conditions` and branches on its out-edges used to take EVERY out-edge whose -condition held, one after another, while its schema, the docs and the engine's own comment all -called it an exclusive gateway; hotcrm#1555 rendered a refusal screen AND ran the conversion in -one execution. Maintainer ruling on #15429 (2026-09-23, 「跟主流对齐」): the gateway follows -BPMN's exclusive gateway, Salesforce Flow's Decision and n8n's Switch default, and taking every -true branch is a declaration the author writes down. - -| | before | after | -|:--|:--|:--| -| two conditioned out-edges, both hold | both successors run, sequentially, nothing reported | the FIRST declared one runs; the second records a `skipped` step | -| `config: { mode: 'inclusive' }` | accepted, never read | every out-edge whose condition holds runs, sequentially | -| none holds | the `isDefault` edge runs | unchanged | -| `mode` beside a non-empty `conditions` list, or outside `'exclusive' \| 'inclusive'` | refused by a direct parse only | refused at `registerFlow` and by `os validate`, with the schema's own sentence | - -## Migration: FROM → TO - -`os migrate meta --from 17` lists the mechanical edits for existing sources and applies them -to the migrated stack: the ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit` -writes `mode: 'inclusive'` onto every decision that has no `conditions` list and two or more -conditioned out-edges, inside ADR-0031 regions included, so a migrated flow runs exactly as it -did. - -```ts -// FROM — every true out-edge ran -{ id: 'verdict', type: 'decision', label: 'Verdict?' } -// TO — what the conversion writes; delete the key where the conditions partition -{ id: 'verdict', type: 'decision', label: 'Verdict?', config: { mode: 'inclusive' } } -``` - -Then review each written key (the paired D3 entry `flow-decision-edge-branching-first-match` -carries the acceptance criteria): delete it where the conditions partition (`== 'a'` beside -`!= 'a'`, `>` beside `<=`, a guard beside `isDefault: true`), keep it where the flow relies on -more than one branch running for one record, and where the overlap was accidental narrow the -conditions into a partition and delete the key. `os validate` reports -`flow-decision-inclusive-overlap` on every decision that keeps the key with two or more -conditioned out-edges, so the review list is the lint output. - -## BREAKING for flows stored in `sys_metadata` — maintainer ruling letter C on #15429 - -A `decision` node stored in `sys_metadata` (a flow built or edited in the Studio designer) with -**no `config.conditions`, no `mode`, and two or more out-edges carrying a `condition`** evaluates -**first-match** after this upgrade: where it took every out-edge whose condition held, it now takes -only the first one that holds, in the order the flow declares its edges. Nothing rewrites that row -— no stored-row migration, no cutoff, no read-path completion — because nothing about a stored row -says it was saved before the flip. The one-line fix, for a node that meant every branch: - -```ts -{ id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } } -``` - -`os migrate meta --stored` (and `POST /api/v1/meta/_migrate-stored`) lists every such node under -`decisionModeReview` — flow row, node id, label and path — on a preview and an `--apply` run -alike, and writes nothing for it: the list moves no row outcome, no count and no exit code, so an -operator can review the candidates before and after the upgrade. A node leaves the list once it -declares `mode`, either member. Every such node in the measured corpus below is a partition, where -the new meaning runs exactly what the old one did. - -Authored sources and built artifacts keep the old behaviour instead, where the source's age is a -fact: `os migrate meta --from 17` writes `mode: 'inclusive'` (above), while the authoring funnel, -the automation engine's flow rehydration seam and the artifact-ingestion door all refuse the -conversion by id — a default flip replayed there would turn a decision written today against this -contract, where an omitted `mode` means exclusive, into an inclusive gateway. - -## Reach, measured at landing - -- Release state: the npm registry's `latest` `@objectstack/spec` is `17.4.0` (`npm view`, - 2026-09-27), whose `json-schema/automation/DecisionConfig.json` declares `conditions` only — - `mode` has not shipped; `.changeset/19867-decision-config-mode.md` and - `.changeset/20168-decision-mode-beside-conditions-refused.md` are still unconsumed in this - tree. So `mode` reaches its first release together with the traversal that reads it and the - conversion that writes it; no published accept set narrows, and the registration and - `os validate` refusals narrow nothing that shipped. -- Corpus census (this repository at the branch base and `objectstack-ai/hotcrm` at `2f7b2326`, - read-only): 30 decision nodes across 48 flows; 17 have two or more conditioned out-edges and - no `mode` (the conversion's positives — every one a hand-written partition, including - hotcrm's `lead_conversion.decision_duplicate`, the #1555 node), 13 have one conditioned - out-edge (left alone), and no node of any other type carries a conditioned out-edge, so the - exclusive traversal is scoped to `decision` with nothing else to migrate. -- What the published surface gains: the D2 conversion and its D3 entry in the protocol-18 - chain (`spec-changes.json`, the upgrade guide), `DecisionConfigSchema.mode`'s describe and - docblock now state the run-time semantics, and `@objectstack/lint` gains - `flow-decision-mode-invalid` (gating) and `flow-decision-inclusive-overlap` (advisory). - -The traversal change is scoped to `decision` nodes: conditioned out-edges of any other node -type keep the every-true-edge traversal they had (none was measured to exist). diff --git a/.changeset/15437-validation-messages-migration-route.md b/.changeset/15437-validation-messages-migration-route.md deleted file mode 100644 index 40e60349cbe..00000000000 --- a/.changeset/15437-validation-messages-migration-route.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The `translation-validation-messages-removed` migration text names the object-scoped bundle key, not just the authored literal - -`validationMessages` was retired in 17.0.0 (#4667). The ADR-0087 conversion that -migrates it told an author to author the message on the rule -(`object.validations[].message`) and stopped there. Since 17.3.0 (#14381, -#14253) that message has a translation route — -`objects.._validations..message`, resolved on the write -path — and the sibling prescription ten metres away in the same package -(`TRANSLATION_KEY_GUIDANCE.validationMessages`, the text the strict door -returns) already names it. - -⛔ Nothing the old text said was false, and none of it is deleted. The defect is -**silence**: this is the *migration* text, read by exactly the population that -authored the retired key — the authors who wanted their rule messages -translated — and it steered them to a plain authored literal without mentioning -that the bundle key now exists. The literal advice stays; the route is added -after it. - -**Two texts in the file carried the narrow prescription, not one.** The -conversion's `summary` is the one the card named; the docblock above it asserted -that rule messages are *"not translated through a group"*, which would have sat -directly above the corrected summary. Both are completed. The docblock keeps its -17.0.0 sentence — still true of the retired key — and says what 17.3.0 changed, -including why the object-scoped group is not `validationMessages` returning (the -retired one was keyed by rule name at the top level, could not tell two objects' -rules apart, and had no reader). - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `packages/spec/src/conversions/registry.ts` is not a -`.zod.ts`, so it is not shipped as source — but two published paths move, -measured on the built tree rather than reasoned about: - -- `dist` is in `files[]`, and the new sentence is emitted into six built files - (`dist/index.js` / `.mjs`, `dist/shared/index.js` / `.mjs`, - `dist/browser/index.js` / `.mjs`); a negative control string scored 0 on the - same tree. An author running `os migrate meta --from 16` reads the changed - notice out of that runtime string. -- `spec-changes.json` is itself listed in `files[]`, and it carries the summary - twice. It is generated (`gen:spec-changes`), and `check:generated` caught it - stale — the conversion registry feeds two generated artifacts, not one. - -`docs/protocol-upgrade-guide.md` is the third, regenerated with -`gen:upgrade-guide` and verified by `check:upgrade-guide`; all three are -regenerated, never hand-edited. - -⛔ No behaviour changes. The conversion id, its `apply`, its accept set and its -fixture are untouched; no authorable key is added or removed. diff --git a/.changeset/15484-rest-log-declared-level-seam.md b/.changeset/15484-rest-log-declared-level-seam.md deleted file mode 100644 index 784144d4b27..00000000000 --- a/.changeset/15484-rest-log-declared-level-seam.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -`packages/rest`'s fault logging gains a **declared level seam**, `OS_REST_LOG`, with the **shipped default unchanged**. At the default — and an unset or unrecognised value *is* the default — a reported fault still prints the whole `Error`: message, `cause` chain and stack frames, exactly as before. ⛔ No wire byte moves, no published payload gains a key, and no existing log line changes shape. - -What is new is that the loud/quiet choice is now **declared and machine-read** instead of implicit in whether an author happened to pass `error` or `error.message`: - -- **`OS_REST_LOG`** accepts `debug` / `info` / `warn` / `error` / `silent` — deliberately the same vocabulary and the same `'info'` default as `@objectstack/objectql`'s `OS_REGISTRY_LOG`, so the two are one logging contract with two populations rather than a second ad-hoc environment variable. Documented for operators in this package's README. -- **`scripts/check-rest-log-declared.mjs`** enforces it: the seam is located by its environment read (never a hardcoded path), the vocabulary is read from `REST_LOG_LEVELS` rather than copied, the two seams' vocabularies are held equal, a harness declaration must name a level the seam actually recognises — an unrecognised one resolves to the default *silently* — and every inline vitest project must carry its own declaration, because a root-level `env` is inert for project runs. -- **The shipped default is gated, not just documented.** Lowering `REST_LOG_DEFAULT_LEVEL` to `error` or `silent` is a finding, because at those levels this package stops reporting faults it is the only reporter of. - -**Why the default does not move.** Measured on one green `packages/rest` run: 2,095 indented `at ` frame lines, 36.7% of captured output, 100% of them arriving through this one shim. They are not dead weight. When a 5xx is withheld from the client, the log is the only copy of the driver text, and that text lives on `error.cause` — printed only because a whole `Error` object, not a summary, reaches `console.error`. Four assertions across `rest-5xx-message-sanitization.test.ts` and `rest-expected-error-logging.test.ts` pin that by asserting the **identity** of the error that arrives, one of them carrying an explicit do-not-delete warning aimed at exactly this repair. - -Operators: nothing to do. A deployment that wants the REST layer quieter can now say so — `OS_REST_LOG=error` drops the warning half, `silent` drops both — but doing so discards diagnostics that have no second copy anywhere, and the README says so at the seam. diff --git a/.changeset/15556-subflow-parent-strand-on-decide.md b/.changeset/15556-subflow-parent-strand-on-decide.md deleted file mode 100644 index 9fc02c9c73f..00000000000 --- a/.changeset/15556-subflow-parent-strand-on-decide.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/service-automation": minor -"@objectstack/plugin-approvals": minor ---- - -An approval `decide()` that resumes a subflow CHILD now tells the caller when that resume bubbles into a PARENT run that stranded — instead of answering full success with nothing to distinguish it from a healthy composition (#15556; the #16472 family ruling, decision batch #76, option A). - -**The composition.** A parent flow parks at a `subflow` node whose child hosts the `approval` node, so the approvals row names the CHILD run. The decision door resumes the child, the child completes, `bubbleToParent` resumes the parent, and the parent's own downstream node throws. The parent lands on the engine's `'stranded'` exit — it consumed its suspension and is now terminal, repairable only by an operator's `restoreConsumedSuspension` — and `bubbleToParent` already logged that at `error` (unchanged by this fix). What the caller was TOLD did not: `resumed: true`, no `resumeError`, and a `runId` naming the healthy child — identical to what a fully healthy composition answers. - -``` -FROM service.decide(requestId, { decision: 'approve' }, ctx) - -> { finalized: true, decision: 'approve', runId: '', resumed: true } - // identical to a healthy composition's answer — no caller can tell - -TO service.decide(requestId, { decision: 'approve' }, ctx) - -> { finalized: true, decision: 'approve', runId: '', resumed: true, - resumeError: "RESUME_FAILED: … its own flow run '' resumed, but the " + - "subflow parent above it — run '' — consumed its suspension " + - "and is now stranded: ", - resumeFailure: { code: 'RESUME_FAILED', runId: '', status: 'stranded', repairable: true } } -``` - -**Additive only — no migration.** `ApprovalDecisionResult.resumeFailure` was already declared (and pinned) in `@objectstack/spec` ahead of this card; this fix is the first producer that fills it. No existing field changes shape, no status code moves (the door still never throws for this shape — `AGENTS.md`'s "a failure handed to the caller" answer does not apply here, since before this fix no caller was told at all), and the door's `error` log line is untouched. A consumer that already ignores unknown fields sees no difference; a consumer that reads `resumeFailure` can now tell a bubbled parent strand from a clean resume without diffing `runId` against a durable run history. - -**What did not move, on purpose.** `RESUME_IN_PROGRESS` / `STORE_UNAVAILABLE` bubble outcomes stay the functional degradation they always were (`warn`, unreported on `resumeFailure`) — the #16472 ruling is scoped to the one exit the engine calls `'stranded'`. The sibling `recall` door (`ApprovalRecallResult.resumeFailure`, #15970) is a separate card and is not touched here. - -**New public surface — the reason for `minor` on both packages, not `patch`.** Getting the parent's strand from the engine to the approvals door without touching `packages/spec` or the wire-visible `AutomationResult` (which a raw REST `POST …/resume` also serves verbatim, so a field there would leak an undeclared key onto every subflow resume, not only an approvals-mediated one) needed a small new internal channel: - -- `@objectstack/service-automation`: `AutomationEngine` gains a new public method, `takeSubflowParentStrand(childRunId: string): SubflowParentStrand | undefined` — read-once (deletes on read), populated only by `bubbleToParent`'s `'stranded'` exit. `SubflowParentStrand` is a new exported interface (`{ runId, repairable: true, error }`). -- `@objectstack/plugin-approvals`: `ApprovalResumeSurface` (already exported from the package entry) gains a matching optional member, `takeSubflowParentStrand?(childRunId): { runId, repairable, error } | undefined`. - -Both are additive and optional; nothing existing changes shape or behaviour. Neither reaches any wire payload — `AutomationResult`, the REST resume door's response, and every other published contract are byte-for-byte unchanged. diff --git a/.changeset/15638-ui-plugin-arm-delete.md b/.changeset/15638-ui-plugin-arm-delete.md deleted file mode 100644 index 6cd641c2a02..00000000000 --- a/.changeset/15638-ui-plugin-arm-delete.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/plugin-hono-server": patch ---- - -The UI auto-discovery block in `HonoServerPlugin.start()` no longer names the retired plugin type `ui-plugin`: the `plugin.type === 'ui-plugin'` disjunct and its "Support legacy" comment are removed, and the block mounts `type: 'ui'` plugins only (#15638). - -Clause-②: no - -- **Why nothing a plugin declares moves.** `ui-plugin` is not a member of the closed plugin-type set (`'standard'` plus `CORE_PLUGIN_TYPES`), and `kernel.use()` already refuses it on both published kernels, before the block can see it, with `PLUGIN_CONTRACT_VIOLATION ... at 'type'`. `LiteKernel.use()` throws it as-is; `ObjectKernel.use()` throws it behind its `Failed to load plugin: NAME - ` prefix. The disjunct was reachable through neither kernel's `use()`, so the deletion changes no accept or reject verdict for any declared value, and the refusal is the generic closed-set one. There is no message specific to this spelling. -- **The one object that stops mounting.** The contract validates at `use()` and stores the plugin object by reference, so an object admitted as `ui` that then rewrites its own `type` to `ui-plugin` before `start()` used to be mounted by the removed disjunct. It no longer is. -- **Fix**: declare `type: 'ui'`, with `staticPath` and `slug` (both required for that type). diff --git a/.changeset/15646-structured-region-pause-and-end-refused.md b/.changeset/15646-structured-region-pause-and-end-refused.md deleted file mode 100644 index 7b62c00f4ab..00000000000 --- a/.changeset/15646-structured-region-pause-and-end-refused.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING for authored metadata** — an ADR-0031 structured region body (`loop.config.body`, a `parallel` branch, `try_catch`'s `try` / `catch`) now refuses two node populations at parse: a node whose TYPE parks the run on every execution, and an `end` node (#15646, absorbing #18112). - -Clause-②: yes - -The flow accept set shrinks for five node types inside region bodies — shapes the runtime never honoured. Both refusals are the authoring-time enforcement of a limit the engine already holds at run time and #3267 ruled 禁: **a region body runs synchronously inside the enclosing run, so it can neither park that run nor terminate it.** - -``` -✗ nodes.1.config.body.nodes.0.type: A `approval` node may not sit inside a structured region — - `loop 'sweep' body → try_catch 'guard' try` is a region body and the `approval` node `sign_off` - is inside it. A region body runs synchronously and cannot durably pause … -``` - -**What is refused** - -- **A node that pauses on EVERY execution** — `screen`, `wait`, `approval`, `approval_revise`. -- **An `end` node**, whatever its `outcome`. An `end` in a region was a no-op, and a refusing one was converted into a region error at the boundary; neither is what the author wrote. - -**⛔ What is deliberately NOT refused: `subflow` and `map`.** Their shipped executors also declare `supportsPause: true`, but they pause exactly when the child flow their `config.flowName` names pauses — a **different metadata record**, not in hand while this flow is parsed. Refusing them by type would also refuse `loop { map(synchronous child) }`, a shape that runs correctly today and is covered by an existing regression suite. A parse-time rule refuses what is statically wrong; a region-contained node that actually suspends is a fact only the run holds. **Nothing an author wrote with a region-nested `map` or `subflow` needs editing for this release.** - -**Why it was silent, measured.** The engine converts a suspension raised inside a region into an error — but the executor has already written its progress state into the ENCLOSING scope by then. Contain that error in a `try_catch` and the residue is read back as progress by the next entry to the same node. On a real `AutomationEngine`, `loop { try_catch { map(pausing child) } }` over 3 iterations × 2 items: not one item's subflow completed, only two of three iterations reached the catch, and iteration 3 read `started === collection.length`, ran nothing, and returned `success` with `summary.failed = 0`. ⚠️ Read that for the MECHANISM, not for this change's reach — the shape it was measured on is a `map`, and making that run's refusal loud is a separate change to the automation engine, not this one. - -### Migration — FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `loop { body: [ …, end ] }` | `loop { body: [ … ] } → end` — give the region a normal exit and put the terminator, with its `outcome` / `message`, on the top-level graph | -| `loop { body: [ wait ] }` | a top-level `wait`, with the top-level graph as the repeating construct — a region body cannot park the run, so the nested form never waited | -| `parallel { branches: [ [ approval ] , … ] }` | put the `approval` on the top-level graph and fan out around it, or split the branch's pausing half into a `subflow` the top-level graph calls | - -The one-line fix is always the same: **move the node onto the top-level graph and route the region's exit to it.** ⛔ Not mechanically convertible — hoisting a node out of a region is a graph rewrite (new edges, a changed exit, sometimes a deleted container) and which shape the author meant is an intent no artifact records, so this ships as an ADR-0087 D3 structured TODO rather than a D2 conversion. - - - -**⚠️ Two boundaries this refusal does not reach, stated rather than discovered.** A pausing node type contributed by a **plugin** is not refused: ADR-0018 left the node-type namespace open and a parse has no registry. A region nested past **`MAX_REGION_DEPTH` (32)** is not judged: the parse walk stops there, and unlike a duplicate node id there is no second spec refusal behind it. For both, the engine's run-time refusal is the only one — unchanged by this change, and not fixed by it. - -⛔ No engine source is edited. What the refusal does to the run time is stated rather than left to be discovered: `AutomationEngine.registerFlow` and the ADR-0087 stored-row rehydration seam both go through `FlowSchema.parse` (`canonicalizeStoredFlow`), so a flow carrying a refused shape no longer registers or rehydrates — it is met at LOAD, not at the region boundary, and a stored row that carries one stops loading until it is rewritten. The engine's own run-time refusals for these shapes stay in place but are reachable only through the two boundaries above; for the `end` arm those are the only remaining path, because the refusal signal it answers is raised at exactly one site — an `end` node whose `outcome` is `refused`. - -**Published surface.** `FLOW_PAUSE_CAPABLE_NODE_TYPES` is published with the four types above. ⚠️ Read its contents, not its name: it is the UNCONDITIONALLY pausing set, not every type that can pause — `subflow` and `map` declare `supportsPause: true` and are deliberately absent, for the reason above. The identifier is unchanged, so this release removes no export. diff --git a/.changeset/15669-try-catch-error-value-widening.md b/.changeset/15669-try-catch-error-value-widening.md deleted file mode 100644 index e6128e21ad6..00000000000 --- a/.changeset/15669-try-catch-error-value-widening.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -`try_catch`'s catch-region binding is annotated as the plain declared type. `TryCatchErrorValueSchema` declares `code: z.string().optional()`, so the local `TryCatchErrorValue & { code?: string }` intersection in `builtin/try-catch-node.ts` added nothing the exported `TryCatchErrorValue` did not already carry, and the comment paragraph beside it explained a spec/engine divergence that no longer exists (#15669). - -**No behaviour change, and nothing executable moves.** The object literal is untouched: `nodeId`, `message`, `code` and `iteration` / `item` are bound under exactly the same conditions as before, so a catch region still branches on `{$error.code}` and still reads an absent `code` as "no classified code", never as "nothing failed". Measured on the built package: `index.js`, `index.cjs`, `index.d.ts` and `index.d.cts` are **byte-identical** before and after; only `index.js.map` / `index.cjs.map` shift (by one byte each), because the replacement comment is two lines longer and the sourcemap encodes line positions. - -The annotation was proven redundant before it was removed — `TryCatchErrorValue` and `TryCatchErrorValue & { code?: string }` are mutually assignable, and `TryCatchErrorValue['code']` is exactly `string | undefined` — and the binding it describes is genuinely pinned: dropping `code` from the literal reddens the two `#14419` discriminator tests in `builtin/create-record-duplicate-code.test.ts`. diff --git a/.changeset/15705-mcp-resume-run.md b/.changeset/15705-mcp-resume-run.md deleted file mode 100644 index c9fb13218e7..00000000000 --- a/.changeset/15705-mcp-resume-run.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/mcp': minor -'@objectstack/runtime': minor ---- - -feat(mcp): `resume_run` continues a flow run that paused on a screen, behind the same gates as `run_action` (#15705) - -Clause-②: yes - -**What changed.** `run_action` on a flow action whose flow stops on a `screen` node answers `status: "paused"` with a `runId` and the `screen` to fill in. Until now nothing on the MCP surface could submit that screen, so the run stayed parked: an agent could start such an action but never finish it. The new MCP tool `resume_run({ runId, values?, confirm? })` submits the screen's field values (keyed by the names in `screen.fields`) and the run continues. It answers with `run_action`'s envelope, `{ ok, action, objectName, recordId?, result }`. A run that pauses on its next screen comes back paused again, so a multi-screen wizard is walked by calling `resume_run` once per screen. - -**Which runs it continues, and no others.** The runtime's bridge admits a call only where `run_action` would admit starting the same flow on the same record for this caller now: - -- **Only the caller's own run.** The run's trigger identity must be the caller. Another user's run, an unknown id and a finished run all answer the same `404 RESOURCE_NOT_FOUND`. A resumed run continues under the identity of the user who started it, so only that user may continue it. -- **`run_action`'s gates, with `run_action`'s helpers.** A `type: 'flow'` action whose `target` is the run's flow, on the run's object, must be AI-exposed (`ai.exposed`), must pass the caller's `requiredPermissions` and must not be switched off (`ACTION_DISABLED`, `409`). An action flagged `ai.requiresConfirmation` needs `confirm: true` on the resume too (`ACTION_CONFIRMATION_REQUIRED`, `428`), because the flow's writes happen after the screen. The exposure and permission refusals answer `403 PERMISSION_DENIED`. So does a run that no flow action targets. -- **The subject record is read again as the caller.** A record the caller can no longer read is refused `404 RECORD_NOT_FOUND`, which is how `run_action` refuses it. -- **Screen pauses only.** A run waiting on anything else (a timer `wait`, an approval) is refused `409 RESOURCE_CONFLICT` and left as it is. - -Every refusal happens before the engine is asked, so the run stays parked. The engine's own answers (a screen submission missing a required field, a concurrent resume, a run that resumed and then failed) reach the caller with the code, status, message and `details` that `POST /api/v1/automation/:name/runs/:runId/resume` gives for the same result. The two doors now share one classification of the engine's answer. It was moved out of the REST route unchanged, and the route's answers are byte-identical. - -**For hosts.** `McpActionBridge` gains an OPTIONAL member, `resumeRun(runId, { values?, confirm? })`. A bridge that implements it gets `resume_run` beside `run_action`, under the same `actions:execute` OAuth scope, on both the HTTP and the stdio transport. A bridge without it is unchanged and does not list the tool. `run_action`'s description names `resume_run` only where it is registered. `@objectstack/runtime`'s MCP bridge implements the member. diff --git a/.changeset/15712-sharing-grants-refused-narrowing-prose.md b/.changeset/15712-sharing-grants-refused-narrowing-prose.md deleted file mode 100644 index 155ca414a43..00000000000 --- a/.changeset/15712-sharing-grants-refused-narrowing-prose.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/plugin-sharing': patch ---- - -docs(plugin-sharing): the `grantsRefused` subtype comment states the NARROWING, not a spec lag (#15712) - -Two comments in this package described a spec/plugin lag that #14969 ended. -`@objectstack/spec` now declares `grantsRefused?: number` on -`SharingRuleEvaluationResult` itself, so "the six declared fields are unchanged" -and "the contract lives in `@objectstack/spec` and is another lane's to move" -read as if the spec were still behind. A reader reconciling the two would -conclude the spec is missing a key it has. - -No code moves. `SharingRuleReconcilePassResult extends SharingRuleEvaluationResult -{ grantsRefused: number }` is a legal covariant narrowing before and after, and -that narrowing is now what the prose says: the spec declares the key OPTIONAL on -purpose — an `ISharingRuleService` implementation that does not count refusals -leaves it ABSENT, and absent is not `0` — while this implementation always counts -them and therefore requires it. The load-bearing paragraph is kept verbatim: -`grantsRefused > 0` is NOT "the pass failed", it is the pass reporting that it met -a record it cannot grant on and CONTINUED. - -What reaches a consumer: doc comments, and only through the published -`dist/index.d.ts` / `dist/index.d.mts`, where the JSDoc on the exported -`SharingRuleReconcilePassResult` ships (705,069 to 705,528 bytes). The -declaration-only projection of that file, comments stripped, is byte-identical -before and after — no exported symbol added or removed, no key changed — and the -JavaScript outputs (`dist/index.js`, `dist/index.mjs`) are untouched, because the -compiler strips comments from them. diff --git a/.changeset/15811-evaluated-expression-slots-source-required.md b/.changeset/15811-evaluated-expression-slots-source-required.md deleted file mode 100644 index bf656858d01..00000000000 --- a/.changeset/15811-evaluated-expression-slots-source-required.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/formula": minor ---- - -feat(spec)!: every engine-evaluated expression slot requires a non-blank `source` — the #15430 rule generalised from the flow-node ledger to the other 36 declaring positions (#15811, decision batch #122 item 2) - - - -**BREAKING** accept-set narrowing on 36 published metadata slots. Each of them -composed `ExpressionInputSchema` and now composes `EvaluatedExpressionInputSchema`, -so an envelope carrying only `ast` (`{ dialect: 'cel', ast: … }` with no `source`) -and a `source` that is blank after trimming — through the envelope key or through -the bare-string shorthand — are refused at the door instead of parsing and then -faulting at run time. The prescription is registered under protocol major 18 as -the semantic migration `evaluated-expression-slots-source-required`. - -**⚠️ Graded `minor`, not `major`, and the ruling said `major`.** Decision batch -#122 item 3 ordered a 「`major` changeset」. This repo's launch-window convention -ships breaking changes as `minor` while the fixed group versions in lockstep, and -`scripts/check-changeset-no-major.mjs` enforces it: a `major` marker here would -promote all ~70 packages to a whole-stack major release, which is a release act. -The convention's own written carriers for breaking-ness are used instead and both -are present — this **BREAKING** banner and the ADR-0087 disposition above. The -ruling's substance (a breaking narrowing, carried by an ADR-0087 semantic -migration entry) is delivered; only the marker differs, and it differs because a -repo gate forbids the marker. - -**What is NOT narrowed.** `ExpressionSchema` / `ExpressionInputSchema` remain the -persistence contract (`source` OR `ast`), by item 2 of the same ruling, and so -does `PredicateInputSchema`, which is a plain alias of the latter. A slot that -only PERSISTS an envelope is untouched; the narrowing is at the slots an engine -EVALUATES. An `ast` carried BESIDE a string `source` stays admitted everywhere. - -**The population was re-derived, not inherited.** By identity — a negative -lookaround on identifier characters, so `CronExpressionInputSchema` and -`TemplateExpressionInputSchema` cannot leak in as substrings — over -`packages/spec/src`, non-test: 34 declaring source lines, two of which are -file-local alias consts (`ui/action.zod.ts` `ActionConditionInputSchema`, -`system/settings-manifest.zod.ts` `SettingsVisibilityInputSchema`) that mount two -slots each, giving **36 declaring positions**. Three of them reach the schema as a -union member rather than head-of-declaration (`RecordAlertProps.visible`, -`ServiceLevelIndicator.successCriteria`, `TraceSamplingConfig.composite[].condition`). - -On **two of those three the sibling arm is untouched**: `RecordAlertProps.visible` -still takes a boolean literal, and `ServiceLevelIndicator.successCriteria` still -takes its structured `{ threshold, operator, percentile? }` object — including one -that happens to carry a `dialect` key. - -⚠️ **On the third, `TraceSamplingConfig.composite[].condition`, the sibling arm -narrows too, and deliberately.** Its structured-filter arm is a bare -`z.record(z.string(), z.unknown())`, which accepted `{ dialect: 'cel', ast }` as an -ordinary filter — so swapping the expression arm changed nothing at all there. That -arm now declines any object carrying a `dialect` key, and six shapes the base -accepted THROUGH THAT ARM ALONE (measured: the base's `ExpressionInputSchema` -refused every one of them) are refused at this slot: - -| authored `condition` | base | now | -|---|---|---| -| `{ dialect: 'cel' }` | accepted | refused | -| `{ dialect: 'js', source: 'x' }` | accepted | refused | -| `{ dialect: 'nope', source: 'x' }` | accepted | refused | -| `{ dialect: 'cel', source: 5 }` | accepted | refused | -| `{ dialect: 'cel', source: 'x', meta: { rationale: 5 } }` | accepted | refused | -| `{ dialect: 'zzz', foo: 1 }` | accepted | refused | - -FROM → TO at that slot: if the value really is a **structured filter**, drop the -`dialect` key (`{ dialect: 'cel', service: 'api' }` → `{ service: 'api' }`); if it is -an **expression**, give it a dialect this platform evaluates and a non-blank `source` -(`{ dialect: 'js', source: 'x' }` → `{ dialect: 'cel', source: 'x' }`). A structured -filter that carries no `dialect` key — `{}`, `{ service: 'api' }`, -`{ attributes: { 'http.route': '/v1/orders' } }` — is accepted exactly as before. - -**Why an authoring-time refusal and not a run-time one.** Measured at the -chokepoint, `celEngine.evaluate` never silently succeeds on either shape — it -returns a `parse` fault — so what happened next was decided entirely by the -slot's fail policy, and the two halves of that population fail in opposite -directions: fail-CLOSED slots (`ObjectFieldGroup.visibleWhen`, -`RowCrudActionOverride.visibleWhen`, `BulkActionDef.visible`, the two -settings-manifest `visible` slots) hid a group, a row button, or silently excluded -every selected record from a bulk run and reported them as *skipped*; fail-SOFT -slots left a gate that had stopped gating. Nothing in between said a word: the -authoring lint `validateVisibilityPredicates` measured 0 findings on an `ast`-only -envelope and 0 on a blank `source`, against two control legs that each measured 1. - -**`@objectstack/formula` gains `printCelAst(ast)`** — the inverse of -`parseCelToAst`, and the lossless half of the migration: an `ast`-only CEL -envelope is printed back to surface syntax mechanically, with no judgment asked of -the author. It is lossless about MEANING, not bytes (the printer re-renders from -the parse tree, so `'x'` comes back as `"x"`), and it answers `null` — never a -guess — for anything it cannot round-trip through the platform's own bounded -parser. That `null`, and every blank `source`, are what the semantic migration -entry's structured TODO covers. - -**The published TypeScript interface `RowCrudPredicates` narrows with it** -(`Expression | ExpressionInput` → `EvaluatedExpression | EvaluatedExpressionInput`), -because it mirrors the two `RowCrudActionOverride` slots and a type that still -promised an `ast`-only envelope would advertise what the schema now refuses. - -**So do the four expression constructors — `expression()`, `cel`, `tmpl`, `cron` -(and therefore the `F` / `P` aliases) — which now return `EvaluatedExpression` -instead of `Expression`.** Each one assigns a `string` to `source` -unconditionally, so the wider return type described none of them; it was slop -that cost nothing until an evaluated slot began requiring `source`, at which -point ``visibleWhen: P`…` `` — the spelling the spec's own docblock teaches — -stopped type-checking, and `@objectstack/platform-objects` failed its DTS build -on exactly that. `EvaluatedExpression` is assignable to `Expression`, so every -persistence-contract slot keeps accepting these values unchanged; what the -narrower return type adds is that an evaluated slot accepts them too. An author -who genuinely has no `source` was never calling these constructors — an -`ast`-only envelope is an object literal, and an evaluated slot refuses it on -purpose. diff --git a/.changeset/15858-rest-server-platform-url-spelling.md b/.changeset/15858-rest-server-platform-url-spelling.md deleted file mode 100644 index 7a47f2063a5..00000000000 --- a/.changeset/15858-rest-server-platform-url-spelling.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -docs(rest): the `'platform'` virtual-id docblock names the live `/environments/` URL family (#15858) - -`RestServer`'s `environmentId === 'platform'` docblock described the reserved virtual id as being addressed *"through the regular project URL shape (`/projects/platform/...`)"* — the spelling ADR-0006 v4's second addendum (D2, executed 2026-08-28) retired with **no alias and no grace period**. It now reads *"through the regular environment URL shape (`/environments/platform/...`)"*. - -**The prefix is corrected rather than the paragraph retired, because the shape is live.** The fork this card opened — *"if the shape is live the sentence needs its prefix corrected, and if it is not, the paragraph may want retiring"* — was decided by a cross-repo reading: the host enables environment scoping precisely so `/api/v1/environments/platform/...` resolves to the control-plane protocol, its kernel resolver returns no per-environment kernel for that id, and a live test drives `routePath: '/environments/platform/meta'`. Framework-side, `resolveProtocol` still short-circuits `environmentId === 'platform'` to the control-plane protocol. Every behavioural claim in the paragraph is true today; only the URL spelling and the phrase "the regular project URL shape" were not. - -What reaches a consumer of this package: the docblock ships inside `dist/index.d.ts` and `dist/index.d.cts` (and the bundles), so `projects/platform` no longer appears anywhere in the published artifact. **No behaviour moves** — comment-only, and the file is line-count neutral at 13,877 lines before and after. - -⚠️ Two things deliberately left alone, both measured rather than overlooked: - -- The sibling site that calls `/projects/:environmentId` **"the retired spelling"** is *correct* — it documents the repair that landed under #16538. Harmonising the two would make the right one wrong. -- The same paragraph's *"It is NOT a row in the projects **table**"* is about a table, not a URL. That is a different question — it turns on what the control-plane row is called today — and it is not guessed into this edit. diff --git a/.changeset/15932-plugin-security-scan-result-surface-retired.md b/.changeset/15932-plugin-security-scan-result-surface-retired.md deleted file mode 100644 index f3e068d3567..00000000000 --- a/.changeset/15932-plugin-security-scan-result-surface-retired.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: retire the plugin-security scan-result surface — zero consumers after the scanner retirement (#15932) - -**BREAKING** — the plugin-security scan-result family is removed. ADR-0049 -enforce-or-remove; maintainer ruling 2026-09-07 (director seat, decision batch -#65), adopted verbatim 「同意」. - -This is the second half of the scanner retirement — issue 14919, a number since -deleted from the board, landed as PR #15930. That change retired `PluginSecurityScanner`, -the `@objectstack/core` class that shipped as a security control and returned -`status: "passed"` for every plugin it was ever handed. The **schemas** it fed -survived it — and that scanner's type-only import was their only importer of any -kind, so the family went from one type-only importer to **zero consumers** while -staying fully published: 27 authorable rows on `authorable-surface/kernel.json`, -six `api-surface` exports, two authorable defaults and two json-schema manifest -keys, with no `.parse` or `.safeParse` site against either schema anywhere. An -author could write any of it, be accepted, and get nothing. That is the -declared-not-enforced shape, one layer out from the class removed for the same -reason. "Declare an owner to enforce" was refused by name: it would rebuild the -scanner just retired. - -### FROM → TO - -| removed | what to write instead | -| --- | --- | -| `KernelSecurityScanResult`, `KernelSecurityScanResultParsed`, `KernelSecurityScanResultSchema` (exports) | nothing — delete the import. No replacement type exists. | -| `KernelSecurityVulnerability`, `KernelSecurityVulnerabilityParsed`, `KernelSecurityVulnerabilitySchema` (exports) | nothing — delete the import. No replacement type exists. | -| `PluginSecurityManifest.scanResults` | delete the key | -| `PluginSecurityManifest.vulnerabilities` | delete the key | -| `PluginQualityMetrics.securityScan` | delete the key | - -**The one-line fix: delete the keys and every import of the two types.** Plugin -security scanning is not a platform capability and there is no replacement -schema. What the platform does still enforce is unchanged: `permissions` and -`sandbox` on the same `PluginSecurityManifest`, and artifact provenance through -`verifyPluginArtifactIntegrity` and the plugin signature verifier — which tell -you an artifact is the one its publisher signed, and never that it is safe. For -dependency vulnerabilities use the tools built for it against your own project -(`npm audit` / `pnpm audit`, Dependabot, the GitHub Advisory Database, OSV), and -treat an unaudited third-party plugin as untrusted code. A publisher who used -`scanResults` to advertise diligence keeps the surviving `securityContact` and -`vulnerabilityDisclosure` blocks, which are contact terms rather than a verdict. - -⚠️ Runtime behaviour is deliberately **unchanged**. Nothing ever read any of -these keys, so deleting one removes no check that was running. A consumer that -gated on `securityScan.passed === true` was gating on nothing — the remediation -is to audit with a real tool, not to find a replacement key. - -### The retirement kit - -- The two **defs** leave the build whole — `RETIRED_DEFS_BY_MAJOR[18]` - (`kernel/KernelSecurityScanResult`, `kernel/KernelSecurityVulnerability`) — - because nothing parses them, so there is no author a tombstone could reach. -- The three **authorable keys** are `retiredKey()` tombstones registered in - `RETIRED_KEYS_BY_MAJOR[18]`. Neither carrying shape is `.strict()`, so a bare - deletion would strip an authored key in silence (ADR-0104): the tombstone is - audible in both channels — `tsc` (input type `never`) and the parse, which - raises the prescription itself. -- **No D2 conversion.** A plugin security manifest and a plugin registry entry - are package artifacts a publisher ships, never stack collection members and - never stored `sys_metadata` rows, so the chain has no seam that would see one - — the disposition the sibling `kernel-plugin-security-durations-unit-in-key` - entry already records for this same manifest. The D3 semantic entry - `plugin-security-scan-result-surface-retired` carries the judgement. -- `PluginSecurityManifest.vulnerabilities` is a **forced consequence**, not one - of the four names the ruling listed: it was the last authorable referent of - `KernelSecurityVulnerability` and could not outlive the def. -- **No deprecation window** (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」). - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` -is published, so this is breaking for consumers no download, dependent or source -telemetry was consulted for — exactly as that retirement's own changeset says of its -three exports. That was an input to the ruling, not a reason to soften the removal. - -⛔ **Untouched, and not checked:** the marketplace `'scanning'` status -(`marketplace.zod.ts`). The ruling made it conditional on a producer grep of -`objectstack-ai/cloud`, and that repository was not reachable from the session -that executed this card, so it stays exactly as it is and its absence from this -diff is not evidence about it. - -⚠️ **The two members the ruling paired with it were ALREADY GONE** — measured on -this tree, not assumed. The incident `'malware'` type was a member of -`system/IncidentCategory`, and the whole incident-response family was retired by -#15513 (maintainer ruling 2026-09-05 — two days *before* the 2026-09-07 ruling -that made it conditional). `marketplace-admin.zod.ts` was deleted outright with -the cloud subpath (#16526). Both files return zero tree entries here, against a -lit control where `'scanning'` still returns a live declaration. So the -conditional question is **one** enum member wide, not three. - -`Clause-②: yes (narrowing)` — a published surface is removed: six exports leave -the built `.d.ts` and three authorable keys stop being writable, so the accept -set a consumer writes against narrows. Nothing is widened and nothing is -renamed. Contract-review tier. - - diff --git a/.changeset/15937-confirmed-blueprint-identity-protocol.md b/.changeset/15937-confirmed-blueprint-identity-protocol.md deleted file mode 100644 index b429de1d117..00000000000 --- a/.changeset/15937-confirmed-blueprint-identity-protocol.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`ToolExecutionContext.confirmedBlueprintIdentity` — the consent digest a route-owning layer stamps on a confirm replay — is now declared in the protocol instead of in one consumer's augmented type (#15937). - -Clause-②: yes (widening) — one new OPTIONAL member on a published interface, so the shape a consumer writes against grows. Nothing previously admitted is refused, no member is renamed or retired, and no producer is required to write it. Contract-review tier. - -`packages/spec/src/contracts/ai-service.ts` declares the tool-execution context a tool handler may rely on. A published handler in `objectstack-ai/cloud` — the `apply_blueprint` authorization gate — already makes a matching blueprint-identity digest one clause of the decision to build a whole app (cloud#1954 / cloud PR #2005), but the member it reads was declared only on cloud's own augmented `ToolExecutionContext` and reached by a structural cast. The protocol is this project's baseline, so a field a handler authorizes on is declared here. - -- **The member is optional and fail-closed.** `undefined` means "no confirmed identity on this turn" and authorizes nothing — the same reading `actor` and `isSystem` already carry (#2991): absence is never a grant. The docblock states it, and the type enforces the handler-side half of it, because a read of `string | undefined` does not compile into a path that assumes a confirmation. -- **Provenance is part of the declaration**, in the shape `userMessageText` already carries: populated by whichever layer owns the agent route (cloud, post-cloud ADR-0025), only ever by in-process server code on that route, and never derived from a request body, a tool argument or the transcript. -- **Nothing in this repository reads it yet**, and nothing here changes behaviour: this is the declaration half. Deleting cloud's augmentation and replacing its cast with the typed read is a cloud follow-up, blocked on this field being published and pinned. -- **The contract is now asserted.** `confirmed-blueprint-identity-contract.pin.test.ts` pins that the member lives on `ToolExecutionContext`, reaches a handler through `ChatWithToolsOptions.toolExecutionContext`, stays optional, and is typed `string` — each negative leg paired with a positive one on the same helper, so a leg that stops detecting anything turns the test-layer type-check red rather than passing quietly. diff --git a/.changeset/15939-duration-unit-keys-jsdoc-divergence.md b/.changeset/15939-duration-unit-keys-jsdoc-divergence.md deleted file mode 100644 index 27dd0bf97a9..00000000000 --- a/.changeset/15939-duration-unit-keys-jsdoc-divergence.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`check:duration-unit-keys` refuses a duration key whose JSDoc names a unit its describe does not - -The gate read a key's unit from `.describe()` and `.meta({ description })` only. -A duration-shaped `z.number()` whose unit was written solely in the JSDoc block -above it appeared in `--list` as a census row with `[prose: -]` and was never -judged — and its own self-test pins *"a describe declared through -`.meta({ description })` is READ — no exemption by blindness"*, which made the -JSDoc blindness read as deliberate, measured coverage. - -**Ruled 2026-09-07 (decision batch #65).** JSDoc is developer commentary, not -governed prose: `.describe()` is what `content/docs/references/**` renders and -what rides into the published dist, and the JSDoc stops at the source file. So -the gate does **not** start reading JSDoc as a unit channel — a unit written -only there still has not satisfied the rule. What it now refuses is the -DIVERGENCE: the JSDoc names a unit and the describe names none (or there is no -describe at all), so the two channels disagree about whether this number's unit -is written anywhere a reader can reach, and the channel that is silent is the -published one. New rule `unit-in-jsdoc-not-in-describe`; the remedy is to move -the unit into the describe, where the existing rule then puts it in the key -name. - -⛔ **The JSDoc is read in exactly one direction: to refuse, never to satisfy.** -A duration-shaped key with no unit in *either* channel is still listed and -still not judged (the #14519 shape, unmoved). The new branch tests for a unit -PRESENT in the JSDoc; it never tests for one absent from the describe, which is -what would have made it the option the ruling declined. - -**The population this rule adds was remediated before the rule landed.** When -the gate was written it found **21** offenders. Ruling A on #15939 sequenced -those out of this change and into seven per-file cards (#17780–#17786), all -merged: eighteen were renames of published keys, each carrying its own ADR-0087 -conversion and `retiredKey()` tombstone, and the other three needed only their -describe corrected. On this tree the gate reads **zero offenders** among **211** -duration-shaped numeric keys across **2482** source files (6 declared `EpochMs` -instants, 11 declared `externalVocabulary` mirrors). ⛔ **No offender was -exempted to reach that zero** — there is no baseline in this gate by ruling, and -none was added. - -**One wrongly-recorded reason repaired, comment-only.** The blindness did not -merely miss keys, it produced confident wrong prose about why they were missed: -the retired-key entry for `SandboxConfig:process.timeout` said the neighbouring -`RuntimeConfig.resourceLimits.timeout` was "outside the gate's population", when -that key was inside the census and merely never judged — its unit lived in a -source JSDoc only. That note now records the true reason, and points at the -neighbour's own entry rather than describing a landed rename as pending. -`registry.ts` regenerated to mirror it. The same wrong reason in the -`metrics.test.ts` burn-rate pin was corrected by #17783 when it renamed that -key, so nothing is owed there. - -⛔ No published key, accept set, default or runtime behaviour moves. diff --git a/.changeset/15970-recall-resume-failure.md b/.changeset/15970-recall-resume-failure.md deleted file mode 100644 index c3e9b48838b..00000000000 --- a/.changeset/15970-recall-resume-failure.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -An approval `recall()` whose resume strands the run now tells the caller WHICH failure it was, in fields — `resumeFailure: { code, runId, status, repairable }` beside the prose `resumeError` — instead of one sentence a caller has to parse (#15970; the #16472 family ruling, decision batch #76, option A). - -**The shape.** A flow parks at an `approval` node; the reject branch's downstream node throws. The submitter recalls the request, which resumes the run down the `reject` edge — and that resume strands it. The withdrawal is durable and the call correctly does not throw, but the engine's own discriminator never reached the caller: `recall` resumes DIRECTLY rather than through `resumeRecordedOutcome`, and its `catch` kept `err.message` alone, discarding the `resumeStatus` (`AutomationResult.status: 'stranded'`) the error already carried one line before the result was built. `repairable` had a producer and, on this door, no consumer. - -``` -FROM service.recall(requestId, { actorId }, ctx) - -> { request: { status: 'recalled' }, runId, resumed: false, - resumeError: "resume of run '' failed: " } - // prose only — nothing says the run is still repairable - -TO service.recall(requestId, { actorId }, ctx) - -> { request: { status: 'recalled' }, runId, resumed: false, - resumeError: "resume of run '' failed: ", - resumeFailure: { code: 'RESUME_FAILED', runId: '', - status: 'stranded', repairable: true } } -``` - -**⛔ The no-throw stays, and that is the ruling's point.** The withdrawal and the record-lock release are the product of this call and they have already happened when the resume fails; making `recall` fail would be the wrong fix, not a stricter one. The door's `error` log line is untouched too, at the same level with the same context keys — the ruling left logging alone, and the report is a sibling of that line, not a replacement for it. - -**Two exits report, and the rest deliberately do not.** A report is stamped exactly where the engine's own verdict says `'stranded'`: this door's own resume stranding, and (the sibling half of #15556, whose producer landed one door over) a resume that SUCCEEDED while the subflow parent above it stranded — which answers `resumed: true` with the PARENT's `runId` on `resumeFailure`, exactly as `ApprovalRecallResult.resumed`'s docblock already declared. Every other exit answers as it always did, with no `resumeFailure` at all: a lost run's honest code is `RESUME_TARGET_LOST` and the tolerated duplicate's is `RESUME_IN_PROGRESS`, and this package's ADR-0112 ledger row admits exactly one code, so stamping `RESUME_FAILED` there would make the discriminator lie about which failure it was — the defect this fixes, one field over. Per the member's own docblock, an absent `resumeFailure` means no report was made, never that no run is stranded. - -**Additive only — no migration, and `patch` rather than `minor`.** `ApprovalRecallResult.resumeFailure` was already declared, exported and type-pinned in `@objectstack/spec` ahead of this card (`contracts/approval-service.ts`, `resume-failure-report.pin.test.ts`); this fix is the first producer that fills it. The delivered diff adds no exported symbol to `@objectstack/plugin-approvals` — nothing new is reachable from its published entry — and adds no key to a payload that did not already declare one. Nothing existing changes shape: a consumer that ignores unknown fields sees no difference, and one that reads `resumeFailure` can now branch on `repairable` and call `restoreConsumedSuspension` on the run the report names. diff --git a/.changeset/15989-file-family-column-step.md b/.changeset/15989-file-family-column-step.md deleted file mode 100644 index 22a24c07144..00000000000 --- a/.changeset/15989-file-family-column-step.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -"@objectstack/driver-sql": minor -"@objectstack/objectql": minor -"@objectstack/platform-objects": minor -"@objectstack/spec": minor -"@objectstack/cli": minor ---- - -feat(driver-sql,objectql,cli)!: the ADR-0104 file-family column step, and the kernel→driver supply that arms it (#15989) - - - -**BREAKING** on the published storage behaviour of `@objectstack/driver-sql`. A deployment that runs `os migrate files-to-references --apply` now has its media columns **retyped and their values rewritten** into the bare-`sys_file`-id encoding, and its driver writes bare ids from the next boot. This completes the maintainer ruling on #15041 (「15041 应该改为实际 id 保存。选A,其他同意」) whose encoding half shipped in the previous release. - -Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. - -## The column step - -`os migrate files-to-references --apply` gains a further step, run **only after** the backfill and its self-check report zero blocking rows — and it moves nothing at all until three gates pass: - -1. the migration's own gate (zero blocking rows); -2. **every** abort pre-check, across **every** planned column, before a single statement runs; -3. no refusals — a column the driver could not plan stops the columns it could. - -**PostgreSQL** and **SQLite** only. ⛔ MySQL is refused by name and belongs to #17788, where its statement ORDER is settled against a real instance rather than transcribed. - -Per column, the shape is read off the column's **physical type**, not off the dialect: a `json` column is retyped (`ALTER … TYPE varchar(2048) USING (col #>> '{}')`), while a column that is already `varchar` — the population `os generate migration --format sql` creates and a JSON-arm driver fills with quoted ids — has its values unquoted in place. SQLite has only the second shape, since it has no json type. - -### ⛔ The abort clause is NOT the one the ADR sketched - -The #15041 addendum prescribed the retype with nothing in front of it while *requiring* the step to abort "on the first cell that is not a JSON string". Those two sentences contradict each other, and which was wrong was settled by running it. Measured on live PostgreSQL 16.13, `USING (col #>> '{}')` is **accepted** over a row holding an inline metadata blob, because `#>> '{}'` extracts *any* json type as text: the bytes survive, but the column is no longer `json`, so an object becomes a plain string in a column whose declared contents are ids — silently, in a migration that reports success. The director ruling (decision batch #120 item 1) replaced the clause with the pre-check that implements the requirement: `json_typeof(col) IS DISTINCT FROM 'string'` on PostgreSQL, and `json_valid(col) AND json_type(col) <> 'text'` on SQLite, where excluding invalid JSON is what keeps a re-run idempotent over cells a previous run already moved. - -Both the destructive form and the guarded one are executed side by side, on one fixture, in this release's own test suite — so the difference stays a measurement rather than a comment. - -## The kernel→driver supply seam - -`SqlDriverConfig.fileColumnsMoved` shipped last release and no host outside the driver supplied it. It is supplied now: `ObjectQL.registerDriver` hands every driver that has the seam a closure over the new `ObjectQL.haveFileColumnsMoved()`, which reads `sys_migration.columns_moved_at` — and requires the `adr-0104-file-references` flag to be verified **as well**, since the stamp alone would attest a column move with nothing attesting the values inside it. - -⭐ **Every way of not knowing still answers "not moved".** The option omitted, a resolver that throws or rejects or answers a non-`true` value, a resolver that never runs because the host never calls `initObjects`, a driver with no such seam, no `sys_migration` object, no row, an unreadable table, a null or empty stamp — all the JSON arm. That is the encoding every deployment in the world is on, and a driver that guessed the other way would write bare ids into a JSON column. - -⛔ **A host that names `fileColumnsMoved` in its own config wins**, in either polarity. The engine only ever fills an empty slot, and never contradicts an explicit composition: overruling a declared `false` is precisely the bare-ids-into-a-JSON-column failure this mechanism exists to prevent. - -## New published surface - -- `@objectstack/spec` — `hasMovedFileColumns(flag)`, the single arbiter of the conjunction above, beside `isDataMigrationFlagVerified` and `authorisesIrreversibleAction`. -- `@objectstack/objectql` — `ObjectQL.haveFileColumnsMoved()`, sharing one memoized read (and one `invalidateDataMigrationFlags()`) with `isFileReferencesMigrationVerified()`, so the two answers can never come out of one another's date. -- `@objectstack/platform-objects` — `recordFileColumnMove(engine, migrationId)`, which refuses to stamp a deployment with no verified flag row. `readDataMigrationFlag` now carries `columns_moved_at`; it previously dropped it, which made a moved deployment indistinguishable from an unmoved one to every caller. -- `@objectstack/driver-sql` — `SqlDriver.setFileColumnsMovedResolver()`, `SqlDriver.planMediaColumnMove()`, and the statement builders `mediaColumnMovePlan` / `mediaColumnMoveDialect` / `isJsonColumnType` with `MEDIA_COLUMN_MOVE_DIALECTS`, `MEDIA_COLUMN_MOVE_ROLLBACK_NOTES` and `MEDIA_ID_MOVE_WIDTH`. The statements live in the package that owns the dialects and measured them; a second copy in the CLI would be a second copy of the clause the ruling got wrong. - -## What does NOT change - -A deployment that does not run `--apply` is byte-for-byte where it was: the column stays `json`, the write still JSON-encodes, and the read still accepts both encodings. A backfill re-run does not set the stamp and — deliberately — cannot clear it either: `recordDataMigrationRun` omits the key rather than writing a preserved value, so a ledger read that FAILS cannot demote a moved deployment back onto the JSON arm. A partial or failed column step records nothing at all, which leaves such a datastore on the arm that reads both encodings. - -`multiple: true` media is untouched on both arms: its value is a list of ids and a JSON column on every deployment. diff --git a/.changeset/16059-startup-orchestrator-shipped-shape.md b/.changeset/16059-startup-orchestrator-shipped-shape.md deleted file mode 100644 index ddc60c360ee..00000000000 --- a/.changeset/16059-startup-orchestrator-shipped-shape.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor ---- - -feat(spec,core)!: the startup contract describes what the kernel produces — the orchestrator vocabulary is retired and `PluginStartupResult` is declared once (#16059) - - - -**BREAKING** — a published exported surface is removed, landing in the launch window as -`minor` (the lockstep convention: `major` is refused by `check-changeset-no-major`, and -breaking-ness is carried by this banner plus the ADR-0087 disposition above). - -`@objectstack/spec` declared a plugin startup ORCHESTRATOR that was never built, and its -one shape that *is* real had drifted away from the kernel that produces it. The maintainer -ruling on this card keeps a startup-result contract, and makes it describe what the kernel -actually returns. - -## What is removed - -`IStartupOrchestrator` (`orchestrateStartup` / `rollback` / `checkHealth` / -`startWithTimeout`) and the three schemas it tied together. Nothing in any repository -implemented the interface and nothing parsed the schemas; `healthCheck` and `HealthStatus` -named a per-plugin startup health probe the runtime has never had. - -| removed | from | what to write instead | -|:--|:--|:--| -| `IStartupOrchestrator` | `@objectstack/spec/contracts` | nothing — plugin startup is the kernel's own boot loop | -| `StartupOptionsSchema` / `StartupOptions` / `StartupOptionsParsed` | `@objectstack/spec/kernel`, `/contracts` | `startupTimeout` on the plugin; `rollbackOnFailure` on the kernel config | -| `StartupOptions.healthCheck` | (with the schema) | **no replacement** — no startup probe system exists | -| `HealthStatusSchema` / `HealthStatus` | `@objectstack/spec/kernel`, `/contracts` | **no replacement** — see above | -| `StartupOrchestrationResultSchema` / `StartupOrchestrationResult` | `@objectstack/spec/kernel` | `ObjectKernel.getPluginStartupDurations()` | - -`StartupOptions.parallel` and `StartupOptions.context` have no replacement either: the -kernel starts plugins sequentially and passes its own `PluginContext`. - -## What survives, re-declared - -`PluginStartupResultSchema` / `PluginStartupResult` stay on both entries, rewritten to the -shape `@objectstack/core` has always returned from `ObjectKernel.startPluginWithTimeout()`. -`@objectstack/core` now **imports** that type instead of declaring a twin, so the two -cannot drift again. - -| member | before (spec) | after (spec and core, one declaration) | -|:--|:--|:--| -| `plugin: { name, version? }` | required | **removed** — write `pluginName: string` | -| `pluginName` | absent | `string`, required | -| `success` | `boolean`, required | unchanged | -| `durationMs` | `number`, **required** | `number`, **optional** (absent when the plugin declares no `start()`) | -| `startTime` | absent (it was core's own deprecated alias) | **removed** — read `durationMs`, which always carried the same value | -| `error` | serializable projection | unchanged (a thrown `Error` satisfies it) | -| `timedOut` | absent | `boolean`, optional — set when the failure was the timeout | -| `health: HealthStatus` | optional | **removed** — no probe ever filled it | - -**The one-line fix:** rename `plugin: { name }` to `pluginName`, delete `health`, and read -`durationMs` wherever you read `startTime`. All three old spellings are `retiredKey()` -tombstones on the surviving schema, so each is a `tsc` error at the construction site and a -parse error carrying the prescription. - -`startTime` is the one member whose removal a reader can OBSERVE: `@objectstack/core` -populated it beside `durationMs` with the identical elapsed value, under its own ADR-0087 -L1 deprecation, and `ObjectKernel.startPluginWithTimeout()` stops setting it here. Mirroring -it on the contract was the alternative and the tree refuses it — `check:duration-unit-keys` -(ruling B on #14478) fails an elapsed number whose key name carries no unit, and neither of -that rule's two schema-declared exemptions fits: it is not an `EpochMs` instant and it -mirrors no external standard. Renaming it to `startTimeMs` would mint a spelling nothing has -ever produced, for a member already documented as slated for removal. - -For `@objectstack/core` consumers the members are unchanged; the one narrowing is that -`PluginStartupResult.error` is now typed as the serializable projection -(`name` / `message` / `stack?` / `code?`) rather than `Error`. The kernel still puts the -thrown instance there, so `result.error instanceof Error` still narrows — only code that -reads an `Error`-only member such as `cause` off it without that guard needs the guard. - -## The retirement kit - -Route 3 of the `spec-property-retirement` playbook: no authored document carried any of -the three defs, so there is no seam for a D2 conversion and no author to hand a tombstone -to. `RETIRED_DEFS_BY_MAJOR[18]` (`kernel/StartupOptions`, `kernel/HealthStatus`, -`kernel/StartupOrchestrationResult`) plus the D3 semantic entry -`startup-orchestrator-retired` **are** the declaration, and the three -`json-schema.manifest/kernel.json` keys plus their 16 `authorable-surface/kernel.json` -lines are deleted deliberately in this same change. The two keys of the SURVIVING result -schema (`plugin`, `health`) take the tombstone route instead, registered in -`RETIRED_KEYS_BY_MAJOR[18]`, because that def keeps emitting and its type is imported by -`@objectstack/core`. - -Runtime behaviour is deliberately unchanged: nothing ever read the retired surfaces, and -the kernel boot loop is untouched. diff --git a/.changeset/16066-query-transport-dialect-declared.md b/.changeset/16066-query-transport-dialect-declared.md deleted file mode 100644 index 6413f950fe5..00000000000 --- a/.changeset/16066-query-transport-dialect-declared.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-protocol": patch ---- - -The query TRANSPORT dialect is declared — keys AND values — and the `findData` fold now derives from that one declaration. - -`FindDataRequestSchema.query` declared `QuerySchema` — the canonical QueryAST — while the shipped `findData` door also accepted a second spelling of the same query through the same slot: `$filter` / `$top` / `$skip` / `$orderby` / `$select` / `$expand` and the plural `filters`. `@objectstack/metadata-protocol` folded them from a module-private table whose own comment called them "the wire-only spellings no schema declares". Two dialects, one slot, one of them declared — so every caller speaking the second was unverifiable at build time and unrejected at runtime. - -**New in `@objectstack/spec/data`** (9 exports, 0 removed): - -- `QueryTransportParamsSchema` / `QueryTransportParams` / `QueryTransportParamsParsed` — the transport parameters, each carrying the value of the canonical slot it folds onto. -- `QUERY_TRANSPORT_ALIAS_SLOTS` — `RPC_QUERY_ALIAS_SLOTS` extended with the transport-only spellings (`filters` / `$filter` onto `where`, `$expand` onto `expand`). -- `QUERY_TRANSPORT_DOLLAR_ALIASES` — the `$`-to-bare pairs that fold in two hops (`$top` onto `top` onto `limit`). -- `QUERY_TRANSPORT_DOLLAR_PARAMS` — the `$` spellings a boundary quotes when it refuses an undeclared one. -- `QueryWithTransportSchema` / `QueryWithTransport` / `QueryWithTransportParsed` — the query slot whose declared input is the AST or its transport spelling and whose parsed output is the AST plus the `count` flag. - -**`FindDataRequestSchema.query` is that slot now.** Its `z.input` admits the canonical AST, the transport spelling, or a bag carrying both. Its `z.output` is `QueryAST & { count?: boolean }` — the canonical AST, plus the response total-count flag, which rides inside this slot on the wire and is read off it by `findData` rather than passed to the engine. The output is CONSTRUCTED: the fold's result is parsed by the AST schema and that parse's result is what leaves the transform, so a transport key or a non-AST value cannot reach a consumer. The transport form is the FLATTENED SPELLING of the canonical AST with a 1:1 alias table — never a second semantics — so `QuerySchema` itself is untouched and still drops a `$` key as unknown. - -**One semantics means one set of VALUES, not only one set of keys, and that is what this declaration now enforces.** Every spelling of a slot accepts the same value shapes; each is lowered to the canonical member's declared shape, or refused. What lowers: a stringly-typed `$top` / `$skip` (`'50'` becomes `50`), a comma list on `$select` / `$searchFields` / `$expand`, a `{field: direction}` sort record, a relation-name list on `populate`, `'true'` / `'false'` on `$count`, and the input-only `FilterArray` sugar (`['status', '=', 'open']`) on every spelling of the filter slot — `where` included — lowered through `parseFilterAST`, the one declared sink (#5158 ruling C; `QuerySchema.where` still refuses the array). - -**What is REFUSED at the parse**, because lowering it would mean parsing the spec must not do, and because emitting it would put a value under the AST type that the AST does not declare: - -- a non-numeric `$top` / `$skip` (`$top: 'abc'`, `$top: ''`) — `400` instead of an engine call with `limit: null`, i.e. an UNBOUNDED read under a `200`, or `limit: 0`; -- a JSON-encoded `$filter` string (`'{"status":"open"}'`); -- an OData sort EXPRESSION on `$orderby` / `sort` (`'name desc'`, `'-created_at'`, `['name']`) — the record and `SortNode[]` forms are unaffected; -- a filter array no lowering can express, such as the INFIX join `[condA, 'and', condB]` — the prefix form `['and', condA, condB]` is the one the platform reads, and the engine already answered `400` for the infix one; -- a `$count` that is neither the boolean nor `'true'` / `'false'`; -- two spellings of one slot carrying different values — reported at the canonical path, quoting the spelling the caller actually wrote (`$orderby`, not `orderBy`). - -These refusals narrow no DECLARED surface: none of these value shapes was ever declared — `FindDataRequestSchema.query` was `QuerySchema`, which STRIPPED every one of these keys rather than declaring it. - -**Five of them were nonetheless SERVED, and now answer `400 VALIDATION_FAILED` at the ingress.** The route forwards the ORIGINAL body, not the parse output, so a key the old schema stripped still reached the door, which read it and answered `200`. A `POST /data/:object/query` body written one of these five ways stops working; each has a declared spelling that means the same thing: - -| body that now answers `400` | what the door served it as | write instead | -|---|---|---| -| `{ $orderby: 'name desc' }` | `orderBy: [{ field: 'name', order: 'desc' }]` | `{ $orderby: { name: 'desc' } }` — or `{ orderBy: [{ field: 'name', order: 'desc' }] }` | -| `{ sort: '-created_at' }` | `orderBy: [{ field: 'created_at', order: 'desc' }]` | `{ sort: { created_at: 'desc' } }` — or `{ sort: [{ field: 'created_at', order: 'desc' }] }` | -| `{ $orderby: ['name'] }` | `orderBy: [{ field: 'name', order: 'asc' }]` | `{ $orderby: { name: 'asc' } }` — or the `SortNode[]` form | -| `{ $filter: '{"status":"open"}' }` | `where: { status: 'open' }` | `{ $filter: { status: 'open' } }` | -| that same JSON string on `filters` or `filter` | `where: { status: 'open' }` | the object form on whichever of the two keys you write | - -**`GET /data/:object` still serves every one of those shapes.** The querystring path does not parse through this schema at all — `FindDataRequestSchema` is parsed at exactly one call site, the POST handler — so `?$orderby=name desc`, `?sort=-created_at` and `?$filter={"status":"open"}` answer exactly as before. What narrowed is the POST body alone — the platform has not stopped accepting these spellings everywhere. - -The remaining refusals in the list narrow nothing that was served correctly; they move an unservable body's refusal earlier — from the engine, or from a wrong answer under a `200`, to the ingress that can name the parameter to fix. - -**`@objectstack/metadata-protocol` folds by the spec export** instead of its own table, and both resolved tables — plus the `$`-parameter list its `UNSUPPORTED_QUERY_PARAM` refusal quotes — are pinned byte-equal to their pre-change values. An undeclared `$` spelling is still refused loudly with the same `400 UNSUPPORTED_QUERY_PARAM`; the sentence now quotes `QUERY_TRANSPORT_DOLLAR_PARAMS` rather than a hand-copied list, so a spelling added to the table cannot leave the refusal naming a set the door no longer has. - -Measured and unchanged: `getData` takes `select` / `expand` directly and carries no `query` slot, and `updateManyData` / `deleteManyData` take `records[]` / `ids[]` — none of the three has a transport-dialect split to declare. - -Clause-②: yes (widening) diff --git a/.changeset/16075-merge-config-object-refused.md b/.changeset/16075-merge-config-object-refused.md deleted file mode 100644 index 11e9fafc1a8..00000000000 --- a/.changeset/16075-merge-config-object-refused.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: `composeStacks` `objectConflict: 'merge'` refuses a fixed-shape config object both objects declare with different values (#16075) - - - -**BREAKING** accept-set narrowing on `composeStacks({ objectConflict: 'merge' })` -— shipped as `minor` under the repo's launch-window convention for breaking -changes. Maintainer ruling on #16075 (ruling record 5563452716, director -decision batch #61, option 1, verbatim 「同意」): the #14848 refusal extends to -fixed-shape config objects. - -**What changed.** #14848 made `'merge'` refuse every object-level -**collection** two stacks declare differently, and left everything else on -later-wins. "Everything else" included eight **fixed-shape config objects** on -`ObjectSchema` — `userActions`, `external`, `tenancy`, `access`, `lifecycle`, -`enable`, `publicSharing`, `protection`. Measured on `main` @ `44ce049a8` -before this change, each of the eight composed to the LATER object's -declaration wholesale, with nothing said: `enable: { trackHistory: true }` -beside `enable: { apiEnabled: true }` lost `trackHistory`, and an add-on -package's `access: { default: 'public' }` switched a core package's -`access: { default: 'private' }` off — the posture downgrade `composeStacks` -already refuses at the top level for `api` / `server`. - -Now, when both objects declare one of them with different values, -`composeStacks` throws the refusal it throws for a collection — same code -(`STACK_COMPOSE_COLLECTION_CONFLICT`), same `status: 422`, same three-line -shape — naming the object, the key and both stacks by manifest id: - -``` -composeStacks conflict: object 'shared' is defined in multiple stacks and its 'access' is declared with different values by 'com.example.a' (stack #0) and 'com.example.b' (stack #1). -objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) is not merged, and neither is a fixed-shape config object (userActions, external, tenancy, access, lifecycle, enable, publicSharing, protection): the later declaration would replace the earlier one wholesale, silently dropping every member 'com.example.a' (stack #0) set. -Fix: declare 'access' on 'shared' in exactly one of the two stacks, make the two declarations identical, or use { objectConflict: 'override' } to hand the whole object to the later stack. -``` - -The config-object half of the refusal set is **derived from `ObjectSchema`'s -shape**, like the collection half — every key whose declared type, through -optional/default wrappers, a `lazy` or a `pipe`'s authored side, is a plain -object and not a collection — so a config object added to the object schema -joins the refusal without an edit to the composer. The collection refusal's -message now lists both kinds; its first and last lines are unchanged. - -**What did not change.** - -- `fields` keeps its documented shallow merge (later fields win, earlier - fields kept). -- **Identical** declarations on both sides pass through and are carried once - — the reading `'merge'` already gives an identical collection. Because the - strict parse fills a config object's member defaults, "identical" is judged - on the parsed objects: `enable: { apiEnabled: true }` and - `enable: { apiEnabled: true, trackHistory: false }` are the same declaration. -- A config object only the earlier object declares is kept; a later object - that does not declare it (or declares it `undefined`) leaves it in place. -- A **scalar** the later object declares (`label`, `sharingModel`, …) still - replaces the earlier one. So does a key whose type is a **union** admitting - an object beside a non-object form — `systemFields` (`false` or an options - object) and `titleFormat` (a template string or an expression object): a - union is not a fixed shape, and the ruling covers the fixed-shape keys only. -- The default `'error'` and `'override'` are untouched, message for message. - -**Who is affected.** Measured on `origin/main` @ `44ce049a8`: **zero** -non-test call sites in `packages/**`, `examples/**`, `apps/**` pass -`objectConflict` at all — the one non-test `composeStacks` call -(`examples/app-multi-package`) passes `{ manifest: 'preserve' }` and takes the -default `'error'`. An external author who opted into `'merge'` and relied on -the later package's config object winning silently now gets the refusal above; -the fix is the one it names. - -Clause-②: yes diff --git a/.changeset/16160-retire-system-write-organization-provenance-waiver.md b/.changeset/16160-retire-system-write-organization-provenance-waiver.md deleted file mode 100644 index a53edf41614..00000000000 --- a/.changeset/16160-retire-system-write-organization-provenance-waiver.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/plugin-sharing': patch ---- - -`plugin-sharing` recognises the engine's organization refusal through objectql's own published recognizer instead of a locally re-spelled literal, and the `PROVENANCE_WAIVERS` row that excused that local spelling is retired with it (#16160). - -Clause-②: no - -The waiver carried its own expiry in its `reason`: *removed together with the stamp site when objectql publishes a recognizer*. It does, so both halves land here — `check:error-code-provenance` reconciles a waiver in three directions at once (the `registeredUnder` key still lists the code, the waived package still does not, and the scan still finds a site for the pair), so removing either half alone reddens the gate on the other. - -- **`ENGINE_ORGANIZATION_REFUSAL_CODE` is gone.** It was a `constdef` stamp site in `plugin-sharing/src/sharing-rule-service.ts` for a code this package only ever RECOGNISES — `@objectstack/objectql` is the emitter and already carries the row. The per-grant catch now asks `isSystemWriteOrganizationRequiredError(err)`, and the `warn` that reports an absorbed refusal names `SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE`. Both are imported from `@objectstack/objectql`, which exports them for exactly this: a consumer performs the `code` compare without authoring the string, so it acquires no stamp site of its own and cannot drift from what the engine throws. -- **Nothing about the absorbed set moves.** The catch stays as narrow as it was — one engine refusal absorbed, everything else rethrown unchanged — and `plugin-sharing` still emits this code nowhere: the surviving mention is a structured log field on the refusal it just absorbed, not a refusal envelope of its own. -- **No error-code membership moves.** `ERROR_CODE_LEDGER` and `StandardErrorCode` are untouched; `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` stays registered under `@objectstack/objectql` exactly as before. The only ledger change is one `PROVENANCE_WAIVERS` element, 10 waivers → 9, and the gate's site census 339 → 338 with `listed` unchanged at 322. diff --git a/.changeset/16166-override-actor-tenant-arm-rung.md b/.changeset/16166-override-actor-tenant-arm-rung.md deleted file mode 100644 index b974fdfd1d7..00000000000 --- a/.changeset/16166-override-actor-tenant-arm-rung.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -`ApprovalService`'s privileged-override gate now resolves TENANT-admin standing from the ADR-0095 capability rung alone. Its tenant arm previously also admitted any principal whose `current_user.positions` contained the built-in identity names `org_owner` or `org_admin`, and a name on that array is not evidence of the capability behind it (#16166). - -`positions[]` carries two different things at once: the ADR-0068 D2 **projection** of a membership role, whose source of truth is `sys_member.role`, and ADR-0057 D4 `sys_user_position` assignment values. A stored assignment row spelling one of those built-in names therefore arrived on the array with no org-administration grant behind it and satisfied the override gate anyway — for `decideNode`, `recall` and the console's participant-visibility read, within that organization. This is the tenant half of the same defect the platform arm of the same predicate had (#15981), and it lands the same way: **read the rung, never the name.** - -- **The tenant rung is not the platform one.** ADR-0095 D3 resolves `TENANT_ADMIN` in `derivePosture` from the org-admin capability grants (`organization_admin` / `organization_admin_no_bypass`) and from nothing else, and those grants are what `packages/spec` declares that rung's source of truth. So the surviving two arms — the derived `posture` and the held capability — are one authority read in two spellings, kept apart only so a transport that never resolved `posture` still reads the grant. -- **The #3424 stuck-approval escape hatch is unchanged** for anyone who actually holds org-admin standing: a genuine `organization_admin` grant still overrides, still only inside its own organization, and the decision is still audited as `via_override`. -- **Who could notice.** A principal whose only claim to tenant-admin override was a stored `sys_user_position` row spelling `org_owner` / `org_admin` loses it. That row was never an assignment of the identity it spells — the platform refuses new ones on write — and the supported route to override standing is the org-admin capability grant, which the membership role provisions automatically for owners and admins. diff --git a/.changeset/16175-schema-tree-freshness-stamp.md b/.changeset/16175-schema-tree-freshness-stamp.md deleted file mode 100644 index 127e6194b3c..00000000000 --- a/.changeset/16175-schema-tree-freshness-stamp.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(devx): the json-schema tree's freshness rule can be answered — a generation stamp acquits a tree whose sources were re-checked-out unchanged (#16175) - -`scripts/check-regen-pending.mjs` exports three freshness predicates over the -same `newestMtime(artifact) < newestMtime(src)` comparison, and all three share -one blind spot: `git merge`, `git checkout` and `git worktree add` re-check-out a -source file with **identical bytes** and bump its mtime, the build that follows -correctly does not run (turbo's cache hashes content), and the rule then refuses -an artifact that is exactly current. - -Two of them were answered already — `distIsStale` by `dist/.build-input-hash-dts` -(#14985/#16176) and `bundlesAreStale` by `dist/.build-input-hash` (#16240). -`schemaTreeIsStale` was the third, and the one with **no evidence of any kind to -read**: nothing recorded which sources `packages/spec/json-schema/` came from. -Measured on a checkout whose `git status` was empty, after a bare -`touch packages/spec/src/data/query.zod.ts`: - -``` -pnpm --filter @objectstack/spec check:docs exit 1 - packages/spec/json-schema is older than packages/spec/src. -``` - -The only remedy on offer was a full `gen:schema` — minutes under a shared verify -lock — for a tree that needed nothing. The same command now exits 0 with no -rebuild, and a genuine source edit still refuses. - -**The evidence is new, because neither `dist/` stamp could stand in.** Both are -written at the END of the build, whereas `gen:schema` is its FIRST step and is -also run standalone and again by `check:authorable-surface` — so a `dist/` stamp -is evidence about `dist/`, and in the standalone case there would be none at all. -`build-schemas.ts` now writes `json-schema/.build-input-hash-schema` as the last -thing it does: one write point, after the unconditional whole-tree regeneration -that precedes its `--check` / `--update-base` fork, so all three entry points are -covered, and after every ratchet that can exit 1, so a refused run vouches for -nothing. - -**⛔ The digest may only ACQUIT, never accuse.** A missing, unreadable or -non-64-hex stamp is `unstamped` — no evidence — and leaves the mtime refusal -exactly where it stood (#4690). Nothing that passes today can start failing, and -the rule keeps its only conviction instrument: mtimes still see the hand-edited -tree and the toolchain change a content digest is blind to. - -**Why this ships, and why it is a changeset rather than `skip-changeset`.** -`json-schema` is in `@objectstack/spec`'s published `files[]`, so the new stamp -travels in the tarball — measured with `npm pack --dry-run`: -`json-schema/.build-input-hash-schema` is present alongside the two existing -`dist/` stamps. One 65-byte file is added to the published package. No export, no -schema key, no runtime behaviour and no authorable surface moves. - -**One other published-adjacent change**, for the same soundness reason: the build -digest (`scripts/build-input-hash.mjs`) now also hashes `/scripts/**` for -packages that have it. `packages/spec`'s generators live there and were in none of -the previous input sets, so an edited generator kept a digest that had not moved — -and a stamp written by the OLD generator would then acquit a tree the new one -emits differently. Widening a digest can only ever WITHHOLD an acquittal, never -grant one, so the two `dist/` stamps become strictly more honest as well; the -first build after this lands re-stamps all three. diff --git a/.changeset/16211-ai-slot-501-not-404.md b/.changeset/16211-ai-slot-501-not-404.md deleted file mode 100644 index 42ffcf8ae1b..00000000000 --- a/.changeset/16211-ai-slot-501-not-404.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -'@objectstack/client': patch ---- - -`client.ai`'s docblock says the AI slot answers 501, names the 401-first and `GET /ai/agents` arms, and stops promising a 404 - -The `ai` namespace docblock described the pre-`capabilityUnavailable` -behaviour: that this repo's dispatcher *"404s `AI service is not configured` -when the service is absent (the open-source default)"*. The dispatcher has -answered **501** since the shared exit landed. `/ai/*` is registered -**unconditionally** (`createAiDomain`, plus the host wildcard across four -methods in every branch of the scoping conditional), so a request reaches a -handler with nothing behind it — which is 501 Not Implemented, not 404. -`packages/runtime/src/domains/unavailable.ts` exists to draw exactly that line: -404 means *the route is not there*, and for `/ai/*` that is false. - -**Why the replacement is narrower than "`/ai/*` answers 501".** That sentence -is not true either, and a caller branching on status needs both exceptions. -Verified against the unserveable-slot branch in -`packages/runtime/src/domains/ai.ts`, in its own evaluation order: - -``` -FROM any /ai/* with no AI service -> 404 `AI service is not configured` - -TO anonymous caller -> 401 (ANONYMOUS_DENY_STATUS; the 501 and - the courtesy below are capability - disclosures, owed to nobody who has - not authenticated) - GET /ai/agents -> 200 { agents: [] } under the envelope's - `data` — a console polls it on every - navigation to decide whether to show - AI affordances - every other /ai/* route -> 501 serviceUnavailableMessage('ai') -``` - -All three arms are already test-pinned in -`domains/ai-anonymous-deny-ordering.test.ts` — this changeset moves no -behaviour, only the sentence describing it. - -**The `GET /ai/agents` courtesy was mentioned nowhere in this docblock**, which -is the one an SDK reader actually opens, so it is added rather than merely -corrected. Also stated now: the 501 body is not a local string — it comes from -the shared `serviceUnavailableMessage`, the same sentence -`discovery.services.ai` reports for the slot, so the two cannot drift into -naming different remedies. - -⛔ No behaviour changes. This is a docblock; no export, authorable key, accept -set or response byte moves. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/client`'s published `files[]` ships `dist`, -and this TSDoc is emitted into all four built artifacts — `dist/index.d.ts`, -`dist/index.d.mts`, `dist/index.js` and `dist/index.mjs` — measured on the -built tree, with the stale `AI service is not configured` sentence absent from -every built file afterwards and the docblock's own neighbouring sentence -present as the lit control. The declarations are what a consumer's editor shows -on hover and what an upgrading agent greps, and they change. - -The two sibling corrections in the same change do **not** publish and are not -named here: `packages/runtime/src/route-ledger.ts` is a CI-audit ledger that is -not exported from the runtime entry (`ROUTE_LEDGER` is absent from -`packages/runtime/dist` entirely), and the `domains/ai.ts` implementation -comment is not emitted — three pre-existing comments from that same file were -probed as controls and none appears in the built output. diff --git a/.changeset/16236-formula-return-type-measure-column.md b/.changeset/16236-formula-return-type-measure-column.md deleted file mode 100644 index 62167c4e6ee..00000000000 --- a/.changeset/16236-formula-return-type-measure-column.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(service-analytics): a `min`/`max` over a `formula` field is typed from the formula's declared `returnType`, not described as `number` (#16236) - -> ⚠️ **Superseded within the same release window — ⛔ do not act on this entry.** -> Everything below was accurate when it was written and is kept as the record of what -> #16236 measured and built. It never reached a published version: **#17560** (director -> ruling, decision batch #127, 2026-09-13) refuses `min` / `max` over a `formula` field -> outright, on the compatibility table's own storage ground — a formula is VIRTUAL in SQL -> storage, no column is emitted, so no aggregate can be lowered to it whatever -> `returnType` says. At the version that compiles this entry such a measure answers -> `DATASET_INVALID` / **400** at compile time instead of carrying any `fields[].type`, and -> the `returnType?: string` member described at the foot of this entry is **not** on -> `AnalyticsServiceConfig.sourceFieldMeta` — it was added and removed inside one release -> window, so no published version ever carried it. ⇒ Read #17560's entry instead; the -> FROM → TO below never became a shipped behaviour. - -**Behaviour change — read this if any dataset measure aggregates a `formula` -field.** `AnalyticsResult.fields[].type` for such a measure column was always -`number`, whatever the formula computes. It is now translated from the field's -declared `FieldSchema.returnType`: - -``` -FROM {"rows":[{"first_label":"alpha","latest_due":"2026-06-01"}], - "fields":[{"name":"first_label","type":"number"}, - {"name":"latest_due","type":"number"}]} - -TO {"rows":[{"first_label":"alpha","latest_due":"2026-06-01"}], - "fields":[{"name":"first_label","type":"string"}, - {"name":"latest_due","type":"time"}]} -``` - -Both values were strings; both descriptors said `number`, so a renderer that -branches on the declared type never reached its textual or temporal branch. - -**The mapping is a TRANSLATION, not a pass-through.** `returnType` speaks the -authoring vocabulary (`number` / `text` / `boolean` / `date`); -`fields[].type` speaks `DimensionType` (`string` / `number` / `boolean` / -`time` / `geo`). Two of the four words do not exist on the wire at all: - -| declared `returnType` | `fields[].type` | -|:---|:---| -| `text` | `string` | -| `date` | `time` | -| `number` | unchanged — the producer's `number` is already correct | -| `boolean` | unchanged — three readings disagree on what `min`/`max` over a boolean returns | - -**A formula with no `returnType` is unchanged.** The key is optional — "absent -when the type can't be proven (an ambiguous/`dyn` expression)" — and an -unproven formula's measure column keeps the `number` it had. The absence is not -read as an answer. That tier is written down as a row in `measureResultType`'s -own table rather than left as an implied code path, and so is the treatment of -a word outside the declared four: left alone, never guessed at. - -**For hosts wiring `AnalyticsService` directly.** `AnalyticsServiceConfig`'s -`sourceFieldMeta` hook gains an optional fourth member on its return — -`returnType?: string` beside `type` / `defaultCurrency` / `max`. Additive: a -host that returns the three-member shape still satisfies the contract and gets -exactly today's behaviour for every column. `AnalyticsServicePlugin` relays the -key automatically, so a host on the plugin needs no change at all. - -⚠️ **Superseded — see the banner at the top.** #17560 removed that member again in -the same release window, so the shape a host writes against is the three-member one -this paragraph calls today's. Nothing to do either way: a host that returns the -fourth key is ignored, not refused. diff --git a/.changeset/16245-bracketed-tag-refusal-openers.md b/.changeset/16245-bracketed-tag-refusal-openers.md deleted file mode 100644 index f8075f0ef7c..00000000000 --- a/.changeset/16245-bracketed-tag-refusal-openers.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/spec': minor ---- - -`ObjectStackProtocolImplementation` and `SysMetadataRepository` no longer open their refusal messages with a bracketed tag restating the `code` the same throw declares — `error` carries the human sentence, `code` carries the machine token, and the token is no longer duplicated onto the prose axis. - -Clause-②: yes - -Every refusal `ObjectStackProtocolImplementation` and `SysMetadataRepository` raised opened with a lowercase `[tag]` that was the restatement of the `code` that very throw declared: `[no_draft]` in front of `NO_DRAFT`, `[item_locked]` in front of `ITEM_LOCKED`, and so on for 38 throw sites across the two producers. They were not invisible. `withoutDeclaredCodePrefix` strips a leading restatement only when the message opens with the declared code followed by a colon (`INVALID_REQUEST: …`); the bracketed lowercase spelling matches neither the casing nor the separator, so it was never stripped and reached the caller in `error.message`. The repo's own de-duplication mechanism existed and did not fire here. - -The maintainer ruling of 2026-08-29 on the `/data` door shipping `FORBIDDEN:` in front of a localized refusal is ONE envelope semantics — `error` is HUMAN LANGUAGE, `code` is the MACHINE TOKEN — and a prefix is removed *because* the same fact already rides the `code` axis. All 38 met that condition by construction. - -## FROM → TO - -| before | now | -| --- | --- | -| `error: "[no_draft] No pending draft exists for view/task_list."` | `error: "No pending draft exists for view/task_list."` | -| `error: "[item_locked] view/task_list is locked (_lock=…)."` | `error: "view/task_list is locked (_lock=…)."` | -| `error: "[NOT_OVERRIDABLE] 'action' is not allowOrgOverride…"` | `error: "'action' is not allowOrgOverride…"` | - -**`code` is unchanged on every one of them**, and it is where the token always also was — `NO_DRAFT`, `ITEM_LOCKED`, `NOT_OVERRIDABLE`, and the 14 others. A reader matching `error.message` for a bracketed tag reads `error.code` for that tag, upper-cased, instead; a reader already using `code` needs no change. The HTTP `status` is untouched. - -- **Measured, not assumed, before it was removed**: 37 literal openers plus one written as `` `[${code}]` `` from the same variable the throw assigns to `err.code` three lines down — that one spelled by interpolation, so it was invisible to every grep for a literal tag and is absent from the card's own inventory. -- **Nothing consumed the tag.** The only consumers found anywhere are strippers: `@object-ui/react`'s `extractWriteErrorMessage` and two `plugin-detail` call sites each remove a leading bracketed prefix before showing the sentence to a user, next to the `SCREAMING_SNAKE:` strip. They confirm the tag was arriving and they cannot break on its absence — the regex simply matches nothing. -- **Two bracketed vocabularies are deliberately kept**: the `path [zod code]` locators inside a validation headline and the `[rule]` locators the author-time gate composes. Neither restates a declared `code` — they name WHICH finding, a fact the envelope carries nowhere else. -- **The published docs that quoted the openers are corrected in the same change.** `ProtocolSchema`'s promotion `describe()` said the lookup 「answers 404 `[no_draft]`」 and now names `NO_DRAFT`, the axis that still carries it; `content/docs/references/api/protocol.mdx` is regenerated from it, never hand-edited. The error catalog's two documented `INVALID_REQUEST` payloads showed a `message` opening with the tag beside a `code` field already carrying the token, and now show what the platform emits. -- ⛔ **Three carriers in `content/docs/releases/v17/` are deliberately left**: release pages record what shipped and a code change does not rewrite them. -- **Pinned as an absence**, because nothing else would notice one coming back: a re-introduced tag reds exactly one per-door pin and a newly-written refusal reds none. diff --git a/.changeset/16270-org-record-tab-strip-provenance.md b/.changeset/16270-org-record-tab-strip-provenance.md deleted file mode 100644 index fb3158e8cb0..00000000000 --- a/.changeset/16270-org-record-tab-strip-provenance.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -Name where the organization record page's Members / Invitations / Teams tab strip is declared, at the three places that assert it (#16270) - -#16270 measured that no object under `packages/platform-objects/src/identity/` declares -the `Field.relatedList` prominence key, and inferred from that a two-way disjunction: -either the metadata is short three `relatedList: 'primary'` declarations, or the three -documents that describe the page as opening on tab-0 **Members** have gone stale. - -**Neither. The premise is false.** The tab strip is declared metadata — -`SysOrganizationDetailPage` in `packages/platform-objects/src/pages/sys-organization.page.ts`, -a `kind: 'slotted'` record page for `sys_organization`, `isDefault: true`, handed to the -runtime by plugin-auth's `pages: [SysOrganizationDetailPage, SysUserDetailPage]`. Its -`slots.tabs` override carries exactly three `record:related_list` tabs — Members, -Invitations, Teams, in that order — and objectui's synthesizer pushes that authored node -and never calls `buildDefaultTabs`, so the strip replaces the synthesized -Details + stacked `Related` one outright and Members really is at index 0. That file was -already in the tree at the commit the card measured. - -`relatedList: 'primary'` is a different mechanism (prominence on a child's lookup field, -promoting one derived list to its own tab). The card looked for that key, correctly found -none, and read the zero as "declared by no metadata". While the `tabs` slot is present, -adding the key would not move this page at all. - -**What changes here is prose only — no metadata, no behaviour.** The two source comments -that assert the tab order and the QA checklist item that grades it now name the page that -declares it, so the next reader does not repeat the measurement: - -- `packages/platform-objects/src/identity/sys-member.object.ts` — the `invite_user` - mirror's rationale -- `packages/platform-objects/src/identity/invite-entry-toolbar.test.ts` — the file header - that states the whole pin's premise -- `docs/qa/platform-checklist/areas/identity-auth.json` — - `identity-auth.org-membership-team-management`, a new `source` entry plus the revision - and history bump its ledger requires. Steps, acceptance clauses, oracles and negatives - are unchanged: a grader grades exactly what it graded before, and now knows that a - Details + stacked `Related` strip means this page failed to load rather than that the - clause was wrong. - -This package ships its `src` comments in `dist` (measured: the new comment text appears -4 times under `packages/platform-objects/dist`, with an exported symbol as the positive -control and the test-file header absent at 0), which is why a comment-only diff here -takes a changeset rather than the publishes-nothing exemption. diff --git a/.changeset/16274-initial-completion-history-guard.md b/.changeset/16274-initial-completion-history-guard.md deleted file mode 100644 index 874bf9769af..00000000000 --- a/.changeset/16274-initial-completion-history-guard.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A run whose nodes all succeeded is no longer answered `failed` — or, under `errorHandling.strategy: 'retry'`, RE-EXECUTED — because its terminal run-history write threw (#16274) - -`AutomationEngine.execute()` and `executeWithoutRetry()` each called `recordLog({ status: 'completed' })` from inside the `try` whose `catch` exists for **node** failures, so a throw out of a history write on a run that had already finished successfully was handled as though a node had thrown. This is the initial-execution half of the pattern fixed on the resume path in 17.4.0; that fix deliberately scoped these two sites out. - -**The consequence was measured, and it is a double run, not just a mislabelled one.** `execute()`'s node-failure arm ends at the retry strategy branch, which hands the false `failed` result to the retry loop; the loop reads `result.success` and therefore re-enters `executeWithoutRetry()` — the whole flow, every node, again. Driven with `maxRetries: 2`: a flow whose node always succeeded ran it **three** times and wrote three `failed` rows, unattended, inside one `execute()` call, with the node's side effects repeated each time. Controls on the same instrument: the identical flow on healthy sinks runs the node once, and a genuine node failure runs it three times (retry working correctly). - -**What can throw there is a host surface, not in-repo code** — which is why it could not be reproduced from inside the package and why the package owed the fix: - -- the run-summary line `logger.info(line, meta)`, on by default (`runSummaryLog: 'info'`) and calling a **host-injected** `Logger`. This one needs no store at all. -- `store.recordTerminal(record)` throwing **synchronously**, before it returns a promise — the `void write.catch(...)` beneath that call only ever sees a returned promise's rejection. Both stores shipped in this package are `async` methods and cannot do it, but `SuspendedRunStore` is an exported interface whose `recordTerminal` is optional, so a host store is unconstrained. (A store returning a non-thenable escapes identically: `write.catch` is then itself a synchronous `TypeError`.) - -On that second variant the old code did not even answer `failed`: the node-failure arm's own `recordLog({ status: 'failed' })` threw again out of the same store and escaped `execute()` entirely — a rejected promise where `AutomationResult` is declared. - -What changes: - -- **Each completion-path history write is guarded at its own call site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. The caller is told the truth — `success: true`, no `status`, the flow's `successMessage`, and a `summary` recomputed by the same pure function `recordLog` runs first — the node runs exactly once, and one `completed` row is recorded rather than `1 + maxRetries` `failed` ones. -- **The swallowed failure is reported once per run at `error`**, with the consequence and the fix in the first line: the run completed, its terminal history row never landed, nothing retries it, and the run must not be re-run. The thrown text rides the structured slot. - -⛔ No `catch` arm's meaning is widened: a genuine node failure still reaches the node-failure arm, is still recorded `failed`, still carries the node's own text, and is still retried the full `1 + maxRetries` times. diff --git a/.changeset/16292-cron-timezone-iana-domain.md b/.changeset/16292-cron-timezone-iana-domain.md deleted file mode 100644 index 77cf36c5c8c..00000000000 --- a/.changeset/16292-cron-timezone-iana-domain.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec)!: `CronSchedule.timezone` is judged by the `iana_time_zone` membership predicate (#16292) - -**BREAKING** — an accept-set narrowing on a published authoring key. -`CronScheduleSchema.timezone` was a bare `z.string().optional().default('UTC')`, so -`defineJob` and `JobSchema.parse` took `timezone: 'UTC+8'` at authoring and build time -and said nothing. It is now judged by `isValueDomainMember('iana_time_zone', …)` — the -predicate `@objectstack/spec/shared` already exports, and the same judge the four -`valueDomain: 'iana_time_zone'` columns (`sys_business_unit.timezone`, -`sys_organization.timezone`, `sys_job.timezone`, `sys_report_schedule.timezone`) are -written against. Shipped as `minor` under the repo's launch-window convention for -accept-set narrowings. - -No job that ran yesterday stops running. The value was already carried unchanged to -`CronJobAdapter.schedule`, where croner — constructed with a callback — throws on a -non-member and `AppPlugin` records a per-job `FAILED TO SCHEDULE` at `error` level plus -a `jobScheduleFailuresTotal` increment: the job was declared and never ran. What moves -is WHEN its author is told, from the first environment that boots to `defineJob` / -`os build`. So a stack whose job carries a zone the platform cannot honour now stops -building instead of booting-and-not-running. - -Membership is the `Intl.DateTimeFormat` probe rather than a checked-in list, so the -accepted set is the host's own tz database — deliberately, and identically to those four -columns, the settings door and `resolveAuthzContext`. It is what every `Intl`-based -consumer downstream accepts, so the parse-time answer and the schedule-time answer -cannot disagree on one host. `UTC`, the key's own declared default, is a member on every -conforming runtime, so an omitted key is untouched. - -`interval` and `once` schedules carry no zone and are unaffected. The boundary type -`JobSchedule.timezone` on `@objectstack/spec/contracts` is a third, separate door and is -deliberately left out of this change. - -Clause-②: yes (narrowing) - - diff --git a/.changeset/16310-orphan-locale-key-gates.md b/.changeset/16310-orphan-locale-key-gates.md deleted file mode 100644 index 106ea55eeab..00000000000 --- a/.changeset/16310-orphan-locale-key-gates.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -fix(lint)!: an orphaned locale key now FAILS the run — `translation-target-unknown` is an `error` (#16310) - -`validate-translation-references` reported every orphan translation key precisely -— the id named, the locale named, the remedy printed — and failed nothing. -`os lint` exits 0 on warnings, the rule hard-coded `severity: 'warning'`, and no -per-rule severity is configurable by a consuming app. So a PR that deletes a -navigation entry, a form section or a view and leaves its locale keys behind was -green on every pipeline on the platform, and the dead keys are actively -misleading afterwards: grepping the id returns a confident-looking hit in every -locale, which reads as "this exists and is translated". - -The forward half of this parity — `i18n/missing-*`, an authored surface with no -translation — already fails, and apps already gate on it. The orphan half now -fails too, so the two halves of one parity have the same enforceability instead -of opposite ones. - -**BREAKING** — a stack carrying an orphan locale key stops passing `os lint`, -`os validate` and `os build`. Measured on one stack with 8 orphan keys planted, -`objectstack lint --json`: - -| `@objectstack/lint` | findings | errors | warnings | `passed` | exit | -| :-- | --: | --: | --: | :-- | --: | -| before this release | 20 | 0 | 18 | `true` | 0 | -| after this release | 20 | 8 | 10 | `false` | 1 | - -The findings themselves are unchanged — same count, same paths, same message and -hint text. Only the severity moves, and with it the exit code. - -**What an author does about it.** In a clean stack, nothing: a tree with no -orphan key reports exactly what it reported before, at the same severities, with -the same exit code (measured — the report is identical field for field apart -from its wall-clock `duration`). In a stack the rule already names findings on, -delete each locale key it names. The key resolves to nothing — the object, -field, view, section, tab, action, param, app, nav item, dashboard, widget or -flow screen it was written for is not in the stack — so removing it changes no -rendered string in any locale. Where the target was renamed rather than removed, -key the translation to the new name instead; the finding prints the declared -names to choose from. - -**This is ONE rule, not "warnings are errors now".** Measured on a planted tree -carrying findings from 13 distinct rules: exactly 1 changed severity, 12 did not, -and the finding set is identical modulo that one severity. -`translation-option-key-unknown` — raised by the same function — stays `warning` -on purpose: a mis-keyed option translation names something real and its remedy is -a rename, not a deletion. `validateTranslatableSections`, the sibling asking "is -there a key at all?", is untouched. - -**Unchanged: the runtime publish gate.** `validateTranslationReferences` reaches -the runtime door on a `flow` write, but the per-write snapshot carries only -`objects` / `permissions` / `books` / `datasets` — `RuntimeStackContext` has no -`translations` member for a host to fill — so the rule sees no bundle and returns -nothing there. Measured: a flow write through `runRuntimeAuthoringRules` yields -0 errors and 0 advisories from this rule. No publish that used to succeed is -refused. - -`TranslationRefSeverity` widens from `'warning'` to `'warning' | 'error'` -accordingly. - - diff --git a/.changeset/16314-contained-failure-rollup-services-half.md b/.changeset/16314-contained-failure-rollup-services-half.md deleted file mode 100644 index 5895d1b2ffd..00000000000 --- a/.changeset/16314-contained-failure-rollup-services-half.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -fix(service-automation): a delegating node rolls its COMPLETED child's contained failures into the run-level `failed` (#16314) - -The services half of #15617's ruling (maintainer 「同意」 on option 1, decision batch #55). The spec half landed the slot: `ExecutionStepMetrics.failures`, declared as *"node executions that failed inside a child run this execution delegated to and went on from"*, folding into `nodes[].failures` and so into `FlowRunSummary.failed`. Until this, nothing populated it — the engine's fold could not see a child's losses, so a parent that delegated its rows reported `failed: 0` while its children lost them. `acted` had rolled up since #4354; the failure count had not, and the two paragraphs of the declaration disagreed for exactly that shape. - -**What moves on the wire.** For a run whose `subflow` or `map` child COMPLETED while containing failures, the delegating node's `nodes[].failures` and the run-level `failed` grow by the child's own `failed` — and the summary line prints it. The measured target from #15617, driven on the real engine: - -``` -parent loop { subflow(child) }, one child failing per five rows - before status=completed selected=5 acted=4 skipped=0 failed=0 - after status=completed selected=5 acted=4 skipped=0 failed=1 - children failed = [0, 0, 1, 0, 0] (unchanged — the child keeps its own row) -``` - -**The boundary, unchanged and pinned as the control.** A child that **failed** rather than contained is the delegating step's own failure, counted once through `nodes[].failures` exactly as it always was: `call: {runs: 5, failures: 1}`, parent `failed = 1`, with nothing of the child's own `failed` riding up. That is the one place this rule parts from `acted`'s, which does carry a failed child's writes. Implementing the symmetric-looking version would count one loss twice, and the control test is red on it. - -**A delegating node's `status` is unaffected.** `FlowRunNodeSummary.status` is declared judged on the node's OWN executions, so a `subflow` step that ran fine and rolled a child's losses up reads `success` with `failures > 0` — and on such a node `failures` may exceed `runs`, as the field declares. The fold takes the status verdict before it adds the roll-up. - -Three producers, each measured rather than assumed: `subflow-node.ts` (synchronous child), `map-node.ts` (per-item children — it does **not** share `subflow`'s roll-up path and needed its own), and `AutomationEngine.creditChildRun` (a child that PAUSED, whose parent step was written at suspend time; both the child-resume up-bubble and the parent-resume down-delegation are completion paths, which is what puts them inside the declared rule). - -`failed` keeps its convention: absent is "not tracked", never zero — an absent `metrics.failures` means the execution delegated nothing or the child tracked no count, and nothing writes a `0` that would claim a measurement. - -PR #15609's narrowed wording — *"no node execution **of this run** failed"* — was true only while the paragraphs disagreed, and is widened back here in the summary-line comment and in `content/docs/automation/flows.mdx`: `failed=0` now reads *"nothing this run caused failed, subflows included"*. - -No API moves: no new export, no new key on any published payload, and the node executors' `NodeExecutionResult.metrics` shape is the spec's already-published one. diff --git a/.changeset/16384-auth-base-path-single-definition.md b/.changeset/16384-auth-base-path-single-definition.md deleted file mode 100644 index 201fac462e1..00000000000 --- a/.changeset/16384-auth-base-path-single-definition.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -fix(plugin-auth): give the auth `basePath` default a single written definition (#16384) - -`'/api/v1/auth'`, the shipped default for `AuthPlugin`'s `basePath` option, was -written independently at four sites: the `AuthPlugin` constructor, two later -re-derivations inside `AuthPlugin` (`registerAuthRoutes`, the OIDC discovery -`.well-known` alias), and `AuthManager.configuredBasePath()`'s own fallback. -Nothing was broken by the duplication — `AuthPlugin` always supplies `basePath` -to `AuthManager`, so the manager's copy was dead on the live path and -unfalsifiable by construction: no test could have caught one copy drifting from -the other three. - -The default now lives in exactly one place, `DEFAULT_AUTH_BASE_PATH` (exported -from `@objectstack/plugin-auth`, declared beside `readMcpServerEnabledEnv` in -`auth-manager.ts`); all four sites import it instead of retyping the literal. -Every site evaluates byte-identically to before — this is a consolidation of -where the value is *written*, not a change to what any site *evaluates to*, and -in particular does **not** touch `AuthManager`'s `configuredBasePath` → -`rootedBasePath` → `getBasePath` normalisation chain (#16399) or the published -OAuth `iss` / RFC 8707 `aud` identifiers those getters produce. - -This is additive and non-breaking — no existing call site's behaviour changes — -but it does add one new named export (`DEFAULT_AUTH_BASE_PATH`) to the -package's public surface, which is what makes this `minor` rather than `patch`. diff --git a/.changeset/16403-picker-reader-position-guard.md b/.changeset/16403-picker-reader-position-guard.md deleted file mode 100644 index 8080ad87504..00000000000 --- a/.changeset/16403-picker-reader-position-guard.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`filter-preset-comparand` enters its `publicPicker` object-binding reader by SCHEMA POSITION rather than by key name, so a node that merely spells `publicPicker` no longer takes its whole filter subtree out of the field-typed arm (#16403). - -`bindAncestors` walked out through a filter's ancestors and matched `if (key === PUBLIC_PICKER_KEY)` on the property NAME. `walkAuthoredFilters`/`scanForFilters` recognise a filter by key at ANY depth on all eight scanned collections, so that reader was reachable from any node named `publicPicker`, anywhere. Its unresolvable exit is `undefined` — no bound object, so arm 2's field-type oracle answers `false` for every key and the subtree is judged by nobody. - -- **No live defect today**: `publicPicker` is declared exactly once as a schema key, on `FormFieldBaseSchema` (`packages/spec/src/ui/view.zod.ts`), and there the reader is correct. What changed is the failure mode the day a second schema declares the same name: it would have inherited this branch silently. Under-reporting is this rule's only permitted failure direction, so the hole would never have VIOLATED that invariant — it would have quietly spent it, where no test asking "was the invariant violated?" could see it. -- **The guard is on the entry, not the exits**: the branch now requires the enclosing ancestor to be a form field (`field`, required on `FormFieldBaseSchema`) — the same read the branch already had to make one line later, so no new coupling between the lint package and the form-view schema. Two of the three exits `#16106`'s review pinned are verbatim untouched: the `picker.object` override (`if (override) return override;`) and both `undefined` legs of the `reference` resolution (`if (!formObject) return undefined;` and the `verdict?.kind === 'ok' ? … : undefined` tail). The third — `!formField` returning `undefined` — is DELETED, and deleting it IS the fix: outside the declared position that line was the silent exit this card is about, while inside the declared position it is unreachable by construction (the guard holding means `formField` is truthy). So the behaviour P3's QUIET pin holds did not move. -- **The `#16106` B1 false refusal stays closed**, measured: a form field's picker filter over a referenced `select` column that shares its name with a parent `date` column still reports nothing, and the positive control — the same filter where the REFERENCED object declares the field as a `date` — still reports at `views[0].sections[0].fields[0].publicPicker.filter[0].value`. diff --git a/.changeset/16421-clause2-direction-arm.md b/.changeset/16421-clause2-direction-arm.md deleted file mode 100644 index 881f2c4630d..00000000000 --- a/.changeset/16421-clause2-direction-arm.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): record the shipped `sys_job` / `sys_report_schedule` IANA narrowing in the ADR-0087 ledger (#16421) - -Clause-②: no - -`#16296` gave `sys_job.timezone` and `sys_report_schedule.timezone` the -`valueDomain: 'iana_time_zone'` declaration. That is a write-time narrowing — a -string these columns used to accept is now refused with the ADR-0114 field code -`value_domain` — and it shipped with no breaking-change marker at all, so the -repo's own detector classified it non-breaking and asked for no ADR-0087 -disposition. Measured on the shipped changeset, not inferred. - -The ledger now carries a `semantic` entry for it -(`platform-timezone-columns-iana-domain-refused`, protocol 18). Nothing is -re-released and nothing is ratified in silence: the entry states what narrowed, -the one-line fix per offending row (write the canonical zone id, or clear the -column), and the fact that a stored non-member is still readable and still -returned unchanged — it fails only on the row's next write. For -`sys_report_schedule` that refusal is the point: a non-member zone was silently -discarding the cron expression and falling back to `interval_minutes` forever. - -No authorable key, export, config field or stored shape moves, and no DDL is -planned — this is a record of a change that already shipped, published so that -`objectstack migrate meta`'s consumers can read it. - -Maintainer ruling, director summon #17, decision batch #2 item 1, option B -(#16421 comment 5572145955, 2026-09-07), quoted verbatim and untranslated: 「同意」. diff --git a/.changeset/16524-meta-read-org-describes.md b/.changeset/16524-meta-read-org-describes.md deleted file mode 100644 index 543176adda7..00000000000 --- a/.changeset/16524-meta-read-org-describes.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -docs(spec): the `organizationId` describes on `GetMetaItemRequestSchema`, `GetMetaItemLayeredRequestSchema` and `GetMetaItemCachedRequestSchema` no longer promise that a supplied organization is always consulted (#16524) - -Clause-②: no - -Each of the three published `describe()`s opened with "Selects the org partition in the ADR-0005 overlay read order" and closed with an absent-only statement ("Absent = environment-wide read …"). Read together, an integrator completes that as *present ⇒ consulted*, and it is not: on `getMetaItem`, `getMetaItemLayered` and `getMetaItemCached` a supplied organization is dropped wherever no org partition applies, and the read resolves environment-wide exactly as if none had been sent. - -The corrected text takes the same shape as the sibling `GetMetaItemsRequestSchema.organizationId` describe: the parameter selects the org partition **when an org partition applies**, and supplying a value "does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide". On `GetMetaItemCachedRequestSchema` the ETag sentence ("Also folded into the ETag, so a scope switch never returns a stale 304 from another scope's cached representation") is true and is kept byte-for-byte. - -Prose only. No key is added, removed or renamed, no export moves, no accept set changes and no runtime behaviour changes — a supplied `organizationId` is still accepted on all three requests, and the runtime still drops it where no org partition applies. What ships is the JSON-Schema `description` of the existing `organizationId` key on the three requests and the matching rows in the generated API reference. diff --git a/.changeset/16583-sdui-parser-binding-field-arm-retired.md b/.changeset/16583-sdui-parser-binding-field-arm-retired.md deleted file mode 100644 index 6deca671bc2..00000000000 --- a/.changeset/16583-sdui-parser-binding-field-arm-retired.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -'@objectstack/sdui-parser': minor ---- - -Retire the zero-writer `binding: 'field'` arm from all three of this copy's declarations, so the save gate states the same one-word vocabulary the renderer already does (#16583) - -objectui retired the same arm from its copy of this package: the maintainer -ruling of 2026-09-07 on objectui#6950 (director decision batch #69) took the -serializer's input boundary, objectui#8315 took the two faces in `types.ts`, -both citing enforce-or-remove on a zero-writer measurement. That ruling names -coordinates in objectui only, and nothing propagates a retirement across the -two copies of `packages/sdui-parser` — so this one kept the arm on all three -declarations while the renderer that ships beside it no longer has it. This is -that port, measured here rather than inherited. - -- `RegistryConfigLike.inputs[].binding` — now `'object'` -- `ManifestInput.binding` — now `'object'` -- `ValidationResult.bindings[].kind` — now `'object'` - -**Breaking for TypeScript consumers, deliberately, and compile-time only.** A -registry config, a hand-written `Manifest` literal or a `bindings[]` entry that -spells `'field'` is now a `tsc` error. Runtime behaviour does not move: types -are erased, this package runs no validator over a `Manifest` it is handed, and -`validateTree` still forwards whatever the manifest says. A pin in -`src/__tests__/binding-field-retired.test.ts` states that limit outright, so the -narrowing is not mistaken for a runtime rejection, and it goes red in both -directions — a `@ts-expect-error` that stops being needed is itself `ts(2578)`, -so widening any of the three declarations back fails the package typecheck on -the very line that documents the retirement. - -**Nothing measured has to be rewritten, and the key was never author-writable -here.** `binding` is not a spec key, has no Zod schema and no stored -representation; it reaches this package only through the structural -`RegistryConfigLike` boundary, which exists so the package can be fed -objectui's `ComponentRegistry.getAllConfigs()` without depending on it. Four -readings on this tree, each with its control: `binding: 'field'` has zero -writers in this repository against a firing `binding: 'object'` control of 2 -(both under `packages/sdui-parser/src/__tests__/`); the tracked -`sdui.manifest.json` — the only manifest this repo produces — carries zero -`binding` keys across all 339 of its inputs; nothing outside the package reads -`binding` or `bindings[].kind` at all, the package's single importer -(`@objectstack/lint`'s `validate-jsx-pages.ts`) destructuring `{ diagnostics }` -only; and no arm of the vocabulary is branched on anywhere, so no consumer -loses a case it was handling. - -**Why the reader face is narrowed too.** The counter-argument — producer to -reader is a subset relation, so a permissive reader is not wrong — was answered -rather than assumed away. `ManifestInput` is not a pure reader face -(`manifestFromConfigs` returns it), and `bindings[].kind` is a pure **producer** -face where the relation inverts: a wider union there accepts nothing extra, it -obliges every consumer to handle an arm this package cannot emit. The two are -coupled by `validateTree`'s `kind: input.binding` assignment, so narrowing one -alone would need a cast at the only conversion site — the lenient consumer-side -fallback Prime Directive #12 bans. The reasoning now lives on the declarations -themselves, where a later reader lands. - -The reopen route is the ruling's own: a measured need for field bindings is -filed as a widening with the vocabulary decided then, not pre-declared here for -a producer that does not exist. - - diff --git a/.changeset/16678-admin-set-user-manager.md b/.changeset/16678-admin-set-user-manager.md deleted file mode 100644 index 6f903e4dcdf..00000000000 --- a/.changeset/16678-admin-set-user-manager.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -'@objectstack/plugin-auth': patch -'@objectstack/lint': patch ---- - -`sys_user.manager_id` gains an admin write surface: `POST /api/v1/auth/admin/set-user-manager` - -`{ type: 'manager' }` is the canonical first rung of a tiered approval ladder, -and it resolves `sys_user.manager_id` — a column **no product surface could -write**. Measured: the generic data path refuses it (the ADR-0092 D2 -managed-update whitelist for `sys_user` is `{name, image, locale}`), the admin -bulk import does not carry it (`admin-import-users.ts` matches `manager_id` 0 -times, against a control of `phone_number` 8), and the column is `readonly` on -the user form. So on any install without a directory sync the rung expanded to -nobody, the request opened on a slate no one could act on, and under the -default `lockRecord: true` the record stayed locked. - -**The endpoint.** A platform admin posts `{ userId, managerId }`; `managerId: -null` clears the link. It is an ObjectStack mount on the raw app ahead of the -better-auth catch-all — the same family as `POST /api/v1/auth/admin/unlock-user` -— platform-admin gated (ADR-0068) and ledgered in `auth-route-ledger.ts`. - -**It is not a new editable profile column, and that is the design.** The -handler runs under a **system context**, so it reaches the column by context -rather than by a whitelist entry — the same way `admin-import-users` already -reaches `phone_number` and `role`. `SYS_USER_PROFILE_EDIT_FIELDS` is -untouched, `MANAGED_EXTENSION_EDITABLE_FIELDS.sys_user` stays `{locale}`, and -`sys_user.manager_id` keeps `readonly: true`, so ADR-0092 D4 still holds by -construction. Since ADR-0092 D5's amendment made Tier-1 membership imply -self-editability, admitting the column to Tier 1 would have handed every member -their own first-rung approver and a widening of their own `own_and_reports` -read scope; it is not admitted. - -**Five refusals, every one enforced at the write** — the only manager-chain -walkers in the open tree are single-hop, so nothing downstream catches a bad -link: self-assignment; a link that closes a cycle (the walk is itself -cycle-safe, so a pre-existing loop is reported rather than hung on); a chain -past the depth cap that ADR-0057 D3's bounded rollups require; a manager -provably outside every organization the user belongs to (beside, not instead -of, the existing routing-time screen); and any identity whose `sys_user.source` -is `idp_provisioned`, where the directory stays the one authoring surface. - -**`@objectstack/lint`** keeps the `approval-approvers-may-resolve-empty` -advisory and its `stackWiresManagerChain` silencer — the dead end it reports -survives the write surface, because a static check still cannot read the -column; only its *cause* became recoverable. What changed is the remedy text, -which named a column with no route and now names the endpoint, its body, how to -clear the link, and what it refuses. The Approvals guide carries the same -rewrite in prose. - -**Why `patch` and not `minor`.** No new exported symbol is reachable from -either published entry: `admin-set-user-manager.ts` is deliberately not -re-exported from `plugin-auth/src/index.ts` and is not named in the package's -`exports` map, so none of `runSetUserManager`, `MAX_MANAGER_CHAIN_DEPTH`, -`SetUserManagerDeps`, `SetUserManagerEngine`, `SetUserManagerResult` or -`SetUserManagerRefusalReason` appears in the built `dist/index.d.ts`. No -already-published payload gains a key — the endpoint's response is a new -payload, not a new field on an old one. A new **route** is wire, and wire -compatibility is not the grading floor. diff --git a/.changeset/16712-position-catalog-refusal.md b/.changeset/16712-position-catalog-refusal.md deleted file mode 100644 index d0c49a9f57f..00000000000 --- a/.changeset/16712-position-catalog-refusal.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -fix(plugin-security)!: a `sys_user_position` write whose `position` names no `sys_position` row in the writer's catalog is refused (#16712, #20297), instead of answering 201 over an assignment that grants nothing - -Clause-②: yes - - - -**BREAKING** accept-set narrowing on the `sys_user_position` write path — -shipped as `minor` under the launch-window convention (`check-changeset-no-major` -refuses `major`; breaking-ness is carried by this banner and the ADR-0087 -disposition, not by the level). Maintainer-confirmed ruling on #16712 (option A), -with the catalog it reads settled on #20297: the writer's organization, under -the platform's standing tenancy rule. - -**What changed.** `sys_user_position.position` is the position's machine NAME -(`sys_position.name`), but it is declared `Field.text`, so a value naming no -catalog row — most often the position's record ID, written where its name -belongs — was stored with a `201` and then resolved to nothing: the holder got -no permission set, no sharing rule reached them, and they signed in to an app -that reads nothing, with no error anywhere. Such a write is now refused: - -- `400 VALIDATION_FAILED`, one `fields[]` entry per offending value at - `field: 'position'`, `code: 'reference_not_found'`, - `constraint: { target: 'sys_position', targetField: 'name' }` — the same - envelope a bad `user_id` or `organization_id` on the same row already gets. -- The message names the value and says the column takes the catalog NAME. When - the value is the record id of a position the writer's own organization can - see, it names that position and says to write its name. - -**Which writes.** Every non-system insert (one row or a batch, refused whole), -every non-system update by id that CHANGES `position`, and every predicate -update (`multi: true`) that sets it. The check runs after authorization: a -caller who may not write the table is still refused `403` on authority and never -sees the catalog verdict. - -**Whose catalog.** The writer's: the positions of the writer's own -organization plus the organization-less ones — the same reach the engine gives -its own lookup-reference check. A name that only ANOTHER organization's -catalog carries is refused exactly like any unknown name, with the same -envelope and the same message, so the answer says nothing about other -organizations. On a single-organization deployment the declared positions -carry no organization and any other position can only carry the one -organization there is, so every writer sees the whole catalog. A writer whose -context names no organization sees every organization's positions. - -**What did not change.** - -- A **deactivated** position is still a catalog row: an assignment naming it is - accepted and, as before, grants nothing (ADR-0049). -- **Stored rows** are untouched. An update that edits another column, or echoes - the unchanged `position` back, is not judged, so an existing row whose name - is no longer in the catalog stays editable. -- **System-context writes** are not judged — the seed loader (which on a fresh - single-organization boot writes `stack.data` before the declared position - catalog exists), invitation acceptance and the platform's own bootstraps. That - is the same stand-down the engine's lookup check takes. - -**Who is affected.** A client, script or AI author that writes a position's id, -a misspelled name, a name not yet created, or — on a deployment that walls -organizations off — a name only another organization has. The fix is the one -the refusal names: write the name of a position in the writer's own catalog, or -create the position there first. diff --git a/.changeset/16746-connect-agent-account-nav.md b/.changeset/16746-connect-agent-account-nav.md deleted file mode 100644 index f0808a8d629..00000000000 --- a/.changeset/16746-connect-agent-account-nav.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -Connect an Agent is reachable from the Account app, so a non-admin can mint their own key - -`POST /api/v1/keys` mints a `sys_api_key` bound to the **caller**, and the -Connect-an-Agent page says the key "acts as you". But the page's only navigation -entry sat in the Setup app, which declares `requiredPermissions: -['setup.access']` — so every non-admin following the shipped two-step guide, and -every reader of the runtime's own error text (`packages/mcp/src/plugin.ts`: -*"mint an API key (Setup → Connect an Agent, or POST /api/v1/keys)"*, and -`README.md`), stopped at step 1 while the endpoint behind the button had accepted -them all along. Measured before: a principal with no system permissions gets -`403 PERMISSION_DENIED` on `GET /api/v1/meta/apps/setup` and `nav_connect_agent` -is absent from the wire. - -`CONNECT_AGENT_UI_BUNDLE` now carries a **second** `navigationContributions` -entry, targeting the `account` app's `grp_account_developer` group beside the -`nav_account_api_keys` entry already shipping there. Measured after, over the -real composition (real `SETUP_APP` / `ACCOUNT_APP` / `SETUP_NAV_CONTRIBUTIONS`, -the real fold and the real RBAC-by-route filter): the same permissionless -principal gets `200` on `GET /api/v1/meta/apps/account` with -`grp_account_developer` carrying `['nav_account_api_keys', -'nav_account_oauth_apps', 'nav_connect_agent']`, while `apps/setup` still -answers `403 PERMISSION_DENIED` with `connect_agent` absent from that body. - -**Nothing else moves.** No backend change, no authorization change, no change to -which permissions exist, and the published "acts as you" promise is unchanged — -it simply becomes keepable for the users it was written for. The Setup entry -stays exactly as it was, so admins keep the page where the guide points, and no -gate is added or removed anywhere: a navigation contribution registers exactly -when the page registers, so an opted-out deployment -(`OS_MCP_SERVER_ENABLED=false`) still gets no page and neither entry. - -⛔ Ungating Setup was **not** the fix, and was measured rather than assumed: the -app-level `setup.access` gate fires before the group gate, so dropping the group -gate alone changes nothing, and dropping both serves 14+ unrelated Setup -surfaces (Users, Organization, Business Units, Branding, Feature Flags, …) to -every signed-in user. ⛔ Nor was a `requiresService: 'mcp'` gate on an -`account.app.ts` entry: the `mcp` service registers unconditionally in `init()` -while this bundle registers behind `isMcpServerEnabled()`, so such an entry -would outlive its page and 404 for every signed-in user on an opted-out -deployment. - -Both entries deliberately share the item id `nav_connect_agent` — one -destination, one identity. That is scoped, not a collision: `SchemaRegistry` -keys contributions by target app and `applyNavContributions(app)` consults only -that app's bucket, so a nav item id is unique within one app's navigation tree, -and the translation bundles are keyed `apps..navigation.`. diff --git a/.changeset/16786-scoped-updatebyid-answer.md b/.changeset/16786-scoped-updatebyid-answer.md deleted file mode 100644 index d138614bf8f..00000000000 --- a/.changeset/16786-scoped-updatebyid-answer.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `IScopedObjectRepository.updateById` declares its answer — the record or `null`, not `any` (#16786) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #15280 used for `SqlDriver.update()` and the `TursoDriver.update()` override, PR #14434 before it on `@objectstack/driver-memory`, and PR #17255 for this card's `objectql` half, which was re-graded from `patch` to `minor` mid-round for exactly this reason). - -`updateById(id, data)` declared `Promise` — the last wide member of a contract whose siblings answer what they mean. It now declares `Promise | null>`: the written record, or `null` when the id matched nothing. - -The declaration is what every layer under it already says, measured rather than inherited: - -- the engine door it forwards to, `IDataEngine.update`, declares `Promise | number | null>`; -- that door's by-id exit calls `IDataDriver.update(object, id, data)`, which declares exactly `Promise | null>`; -- `packages/objectql`'s `ObjectRepository.updateById` declared `Promise` to MATCH this member rather than independently of it, and PR #17255 said so in its own docblock when it deliberately left this half open. - -The `number` limb `update` carries — the affected-row COUNT a predicate write resolves — is **not** declared here, and that is a measurement too: the implementation binds both the payload id and a pure-id `where` and never declares `multi`, so the shared update dispatch answers `by-id` for every call this signature admits. A falsy id (`0`, `''`) is a REFUSAL, not a `null`: it identifies no row, so the dispatch rejects and the call throws. - -Ruling A on #16231 settled the rule — #15823's `find()` narrowing extends to the sibling doors — and enumerated `scoped-context.ts:148` / `:164`, not this member. It is narrowed because the measurement says the declaration was wider than every implementation and wider than the door it forwards to, ⛔ not because a ruling named it. - -A hook or service that assigned the result into a record slot, or read a field off it, through an `IScopedObjectRepository`-typed door now separates the `null` arm first. No runtime behaviour changes. The in-repo census through the interface-typed door is the contract's own suites, which already answer the narrow shape. - - diff --git a/.changeset/16804-dev-https-cert-key.md b/.changeset/16804-dev-https-cert-key.md deleted file mode 100644 index 611a265333c..00000000000 --- a/.changeset/16804-dev-https-cert-key.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/cli': minor -'@objectstack/plugin-hono-server': minor ---- - -feat(cli): `objectstack dev --cert --key ` terminates TLS in the dev process, and the canonical origin follows the listener (#16804) - -An interactive MCP client refuses to start an OAuth sign-in against a non-TLS -URL, so the self-serve identity path the product advertises — "interactive -clients just open a browser login" — could not be exercised against a local dev -server at all. The only way round it was a hand-built https reverse proxy plus -`OS_AUTH_URL`, a page of setup that every developer, demo and video recording -repeated off-camera. - -**Bring your own certificate.** Nothing here generates one, and nothing here — -not the code, not `--help`, not any doc page — says anything about installing a -certificate into a system trust store. 「⛔ 不生成自签 CA;⛔ 不打印、不文档化任何 -「把 CA 装进系统信任库」的指引——信任库是开发者自己的事」. The trust store is the -developer's own business; this feature's whole job is to *use* the certificate -they already have. - -```bash -objectstack dev --cert ./localhost.pem --key ./localhost-key.pem -``` - -Both flags are required together — half a pair is refused by name — and an -unreadable file is refused rather than degraded to a plain-http listener. - -**What follows the listener.** With both flags given, everything this boot -advertises is `https://localhost:`: the two `/.well-known/*` discovery -documents, the CSRF allow-list, the ready banner's `API:` / `MCP:` rows, the -`🤖 MCP server` connect hint, and the runtime state file the `os dev` parent and -external supervisors dial. Only the built-in default at the end of the base-URL -chain moves — `OS_AUTH_URL`, `BETTER_AUTH_URL` and `OS_BASE_URL` keep winning, -an `http://` value included, because they name where a deployment is *reached* -rather than what this process *bound*. - -**Without the flags nothing changes**, byte for byte — pinned by ablation legs -rather than asserted. - -`@objectstack/plugin-hono-server` gains the option this is built on: -`HonoPluginOptions.tls` (`{ cert, key }` PEM bytes) makes the adapter bind a TLS -listener with the same fetch handler, the same route table and the same graceful -drain. Absent, the listener is plain http exactly as before. diff --git a/.changeset/16870-scope-beside-superuser-bit-refused.md b/.changeset/16870-scope-beside-superuser-bit-refused.md deleted file mode 100644 index 63bcfa97d27..00000000000 --- a/.changeset/16870-scope-beside-superuser-bit-refused.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): an object permission that declares a depth axis beside the super-user bit which short-circuits it is now REFUSED, instead of being stored and counted as coverage (#16870) - -**BREAKING** — `ObjectPermissionSchema` no longer accepts a `readScope` beside -`viewAllRecords: true`. Two sibling shapes are refused with it, read off the -same resolver lines rather than guessed at. - -The pair was accepted with **zero diagnostics**, materialised into -`sys_permission_set.object_permissions`, and counted by a capability census -reading the deployed shape as coverage — while the read stayed org-wide. -`PermissionEvaluator.getEffectiveScope` answers `org` on the super-user bit -**before** it consults the depth key, and `getDeclaredScope` (the ADR-0090 D10 -delegated-path input) carries the identical short-circuit ahead of the identical -read, so the declared narrowing was dropped from the delegation fold as well. - -⇒ the author declared a narrowing, the platform stored it, an audit of the -deployed shape reported the capability as exercised, and the read was still -org-wide. That is ADR-0049 `declared ≠ enforced` at the capability container -itself, and the accept set is the only door that stops the declaration from -being STORED: a diagnostic raised later fires after the shape is already there. - -``` -FROM ObjectPermissionSchema.parse({ allowRead: true, viewAllRecords: true, - readScope: 'own_and_reports' }) - -> { …, viewAllRecords: true, readScope: 'own_and_reports' } // stored, unread - -TO -> ZodError, located at ['readScope']: - "readScope: 'own_and_reports' is declared beside viewAllRecords: true, - which already grants org-wide read. … Delete readScope if the org-wide - read is intended, or set viewAllRecords: false if the narrowing is." -``` - -**Which pairs move, and the one that deliberately does not.** The refusal is the -two short-circuits, transcribed: - -| declaration | resolver | verdict | -|:--|:--|:--| -| `readScope` + `viewAllRecords: true` | `opClass === 'read' && (viewAllRecords \|\| modifyAllRecords)` | **refused** | -| `readScope` + `modifyAllRecords: true` | same disjunct | **refused** | -| `writeScope` + `modifyAllRecords: true` | `opClass === 'write' && modifyAllRecords` | **refused** | -| `writeScope` + `viewAllRecords: true` | the write short-circuit does not name `viewAllRecords` | **accepted — honoured, and refusing it would delete a real grant** | - -⛔ **What `viewAllRecords: true` GRANTS is untouched.** This changes which -declarations are accepted, never what an accepted one does — a permission- -semantics change is not in this change's remit. `viewAllRecords: true` alone, -`viewAllRecords: false` beside a `readScope` (the ordinary, honoured shape), and -a bare `readScope` all parse exactly as before; each is pinned as a -cost-direction guard in `permission.test.ts`, and an ablation that widens the -refusal one shape too far turns the `writeScope`-beside-`viewAllRecords` pin red. - -**The wire surface stays tolerant.** The refinement rides on the AUTHORING -wrapper only; `EffectiveObjectPermissionSchema` extends the unrefined base, so a -server still running an older toolchain can return a stored pair in an -effective-permission response without crashing a client (#4001's authorable/wire -split). `AccessMatrixEntry` likewise keeps describing the pair: it is a derived -SNAPSHOT shape whose committed `access-matrix.json` may predate this refusal, and -its tolerance is now stated with that reason in `explain.test.ts` rather than -reading as evidence that the platform accepts the declaration. - -**Scope is one object-permission entry**, which is exactly the resolver's input — -`resolveObjectPermission` returns a single entry (explicit, else the `'*'` -wildcard) and never merges two. A super-user bit in one permission set widening -past another set's `readScope` is ADR-0090's documented additive "widest wins" -semantics, not a contradictory declaration, and is not judged here. - -**Nothing in the fleet moves.** Measured across shipped defaults, both seeded -examples, two built access matrices, the built artifact fixture and every tracked -`.ts` / `.json`: **0** object permissions carry any refused pair, with lit -controls on every probe (130 nodes declaring `viewAllRecords`, 53 of them `true`, -18 declaring `readScope`; 133 brace-local `viewAllRecords: true` literals). - - diff --git a/.changeset/16872-generated-i18n-provenance-population.md b/.changeset/16872-generated-i18n-provenance-population.md deleted file mode 100644 index 13362308dec..00000000000 --- a/.changeset/16872-generated-i18n-provenance-population.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/cli': patch ---- - -fix(platform-objects,cli): the generated i18n staleness predicate judges every section a run generated, not two fixed names - -`os i18n extract --no-objects-only --fill=default --source-hashes` emits -`apps` / `dashboards` / `pages` leaves and fills them from the source locale — -leaves carrying exactly the property the GENERATED staleness predicate exists to -judge — but the population that predicate walked was the fixed -`GENERATED_SECTIONS` list (`['objects', 'metadataForms']`). So no provenance -record was written for such a leaf, none was read back, and a `--fill=default` -copy left behind by a revised source kept being served as a superseded draft -with every i18n gate green. The hand-authored predicate does reach those paths, -but it judges against `LOCALE.source-hashes.ts`, which by construction carries -no entry for a leaf a generator produced. Neither mechanism covered them. - -The population now follows the RUN, at both ends: - -- **write** — `collectFilledFromHashes` takes a new **optional** fourth - parameter, `sections?: readonly string[]`, defaulting to `GENERATED_SECTIONS`. - `collectGeneratedLeaves` takes the same optional second parameter. Every - existing call site compiles and behaves exactly as before; `os i18n extract` - passes the sections it actually built. -- **read** — `findStaleFills` walks the sections the recorded table itself - names. One run wrote that table, so the table is the record of what that run - emitted, and the two ends cannot disagree about it. For every table committed - today this resolves to `['objects', 'metadataForms']`, so no served byte moves. - -Adding `'apps'` to `GENERATED_SECTIONS` was the other available shape and is -deliberately not taken: it would make `collectSourceLeaves` and -`collectGeneratedLeaves` walk one section — two predicates permanently on one -path — and it would assert `apps` is always generated, which is false for every -bundle set that ships. Both constants are unchanged and pinned unchanged. - -Widening the generated population is safe in a way widening the hand-authored -one would not be, because the rule is self-discriminating per leaf: a record is -written only when `value === currentSource` or `previous[path] === hash(value)`, -so a leaf someone actually translated satisfies neither and stays -legacy-trusted however wide the walk. The section list was the only part of the -mechanism that could not tell a fill from a translation. - -No committed bundle or companion byte moves in this repository. All nine -`--source-hashes` configs run the default `--objects-only`, whose commit layer -already narrows the run's table to the sections it emits a bundle for. The 387 -hand-recorded digests across `zh-CN` / `ja-JP` / `es-ES` are neither read, -written, shadowed nor lost — `apps` stays in `HAND_AUTHORED_SECTIONS`, -`collectSourceHashes` still walks it, and the extractor still never writes that -file. Its header now states which table a maintainer keeps for a path that can -appear in both, and why the overlap cannot serve wrong text. diff --git a/.changeset/16875-nav-recordid-viewname-tolerated.md b/.changeset/16875-nav-recordid-viewname-tolerated.md deleted file mode 100644 index a07cafb6b61..00000000000 --- a/.changeset/16875-nav-recordid-viewname-tolerated.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`ObjectNavItem.recordId`'s docblock said it was "Mutually exclusive with `viewName`" — the guard tolerates that exact pair, deliberately - -The docblock read *"Mutually exclusive with `viewName` (viewName is ignored if -both are set)"*. The parenthetical was the tell: *"ignored if both are set"* -describes a **precedence**, not a refusal, so the sentence's own second clause -contradicted its first — and the code agrees with the second clause. -`recordId` + `viewName` parses clean through `NavigationItemSchema`; it is the -one legacy combination `objectNavTargetExclusivity` lets through, and that -guard's own docblock says so in as many words. - -**The harm direction is silent in both directions.** An author (or an agent) -who read "mutually exclusive" would avoid a combination the platform accepts, -or file a bug when it parses. Two docblocks in one file described one rule and -disagreed; the guard's was right. - -⛔ **No behaviour changes, and the asymmetry is not "unified".** The tolerance -is a recorded decision, and `app-nav-target-exclusivity-export.test.ts` already -pins `recordId` + `viewName` as accepted precisely so that making the target -fields pairwise exclusive goes red. This changeset corrects the **prose** only: -no schema, no guard, no accept set, no authorable key, no export moves. The -`.describe()` strings — the ones that reach `content/docs/references/` — are -untouched. - -The corrected docblock now says the pair is tolerated rather than refused, -names the guard that tolerates it, and points at the test that pins it. The -same test file gains a fifth leg asserting the docblock against the accept set -it describes, so the next copy of this sentence goes red instead of shipping: -prose is the only place the tolerated pair is documented, so nothing else was -watching it. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/spec`'s published `files[]` carries both -`dist` and `src/**/*.zod.ts`, and `src/ui/app.zod.ts` matches that glob — the -edited file is shipped as source verbatim. Measured on the built artifact as -well: the new sentence is present in **18** built files under `dist/` and the -old spelling in **0**, with two untouched sentences from the same region -(`navigate straight to the detail page`, and the `filters` docblock's own TRUE -exclusivity claim over `recordId` / `viewName`) present in **18** each as the -lit controls, so the zero is a reading and not a mistyped anchor. The -declaration files do not carry it — this is a field-level docblock inside a Zod -shape — which is why the reach is stated as the bundles and the shipped source -rather than as `.d.ts`. diff --git a/.changeset/16884-boot-refusal-comments-registered.md b/.changeset/16884-boot-refusal-comments-registered.md deleted file mode 100644 index 46e3d5e8a37..00000000000 --- a/.changeset/16884-boot-refusal-comments-registered.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/core': patch -'@objectstack/driver-sql': patch ---- - -Three source comments now state the registered position for the `door: 'none'` boot-refusal codes instead of the pre-#16404 one - -`SERVICE_NOT_REGISTERED`, `PLUGIN_CONTRACT_VIOLATION` and — as the worked -example the `driver-sql` comment cites — `MONGODB_MULTI_TENANT_UNSUPPORTED` are -all registered in `ERROR_CODE_LEDGER`. #16649 registered fourteen `door: 'none'` -codes under the #16404 door-or-no-door ruling, and re-registered the MongoDB one -that #8035 had removed. Three TSDoc comments still asserted the position that -preceded that ruling — that these codes are deliberately not wire vocabulary, -and that registering one is "not something to start doing at a door" — and each -was false the moment #16649 landed. They also pointed at -`dispatcher-error-vocabulary.ts`'s `boot-refusal` verdict, which the same PR -ratcheted from fourteen rows to zero, so the pointer dangled. - -These docblocks ship inside each package's `dist/*.d.ts`, which is why this is a -published change rather than an internal one: the sentence is what an agent or -an IDE reader sees at the point it decides whether the code needs registering. - -⛔ No behaviour changes. Every reachability sentence is kept verbatim — none of -these codes reaches an HTTP door on this tree — no code is added, removed or -re-registered, and no gate moves. With every comment character removed by -`scripts/js-comment-mask.mjs`, all three files' executable token streams are -byte-identical to the commit this branched from. diff --git a/.changeset/16885-retire-navigation-view.md b/.changeset/16885-retire-navigation-view.md deleted file mode 100644 index 3e3160f22cf..00000000000 --- a/.changeset/16885-retire-navigation-view.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING** — retire `ListViewSchema.navigation.view`, the detail-view binding nothing -ever resolved. - -`navigation.view` was an unconstrained string whose describe promised *"the form view to -use for details"*. No layer from spec to console ever resolved a view by that name. Its -one read in the shipped console passed the value into the **second argument of -`onNavigate`** — the slot that otherwise carries the navigation-MODE token — so an -authored name did not select a view, it **substituted for the mode**. A consumer in the -same bundle reads that argument against a closed two-value vocabulary (`edit` / `view`), -so any other authored value matched neither branch: invisible on grids whose handler -takes one argument, a dead row click on the ones that do not. - -The enumeration behind the removal was exhaustive rather than sampled — every `.view` -property read in the bundle (exactly three) and every `formViews` read — and **no read -anywhere is keyed by an authored view name**. There was no path by which the key could -resolve one. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-13 (director decision -batch #126 item 4, option B). Zero authored instances in this repository; the one -external author removed its occurrence. - -## FROM → TO - -| you wrote (17.4 and earlier) | write instead | -| --- | --- | -| `navigation: { view: 'summary_view' }` on a list view | `navigation: { }` — delete the key. Then publish the layout you wanted as a `record` page on that object and mark the one that should open `isDefault` | -| `navigation: { mode: 'drawer', view: 'edit_form' }` | `navigation: { mode: 'drawer' }` — the mode, size and every other key of the block are **unchanged** | - -**The one-line fix:** delete `view` from the list view's `navigation` block; to choose -what opens for a record, assign a `record` page to the object and let `isDefault` pick -the one that opens. - -Nothing regresses by deleting it: the key never selected anything. What decides how the -detail is surfaced is `mode` and `size`, and both are untouched. - -## The retirement kit - -- **`navigation.view`** — a `retiredKey()` tombstone on `NavigationConfigSchema`. `tsc` - types the key `never`, so writing it fails at the authoring site; a value reaching a - parse raises the prescription rather than a bare unrecognized-key report. Refused at - all three doors — `ListViewSchema`, `ObjectListViewSchema` and the flattened - `PUT /api/v1/meta/view` overlay — and pinned at each. -- **ADR-0087 disposition: a D3 SEMANTIC entry**, `list-view-navigation-view-retired`, not - a D2 conversion. A mechanical strip would delete the key without recording which list - view lost it, and an author who wrote it wanted a named detail layout — a want page - assignment serves and a stripped key does not record. So the TODO names the surface and - hands the judgement back, which is what a semantic entry is for. The tombstone - prescription therefore carries **no** `os migrate meta` sentence: that sentence is owed - only where a conversion covers the surface. -- **The five surviving keys of the block** — `mode`, `preventNavigation`, `openNewTab`, - `size`, `width` — are unchanged, and pinned accepting beside the refusal. A tombstone - that broke its live siblings would satisfy every refusal assertion while being a larger - bug; `navigation` is one closed shape, so that blast radius is the whole block. -- **`ui/NavigationConfig:view`** is registered in `RETIRED_KEYS_BY_MAJOR[18]`, which is - also what starts its aging clock. - -## What is deliberately NOT in this change - -`view/list/navigation`'s six children are unclassified in the liveness ledger because -`check-liveness` drills one level. That is #17424's subject and is cited here, not fixed: -the ledger row for `navigation` itself is untouched, and no row exists for `view` to -update. - -The sibling `objectui` contract twin — `ViewNavigationConfig`, a re-export of this very -type — is in the other repository and is left to it. Its parity pin authors -`{ view: 'summary_view' }` as a legal value, so it needs the tombstone pin before that -repo picks up a spec carrying this retirement. - -Clause-②: no - - diff --git a/.changeset/16894-kanban-config-titlefield.md b/.changeset/16894-kanban-config-titlefield.md deleted file mode 100644 index f35f9c0ebae..00000000000 --- a/.changeset/16894-kanban-config-titlefield.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`KanbanConfigSchema` now declares `titleField` — optional `z.string()`, the key the board already reads and the schema refused by name (#16894). - -`KanbanConfigSchema` is a `strictObject`, and it was the one item-titled view config of its family that omitted the key: `GalleryConfigSchema`, `TimelineConfigSchema`, `CalendarConfigSchema`, `GanttConfigSchema` and `ListMapConfigSchema` all declare `titleField` under the same name and the same `z.string()`. An author writing `kanban: { titleField: 'subject' }` — the spelling the renderer honours — was refused with `unrecognized_keys=["titleField"]`, while objectui's own mirror accepted it only by not looking. Declared here under the director seat's decision batch #87 (objectstack-ai/objectui#8367), confirmed by the maintainer verbatim 「批 #87 同意」. - -**Clause-②: yes (widening)** — one new declared key on a published, strict accept set, so the set a consumer writes against grows. Nothing previously admitted is refused, and nothing is retired. Contract-review tier. - -- **Optional, not required.** The shape is the one `CalendarConfigSchema` already writes down for this exact key: absence resolves through the ADR-0079 record display-name chain (`titleFormat` → `displayNameField` → type-aware derivation → `'Untitled'`), so requiring it would demand more than the renderer reads — the shape ruling #13748 forbids (「不要求超过渲染器真正需要的」). `TimelineConfigSchema` and `GanttConfigSchema` spell it required and are the two siblings this declaration deliberately does not copy. -- **No migration, no tombstone.** Nothing moves or is renamed: a board authored before this release parses unchanged, and `kanban.titleField` is simply no longer refused. -- **The generated projections move with it** — `authorable-surface/ui.json` gains `ui/KanbanConfig:titleField`, and the `ListView` / `ObjectListView` kanban shape lines in `content/docs/references/ui/view.mdx`, `content/docs/references/api/protocol.mdx` and `content/docs/references/data/object.mdx` gain `titleField?: string`. diff --git a/.changeset/16910-flow-edges-recordsof.md b/.changeset/16910-flow-edges-recordsof.md deleted file mode 100644 index 1bea0bde0b8..00000000000 --- a/.changeset/16910-flow-edges-recordsof.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -A flow's `edges` list no longer takes the whole authoring gate down when one of its members is not a record — the sibling list #16751's repair did not reach (#16910). - -`lintFlowPatterns` read `.label` off each member of `flow.edges` behind nothing but an `Array.isArray` check, which proves the LIST and never its MEMBERS. A YAML `edges:` list item left empty deserialises to `null`, so hand-written metadata turned `objectstack validate` into an uncaught `TypeError` out of a function contractually typed `(stack) => FlowLintFinding[]`: - -``` -edges:[null, valid] threw=YES TypeError: Cannot read properties of null (reading 'label') -edges:[undefined, valid] threw=YES TypeError: Cannot read properties of undefined (reading 'label') -``` - -A linter that throws instead of reporting fails hardest on exactly the documents it is most needed for, and the author gets a stack trace where a diagnostic belongs. - -- **The junk member is DROPPED, silently**, through `recordsOf` — the same coercion, from the same one home (`object-graph.ts`), that #16751 chose for the seven flow-NODE-list readers, so two sibling lists on one flow member cannot disagree about what a malformed member means. -- ⭐ **The valid edge beside it is still JUDGED.** "No longer throws" is half a contract: a guard that abandoned the list would satisfy it and would have traded the crash for silence. Measured against a control holding the same flow without the junk member, the surviving finding is identical in rule and location, and no finding is invented about an entry no author wrote. -- **Two rules, not one.** `os validate` runs the rule TABLE, so one throwing reader takes every other rule's verdict down with it: once `lintFlowPatterns` stopped throwing, the identical defect surfaced one file over in `validateStackExpressions`, which read the same list through the same double cast. Both are repaired here; repairing only the filed one would have left the gate down on the same document. -- ⭐ **Which reader actually carried the crash, measured by ablation** — both edge walks read `graph.edges`, not the flow's own list, because `collectFlowGraphs` re-exposes whatever array it is handed. Reverting `graph.edges` alone in either file reds the new cases (10 failures in `lintFlowPatterns`, 6 in `validateStackExpressions`); reverting either `flow.edges` coercion alone leaves them green. The two `flow.edges` coercions are therefore **defence in depth, not the load-bearing fix**, and are kept deliberately: they hand the COERCED array to `collectFlowGraphs` rather than the raw one, which is the discipline the node lists already follow, and they keep two sibling lists on one flow member reading the same way. ⛔ Read them as belt and braces, not as one repair written twice. -- **The producer's edge side is still member-blind.** `collectFlowGraphs` filters the nodes it hands out and forwards edges untouched, so `FlowGraph.edges` is declared `FlowEdgeParsed[]` and can contain a non-record. It does not dereference them today, which is why the consumer coercion is sufficient; that asymmetry is filed separately rather than widened here. -- **No new finding id and no new diagnostic.** On every well-formed document the output is byte-identical; the only behaviour that changes is on input that previously crashed. diff --git a/.changeset/16927-agent-tools-retirement-citation.md b/.changeset/16927-agent-tools-retirement-citation.md deleted file mode 100644 index 820b0915048..00000000000 --- a/.changeset/16927-agent-tools-retirement-citation.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The `agent.tools` rejection now says why ADR-0064 binds, so its `Proposed` status does not read as "not yet in force" - -An author who writes the retired `agent.tools` key gets the tombstone's -prescription, which rests the rule on **ADR-0064** (*"an agent's tool set is the -union of its surface-compatible skills' tools"*). Following that citation lands -on a record whose own header reads `**Status**: Proposed (2026-06-22)` and -carries a `🔶 Cloud-owned — superseded in part by cloud ADR-0025` callout. From -the record itself an author cannot tell that the rule still binds them — the -weaker reading is the one the metadata invites. - -ADR-0064 stays the cited authority, because it is the record that states the -invariant the key violated; **ADR-0109** (`Accepted — implemented (Phase 1)`) -names `agent.tools` nowhere and only *builds on* that invariant, so retargeting -the citation would send the author to a record that does not contain the rule -they broke. The message instead gains one clarifying clause: the `Proposed` / -cloud-owned status scopes the **runtime** half (tool resolution, which lives in -cloud `service-ai`), while the **authoring** half is in force in this repo and -ADR-0109 is the in-repo record carrying it. - -Prose only — the rejection, the retirement and the accept set are unchanged. diff --git a/.changeset/16929-page-assigned-profiles-removed.md b/.changeset/16929-page-assigned-profiles-removed.md deleted file mode 100644 index 90876d8ba7a..00000000000 --- a/.changeset/16929-page-assigned-profiles-removed.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING** — remove `page.assignedProfiles`, and answer `profiles:` / `assignedTo:` with the permission-set route instead of correcting an author into the retired vocabulary. - -`PageSchema` carried an authorable key named for the concept **ADR-0090 D2** deleted ("The Profile concept is removed — `isProfile` deleted, not deprecated"), and the schema's own alias table rewrote an authored `profiles:` **into** it — two files from `security/permission.zod.ts`, which answers the same word with *"`profiles` is not a PermissionSet field (ADR-0090 D2: no Profile concept)"*. One word, two opposite answers, depending on which schema received it. - -It also enforced nothing. Measured across this repository and objectui at the ruling: **zero readers** — every hit was a declaration, a generated artifact, prose, a `CHANGELOG` or a round-trip test — so a page that "assigned profiles" stayed open to every caller who could reach it, while the Studio form and four locale bundles told the author it was an access list. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12. - -## FROM → TO - -| you wrote (17.4 and earlier) | write instead | -| --- | --- | -| `assignedProfiles: ['sales_manager']` on a page | delete the key. Gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (`sys_position_permission_set`) | -| `profiles: [...]` on a page (the alias corrected it into `assignedProfiles`) | the same — the alias is now a refusal naming the permission-set route, and it never accepted the key anyway | -| `assignedTo: [...]` on a page | the same | - -**The one-line fix:** delete the key; page audience is the permission set's. - -`os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. - -## The retirement kit - -- **A `retiredKey()` tombstone, not a bare deletion.** `PageSchema` is still parsed from the `page` metadata-type root, so there is an author to teach: `tsc` types the key `never`, and a value reaching a parse raises the prescription rather than a bare unrecognized-key report. The key therefore stays in the walked shape, which is why its liveness row stays too (as `dead`, the `rls.priority` precedent) and why the authorable-surface baseline marks it `[RETIRED]` rather than losing the line. -- **The two alias entries are gone from `aliases` and present in `guidance`.** This narrows nothing: an alias table runs only from the `unrecognized_keys` path, so `profiles:` and `assignedTo:` were *already refused* — the entries only decorated the rejection, and they decorated it with the retired word. Measured before and after on the built artifact: same `issue.code`, same `path`, different text. -- **`page.form.ts`** — the `assignedProfiles` input and its `helpText: 'Profiles that can access this page'` are removed, and with them the four locale bundles that shipped it translated (`zh-CN` 「指定配置文件」, `ja-JP`「割り当てプロファイル」, `es-ES` "Perfiles asignados"). A form input for an unwritable key is the false-compliant UI half of a retirement. -- **Three records that asserted the key WAS enforced are corrected in the same change** — one place alone only moves the lie. `liveness/page.json` graded it `live` on the strength of an objectui bridge at `react/src/spec-bridge/bridges/page.ts`, a path that does not exist in that repo (the row itself stays, regraded `dead`: the tombstone keeps the key in the walked shape, so the row remains and records why). `api/protocol.zod.ts` and `metadata-protocol`'s search-sweep comment both said the page's "own audience gate" applied at page render; it did not, and a page has no audience gate of its own. - -## What an operator with a STORED page sees - -A `sys_metadata` `page` row written before this release can carry `assignedProfiles`. Nothing breaks at read: the ADR-0087 conversion `page-assigned-profiles-removed` (protocol 18) replays on rehydration and strips the key, so the row is served canonical. `os migrate meta --stored --apply` rewrites the rows so the warn stops; the next save through `PUT /api/v1/meta/page` heals one row the way it heals any pre-protocol shape. - -⚠️ The strip is the mechanical half only. The paired D3 semantic entry `page-assigned-profiles-audience-to-permission-set` carries the judgement: which permission set a given profile name corresponds to is not derivable by a walker, so each name in a retired list has to be re-expressed as a permission set plus a position. Deleting the key **changes no behaviour and closes no hole** — the page was already open to everyone who could reach it. It stops an unkept promise from being made. - - diff --git a/.changeset/16974-inbox-message-actor-id.md b/.changeset/16974-inbox-message-actor-id.md deleted file mode 100644 index 96048bdb3dd..00000000000 --- a/.changeset/16974-inbox-message-actor-id.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/service-messaging": minor ---- - -`sys_inbox_message` rows now carry **`actor_id`** — who caused the notification — and the actor travels there end to end from the `emit()` that raised the event. - -Until now an inbox row could not answer "did I cause this?". The actor stopped one layer upstream on `sys_notification.actor_id`, and the shipped default permission sets grant a member no read on `sys_notification`, so the value was behind an FK hop into an object the reader cannot open. Consumers implementing the standard "do not notify me of my own action" rule had nothing to compare, and the visible failure was the notification that says *you* just did the thing you just did. - -The path, one leg per seam, no new read anywhere: - -- **`Notification.actorId?: string`** (`channel.ts`) — the per-recipient unit every channel implementation consumes gains an optional member, with the same semantics as `sys_notification.actor_id`. -- **`emit()`** projects `EmitInput.actorId` onto that unit on the P0 inline path, and **`enqueueDeliveries`** snapshots it into the delivery row's payload on the P1 outbox path — beside the rendered title/body, under the rule the enqueue path already states in its own comment: an event edited after enqueue cannot rewrite an in-flight send. `DeliveryPayload.actorId?: string` is declared rather than left to that type's index signature. -- **The dispatcher** reads it back off that snapshot in `processRow`. It deliberately does **not** re-read `sys_notification`, which would cost one read per delivery and break the snapshot rule. -- **The inbox channel** writes `actor_id: n.actorId ?? null`, and `sys_inbox_message` declares `actor_id` as a `sys_user` lookup. - -**A digest row keeps `actor_id` null by construction.** A collapsed group has no single actor, so asserting "you caused this" over a message that also carries other people's events would be wrong; `processDigestGroup` sets no actor and the object's own description says so. - -**Existing rows read `actor_id` null**, which a consumer's `row.actor_id === currentUserId` evaluates as "not mine" — the pre-change behaviour for rows written before this release. Nothing is backfilled: the value was never captured on those rows, so any backfill would be invented. diff --git a/.changeset/17022-agent-dual-attribution.md b/.changeset/17022-agent-dual-attribution.md deleted file mode 100644 index 88dceaf2783..00000000000 --- a/.changeset/17022-agent-dual-attribution.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/core': minor -'@objectstack/objectql': minor -'@objectstack/plugin-audit': minor ---- - -Record the acting agent on the audit row — ADR-0090 D10 rule 4 dual attribution - -A `sys_audit_log` row written by an MCP OAuth client acting for a human used to -be byte-identical to a row that human wrote in the Console. The envelope carried -the delegation (`principalKind: 'agent'` + `onBehalfOf`), the row did not, and -nothing in between copied it: `assembleExecutionContext` consumed the OAuth -`azp` as a boolean and dropped the value, so the acting client did not exist -downstream of the door at all. - -The delegation now travels the whole way and lands on the row: - -- `ExecutionContext.performedBy` (`{ clientId }`) — decided at the `/mcp` OAuth - door, on the same branch that already decides `principalKind: 'agent'` and - `onBehalfOf`; a member of the closed entry field set like every other. -- `HookContext.provenance.performedByClientId` — the hook-layer carrier, beside - `flowRunId` and `attributedUserId`. Provenance, not `session`: no - caller-gating hook may read the client as the caller. -- `sys_audit_log.metadata` gains `{ performed_by, on_behalf_of }` on a delegated - write, and nothing at all on a personal one — the two shapes are told apart by - absence rather than by guesswork. - -Additive, and attribution only. `user_id` stays the human, so owner-stamping, -`current_user.*` RLS and the `sys_user` join are untouched (ADR-0073 D3 — -attribution is not ownership). `actor` is untouched too: ADR-0118 D1/D5 keeps -that column two-valued — a user id, or `null` for the system — and answers -"which non-user acted" with an added attribution field rather than a second -actor vocabulary. No existing row changes meaning, and no historical row is -rewritten. - -Rule 4's third element, the run id, is NOT delivered here and is not declared -either: nothing on the request path mints one today (`ExecutionContext.traceId` -is declared but resolved by no transport entry point), and declaring a carrier -nothing populates is the defect this change exists to close. diff --git a/.changeset/17053-list-view-sort-string-clause-retired.md b/.changeset/17053-list-view-sort-string-clause-retired.md deleted file mode 100644 index e0f886baea1..00000000000 --- a/.changeset/17053-list-view-sort-string-clause-retired.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `ListViewSchema.sort` retires the bare string clause — the PRODUCER half of the sort seam, so the contract stops minting documents its own consumer refuses (#17053; objectui#8221, decision batch #77 option B) - - - -**BREAKING** accept-set narrowing at `view.sort` — the list-view doors -(`ListViewSchema`, and the `ObjectListViewSchema` copy behind `object.list` / -`object.listViews.*`) — shipped as `minor` under this repo's launch-window -convention for breaking changes, the same grade its sibling -`object-block-sort-item-array` took for the two `ComponentPropsMap` doors. The -mechanical prescription is registered under protocol major 18 as -`list-view-sort-string-clause-to-array`. - -**Why this is graded on the seam, not on the string.** objectui ruled one sort -orthography platform-wide — the array (objectui#8221, decision batch #77, -2026-09-07, option B) — and objectui PR #8758 executes it: `convertSortToQueryParams` -refuses a runtime string and its diagnostic names the array form. `ListViewSchema` -is the producer of exactly those documents: `object.list.sort` is what -`deriveRelatedLists` reads. So until this release a view authored with -`sort: 'created_at desc'` **validated here, cleanly, and then failed downstream** — -the contract minting a shape its consumer rejects, with the author told off by -the wrong layer. Re-measured on this tree before the change, with `bogusProp` -refused by name on the same call as the firing control: `'name desc'`, `'-name'` -and the array form all returned `success: true`, and only a bare number was -refused (`sort/invalid_union`). - -`sort` survives as a key, one union arm lighter, so this is a VALUE narrowing with -no `retiredKey()` tombstone to hang a prescription on. The surviving array member's -own `error` map carries it, keyed on `issue.input` being a string — the same shape -`view.type`'s retired `'page'` value and `view.exportOptions`' retired `'pdf'` value -already use in this schema. Every other invalid value (a number, an object, a -string reaching a *descendant* such as a misspelled `order`) keeps zod's default -report, so nobody is told a clause they never wrote "was removed". - -**Migration** (`list-view-sort-string-clause-to-array`, a D2 conversion, not a -semantic TODO — the rewrite is lossless and wholly mechanical): -`sort: 'created_at desc'` becomes `sort: [{ field: 'created_at', order: 'desc' }]`; -a bare field name meant ascending, so `sort: 'created_at'` becomes -`sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required on the entry -and is written out rather than omitted; a comma-separated clause becomes one array -entry per key, in the same order. `os migrate meta --from 17` lists these edits for -author sources, and stored rows replay them through `applyConversionsToStoredItem`. - -**The narrowing was not free, and the population was measured rather than assumed.** -A tree-wide census over both the TS and JSON spellings of a string-valued `sort`, -read as STRUCTURES rather than counted as tokens, found the clause authored on -three live in-tree sites, all converted here: the shipped showcase list view -`examples/app-showcase/src/ui/views/task.view.ts` (`'estimate_hours desc'`, carried -since objectui#2601 as a deliberate live coverage fixture for the string form), the -frozen `packages/lint` snapshot of that same shipped shape, and the published -`skills/objectstack-ui` list-view rule. The census fired: it *found* documents, and -`tsc` independently reds on the first two the moment the arm is removed. Sites -deliberately NOT converted, having been read rather than grepped: ObjectQL -`query.sort` and the wire `normalizeSortNodes` (different doors, different -dialects), `packages/spec`'s `book`/`doc` field-mapping records whose `sort: 'order'` -is an unrelated key of the same name, and the `packages/lint` rule fixtures, which -feed the PRE-parse walker and never reach this schema. - -**Not moved by this release.** `RecordRelatedListProps.sort` keeps its declared -string arm. That string is the `'field'` / `'-field'` dialect normalised by -objectui's own `RelatedList.normalizeSortSpec`; it never reaches -`convertSortToQueryParams`, and retiring it was not ruled. For the same reason the -conversion above declines any clause that does not parse as ` [asc|desc]`: -guessing a direction for `'-name'` would invent an ordering the author never wrote, -so on a list view it meets the door's prescription instead. diff --git a/.changeset/17054-calendar-config-all-day-field.md b/.changeset/17054-calendar-config-all-day-field.md deleted file mode 100644 index 7c968a670ac..00000000000 --- a/.changeset/17054-calendar-config-all-day-field.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`CalendarConfigSchema` now declares **`allDayField`** — the fifth field binding on a calendar config, and the one key the rest of this package already published as a member while the schema refused it by name. - -**The trap this closes.** The `object-calendar` door refuses a flat `allDayField` and prescribes, verbatim: *"Write this as a key of the `calendar` config object instead — `calendar: { startDateField, endDateField, titleField, colorField, allDayField }`."* That block's `calendar` prop `.describe()` publishes the same five-key shape, and it ships to `content/docs/references/ui/component.mdx`. An author who followed the prescription on a stored view was refused a **second** time, by a different schema with a different message — `Unrecognized key(s) on this calendar configuration: allDayField` — and neither message said the key was not a member at all, so the natural next move was to assume a typo and try more spellings. - -**Why the schema was the wrong half, measured rather than assumed.** The key is honoured, not inert. At the objectui pin this repo builds against, `ListView`'s `collectViewFields` reads `calendar.allDayField` into the fetch projection and its calendar branch forwards the authored block onto the `object-calendar` node, where `getCalendarConfig` resolves it; objectui then made it load-bearing in the render itself. Trimming the prescription instead would have left a shipped capability with no protocol carrier — and the mirror that carries it today keeps `.passthrough()` explicitly so the key is not stripped, which means a later hardening there would silently drop it. - -**What is authorable, and what still is not.** - -```ts -// accepted -calendar: { startDateField: 'start_date', endDateField: 'end_date', - titleField: 'subject', colorField: 'status', allDayField: 'is_all_day' } - -// still refused — one key per concept, not a second authorable spelling -{ type: 'object-calendar', allDayField: 'is_all_day' } -``` - -`allDayField` **names a boolean field, not a value**: a record whose flag is true draws as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit it and the renderer's existing inference is untouched — an event with no end date draws as all-day — so every calendar that never authored the key renders exactly as before. - -**The opening is one key wide.** `defaultView` stays refused on this config: it is the renderer's initial view mode, a UI preference rather than a field binding, and it already has its own declared home as an `object-calendar` component prop. Unknown keys are refused in the same shape as before, and `startDateField` is still required. - -Purely additive: nothing that parsed before is refused now, and no key is renamed or removed. diff --git a/.changeset/17080-per-release-spec-changes.md b/.changeset/17080-per-release-spec-changes.md deleted file mode 100644 index a0cb92d2999..00000000000 --- a/.changeset/17080-per-release-spec-changes.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/cli': minor ---- - -feat(spec): `spec-changes.json` ships a per-release section, verified against both tarballs (#17080) - -Clause-②: yes (widening) — one new OPTIONAL section on a published artifact plus one new -`os validate --json` key. Nothing previously present is renamed, retired or reshaped: the -`aggregate` and `perMajor` records and every existing key keep their spelling and meaning. -Contract-review tier. - -`spec-changes.json` (ADR-0087 D4) is keyed to the **protocol major**, while this repo's -launch-window convention ships BREAKING entries as **minors**. A consumer crossing one minor -therefore reads a file whose finest question is "16 → 17" — answered long ago — with -`added: 0, removed: 0`, which reads as *nothing changed*. Measured on the published tarballs: -between `@objectstack/spec@17.3.0` and `17.4.0` the export surface gained **225** exports and -lost **51**, and the shipped manifest reported zero of each. - -**What ships now.** The published artifact carries a `release` section — `fromVersion` → -`toVersion` at package-version resolution, with `added` / `removed` (the exports that arrived -and left, each named `": ()"`) and `converted` / `migrated` (the ADR-0087 -D2/D3 entries first registered in that release): - -```bash -jq '.release | {fromVersion, toVersion, added: (.added | length), removed: (.removed | length)}' \ - node_modules/@objectstack/spec/spec-changes.json -os validate --json | jq .specReleaseChanges # the same data, via the CLI -``` - -**The committed copy is unchanged and stays deterministic.** The section is a function of a -previously *published* tarball, so it is generated at publish time only; `check:spec-changes` -keeps the registry-only projection in the tree exactly as it was. - -**A wrong change file is worse than none, so it is gated.** Before anything reaches npm the -release lane recomputes the delta from the two tarballs — the previously published one and the -one about to be published — and refuses to publish when the section disagrees, naming the -disagreeing exports and the direction of each disagreement. A release whose data would mislead -does not ship. - -**Absence stays distinguishable from zero.** When the previous tarball carries no export -snapshot the section is omitted rather than emitted empty, and `specReleaseChanges` is `null` -in exactly that case: a consumer must never read "could not be computed" as "nothing changed", -which is the defect this closes. - -New public exports on `@objectstack/spec`: `SpecReleaseChangesSchema`, -`SpecReleaseSurfaceSchema`, `composeReleaseChanges`, and the types `SpecReleaseChanges`, -`SpecReleaseSurface`, `PreviousReleaseRegistries`, `ReleaseSurfaceDiff`. diff --git a/.changeset/17081-dev-admin-banner-says-what-it-sees.md b/.changeset/17081-dev-admin-banner-says-what-it-sees.md deleted file mode 100644 index 263d025f937..00000000000 --- a/.changeset/17081-dev-admin-banner-says-what-it-sees.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): the boot banner's `🔑 Dev admin` says what that account will and will not see (#17081) - -`--seed-admin` (on by default in `os dev`) prints one credential, and it is the -**only** one a first-run operator is given. It is also, by construction, the -account with every *platform* capability and no *app-declared* one: its standing -is `admin_full_access`, whose `systemPermissions` are `setup.access`, -`studio.access`, `manage_users`, `manage_metadata`, `manage_platform_settings` -and `manage_sharing` — all platform built-ins — plus the `'*'` -view-all/modify-all record bits. - -So in any app that gates its apps, tabs or nav entries on -`requiredPermissions` — the filter `/me/apps` and `/meta/app` apply, and a -first-class platform feature the docs teach — the credential the terminal hands -over is the account that resolves to an **empty navigation**. A downstream -maintainer ran `pnpm dev`, signed in with it, and read the empty shell as a -broken product. The app was correct. The banner had asserted a login and said -nothing about its audience, and it outranks whatever the app's own README says, -because it sits directly under the command that was just run. - -FROM → TO, on a boot that seeds: - -``` - 🔑 Dev admin: admin@objectos.ai / admin123 - seeded on empty DB · dev only — do not use in production -+ platform admin — Setup, Studio and every record, but NO app-declared capability, so -+ an app that gates navigation on requiredPermissions may show it an empty menu; grant -+ it a permission set under Setup → Users, or sign in as an account your app seeds -``` - -**Nothing about the seed changes.** What the first run creates — the account, -its address, its password, its promotion to platform admin — is a product-shape -decision and is untouched; only the banner's words move. The three lines print -only inside the branch that already prints the credential, so a boot that seeds -nothing is byte-identical to before. - -Dim continuation lines rather than a warning, deliberately: ADR-0115's -`OS_ALLOW_DEV_PLUGIN` amendment excluded the dev-admin seed from that hazard set -because "a warning about a non-event spends the attention the real ones need". -That exclusion is kept — this qualifies an event that just happened, on the line -that already announces it, and adds no new line where there was none. - -The route the sentence names is asserted against the declarations that make it -reachable, not re-spelled: `SETUP_APP.requiredPermissions` is a subset of what -this account holds, the `Users` entry is ungated, and the `sys_user` detail page -carries the "Grant permission set" related list. A rename on any of those reds -the pin instead of leaving the banner pointing at nothing. diff --git a/.changeset/17093-scaffold-scim-retirement-note.md b/.changeset/17093-scaffold-scim-retirement-note.md deleted file mode 100644 index e5a729e2e2e..00000000000 --- a/.changeset/17093-scaffold-scim-retirement-note.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/cli': patch -'create-objectstack': patch ---- - -The scaffolded `pnpm-workspace.yaml` records the retired `@better-auth/scim>better-call` peer rule instead of advertising it as live - -`objectstack init` wrote a paragraph into every project it scaffolds explaining -an `@better-auth/scim>better-call` suppression that is not in the map it -annotates — the entry retired with objectstack#3653, and `init.test.ts` pins its -absence. All three of its claims were false on today's tree as well: -`@better-auth/scim` is not "held at a release candidate deliberately" (it is -pinned at exact stable `1.7.3`), and stable `@better-auth/scim@1.7.3` declares -`peerDependencies["better-call"]` as the exact string `1.4.0` — the single copy -`better-auth@1.7.3` itself depends on — so the `1.3.7` skew the paragraph -described does not exist. - -It now records the retirement, in the shape `create-objectstack`'s bundled -`blank` template already used, and dates the measurement the way the -neighbouring `better-sqlite3` paragraph in the same block does. Both scaffold -paths previously named `1.7.1` as the current pin; both now name the measured -`1.7.3`, so the two paths tell a user the same thing. - -Comments only — no declaration moves. The rendered `allowedVersions` map is -byte-identical before and after, so no resolution, lockfile or suppression -changes. diff --git a/.changeset/17108-element-text-variant-published-nine.md b/.changeset/17108-element-text-variant-published-nine.md deleted file mode 100644 index 70008c9eab2..00000000000 --- a/.changeset/17108-element-text-variant-published-nine.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`element:text.variant` accepts the nine values objectui's text node publishes — `h1`–`h6`, `body`, `caption`, `overline` — and still accepts `heading` and `subheading` (#17108). - -Clause-②: yes (widening) - -Release 1 of 2 for the objectui#7450 convergence (director batch #71, 2026-09-07, maintainer verbatim 「其他同意」), split across two releases by the maintainer's decision of 2026-09-09, option B. This release is **additive only**: the accepted set grows by seven and nothing is refused that was accepted before, so an out-of-repo author can converge on a released pin before any spelling stops working. - -Measured on the 17.3.0 declaration, per value, through `ElementTextPropsSchema.safeParse`: `h1`–`h6` and `overline` were refused with `invalid_value`; they are accepted now. `heading`, `subheading`, `body` and `caption` were accepted and are accepted now. A value outside the eleven — `small` — is still refused with `invalid_value` at path `variant`, so the enum remains a closed set rather than having stopped judging `variant` at all. - -- **`.optional().default('body')` is kept, deliberately.** An `element:text` node parsed without a `variant` still materialises `variant: 'body'`, exactly as before. Absence is the one thing a widening must not move, and the `ui:text` side of the platform deliberately does *not* synthesise `body` for an absent `variant` (objectui#6942) — that asymmetry is pre-existing and is left where it was. -- **⛔ Nothing is retired.** `heading` and `subheading` become named refusals carrying migration hints in **release 2**, which is a separate card and is blocked on a value-level retirement mechanism that does not exist yet: `retiredKey()` and ADR-0087 D2 retire a *key*, not a *value*. Authors who want to move early can write `h2` for `heading` and `h3` for `subheading`; neither spelling stops working in this release. -- **No renderer changes here.** `element:text`'s renderer, its designer inspector options and its i18n rows are objectui's, on the released pin, and land on objectui's side of the sequence. - -Generated projections follow the declaration: the `content/docs/references/ui/component.mdx` property table widens. `check:api-surface` reports nothing removed or narrowed. diff --git a/.changeset/17114-fold-admission-tenancy-classification.md b/.changeset/17114-fold-admission-tenancy-classification.md deleted file mode 100644 index ac07859dfc6..00000000000 --- a/.changeset/17114-fold-admission-tenancy-classification.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/runtime': patch -'@objectstack/mcp': patch ---- - -refactor(runtime,mcp): the last two admission doors classify the `tenancy` rejection through the shared `classifyAdmissionTenancyPosture` (#17114) - -`@objectstack/core`'s `classifyAdmissionTenancyPosture` is the one place the -#13906 decision 1 option A classification lives: a branded "never registered" -rejection is the supported no-tenancy composition and answers a quiet -`undefined`, while every other rejection becomes -`AuthzStoreUnavailableError('tenancy', err)` — ADR-0112 `SERVICE_UNAVAILABLE` / -503 — because the posture is an authorization INPUT and admission was never -decided. - -Two admission doors were still hand-writing that classification, out of the -declared scope of the fold that extracted it: - -- `@objectstack/runtime`'s `resolveExecutionContext` — the REST/dispatcher - entry-point identity resolver; -- `@objectstack/mcp`'s `resolveStdioTenancyPosture` — the stdio door's **async - kernel** leg. - -Both now call the shared function. ⛔ **No behaviour changes at either door.** -Tenancy posture decides which rows a caller may see, so a divergence between -copies would be two answers to "whose data is this", and the copies are the -stale ones by construction — the shared version is the one that will be -maintained. - -**The resolution stayed at each seam, deliberately.** The extractable part is -the classification, not the resolution: each door keeps its own accessor guard -and hands its own former accessor expression in as the thunk, so the helper -never learns *how* a seam reaches the service. A helper that owned the wiring -too would be wrong for one seam or grow a flag per seam. - -**One neighbouring leg is deliberately NOT folded.** The stdio door's **sync** -fallback is taken only on a `KernelBase`-shaped host with no `getServiceAsync`, -whose accessor reports its one possible fault — nothing registered under that -name — **unbranded**. Routing it through the shared classification would mint a -503 outage out of a supported composition, so its bare `catch` remains that -seam's recorded decision. A test arm now fails if that leg is ever folded. - -Shipped rather than `skip-changeset`: both packages publish `files[]: ["dist"]`, -and the built `dist` of each carries the new call (2 files each, measured after -a real build, with a symbol known-absent scoring 0 and -`isServiceNotRegisteredError` scoring 4 in `runtime/dist` as the lit control). -`@objectstack/mcp`'s `dist` no longer mentions `isServiceNotRegisteredError` at -all. diff --git a/.changeset/17124-daterange-array-arm-arity.md b/.changeset/17124-daterange-array-arm-arity.md deleted file mode 100644 index 2732009bb36..00000000000 --- a/.changeset/17124-daterange-array-arm-arity.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -fix(analytics): a `dateRange` array that is not a two-bound window is refused, once, instead of meaning three different things (#17124) - -`AnalyticsDateRangeSchema`'s array arm is a bare `z.array(z.string())` with no -length constraint, so `dateRange: ['2026-01-01']` is schema-valid and reaches the -analytics faces through `POST /analytics/dataset/query`, which types its selection -from `AnalyticsQuery` and never Zod-parses it. The four faces in this package that -read the arm answered it three different ways — measured over one authored -document and four rows: - -| face | `['2026-01-01']` meant | -|---|---| -| `ObjectQLStrategy.dateRangeBounds` | the point window `created_at >= '2026-01-01' AND <= '2026-01-01'` | -| `NativeSQLStrategy` | no time clause at all — the whole dataset | -| the draft-preview evaluator | an upper bound of the string `"undefined"`, which every ISO date sorts below — everything from that day onward | -| `DatasetExecutor`'s `compareTo` pass | the point window, shifted — compared against a primary pass that may have read all of history | - -For a dashboard that is one day's number, the whole dataset's, and everything -from that day onward, from the same document, decided by which backend answered. -`[]` and `[a, b, c]` split the same three ways, and `[null, null]` reached -`parseUTC(null)` as a bare `TypeError` — a 500 for a malformed request. - -One rule is now the single reading of the arm and all four faces call it; the -three divergent fallbacks are deleted. An array that is not exactly two string -bounds is refused with the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 -envelope — the answer the contract already gives for a `dateRange` that does not -denote a window. A two-element window is untouched on every face, bound for -bound, including the inclusive upper reading a caller's bounds keep (#16179) and -the half-open bare-day widening on the SQL side (#3777). - -### Write both bounds - -| wrote | write instead | -|---|---| -| `dateRange: ['2026-01-01']` | `dateRange: ['2026-01-01', '2026-01-01']` | - -That spelling already selects exactly that one day on every face, and it is the -same instruction #16322 shipped for the single-day string dialect. - -⭐ Shipped as `patch`, not as a breaking narrowing, because nothing DECLARED -moves. The spec's own refusal wording already states that *"an explicit window is -the two-element array [start, end] of ISO dates or {date-macro} tokens"*, and -#16322's shipped migration table already told authors to write a single day as -`['2026-01-20', '2026-01-20']`. A one-element array was therefore never a valid -document; it was an invalid one that four faces answered arbitrarily, and a -behaviour that was never one behaviour is not a behaviour this removes. The Zod -type admitting the shape is weaker than the contract the same file states — -tightening it is a separate, spec-owned question. diff --git a/.changeset/17135-field-consumers-synthesized-layout.md b/.changeset/17135-field-consumers-synthesized-layout.md deleted file mode 100644 index 9364b5b41c9..00000000000 --- a/.changeset/17135-field-consumers-synthesized-layout.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -`field-no-consumers` now reads two consumers that name the field nowhere in metadata — a declared field group placing it on the synthesized layout, and the column a seed or import mapping matches on (#17135). - -The rule's first run on a real application reported 12 fields, and all 12 were on screen or load-bearing that day. Both misses are now read off the spec rather than off a hand-kept list, the way the rule's other two exemptions already are: - -- **The synthesized layout.** `deriveFieldGroupLayout` (ADR-0085 §5) is the one derivation every renderer applies — form, detail, drawer and designer — and it places a field by its `group` membership, not by naming it in a `fields: [...]` array. A field the derivation puts in a **declared** group is therefore drawn, and is credited as a display site. The derivation's trailing untitled bucket is deliberately **not** credited: it collects everything the author did not place, so crediting it would hand the display verdict to every visible field in every app. -- **An upsert identity.** A carrier root holds values that are written and labels that are carried, and the root decided the bucket before anything else could ask. But a seed's `externalId` and an import mapping's `upsertKey` name the column the loader **matches on** — it reads that column on every row to decide insert from update. A seeder-only identity column is consumed by being an identity. - -⛔ Nothing exempts `hidden` as a category. A `hidden` field no upsert matches on and nothing reads is still reported, and a `hidden` field in a declared group earns nothing from the layout, because the derivation never draws one. - -Measured on `hotcrm@965933b` (the tree the 12 were reported on): **12 findings → 0**, with the synthesized layout accounting for 11 and the upsert identity for 2 (they overlap on one field). Against the same application with six deliberately unconsumed fields injected — ungrouped, undeclared-group, hidden-in-a-group, hidden + readonly, a field on an object declaring no groups, and the matched pair of a seeded identity against an identical declaration nothing matches on — all six are still reported and only the identity goes quiet. diff --git a/.changeset/17147-granted-permissions-registered-not-enforced.md b/.changeset/17147-granted-permissions-registered-not-enforced.md deleted file mode 100644 index 3eca2a115cd..00000000000 --- a/.changeset/17147-granted-permissions-registered-not-enforced.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/core': patch ---- - -Say what the install-time granted permission set actually does: it is REGISTERED at load and refuses nothing. - -Four shipped sentences claimed the structured `manifest.permissions` / `granted_permissions` set was enforced. Measured on `9bd4344e4`: `SecurePluginContext` — the only reader of `PluginPermissionEnforcer`'s service and hook gates — has zero production construction sites, and `enforceFileRead` / `enforceFileWrite` / `enforceNetworkRequest` are called by nothing at all, `SecurePluginContext` included. So #13457's binding registers a consented set that nothing queries, and the `fs` and `network` classes have no enforcement surface even in principle. - -Corrected, each to the same truthful split ("registered at load · queried by nothing · refuses no operation"): the `registerGrantedPermissions` docblock, the `PluginPermissions` schema docblock, the `manifest.loading` tombstone prescription, and the ADR-0087 D3 entry that ships that prescription into `docs/protocol-upgrade-guide.md`. The hand-written plugin development guide gains the same note beside its permission table. - -`plugin-runtime-tier-truthful-text.test.ts`'s coordination pin — which held the permissions half verbatim so it would go red the day that half was corrected — has been discharged and replaced by pins on the truthful text, in both carriers, each with the negative assertion that keeps the retracted sentence from returning beside it. - -New in `@objectstack/core`: `granted-permissions-not-enforced.pin.test.ts` pins the MEASUREMENT as well as the words, so the claim cannot rot in either direction. It fails the day a production `SecurePluginContext` construction site appears — i.e. the day the ADR-0025 materialize seam lands — and names every text that then becomes false. - -No behaviour changes: no accept/reject, no registration, no gate is added or removed. diff --git a/.changeset/17147-retracted-verb-repo-wide.md b/.changeset/17147-retracted-verb-repo-wide.md deleted file mode 100644 index b0f43a95543..00000000000 --- a/.changeset/17147-retracted-verb-repo-wide.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/core': patch ---- - -Sweep the retracted "enforces exactly the consented surface" phrasing repo-wide, not just in the file it shipped on. - -The #17147 pin read one file, and a post-merge sweep found what that missed: `artifact-granted-permissions.test.ts` carried the retracted sentence as a CASE TITLE — "a CONSENTED entry enforces exactly the consented surface" — beside a sibling titled "registered, and denies". Neither case asserts a refusal; both read a permission bag and check what it answers. But a case title is read as evidence (ADR-0033), and those two said the platform confines plugins while nothing on the tree queries the registry at all. - -Both titles now name what they assert, the file carries a verb-discipline note (`answers` / `registered` / `bound`; ⛔ never `enforces` / `denies` / `gates` / `refuses` / `blocks` until the seam exists), and the pin's negative assertion is a repo-wide `git grep` excluding only its own specimen — with an anti-vacuity limb so a broken scan cannot read as a clean one. - -No behaviour, no assertion semantics, and no accept/reject changes. diff --git a/.changeset/17152-d3-per-family-house-rule.md b/.changeset/17152-d3-per-family-house-rule.md deleted file mode 100644 index 9cadf56b8a5..00000000000 --- a/.changeset/17152-d3-per-family-house-rule.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Corrects the ADR-0087 D2/D3 house-rule prose across the migration chain's doc comments (`packages/spec/src/migrations/types.ts`, `packages/spec/src/migrations/registry.ts` and `packages/spec/src/migrations/spec-changes.ts`) so it states the ruled convention: every retirement family gets one D3 `semantic` entry, even when a lossless D2 conversion also exists for it, and D2 carries the mechanical data repair only (#17152). - -Clause-②: no - -The shape ruled superseded (director-seat class-one self-adjudication on #17152, authority: the maintainer's ruling on #15954) was stated in several places, each in its own words: `types.ts`'s module docblock said "breaks with no lossless mapping"; `types.ts`'s `SemanticMigration`/`MigrationStep`/`reason` doc comments said "A non-lossless change", "Why this is not losslessly convertible" and "Non-lossless changes authored for this major"; `registry.ts`'s module docblock said "the non-lossless residue D2 could not express"; and `spec-changes.ts`'s `SpecMigratedSchema` said "A semantic (non-lossless) migration" with a `rationale` `.describe()` of "Why it is not losslessly convertible". All are corrected to the same meaning: a D3 entry is owed per retirement family regardless of whether D2 is lossless, and `reason`/`rationale` now say why the consumer still owes a judgment rather than why the change cannot be losslessly converted. - -No entry, gate or runtime behaviour changes; this is a doc-comment correction, filed as `patch` because the `types.ts` prose ships verbatim into the published `dist/index.d.ts` / `dist/index.d.mts` (measured: `grep` after a real build finds each corrected sentence there, with `MigrationStep` and `MIGRATION_SUPPORT_FLOOR` as positive controls proving `dist` is readable). `registry.ts`'s docblock does not ship to `dist` at this head (same positive controls, zero hits either wording). `spec-changes.ts`'s `.describe()` text ships in the runtime bundles (`dist/index.js`, `dist/index.mjs`, `dist/browser/*`), not in the `.d.ts`, `json-schema/**` or `spec-changes.json` (measured the same way, with `SpecMigratedSchema` as the `.d.ts` positive control). `check:spec-changes` / `check:upgrade-guide` / `check:authorable-surface` all report their generated artifacts unchanged. Both are corrected for the same reason — each is prose the card and the ruling target. diff --git a/.changeset/17157-cache-warmup-scheduled-strategy-retired.md b/.changeset/17157-cache-warmup-scheduled-strategy-retired.md deleted file mode 100644 index 6b7a6394c1d..00000000000 --- a/.changeset/17157-cache-warmup-scheduled-strategy-retired.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: retire the `scheduled` cache-warmup strategy — the cron it selected left in this same major, and nothing ever warmed on a cadence (ADR-0049) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` (the -lockstep convention: `major` is refused by `check-changeset-no-major`, and breaking-ness -is carried by this banner plus the ADR-0087 disposition above). - -`CacheWarmup.strategy` no longer accepts `'scheduled'`. - -| | before | after | -|:--|:--|:--| -| accept set | `'eager' \| 'lazy' \| 'scheduled'` | `'eager' \| 'lazy'` | -| describe | `… lazy (on first access), scheduled (cron)` | `… lazy (on first access)` | -| a document writing it | parsed green | **refused**, with the prescription | - -**The one-line fix:** write `strategy: 'eager'` (warm at startup) or `strategy: 'lazy'` -(warm on first access). For a warmup on a **cadence**, declare a `job` — that is the one -cron slot this platform evaluates: - -```ts -defineStack({ - jobs: [{ name: 'warm_config_cache', schedule: { expression: '0 * * * *' }, handler: 'warmConfigCache' }], -}); -``` - -## Why - -`cron-typed-positions-retired` (17.x → 18, #16320) deleted `CacheWarmup.schedule`, the -cron key this enum member selected, and left the member standing on the reading that it is -"a value, not a position the ruling names". That was a statement about that ruling's -**scope**, not a finding that the value was sound. After the deletion the member declared a -warmup cadence with **no key left to configure it and no engine that has ever run one**, -while its own `.describe()` still promised `(cron)` — ADR-0049 declared-not-enforced, in -the form Prime Directive 10 names outright: a capability advertised that the runtime does -not deliver. - -Nothing on the platform reads `CacheWarmupSchema`: outside its declaring file it resolves -to the generated reference page's import line, the `declaration-map` / `export-origins` -catalogues, the ADR-0058 D7 ledger comment and two of this package's own test files — zero -runtime consumers, measured beside a lit control (`ConnectorSchema`, 46 files, same sweep). -So **no runtime behaviour changes**: no warmup has ever run on a schedule, before or after. -What changes is that the contract stops promising it. - -## The retirement kit - -- the member leaves `z.enum(['eager','lazy','scheduled'])` and the `.describe()` stops - saying `(cron)` (`system/cache.zod.ts`) -- the prescription hangs on **the enum's own `error` map, dispatched by `issue.input`** — - the established route for an enum-VALUE retirement (`crypto.hash` on - `HookBodyCapability`, `object.managedBy: 'system'`, `HotReloadConfig.stateStrategy`). - There is no value-level analogue of `retiredKey()` and none is invented here. Only the - value that **used to be legal** gets the "was removed" sentence; `strategy: 'sheduled'` - keeps zod's own enum message, which already lists the legal values -- an **ADR-0087 D3 semantic entry**, `cache-warmup-scheduled-strategy-retired` — a semantic - entry rather than a D2 conversion because there is **no source to rewrite**: `CacheWarmup` - is bound to no metadata type and embedded in no stack collection, so no authored document - and no stored row has ever carried this value, and `os migrate meta` has nothing to list. - That is also why the prescription carries **no `os migrate meta` sentence** — it would - promise a listing the tool cannot produce, which is the very defect this card is about -- **nothing in `RETIRED_KEYS_BY_MAJOR`** — no authorable *key* changed — and **no - `retiredKey()` tombstone**, which tombstones keys, not values -- pin tests (`system/cache.test.ts`): the refusal and its prescription, a **lit control** - that a typo is *not* told it "was removed", and that the surviving members and the - `'lazy'` default still parse. `cron-typed-positions-retirement.test.ts`'s warmup fixture - moves to `'eager'`, since a fixture must be well-formed under the current schema - -## ⚠️ The four surface ratchets are byte-identical across this change, and that is correct - -An enum-VALUE narrowing moves no position, no exported name and no expression-typed slot: -`authorable-surface/` keys on **positions** (`system/CacheWarmup:strategy` stays — the key -is untouched), the ADR-0058 D7 ledger on **expression-typed slots**, and `api-surface/` / -`json-schema.manifest/` on **names**. None of them reads a def's *value set*, so none of -them can fail on this change — the `crypto.hash` precedent measured exactly this. The pin -tests above are therefore not a formality: they are the only instrument this retirement -has, and a green CI run on its own says nothing about whether the value is gone. diff --git a/.changeset/17158-export-job-family-retired.md b/.changeset/17158-export-job-family-retired.md deleted file mode 100644 index 7c2ecc44ec1..00000000000 --- a/.changeset/17158-export-job-family-retired.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING** — the export-job API family, the `IExportService` contract and `ScheduleState` leave the public surface (#17158). - -A `major`-class change, recorded as `minor` under the launch-window convention. Maintainer ruling A (decision batch #122 item 3, 「同意」), landing route A (decision batch #221 item 2, 「同意」: objectui retired its side first, in objectui#10247), and a scope note (「同意」) that puts the export-job list pair in; ADR-0049 enforce-or-remove. - -**Why.** `@objectstack/spec` declared a complete asynchronous export API — create a job, poll its progress, fetch a download link, list jobs, schedule a recurring export, cancel — and nothing on the platform served any of it. `@objectstack/rest` mounts no `/api/v1/data/export` route and no `POST` on `/api/v1/data/:object/export`; `IExportService` had no provider; and no package, example, app or skill in this repository, in objectui at the pinned sha, or in cloud read any of the names. An AI following the generated API reference wrote calls that answer `404`. The scheduled-export shapes were worse than unserved: after the cron positions were deleted earlier in this release line, `ScheduledExport.schedule` and `ScheduleExportRequest.schedule` were REQUIRED blocks that could hold no schedule, so an author who filled in the `timezone` believed they had scheduled something. `ScheduleState` described the runtime state of a scheduled flow that no scheduler ever wrote or read. - -### FROM → TO - -| removed | from | what to write instead | -| --- | --- | --- | -| `ExportJobStatus`, `CreateExportJobRequestSchema` / `CreateExportJobResponseSchema`, `ExportJobProgressSchema` (with their types and `…Parsed` aliases) | `@objectstack/spec/api` | nothing — no route ever created or tracked an export job. To export records, call the served synchronous door `GET /api/v1/data/:object/export` (the SDK's `data.export`), which answers the file itself as CSV, JSON or XLSX. | -| `GetExportJobDownloadRequestSchema` / `GetExportJobDownloadResponseSchema`, `ListExportJobsRequestSchema` / `ListExportJobsResponseSchema`, `ExportJobSummarySchema` (with their types and `…Parsed` aliases) | `@objectstack/spec/api` | nothing — no job ever existed to download or list. | -| `ScheduledExportSchema`, `ScheduleExportRequestSchema` / `ScheduleExportResponseSchema` (with their types and `…Parsed` aliases) | `@objectstack/spec/api` | a `Job` (`system/job.zod.ts`) whose handler performs the export, with its cadence on `Job.schedule.expression` — the one cron slot the platform evaluates. | -| `ExportApiContracts` | `@objectstack/spec/api` | nothing — every route it named was unserved. | -| `IExportService`, `CreateExportJobInput`, `CreateExportJobResult`, `ExportJobDownload`, `ListExportJobsOptions`, `ExportJobListResult`, `ScheduleExportInput` | `@objectstack/spec/contracts` | nothing — no provider ever bound the contract. | -| `ScheduleStateSchema`, `ScheduleState`, `ScheduleStateParsed` | `@objectstack/spec/automation` | nothing — a scheduled flow declares its cadence on its start node (`config.schedule`), and its run history is `ExecutionLog` / `FlowRunSummary`. | - -**The one-line fix: delete every import of the names above, and every request to `/api/v1/data/export/…` or `POST /api/v1/data/:object/export`.** The compiler finds the imports (`TS2305: Module '"@objectstack/spec/api"' has no exported member …`); a hard-coded path has to be searched for. No behaviour is lost — none of those requests was ever answered. - -**What stays.** `ExportFormat`, `ExportImportTemplateSchema`, the import validation shapes and the whole import-job family in the same module — `ImportJobStatus`, `CreateImportJob…`, `ImportJobProgress…`, `ListImportJobs…`, `ImportJobApiContracts` — are served and unchanged, as is `GET /api/v1/data/:object/export`. - -**Read together with the cron-positions retirement in this release.** That entry says `ScheduledExport.schedule` / `ScheduleExportRequest.schedule` keep their `timezone` and `ScheduleState` keeps `timezone`, `status` and `nextRunAt`; this retirement removes those defs whole, so none of those shapes remains to carry them. - -⚠️ Runtime behaviour is deliberately **unchanged**: nothing ever mounted a retired path or parsed a retired shape, so every request answers exactly as before. The removal retracts a false claim, not a capability. **No deprecation window** (startup-stage posture: retirements take effect immediately). - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** Inside this repository the names occurred only in their declarations, their own tests, generated artifacts and prose; objectui at the pinned sha names none of them in code (it retired its unimplemented async-export path in objectui#10247), and cloud names none; `@objectstack/spec` is published, so readers elsewhere were not measured. - -The ADR-0087 D3 semantic entry `export-job-family-retired` carries the judgement, and the thirteen defs are registered in `RETIRED_DEFS_BY_MAJOR[18]`: none of these shapes is a stack collection or a metadata type, so there is no source for a D2 conversion to rewrite and no carrier key for a tombstone. - -Clause-②: no - - diff --git a/.changeset/17159-etl-retirement-syncconfig-sentence.md b/.changeset/17159-etl-retirement-syncconfig-sentence.md deleted file mode 100644 index e1d66cef4bc..00000000000 --- a/.changeset/17159-etl-retirement-syncconfig-sentence.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the `etl-pipeline-layer-retired` D3 entry stops promising that connector-attached sync is EXECUTED - -The entry's `replacement` string is an ADR-0087 D4 projected field: it ships verbatim in -`packages/spec/spec-changes.json` (twice — the flat entry and the composed record), which is -in this package's `files[]` and therefore in the published tarball, and it renders into -`docs/protocol-upgrade-guide.md`. It is the advice an author displaced by the ETL layer's -retirement actually reads, and it said connector-attached synchronisation is -`ConnectorSchema.syncConfig`, "which IS parsed and executed". - -Parsed is true. Executed never was, and this tree measures it: - -- `AutomationEngine.registerConnector` / `registerDegradedConnector` - (`packages/services/service-automation/src/engine.ts`) run `ConnectorSchema.parse(def)` and - store the parsed definition in the engine's connector map. Only `actions` is read back off - it; `syncConfig` is never read. -- `syncConfig` has no reader outside `packages/spec` at all — the only non-spec occurrences in - `packages/` are two comment lines in the D7 expression-conformance ledger. That is the same - measurement that retired `syncConfig.schedule` in 18 under ADR-0049, and it is already - stated at the schema (`integration/connector.zod.ts`). - -The corrected sentence says what the block IS and what actually happens to it — parsed and -validated, then inert — and then names the surface that IS executed, so the reader still has -somewhere to go: a connector's `actions`, dispatched by a flow's `connector_action` node, -which resolves the registered handler and awaits it. - -Nothing about the ETL retirement itself changes: no key moves, no accept set moves, no schema -changes. The registry, `spec-changes.json` and the upgrade guide were regenerated by their -generators, and the corrected claim is pinned in `migrations.test.ts` beside the other -projected-string corrections so it cannot regress. diff --git a/.changeset/17166-object-grid-export-options-describe-members.md b/.changeset/17166-object-grid-export-options-describe-members.md deleted file mode 100644 index fa6e4ad06d2..00000000000 --- a/.changeset/17166-object-grid-export-options-describe-members.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`ComponentPropsMap['object-grid'].exportOptions` names all five members the renderer reads, not two - -The entry is `z.unknown()`, so nothing about this key is parsed, refused or -stripped: a member that does not exist draws no error and has no effect, and a -member that does exist cannot be discovered from the schema. That makes the -`.describe()` string the entire account of the key's shape rather than a summary -of an enforced one — and it projects straight into -`content/docs/references/ui/component.mdx`, which is what an author (or a -generating model, ADR-0033) reads. - -It named two members, `formats` and `streaming`. The only renderer reads five. - -Measured at the `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` -— objectui `packages/plugin-grid/src/ObjectGrid.tsx`, through the -`schema.exportOptions` expression and the `exportConfig` local bound to it, with -objectui's own scanner (`ObjectGrid.exportOptionsKeys.test.ts`, whose -comment/string stripping is what stops a prose mention of a key being counted as -a read): `formats` 2 read sites, `streaming` 2, `maxRecords` 1, -`includeHeaders` 1, `fileNamePrefix` 1, and an absent-name control -(`zzzNotAMember`) 0 on the same instrument — which is what makes those five -counts readings rather than a matcher that matches anything. The same instrument -answers the same five, with the same per-member counts, at objectui -`3fbdd4a2dae1`, so the set is not an artefact of the pin's age. - -The three missing members are `maxRecords`, `includeHeaders` and -`fileNamePrefix`. An author reading the old string learned that -`exportOptions` takes `{ formats, streaming }` and had no way to reach the other -three short of reading the renderer's source — the shape objectstack#8010 -closed for this same key one layer out, when `streaming` was read for releases -while no schema declared it. - -⛔ The key is unchanged: it stays `z.unknown()` and no accept set moves in either -direction. Giving `exportOptions` a real shape is a separate and much larger -change with its own review requirements; this is the docs half only. - -The new list is not restated in prose that can drift on its own. A pin holds the -describe string's member enumeration equal to the members -`ListViewExportOptionsSchema` declares — the spec's own five-key declaration of -this same authoring block, reached through `ListViewSchema.exportOptions`'s -object branch and itself derived from that same read set. Both spellings reach -one renderer, so narrowing or widening the declared block now reds the -`z.unknown()` prose instead of leaving it quietly behind: the declared side has -parse failures to catch drift, this side had nothing. The pin also records that -the key is unvalidated today, so the day it grows an accept set is a deliberate -decision rather than a silent one. - -`content/docs/references/ui/component.mdx` is regenerated from the string -(`gen:schema` then `gen:docs`) and carries the same one-line change. diff --git a/.changeset/17167-organization-probe-records-empty-channel.md b/.changeset/17167-organization-probe-records-empty-channel.md deleted file mode 100644 index a09236e8cc5..00000000000 --- a/.changeset/17167-organization-probe-records-empty-channel.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -The seed-tenancy backfill's organization probe records the operator channel as is — an empty one included — instead of the placeholder `'unknown error'` (#17167) - -`packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts` had one site left -that did not follow the rule the rest of the file follows. Where the other four -`operatorFacingErrorText` calls record the helper's return value as is, the -`sys_organization` probe spelled `operatorFacingErrorText(e) || 'unknown error'`, so a -backend that failed WITHOUT saying anything was recorded as having said -`'unknown error'` — words no backend produced, in a field an operator reads to find out -which probe failed and why. - -**Measured before and after**, driving `backfillSeedTenancy` at each site in that file -with the same three empty-channel shapes (a thrown `''`, a thrown `[]`, an `Error` whose -`name` and `message` are both empty) and with `new Error('boom')` as the control: - -| site | before | after | -|---|---|---| -| split probe → `result.detail` | `''` | `''` | -| **organization probe** → the warning's `organizationProbeError` | **`'unknown error'`** | **`''`** | -| duplicate-list probe → the warning's `error` | `''` | `''` | -| stamp → the warning's `error` | `''` | `''` | -| counter merge → the warning's `error` | `''` | `''` | - -The control records `'boom'` at every site in both columns. - -**Why this was not a one-line deletion.** The placeholder was carrying two jobs and only -one of them was a record: the site also read `organizationProbeError === ''` as "the probe -did not fail", which is how a failed probe is kept out of the benign `no-organization-yet` -branch (#9261 — unknown is not zero). Deleting the placeholder and putting nothing in its -place was measured: a thrown `''` then reports `no-organization-yet` and warns about -nothing, while the control still reports `skipped-ambiguous-organization`. So the failure -fact moved into the TYPE — `organizationProbeError` is `string | undefined`, `undefined` -means the probe answered, and every string, empty or not, is a failure. The text is then -free to say exactly what the backend said. - -**What does NOT move.** No status value changes for any input: an organization probe that -throws still reports `skipped-ambiguous-organization`, whatever its channel holds, and -`SeedTenancyBackfillStatus`, `SeedTenancyBackfillResult` and every exported signature are -unchanged. This probe's text never reached the returned result in the first place — it is -carried only by the warning this migration logs (measured: the control text appears in -`result.detail` at the split-probe site and appears nowhere in the returned object at this -one). - -**One operator-visible detail beyond the text.** The warning's structured field is now -absent when the probe answered and present-but-empty when it failed silently, so "empty" -and "there was no failure" stay distinguishable in the stored line — the one job the -placeholder was doing that a reader could have depended on. The sentence in the same -warning drops its parenthetical rather than filling it in: `the sys_organization probe -FAILED, so the count above is "unknown"` when the backend said nothing. diff --git a/.changeset/17175-non-raising-table-presence-probe.md b/.changeset/17175-non-raising-table-presence-probe.md deleted file mode 100644 index 4b5a313531f..00000000000 --- a/.changeset/17175-non-raising-table-presence-probe.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -The `kernel:ready` migrations ask whether a table exists WITHOUT running a statement that has to be refused, so a normal boot stops printing `[sql-driver] DATABASE_ERROR … no such table` (#17175) - -Two migrations on the boot hook asked "does this table exist?" with a statement -that cannot succeed when the answer is no — `SELECT "tenant_id" FROM -"_objectstack_sequences" WHERE 1 = 0` in `seed-tenancy-backfill.ts`, and `SELECT -1 FROM sys_setting WHERE 1 = 0` in `sys-setting-identity-index.ts` — and read -the refusal as "no". Both are correct on their own terms. Both make -`SqlDriver.execute()`'s raw terminal write the statement and the dialect's -message to the operator's log on the way out. - -Measured on this tree against real `better-sqlite3`: exactly one line per probe, -on `console.warn` — i.e. **stderr** — carrying both the `DATABASE_ERROR` token -and `no such table`. It fires on **every boot** of every install that has never -allocated an autonumber, and again on every boot of every kernel that does not -register the optional `service-settings`. - -⭐ The cost is not the line. It is that operators learn this product prints -errors when nothing is wrong, and then miss the one that matters. A consumer told -to read the boot log (`objectstack-ai/hotclm`'s `AGENTS.md` names `no such table` -as a failing boot) must either ignore an unactionable ERROR every boot or chase a -platform-internal probe. - -**The question is now asked of the CATALOG.** A new shared -`migrations/read-probe.ts` compiles one arm per dialect family — `sqlite_master` -for SQLite, `to_regclass` for Postgres, `information_schema.tables` scoped with -`DATABASE()` for MySQL — each of which returns zero rows for a table that is not -there instead of being refused. Both migrations call it; the probe lives once, -not once per site. - -**⛔ Why not in the driver.** Quietening a refusal requires classifying it, this -repo has one predicate for that (`isMissingTableError`), and it needs the name of -the thing the caller was reading — which the raw path structurally does not have -(`rawStatementFaultError` declares no targeted table, and -`driver-error-classification.callers.test.ts` fails any in-repo call that omits -`readObject`). An unclassified demotion of the driver's raw terminal would -quieten real failures too. The caller knows the table; the driver does not. - -**⛔ The fence, and it is the one way this repair can go wrong.** A catalog arm -mis-compiled for some dialect would be refused, caught by the same `catch` the -expected miss uses, and read as "the table is not there" — turning a stored-row -data repair into a silent no-op on whichever dialect nobody exercised. So the -probe answers four verdicts rather than a boolean, and `'unreadable'` is never -folded into `'absent'`: it is returned, and reported at `warn`. An unrecognised -dialect gets no guessed catalog statement at all — it keeps the caller's own -`WHERE 1 = 0` probe, whose refusal is now *classified* with -`isMissingTableError(error, table)` rather than swallowed as absence. - -**Why `minor`.** - -- `SeedTenancyBackfillStatus` gains `'unreadable'`. It is an OUTPUT union, so no - input a caller writes is affected; the one consumer shape that could break is - an exhaustive `switch` with a `never` default, which is why this is not a - `patch`. -- `ensureSysSettingIdentityIndex` gains an optional third parameter - (`{ client? }`). Callers that pass two arguments are unchanged and keep - today's behaviour exactly — without a client there is no catalog arm and the - pre-existing probe runs. -- `buildSequencesPresenceSql` and `buildSysSettingPresenceSql` are unchanged in - text and still exported. They are no longer what the boot path runs first. -- `isResultSet` and `normalizeRows` moved to `migrations/read-probe.ts` and are - re-exported from `seed-tenancy-backfill.ts` unchanged, so the package index and - every importer see no difference. - -**What did NOT change.** #10789's ruling stands: a seam that accepts a statement -and returns no result set still reports `absent` with the `detail` that separates -it. The driver's error channel is untouched — a statement the backend genuinely -refuses is still written to the log in full, asserted against the same driver and -the same sink in the same test as the silence. - -**Dialect coverage, stated rather than implied.** The SQLite arm is pinned end to -end against a real `SqlDriver` (`packages/runtime`'s -`seed-tenancy-autonumber-split.integration.test.ts`); the MySQL arm runs against -the live server in `seed-tenancy-backfill.live-mysql.test.ts`, in both directions -and with the connected-schema scope measured. ⛔ The **Postgres** arm is NOT -MEASURED against a live server: this package has no live-PG harness, no `pg` -dependency, and its CI leg supplies `OS_TEST_MYSQL_URL` only while filtering to -`live-mysql`. Its statement text is pinned; running it is not. diff --git a/.changeset/17177-seed-summary-declares-its-scope.md b/.changeset/17177-seed-summary-declares-its-scope.md deleted file mode 100644 index ba396c90f33..00000000000 --- a/.changeset/17177-seed-summary-declares-its-scope.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Seed loader: the pass-2 deferred-reference diagnostics now say which moment they describe - -`SeedLoaderService` runs inside `AppPlugin.start()`, which the kernel completes for every -plugin before it fires `kernel:ready` — where the first-admin handoff -(`claimSeedOwnership`) re-owns every `owner_id IS NULL` row of every user-authored object. -That handoff is the designed completion of a NULL owner column, so two of the loader's -pass-2 lines — `Deferred reference UNRESOLVED after pass 2` and -`Deferred reference back-fill FAILED` — were making a bare present-tense claim -(`x.owner_id stays NULL`) that the same boot then made false, with nothing in either the -log or the table to tell an operator that the other reading existed. - -Both lines now read `is NULL at the end of pass 2` and carry a scope sentence naming the -boot step that can supersede them and stating that a non-NULL value found later is not -evidence the reference resolved. Level, error count and remedy are unchanged — this is a -scope declaration, not a silencing. The two `Deferred reference DROPPED` lines are -deliberately untouched: they report a row that never landed, so no later boot step can -write a column of it and their claim survives to the end of boot as written. - -Nothing an author writes changes. Anything that greps the loader's output for the literal -`stays NULL` on these two lines should grep for `is NULL at the end of pass 2` instead. diff --git a/.changeset/17178-seed-write-execution-context-export.md b/.changeset/17178-seed-write-execution-context-export.md deleted file mode 100644 index 1ec0b5f451e..00000000000 --- a/.changeset/17178-seed-write-execution-context-export.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-protocol': patch -'@objectstack/runtime': patch -'@objectstack/verify': patch ---- - -`@objectstack/spec/kernel` exports `SEED_WRITE_EXECUTION_CONTEXT`, the one spelling of the seed-write posture every seeder now reads - -The execution context a seed write must use — `isSystem`, `skipTriggers`, -`seedReplay` — had **no exported form**, so every seeder held a private copy of -it and nothing held the copies equal. There were three on `main`: -`SeedLoaderService.SEED_OPTIONS` (`@objectstack/metadata-protocol`), -`SEED_WRITE_OPTIONS` (`@objectstack/runtime`'s `AppPlugin`, whose own docblock -already recorded that it "mirrors" the first) and `SEED_CONTEXT` -(`@objectstack/verify`'s fixture writer, which spelled it a third time -specifically because the runtime kept its copy module-private). - -**Why a shared constant rather than three accurate copies.** `skipTriggers` is -what suppresses "on create" automation for seed rows, and `isSystem` alone does -**not** suppress dispatch. A seed path that lost that flag once seeded with -automation live while the main path had it suppressed — a self-trigger loop that -wedged first boot (#3760). A constant whose divergence re-opens a boot-wedging -defect is a kernel semantic, not a local detail. - -**What is exported, and what deliberately is not.** The **inner** -`ExecutionContext` value, and nothing wrapped around it: - -```ts -import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; - -await ql.insert(object, rows, { context: SEED_WRITE_EXECUTION_CONTEXT }); -``` - -The `{ context: … }` options bag stays at the call site. It is what all three -sites ultimately hand to `insert`, but it is an options envelope rather than the -posture: its type differs per engine method, so freezing one bag onto the -protocol surface would serve `insert` and no other operation, and it is -precisely the convenience bundle this export is not. - -⛔ **No behaviour change.** The value is byte-identical to all three previous -copies, the three flags keep their existing meanings, and no seed path changes -what it writes or how. The three former copies now read this export, so the two -option bags are `{ context: SEED_WRITE_EXECUTION_CONTEXT }` and the `verify` -context is the export itself. - -**Additive, so `minor` on `@objectstack/spec`**: one new name on the existing -`./kernel` entry point, no existing export removed, renamed or narrowed. The -three consumers take `patch` — their published `dist` changes (an import edge, -and the constant now resolves through `@objectstack/spec/kernel`) while their -own public surfaces do not move. diff --git a/.changeset/17189-app-capability-not-high-privilege.md b/.changeset/17189-app-capability-not-high-privilege.md deleted file mode 100644 index 3b84fef065d..00000000000 --- a/.changeset/17189-app-capability-not-high-privilege.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): an app-declared capability token is not a platform system permission at the `everyone` anchor - -`describeHighPrivilegeBits` counted **any** non-empty `systemPermissions` as a -high-privilege bit, so a permission set carrying the capability token its own -app declared could not be bound to the `everyone` audience anchor: - -``` -FROM describeHighPrivilegeBits({ systemPermissions: ['clm_requester.access'] }) - -> 'system permissions' // the app's own navigation gate, refused -TO describeHighPrivilegeBits({ systemPermissions: ['clm_requester.access'] }, - { declaredCapabilities: ['clm_requester.access'] }) - -> null -``` - -One list carries two unlike things: the platform's own powers (`manage_users` -and friends) and a capability a package **declared for itself** (ADR-0066 D1, -entering `sys_capability` with `managed_by: 'package'` + `package_id` -provenance). An app whose navigation gates on its own token therefore could not -ship the set every employee holds — the set's own gate made it unbindable — and -authors were pushed toward declaring no gates at all, the opposite of what -ADR-0066 D1 exists to encourage. - -**The discriminator is provenance, not spelling.** Both predicates -(`describeHighPrivilegeBits`, `describeAnchorForbiddenBits`) take a new optional -`AnchorBindingContext` naming the capability names *this stack declared*; a -token on that list is the app's own gate and is not counted. ⛔ A naming-syntax -rule (dotted ⇒ app token) was considered and rejected: it misjudges in silence -the first dotted platform permission — `setup.access` is one today — and the -first undotted app token. - -**What is still refused**, each pinned in `high-privilege.test.ts`: - -- a platform capability name, **however it is declared** — a package declaring - `manage_users` cannot launder it past the gate (the platform floor); -- any token absent from the declared list, and every token when no list is - passed — omission gets the pre-change verdict, so the narrowing fails closed; -- a mixed set: one unexcused token still refuses the whole set; -- the `guest` tier (ADR-0090 D9), which does not honour the excusal at all — - D5 speaks for authenticated members, and anonymous visitors are not that. - -**No shipped behaviour moves in this release.** Every current caller invokes the -predicates with the old arity, and with no context the code path is identical — -so this release widens the API, not any live anchor binding. The -`@objectstack/plugin-security` boot refusal and the `@objectstack/lint` -`security-anchor-high-privilege` rule pass the declared list in a follow-up, in -the ruled order (protocol first). - -ADR-0090 D5's offending-bit list is revised to match in its own governed PR -(objectstack#17814), per the ruling's 「ADR-0090 修订单独受管 PR」: the offending -bit is a `systemPermissions` entry naming a **platform** system permission. -Both halves are phase ①; ⛔ neither lands without the other following. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/spec`'s published `files[]` ships `dist`, and -the new code reaches it. - -Counts below are taken on a **clean full build of this head** — an empty `dist`, -then `pnpm --filter @objectstack/spec build` with both passes (JS and DTS): exit -0, `check-dts-emitted` reporting 34/34 declaration files, and -`dist/.build-input-hash` and `.build-input-hash-dts` both matching `src`. The -build state is named because it changes the answer: on a JS-only `dist` — one -still mid-DTS, or built under `OS_SKIP_DTS` — every declaration file is missing -and each count below that reaches one is halved. - -| identifier | built files | where | -|---|---|---| -| `declaredCapabilities` | **4** | `security/index.js`, `index.mjs`, `index.d.ts`, `index.d.mts` | -| `AnchorBindingContext` | **2** | `index.d.ts`, `index.d.mts` — a type, so the declarations are its whole published reach | -| `appDeclaredCapabilityNames` | **2** | `index.js`, `index.mjs` — module-private, so it has no declaration presence at all | -| `describeHighPrivilegeBits` | **4** | the positive control: a symbol already known to ship | - -Negative control: a sentence occurring **only** in the ADR revision — `As first -written, the bullet above made` — occurs in **0** built files, and `docs/adr/**` -is in no package's `files[]`. ⚠️ The control has to be a sentence the source -does not also carry: `The platform floor is absolute` reads 2, not 0, because -that sentence is in this predicate's JSDoc as well as in the ADR, and an emitted -JSDoc reaches `index.d.ts` / `index.d.mts` like any other declaration text. diff --git a/.changeset/17210-oauth-register-name-trap-prose.md b/.changeset/17210-oauth-register-name-trap-prose.md deleted file mode 100644 index 986ddc0ea5f..00000000000 --- a/.changeset/17210-oauth-register-name-trap-prose.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/client': patch ---- - -`oauth.applications.register`'s docblock says where a plain `name` IS honoured, and that it is not this route - -A caller who wants to name an OAuth client reaches for `name`. On the route this -method posts — the provider's `/oauth2/create-client` — that member is not in -the body schema and is stripped: driven on a real socket, the call answered -**201** and the value was absent from the response, from `applications.get`, -from `applications.list`, and `null` in the `sys_oauth_application` row's `name` -column. Nothing in the answer says so. - -The spelling is not wrong everywhere, which is what made it worth writing down: -`POST /api/v1/auth/sys-oauth-application/register` — the session-required -ObjectStack mount behind the Console's *Setup → OAuth Applications* form — -answered **200** to the same body, mapped `name` onto `client_name`, and set -that column. That mount is `disposition: 'server-only'` in the auth route ledger -and objectstack#17210 ruled it stays that way, so no SDK method builds its URL. - -The docblock now states both halves where the caller reads them: post -`client_name` to name a client from here, and `redirect_uris` must arrive -pre-split — the newline-separated-textarea split is the Console wrapper's, not -this route's. - -Docblock only. No method is added, no request or response type changes, and the -ledger row is untouched — but the text ships inside `dist/*.d.ts` as editor -hover, so it is a `patch` rather than a no-publish change. diff --git a/.changeset/17212-find-failure-log-level-warn.md b/.changeset/17212-find-failure-log-level-warn.md deleted file mode 100644 index f7c7e0275c3..00000000000 --- a/.changeset/17212-find-failure-log-level-warn.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a failed `find` reports at `warn`, not `error` — the caller was already told (#17212) - -`find` ends its `catch` with `throw e`, and one frame down `reportFindFailure` -logged every failure it did not classify as a missing table at ERROR. AGENTS.md -→ *Degradation log levels* names that exact shape and forbids it: "a failure -handed to the CALLER is not a degradation at all … Do not bolt a `logger.error` -onto such a site." It is the read-door twin of the write doors' move to `warn` -(#17052). - -**Nothing else about the entry moved.** Same message (`Find operation failed`), -same `object` meta, and the message and stack still travel with it: the `Logger` -contract gives an `Error` slot to `error`/`fatal` only, so the engine builds the -`{ error: { message, stack } }` meta that slot used to build — handing the Error -to `warn` as meta would have serialised `{}`, because those two fields are -non-enumerable. The throw is unchanged, and so is the missing-table branch, -which stays at `debug` without a stack. On the SQL read path the fault is also -reported one frame down on the driver's own `warn` line, as before. - -If you grep your logs for this message, keep the message and drop the level -from the pattern. If you alert on error-level lines from `@objectstack/objectql`, -a failed read no longer raises one — the read's exception still does. diff --git a/.changeset/17215-oauth-register-redirect-uris-optional.md b/.changeset/17215-oauth-register-redirect-uris-optional.md deleted file mode 100644 index b8e4f62fe75..00000000000 --- a/.changeset/17215-oauth-register-redirect-uris-optional.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): `oauth.applications.register` declares `redirect_uris` optional, matching the body schema of the route it posts to (#17215) - -`ObjectStackClient.oauth.applications.register` declared `redirect_uris` **required**. `POST /api/v1/auth/oauth2/create-client` is mounted verbatim from `@better-auth/oauth-provider`, and that route's body schema declares the member **optional** — so a request the route accepts had no spelling through this SDK. The caller never got a wrong answer; they got a call they could not write. - -## What changes for a caller - -Nothing they have to do. Every existing call still compiles — this only *adds* spellings: - -```ts -// now expressible, and accepted by the route: -await client.oauth.applications.register({ client_name: 'My App' }); - -// unchanged, and still the right call when you have redirect URIs: -await client.oauth.applications.register({ - client_name: 'My App', - redirect_uris: ['https://app.example.com/cb'], -}); -``` - -⛔ Not breaking in this direction — relaxing a required member to optional keeps every existing call valid. Tightening it back later would be breaking, which is why the parity is now pinned. - -## Measured at runtime, not read off a `.d.ts` - -The vendor body schema was re-introspected the way the card's original measurement was taken: instantiate `oauthProvider()`, walk `endpoints`, find the endpoint whose `path` is `/oauth2/create-client`, read `options.body`. At the installed **1.7.3** (the card measured 1.7.2; the package has since moved) the object still declares **21 members and every one of them is optional**, and `body.safeParse({ client_name: '…' })` succeeds with `redirect_uris` absent. - -⚠️ Optional does **not** mean an empty array will do: the vendor refuses `[]`, so when the member is present it must be non-empty. Omitting it and passing `[]` are different requests and only the first is legal. Nor does it mean a client registered without redirect URIs is *usable* — it cannot complete an `authorization_code` flow. The type states what the route accepts, never that every accepted call yields a client fit for every grant; the docblock now says both. - -## Why it was required, for the record - -Not as a guard. It is residue from the method's first commit, which declared `client_name` required too; the same-day follow-up relaxed `client_name` and left this one behind. No comment, test, ADR or review thread ever asserted a reason for it — which is exactly why it read as a defect to the next auditor. - -Nothing else on the signature moves: the other ten members are byte-identical. diff --git a/.changeset/17231-multi-value-column-storage-notnull.md b/.changeset/17231-multi-value-column-storage-notnull.md deleted file mode 100644 index 23160665027..00000000000 --- a/.changeset/17231-multi-value-column-storage-notnull.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -`storage.notNull` now binds a multi-value column, as ADR-0113 says it does - -`SqlDriver.createColumn` decides the JSON column shape before its per-type -switch, and it `return`ed there — above the ADR-0113 nullability line and above -the column DEFAULT. So `storage: { notNull: true }` on a multi-valued field was -silently inert on the platform's own table, while both `os generate migration` -formats emitted the constraint from the same declaration: - -``` -{ d_multi_notnull: { type: 'lookup', reference: 'sys_user', multiple: true, storage: { notNull: true } } } - -field driver sqlgen tsgen -d_multi_notnull null=YES null=NO null=NO ← before -d_multi_notnull null=NO null=NO null=NO ← after -``` - -One declaration, two databases: an INSERT omitting the field was accepted by the -platform's own table and refused by every table built from a generated -migration. - -ADR-0113 P0 names this site verbatim — 「the physical constraint now keys off the -explicitly-authored `storage.notNull` at that same `#createColumn` site」 — and -carves out no field type. `storage.notNull`'s only declared exclusivity is -`requiredWhen`, at the parse seam, so `multiple: true` + `storage.notNull` is an -authorable declaration this site was dropping on the floor. The differ, the -ADR's other named consumer in this package, never had the gap: `fieldHasColumn` -answers the multi-value question first and the nullability comparison then runs, -so the platform reported DESTRUCTIVE `tighten_not_null` drift against tables it -had just created itself, with no rows in them. That self-inflicted report is -gone. - -⚠️ Not the destructive ceremony ADR-0113 routes around. `createColumn` runs on -`CREATE TABLE` and on `ALTER TABLE ADD COLUMN`, so the column constrained here -is always EMPTY — the same reason the string family's #11431 note gives for -sizing a `varchar` at this site. Imposing `NOT NULL` over an EXISTING column's -possibly-null data stays `tighten_not_null`, destructive category, behind -`os migrate apply --allow-destructive`, untouched. - -⛔ Not a widening, and nothing else acquired the constraint: `multiple: true` -alone still produces a nullable column, and `required: true` alone still does -too — it is the write-time contract the engine enforces, never the column -(ADR-0113). The column DEFAULT is still not emitted on this path either: the -multi-value shape has no scalar DDL form, and `os generate migration` skips it -for the same recorded reason, so the two producers already agreed there. diff --git a/.changeset/17234-signin-signup-session-envelope.md b/.changeset/17234-signin-signup-session-envelope.md deleted file mode 100644 index ce8d290c5d1..00000000000 --- a/.changeset/17234-signin-signup-session-envelope.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): `/sign-in/email` and `/sign-up/email` now attach the `session` their declared `SessionResponse` envelope requires (#17234) - -Both routes answered `{ token, user }` (`/sign-in/email` also carries -`redirect`) with no `session` member anywhere in the body or the response -headers, so `SessionResponseSchema.safeParse` on `auth.login()` / `auth.register()`'s -return value always reported a `data.session` issue — the second of two -departures measured on #17234 (`success` was closed in the previous round). - -**The fix is a read, never an invention.** better-auth stores sessions in the -database by default and `internalAdapter.createSession` is awaited to -completion — including the write — before either endpoint returns its -`{ token, user }` body (measured against the installed `better-auth@1.7.3`, -`dist/db/internal-adapter.mjs:247-319`). So the row the response's own `token` -names is already committed by the time this repo's global `after` hook runs. -The fix reads it back through `internalAdapter.findSession(token)` — the exact -seam `/get-session` already uses for `data.session` — and attaches it. No id or -expiry is ever fabricated; a read that fails for any reason (no -`internalAdapter`, no row, any error) leaves the response exactly as -better-auth wrote it. - -``` -FROM POST /api/v1/auth/sign-in/email -> 200 { redirect, token, user } -TO POST /api/v1/auth/sign-in/email -> 200 { redirect, token, user, session } - -FROM POST /api/v1/auth/sign-up/email -> 200 { token, user } -TO POST /api/v1/auth/sign-up/email -> 200 { token, user, session } -``` - -`session` is the SAME row a following `/get-session` call reads (same `id`, -same `expiresAt`, same `userId`) — one row read twice, not two arrangements — -and `session.token` is the same UNSIGNED credential the body already carried -at `token` / `data.token`, not a second credential this fix introduces. - -⛔ **No wire byte moves on any other member.** `token`, `user`, `redirect` are -byte-identical; `data.token` and the client's auto-`this.token = data.token` -are unchanged and pinned. `auth.me()` / `auth.refreshToken()` (`/get-session`, -#16760) are untouched — this change is scoped to the two credential-issuing -routes. - -This is additive on an already-declared field — `SessionResponseSchema.data.session` -existed in `@objectstack/spec` before this card; the two routes simply did not -serve it. No schema changes, no new exported symbol, no new key on any -published payload. diff --git a/.changeset/17235-sessionuser-image-nullish.md b/.changeset/17235-sessionuser-image-nullish.md deleted file mode 100644 index 7909ad02b36..00000000000 --- a/.changeset/17235-sessionuser-image-nullish.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`SessionUser.image` is declared `z.string().nullish()` — a string, `null`, or the key absent are all accepted — so a signed-in user who never set an avatar parses against the schema this platform publishes (#17235). - -`z.string().optional()` admitted a string or the key's absence, and refused `null`. better-auth owns the avatar column, stores it nullable, and serialises it present-and-null, so every `/auth/*` session body the platform produces carried a value the declaration rejected. Measured through a real `AuthManager` (better-auth 1.7.2) over a real `ObjectQL` on a real `SqliteWasmDriver`: `get-session`, `sign-up/email` and `sign-in/email` all serve `"image": null` for a freshly signed-up user, and the full envelope failed on exactly that one path: - -``` -SessionResponseSchema.safeParse(await client.auth.me()) - -> [{ path: ["data","user","image"], code: "invalid_type", - message: "Invalid input: expected string, received null" }] -``` - -That parse now succeeds on all three routes. - -- **The declaration was the thing that was wrong.** AGENTS.md Prime Directive #12's default — fix the producer, never widen the consumer — rests on a premise it states out loud, that we own both ends. We do not: the nullable column belongs to a third-party model, so PD #12's own exit clause ("change the spec only when the spec itself is genuinely wrong, and then deliberately") is the operative sentence. Normalising `null` away at the producer seam was considered and refused: it is a permanent rewrite layer between the platform and a dependency's data model. -- **A pure widening, and nothing else.** `.nullish()`, not `.nullable()`: the key's ABSENCE is a legal shape today and no producer was ever measured omitting it, so `.nullable()` would have retired a live shape as the price of admitting `null`. Every body legal before this change is still legal. -- **Still refuses what it should.** A number and an object are rejected at `data.user.image` exactly as before; the only accept-set row that moved is `null`. -- **No key is added or removed** — `image` was already authored and already published, so no authorable surface moves and nothing is retired. diff --git a/.changeset/17260-object-kanban-quick-add-retired.md b/.changeset/17260-object-kanban-quick-add-retired.md deleted file mode 100644 index 4a611078cd8..00000000000 --- a/.changeset/17260-object-kanban-quick-add-retired.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: retire `ObjectKanbanProps.quickAdd` — the `object-kanban` board forwarded it and nothing ever read it (ADR-0049) - - - -**BREAKING** — `quickAdd` is retired from the `object-kanban` component props. Executes the -objectui#8285 director-seat ruling (decision batch #91, 2026-09-08, standing maintainer -delegation), ruled **option B**: the key leaves the board and stays only on the `kanban-ui` -block, where a React host can supply the runtime function the control needs. - -| | before | after | -|:--|:--|:--| -| `object-kanban` | `quickAdd: true` parsed clean and did nothing | refused by the tombstone, with the prescription | -| `kanban-ui` (objectui block) | the control works when the host passes `onQuickAdd` | **unchanged** | - -**What was actually wrong.** Measured at the `.objectui-sha` pin this repo builds against -(`53ded82bf`): the board FORWARDS the key — `ObjectKanban.tsx:931` spreads the authored bag -into `KanbanRenderer`, which passes `quickAdd={schema.quickAdd}` alongside -`onQuickAdd={schema.onQuickAdd}` (`plugin-kanban/src/index.tsx:196`) — but `KanbanImpl` -gates the affordance on **both** (`:355`, `:368`), and `onQuickAdd` is a host-supplied -FUNCTION that JSON cannot carry and that no producer puts on an `object-kanban` node. -`ObjectKanban.tsx` names neither half of the pair (0 occurrences each, against 6 for the -sibling `onCardClick` in the same file), so the gate was permanently false. - -**And the drop was not silent, which is what made it worse than silence.** objectui's html -tier reported the published key as `unknown-prop` — the same diagnostic a typo gets — and -its registry↔spec ledger records it as `ESCALATED (object-kanban.quickAdd — measured NOT -honoured)`. An author following the published contract met a tool that contradicted it, with -nothing in either message to say which side was wrong. The tombstone collapses both halves -onto one answer. - -## What to write instead - -Nothing, on this board: there is no per-column quick-add affordance on `object-kanban` and -there never was one. Delete the key. - -```ts -// before — parsed clean, rendered nothing -{ type: 'object-kanban', properties: { objectName: 'crm_task', groupBy: 'status', quickAdd: true } } -// after -{ type: 'object-kanban', properties: { objectName: 'crm_task', groupBy: 'status' } } -``` - -The control itself is not withdrawn from the platform. It stays on the `kanban-ui` block, -which a React host renders directly and can hand the `onQuickAdd` slot to — that is what the -ruling preserved deliberately. - -Existing sources: `os migrate meta --from 17` lists the mechanical edits; apply them by hand. - -The retirement kit: - -- a `retiredKey()` tombstone on `ObjectKanbanPropsSchema` — `tsc` types the key `never`, and - a value reaching the parse raises the prescription rather than a bare unknown-key verdict -- the D2 conversion `object-kanban-quick-add-removed` (`RETIRED_KEYS_BY_MAJOR[18]` entry - `ui/ObjectKanbanProps:quickAdd`, wired into the protocol-18 chain step) — a **pure lossless - delete**, since the key never had an effect to preserve, scoped by component `type` so the - live `kanban-ui` spelling stays out of its reach -- the `authorable-surface/ui.json` row becomes `ui/ObjectKanbanProps:quickAdd [RETIRED]`, and - the generated reference page prints the prescription in place of the old describe -- the schema docblock's read-point list is corrected in the same stroke: it named `quickAdd` - among the keys reached "via the forwarded schema", a sentence true about the FORWARD and - false about the READ — which is how the key kept re-authorizing itself -- pin tests (`ui/component.test.ts`): the refusal carries the prescription; a clean parse does - not materialize the key; and the control pair separating the tombstone's answer from the - strict unknown-key arm's, so a shape that had merely DROPPED the key could not pass -- no liveness-ledger row (component props are not an enrolled ledger type) and no form or - i18n edit: zero `object-kanban` components are authored anywhere under `examples/` or - `apps/` (control: `object-grid` 3, `object-metric` 8 in the same corpora, same instrument) -- `api-surface/` is unchanged, correctly: it ratchets export existence, and no export leaves — - `ObjectKanbanProps` still exists, one key narrower diff --git a/.changeset/17265-nested-hook-refusal-is-a-rejection.md b/.changeset/17265-nested-hook-refusal-is-a-rejection.md deleted file mode 100644 index 777560ecd98..00000000000 --- a/.changeset/17265-nested-hook-refusal-is-a-rejection.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -A sandboxed hook's business refusal reached through a script action answers 4xx, not `500 INTERNAL_ERROR` - -`POST /api/v1/actions/:object/:action` answered **`500 INTERNAL_ERROR`** when a -`beforeUpdate` hook refused a state transition for a business reason and the -refusal travelled out through the action body's `ctx.api` write. The same refusal -has answered **`400`**, with the hook's sentence verbatim, on `/data` since -objectstack#11588. A 500 tells every client "the platform broke", so a -well-behaved one retries, alerts or pages for a guard that will never say yes. - -**Where the producer was.** Not in the action route's classifier — that read the -shape it was handed correctly, and both sides of the line it pins (`a deliberate -REJECTION is a 400` / `an unexpected FAULT is a 500`) are unchanged. The refusal -arrived already stripped of every mark that says "a body reported this on -purpose", one VM hop earlier: `hostErrorToVm` marked **every** `SandboxError` -crossing into the action body's VM as the sandbox's OWN fault (objectstack#4431) -on an `instanceof` test — and a nested sandboxed hook's refusal *is* a -`SandboxError`, wrapped by the same runner one level down. The pump branch that -reads that marker then discarded `innerMessage`, `code`, `status` and `fields`, -and the classifier read the missing business message as a crash. - -**What changed.** The marker now asks the question the `/data` door asks — -`sandboxBusinessMessage`, objectstack#11588 — instead of testing the error's -class. Both of that predicate's conditions travel, because both are load-bearing: -a capability denial carries no business message and stays a fault, and a nested -body that **crashed** carries `TypeError: …` and stays a fault too. - -**No status was picked for this route.** It matches what `/data` already answers -for the same producer: the status the body declared, or `400` when it declared -none. A refusal that declares `{ status: 409, code: 'RECORD_LOCKED' }` now -reaches the caller as `409 RECORD_LOCKED` instead of losing both. - -**The sentence a caller receives is byte-identical to what the 500 carried** — -this moves the status, not the prose. The flattened `SandboxError: ` name prefix -is stripped on the rejection path by the same helper the fault path already used. - -No authorable key, accept set or export surface moves; no consumer needs a -change. Clients branching on 5xx to decide whether to retry will stop retrying -these refusals. diff --git a/.changeset/17274-invitations-resend-team-placement.md b/.changeset/17274-invitations-resend-team-placement.md deleted file mode 100644 index c5559fb4b84..00000000000 --- a/.changeset/17274-invitations-resend-team-placement.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): `organizations.invitations.resend` forwards `teamId`, so resending a team invitation keeps its team (#17274) - -`resend` has declared `teamId?: string | null` since the `organizations.*` family's first commit and has never forwarded it. The re-invite it issues carried `email`, `role` and `organizationId` only, so a caller resending a TEAM invitation passed the team, the compiler accepted it, the request succeeded — and the invitation landed with no team. Nothing refused, nothing warned, and the success path carried no trace of the loss. The published type is the contract a caller reads, and it promised a placement the call could not make. - -**Which of the two repairs this is, and what decided it.** The card left the direction open between forwarding the member and deleting it, and required the endpoint to be DRIVEN rather than read off the vendor's types. Driven — a real `AuthManager` (better-auth 1.7.3, organization plugin, `teams: { enabled: true }`, the posture `auth-manager.ts` hard-wires) over a real `SqliteWasmDriver`, with the SDK's own `fetch` handing each `Request` to `AuthManager.handleRequest`: - -| body sent to `POST /organization/invite-member` | answer | -|:--|:--| -| `{ …, teamId: '' }` | `200`, and the invitation's `teamId` is that team | -| `{ …, teamId: 'team_does_not_exist' }` | `400` `Team not found` (`TEAM_NOT_FOUND`) | -| `{ …, teamId: null }` | `400` `[body.teamId] Invalid input` (`VALIDATION_ERROR`) | -| `{ … }` — no `teamId` member | `200`, and the invitation's `teamId` is `null` | - -Row 1 settles it: the endpoint accepts a team on this call, the placement is stored on the invitation row and read back by `invitations.list`. Deleting the member would therefore have removed a capability the wire really has, so it is forwarded. - -**It is not forwarded verbatim, and rows 3 and 4 are why.** `null` is this SDK's own spelling of "no team" — `invitations.list` answers `teamId: string | null`, and handing that object straight back to `resend` is the ordinary way to resend. The vendor's spelling of the same fact is ABSENCE. A bare spread would put `teamId: null` on the wire and convert today's silent drop into a `400` for every round-tripping caller: a second defect wearing the fix's clothes. So `invite` lifts `teamId` out of the spread and sends it only when it is a string; `null` and an omitted member both send no `teamId` at all. ⛔ Nothing else is normalised — an unknown id keeps reaching the vendor, because `TEAM_NOT_FOUND` is the loud refusal that replaces the silent drop. - -**`organizations.invite` gains the same `teamId?: string | null` member.** It is the only route `resend` has to the wire, and declaring the member is what lets the placement be typed rather than smuggled. Purely additive on a published request type: every existing call compiles and sends byte-identical requests, which the sibling byte pins on `invite` assert unchanged. - -`resend` also stops spelling its own `role ?? 'member'` and takes `invite`'s default instead — one family, one substitution, no second copy to drift. Behaviour-neutral: an omitted or explicitly-`undefined` `role` still reaches the wire as `'member'`, in the same position, and a caller-named role still survives. - -Pinned in `packages/client/src/organization-invitation-resend-team-placement.test.ts`: the placement over the real vendor, its read-back through `list()`, the `TEAM_NOT_FOUND` refusal, the `null` round trip that fails on the verbatim forward, and full-string equality on the request bytes for both methods. diff --git a/.changeset/17281-metadata-drops-dead-platform-objects-dep.md b/.changeset/17281-metadata-drops-dead-platform-objects-dep.md deleted file mode 100644 index 7f23ac13672..00000000000 --- a/.changeset/17281-metadata-drops-dead-platform-objects-dep.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/metadata': patch ---- - -`@objectstack/metadata` no longer declares `@objectstack/platform-objects`. - -The dependency was the retired `adr-0030-notification-event` migration runner's, -and that runner was its only consumer. Nothing under `packages/metadata/src` -carries a `@objectstack/platform-objects` specifier any more, so the declaration -described an edge the package no longer has. The two test-tooling entries that -existed only to serve it go with it: the `@objectstack/platform-objects/system` -alias in `vitest.config.ts` (whose comment still cited the retired migration's -receipt cases as its reason) and the matching `paths` mapping in `tsconfig.json`. - -## What an installing consumer should check - -⚠️ This is a **published** package dropping a declared dependency, so it changes -what an install tree contains, not just what this repo builds. If you import -`@objectstack/platform-objects` **without declaring it**, and it resolved for you -only because `@objectstack/metadata` hoisted it, that resolution is gone — the -fix is one line, and it is the supported spelling either way: - -``` -pnpm add @objectstack/platform-objects # or npm/yarn equivalent -``` - -`@objectstack/platform-objects` is published on its own and is unchanged by this; -nothing is renamed, removed or re-exported. - -⛔ Nothing `@objectstack/metadata` itself ships is affected. Measured rather than -asserted: its built `dist/` (30 files, 10 declaration files) carries **zero** -occurrences of `platform-objects`, against a positive control in which all nine -of its other declared dependencies appear in four to twelve dist files each. No -runtime import and no type reference reaches it, so no consumer can arrive at it -through anything this package publishes. - -Grade `patch`, measured rather than defaulted: no export moves, no accept-set -widens, no runtime behaviour changes. Not `skip-changeset` either — `package.json` -is shipped by `npm pack`, and a consumer's install tree is what changes. diff --git a/.changeset/17281-platform-objects-attest-fresh-datastore.md b/.changeset/17281-platform-objects-attest-fresh-datastore.md deleted file mode 100644 index 1d7e5c46a0f..00000000000 --- a/.changeset/17281-platform-objects-attest-fresh-datastore.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/platform-objects': minor ---- - -A datastore created from empty now attests **two** creation-attested migration ids, not -three. - -`attestFreshDatastore` (`@objectstack/platform-objects/system`) writes one `sys_migration` -row per id in `CREATION_ATTESTED_MIGRATION_IDS` (`@objectstack/spec/system`) at the moment -a store is created from empty. That tuple lost `'adr-0030-notification-event'` when the -ADR-0030 notification cut-over was retired, so a store born on this version is attested for -`'adr-0104-file-references'` and `'adr-0104-value-shapes'` alone. - -## What an operator sees - -- A fresh deployment's `sys_migration` table holds **two** creation-attested rows where it - held three. Nothing else about them moves: both carry the same - `attested: 'datastore-created-empty'` marker in `details`, and both ADR-0104 gates are - enabled from birth exactly as before. -- **No row is written under `'adr-0030-notification-event'` any more, and nothing reads - one.** A deployment that already holds such a row keeps it, untouched — - `NOTIFICATION_EVENT_MIGRATION_ID` (`@objectstack/spec/system`) survives as that row's - name so the table stays readable by an operator. The id gates nothing, and never did. -- Nothing this package exports is renamed, removed or re-signed. `attestFreshDatastore` - takes the same arguments and answers the same shape; a caller passing its own - `migrationIds` is unaffected, because only the default moved. - -There is nothing to adopt and no command to run. Pre-ADR-0030 `sys_notification` rows are -not carried by the platform on this line, so a store created from empty has nothing the -retired id could have attested. diff --git a/.changeset/17290-insertmany-dropped-fields-name-no-row.md b/.changeset/17290-insertmany-dropped-fields-name-no-row.md deleted file mode 100644 index f85ab720f1b..00000000000 --- a/.changeset/17290-insertmany-dropped-fields-name-no-row.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/objectql': patch -'@objectstack/spec': patch ---- - -fix(metadata-protocol): `insertManyData` reports the dropped-field union at BATCH level instead of naming rows it cannot identify (#17290) - - - -**BREAKING** — `@objectstack/metadata-protocol`'s `insertManyData` no longer hangs -`droppedFields` on each entry of `outcomes`; the response itself carries it, beside -`outcomes`, exactly as `createManyData` already does. A TypeScript consumer that read -the per-row member stops compiling, and the compiler names the site. The set reported -is the same set — what is gone is a per-row attribution that could not be computed -here and was wrong whenever it mattered. Nothing authored or stored changes shape. - -**What it got wrong.** Every create-side strip is the engine's, and its -`onFieldsDropped` event is the UNION over the batch — the listener signature -carries no row index. This seam reconstructed a row set from that union by -asking which rows SUPPLIED each dropped name -(`[...engineDropped].filter((f) => f in supplied)`), on the stated premise that -"the strip only removes keys the ROW ITSELF supplied, so a dropped name belongs -to exactly the rows whose supplied payload carried it". Maintainer ruling C -falsifies the premise: the static-`readonly` strip runs INSIDE `engine.insert`, -AFTER the `beforeInsert` hooks, and exempts keys a hook itself assigned — -recorded per row (`hookWrittenKeys: rowHookWrittenKeys[i]`). So in a batch where -a hook stamps a protected key on some rows and not others: - -- row A supplied `approval_status`, no hook write ⇒ stripped, enters the union; -- row B supplied `approval_status`, its hook re-assigned it ⇒ **kept and - written**; -- and row B's outcome carried `droppedFields: [{ fields: ['approval_status'] }]` - on a record that still held `approval_status`. - -A row the batch culled before the strip ran (a per-row validation failure) was -named on the same test, having dropped nothing at all. - -⇒ A wrong attribution costs the reader a wrong investigation, and the import -surface — which prefers this path over `createManyData` — is the consumer most -likely to act on it while reconciling what landed. - -**Why not attribute per row instead.** The honest set is `{rows whose payload -carried N}` minus `{rows whose beforeInsert hook assigned N}`, and the second -half is computed per row upstream but does not cross this seam. The outcome's -own `record` cannot stand in for it: a stripped `readonly` field is RE-DEFAULTED -over exactly the keys the strip took, and a stripped `autonumber` is refilled by -`applyAutonumbers` — so on both, the key is PRESENT on the row that really did -drop it, and a post-hoc "is the key still there?" check would delete true -attributions while leaving the hook-exempt false one standing. Comparing values -fails on the very case `hookWrittenKeys` exists for: the hook assigning the -value the caller also sent. Restoring row precision means giving the engine's -drop report a per-row channel, not a reconstruction at the call site. - -**Prose corrected with it**, by CLAIM rather than by spelling — the docblock -that authorised the inference is the thing that re-authorises the next author: -`insertManyData`'s own docblock and `createManyData`'s parenthetical -(`@objectstack/metadata-protocol`), `mergeDroppedFieldEvents`'s closing -sentence, `engine.insertMany`'s docblock claim that "a caller holding the input -rows can attribute each name back to the rows that carried it" -(`@objectstack/objectql`, TSDoc emitted into its published `.d.ts`), and -`CreateManyDataResponseSchema.droppedFields`'s `.describe()` parenthetical -(`@objectstack/spec`, a string printed AT the customer). - -**Unchanged.** `updateManyData` and `batchData` keep per-row `droppedFields`, -and they always could: each row is its own `engine.update` / `engine.insert` -call, so that call's events are that row's — earned mechanically, not inferred. -`createManyData`'s aggregated shape is untouched. No strip changes, no row -changes, and the same field names are reported. diff --git a/.changeset/17299-view-union-retirement-prescription.md b/.changeset/17299-view-union-retirement-prescription.md deleted file mode 100644 index 7460867e9b3..00000000000 --- a/.changeset/17299-view-union-retirement-prescription.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -A retirement prescription is the top-level message a `PUT /api/v1/meta/view` 422 carries, instead of sitting buried in `invalid_union` sub-errors - -`ViewMetadataSchema` is the union behind the runtime write door — the one an -MCP/AI author reaches, with no CLI anywhere on the path. A shape-level refusal -raised inside one of its four branches did not become the union's message: the -top level read zod's bare `Invalid input`, and the upgrade prescription sat at -`error.issues[0].errors[k][j].message`. Every retirement this platform wrote for -list and form views was therefore invisible at the one door its intended reader -uses — shipped behaviour since 17.0.0 for `virtualScroll`, `striped` and -`bordered`, not a recent regression. - -The lift is family-wide rather than per case. `retiredKey()` raises one declared -issue shape — `code: 'invalid_type'`, `expected: 'never'`, with the prescription -as its `message` — so the union's existing `.check()` now lifts that message -verbatim from the branch the body claims. The next retirement on this shape is -surfaced without anyone remembering to wire it, which is what a per-case fix -could not promise. - -What does not move: the accept/reject verdict of every body (the lift runs after -the union has reached its verdict and writes one string), the issue codes, the -nested `errors` array and its order, and the message of every refusal that is -not a retirement — a plain shape error still reads `Invalid input`, and a -curated unknown-key refusal still reads exactly as it did. That boundary is -measured, not asserted: `strictObject()` closes a shape with a `z.never()` -catchall, so the union's members reach 67 `never` leaves of which only 8 are -tombstones — zod folds a rejecting `never` catchall into `unrecognized_keys`, so -the other 59 never raise the lifted shape at all. diff --git a/.changeset/17306-screen-field-bound-help-lookup.md b/.changeset/17306-screen-field-bound-help-lookup.md deleted file mode 100644 index cc9b1064592..00000000000 --- a/.changeset/17306-screen-field-bound-help-lookup.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-automation': minor ---- - -A flow screen field can now express a numeric bound, help text and a lookup target — spelled with the object field's own key names - - - -`ScreenFieldConfigSchema` was `.strict` over exactly -`name`/`label`/`type`/`required`/`options`/`defaultValue`/`placeholder`/`visibleWhen`, -so three ordinary authoring intents had **no expression at all**. They did not -degrade quietly — `max`, `helpText` and every lookup-target spelling were -refused BY NAME — but a loud refusal with no landing key is still a dead end, -and the reference app worked around all three in prose: a discount ceiling -interpolated into the `label` and the `placeholder` (with a comment explaining -why there was no `max`), and a `type: 'lookup'` field whose `placeholder` asked -a human to type a record id because the picker could not be pointed anywhere. - -Four keys land, and **their names are derived from `FieldSchema`, not invented** -— one platform, one field vocabulary, so a name learned on an object field means -the same thing on a screen field: - -| Key | Derived from | | -|:---|:---|:---| -| `min` / `max` | `FieldSchema.min` / `.max` | the bound pair | -| `inlineHelpText` | `FieldSchema.inlineHelpText` | help under the input — `FieldSchema` renames `help`/`helpText`/`hint`/`tooltip` onto it, so a screen-local `helpText` would have been a second contract for one question | -| `reference` | `FieldSchema.reference` | the object a `type: 'lookup'` field picks records from | - -**The bound is enforced, not advisory.** It rides to the client on -`ScreenFieldSpec` so the user is stopped at the input, **and** -`validateScreenInputs` re-checks it when the run resumes (`min_value` / -`max_value`, both already in the ADR-0114 D2 field-error catalog — no new error -code). A screen field's declared contract is the only contract behind it, so a -bound the dialog alone applied would be bypassed by any caller posting to -`resume` directly — the gap #4477 closed for `required`. - -That sentence needs no "when the value is a number" qualifier, because the -value SHAPE is checked first: on a `type: 'number'` field a present value that -is not a finite JSON number is refused with `invalid_type` (also already in the -catalog — still no new code), ⛔ **not coerced**. Before this, a bound pass that -compares numbers was satisfied by anything that never reached it, so `"25"` -under a `max` of `20` was conformant. One member of the open `type` vocabulary -is read as a value domain; every other widget hint stays open, and a bound on a -non-numeric field still constrains nothing. - -**Delivered with its rendering, not ahead of it.** The executor forwards all -four onto the wire and the Studio designer form offers all four as repeater -columns; `builtin-node-form-zod-ledger.test.ts` reconciles the two key sets -against the Zod in both directions, so a key declared here and absent from the -form fails that test rather than shipping as a field nobody can author. - -**BREAKING** in the accept-set sense, in TWO places — landing as `minor` on -both packages because the launch-window guard (`check-changeset-no-major`) -keeps breaking changes off `major` outside pre-mode, not because the narrowing -is small. Both were ruled (maintainer ruling A′, decision batch #130 item 1, -2026-09-13); this release is **not** purely additive. - -1. `reference` is **required** when `type` is `lookup`, as it is on an object - field. A picker with no target object resolves nothing — ADR-0078's own - example of silently-inert metadata — and a degraded shape that ships today - is not a reason to bend the contract to it. A stored flow with a bare - `lookup` screen field parsed before and does not now. There is **no lossless - conversion**: nothing in the metadata says which object the author meant, so - this is an ADR-0087 **semantic** migration entry — a structured TODO - (`screen-field-lookup-reference-required`) that names the flow and the field - for a human to answer — and ⛔ never a D2 conversion that would have to - invent a target. -2. A non-number submitted for a `type: 'number'` screen field is refused on - resume (`invalid_type`) instead of passing silently. A resume bag that was - accepted before can be refused now; it was never doing what its author - declared. - -Everything else is additive: the bound itself fires only on a field that -declares one, which nothing did before this release. - -The neighbouring spellings are refused **with their landing key** rather than -with a bare key list: `help`/`helpText`/`hint`/`tooltip` name `inlineHelpText`, -and `object`/`referenceTo`/`targetObject`/`lookupObject`/`relatedTo`/`target` -name `reference`. ⚠️ `object` means different things one level apart — on the -screen **node** it renames to `objectName`, on a screen **field** it can only -mean the lookup target — so it earns its own row on both. - -**One stale claim corrected in passing, because this change falsified it.** The -flows translation surface documented `help`'s exclusion as *"`ScreenFieldConfig` -declares nothing help-shaped at all"*, in `translation.zod.ts`'s guidance string -(which enumerated the old key set verbatim), its doc block, and -`i18n-resolver.ts`'s `FLOW_SCREEN_FIELD_COPY_KEYS`. The screen field now -declares `inlineHelpText`, so the copy is real. The exclusion **stands** — the -flows bundle still carries `label` and `placeholder` only, and growing that face -is a ruled step against the #7646 enumeration, not a resolver-side accretion — -but its reason is now stated as a not-yet instead of telling an author the field -has no help copy when it has. ⛔ No translation key was added and no resolver -behaviour moved. diff --git a/.changeset/17319-action-bulk-dispatch-contract.md b/.changeset/17319-action-bulk-dispatch-contract.md deleted file mode 100644 index a2a9ddf6dfb..00000000000 --- a/.changeset/17319-action-bulk-dispatch-contract.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/lint": minor ---- - -An action can now **declare which bulk dispatch contract its body is written for**, and a list view that wires it the other way is refused at authoring time instead of handing the body the opposite input in silence. - -A list view has always been able to wire the same declared action two ways, and the two deliver opposite shapes to the same body: `bulkActions: ['']` promotes the action to a def and dispatches it **once per selected row** (that row's `recordId`, no `_selectedIds`), while a `bulkActionDefs` entry with `execution: 'aggregate'` makes **one** dispatch for the whole selection (every id in `params._selectedIds`, no `recordId`). The action declared neither, so both mismatches failed quietly and in opposite directions — an aggregate body wired bare-string read `_selectedIds` as `undefined`, fell into its single-record branch and reported success for one row out of ten; a per-record body wired aggregate found no `recordId` and threw its own "nothing selected", which reads like a selection bug. Nothing caught either: `recordId` and `_selectedIds` are both built-in action params (ADR-0104), so the strict params gate admits either bag without a word, and the wiring lives on the view while the declaration would live on the action, so no single parse has both halves. - -- **`ActionSchema` gains `execution`**, and it is `bulkActionDefs`' own vocabulary — the same key, the same two values (`'perRecord' | 'aggregate'`), the def's `BulkActionExecutionSchema` **imported rather than re-declared**, so there is no second spelling to drift. The near-miss keys (`dispatch`, `dispatchContract`, `bulkExecution`, `bulkDispatch`) rename onto it; ⛔ `mode` deliberately does **not**, because on an action `mode` is a declared key of its own. -- **`@objectstack/lint` gains `action-dispatch-contract-mismatch`** (severity `error`), a member of the reference-integrity suite, so it runs on `os validate`, `os lint` and `os compile` at once. It names the action, the view and **both** contracts — the declared one and the wired one — and offers both ends of the fix, because which end is wrong is the author's call. It judges every list tier: a view's `list`, each `listViews.`, and an object's own `listViews`. -- **⛔ No silent default.** `execution` is optional and an action that omits it is *undeclared*, never defaulted to a contract — which is also the honest state of a body written to serve both (it reads `recordId` *and* `_selectedIds`), and why no third enum member was added. Existing sources are migrated by the new ADR-0087 semantic entry `action-bulk-dispatch-contract-undeclared`, which derives the declaration from the view wirings where they are unambiguous and hands back a structured TODO where one action is wired both ways. - -Nothing about dispatch changes: this release adds a declaration and a build-time refusal measured against it. Existing apps are unaffected until they declare the key — the new rule has nothing to judge on an undeclared action, by construction. diff --git a/.changeset/17320-filter-rule-array-guidance.md b/.changeset/17320-filter-rule-array-guidance.md deleted file mode 100644 index 8efc475d89e..00000000000 --- a/.changeset/17320-filter-rule-array-guidance.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The seven converged rule-array `filter` doors name the ViewFilterRule array form when they refuse the record form - -Seven `filter` doors converged on `z.array(ViewFilterRuleSchema)` in the -objectui#6206 family — `ElementDataSourceSchema.filter` (`ui/page.zod.ts`) and -the `object-grid` / `object-metric` / `object-kanban` / `object-calendar` / -`element:number` / `element:record_picker` rows of `ComponentPropsMap` -(`ui/component.zod.ts`). Each previously accepted the MongoDB-style record -(`{ status: 'active' }`), and each now refuses it — measured on the built -artifact, with exactly one issue apiece: `invalid_type` at `filter`, *"Invalid -input: expected array, received object"*, and nothing else. - -The prescription for that transition was already written down twice, in two -places a parse never reaches: every one of the seven `.describe()` strings, and -in full in the three `18.*-filter-rule-array` semantic migration entries. -Nothing bridges `.describe()` into a zod issue and this package installs no -global error map, so the one population whose metadata the convergence broke — -the authors, human and AI, who wrote the previously-legal form — received the -single sentence that does not say what to write instead. - -Each of the seven now answers that value with the new spelling, through the -zod-v4 `{ error }` param this package already uses for targeted guidance -(`shared/expression.zod.ts`, `ui/view.zod.ts`, `shared/strict-object.ts`): - -> `filter` on this `object-grid` takes the ViewFilterRule ARRAY form -> `[{ field, operator, value }, ...]`, and this value is the MongoDB-style -> record form this door took before the one-filter-orthography convergence. -> Write one rule per record key — they AND — so this filter becomes -> `[{ field: 'status', operator: 'equals', value: 'active' }]`. Legacy operator -> shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse. -> Full conversion table: migration -> `element-data-source-and-object-block-filter-rule-array`. - -Following `strictObject`'s model rather than transcribing a sentence seven -times: the rule shape is read from `ViewFilterRuleSchema`'s own shape, the -canonical operator is `normalizeFilterOperator('eq')` — the same fold the door -itself runs — and the worked rewrite is computed from the author's own record, -so the example names their fields. A pin holds each door's `migration` id equal -to a real registry entry and each door's `surface` equal to the one its own -`strictObject` declaration registered. - -⛔ No accept set moves. The doors refuse exactly the shapes they refused -before, the generated `json-schema/` and `authorable-surface` artifacts are -byte-identical after the change, and the map returns `undefined` for everything -that is not a plain record — so an array author's element-level issues -(`filter.0: Invalid option: expected one of "equals"|…`) and a non-record value -(*"expected array, received string"*) still arrive in zod's own words. - -**Shipped, which is why it carries a changeset rather than `skip-changeset`.** -Measured on the built artifact after both tsup passes finished: the new message -text is present in **18** published files of `npm pack --dry-run`'s 2012, the -test-only text is present in **0** (negative control), and a pre-existing -shipped string reaches **62** as the lit control proving the scan reaches. -`src/ui/page.zod.ts` and `src/ui/component.zod.ts` are also shipped as source -by `files[]`'s `src/**/*.zod.ts`. diff --git a/.changeset/17321-conversion-todo-channel.md b/.changeset/17321-conversion-todo-channel.md deleted file mode 100644 index 4c243c98736..00000000000 --- a/.changeset/17321-conversion-todo-channel.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-protocol": minor ---- - -feat(spec,metadata-protocol): `os migrate meta --stored` lists every stored page filter the record-filter conversion leaves as stored, as a TODO naming the page, the block and why — the ADR-0087 D3 TODO channel (#17321, ruling B item 2) - -**Clause-②: yes** — `@objectstack/spec` gains public exports (`CONVERSION_TODO_CODE`, -`ConversionTodoDetail`, `ConversionTodoNotice`, and the optional `ApplyConversionsOptions.onTodo` -and `ConversionContext.reportTodo`), and `@objectstack/metadata-protocol` gains -`StoredMigrationTodo` and `StoredMigrationRow.todos`. No door's accept set moves, and nothing -that was left as stored before starts converting: every stored body is rewritten exactly as it -was. - -**What was silent.** The D2 conversion `page-component-filter-record-to-rule-array` leaves a -stored filter as stored wherever no lossless rule-array spelling exists — above all a record -carrying `$and` / `$or` / `$not`, which is never flattened. It emitted nothing for such a site, -and `os migrate meta --stored` reads conversion notices as its change signal, so a page whose -only legacy filter carried a combinator was reported as **already on protocol**. - -**What it says now.** Each such site is a structured TODO (code `OS_METADATA_CONVERSION_TODO`) -carrying its path, the shape left in place, and a reason that names the block (its type, and its -`id` when it has one) and what blocks the rewrite — the combinator by name, the operator -(`$null`, `$exists`, an AST `like`), the null or array value, the rule the door would refuse, or -the inline rows the block renders. The stored pass lists them under their row, whatever the -row's outcome: - -```text -⚠ 1 row(s) are outside this pass — each row's reason says why: - • page/pipeline_board [env-wide] — the conversion chain rewrites nothing here: it left 1 site(s) of this row as stored, … - TODO page-component-filter-record-to-rule-array: {"$or":[…]} left as stored at pages[0].regions[0].components[0].properties.filter — On the `object-kanban` block, this filter carries the combinator `$or`: … -☐ TODO: 1 site(s) in 1 row(s) are left as stored — no conversion can rewrite them without changing what they mean, so no run of this pass will. … -``` - -The same list is `rows[].todos` in `--json` and in the `POST /api/v1/meta/_migrate-stored` -report. A run with no TODO prints exactly what it printed before. - -**Outcome and exit code.** A row whose only finding is TODOs has nothing to persist and is now -reported `skipped` (it was `canonical`). Like every other skip class it does not change the -run's exit code: no run of this pass can clear it, because the conversion must not flatten a -combinator — it is the hand rewrite's to decide. A row that also converts something keeps the -outcome its conversion gives it, with its TODOs listed beside its notices. Measured through the -write path: on `--apply`, a row whose leftover sits in a block's `properties.filter` or -`properties.defaultFilters` is rewritten (its lossless filters persist; the metadata API's save -does not refuse block props by component type), while a leftover in `dataSource.filter` fails -the save, and the row's TODO says why. - -**For code calling the conversion layer.** `onTodo` and `reportTodo` are optional. Only the -stored-metadata pass passes a sink today; every other seam leaves the site as stored silently, -exactly as before. diff --git a/.changeset/17321-record-filter-d2-conversion.md b/.changeset/17321-record-filter-d2-conversion.md deleted file mode 100644 index 24cc0904278..00000000000 --- a/.changeset/17321-record-filter-d2-conversion.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): a stored record-form `filter` at the converged rule-array doors is converted to the rule array wherever the mapping is lossless — the ADR-0087 D2 conversion `page-component-filter-record-to-rule-array` (#17321, ruling B) - -The one-filter-orthography convergence moved every page-component `filter` door onto the -`ViewFilterRule` array and refused the MongoDB-style record it used to take — but converted -nothing at rest. A page saved with the old form kept rendering, and the next person to open it -in the builder and press Save was refused. This release adds the mechanical half. - -**What converts** — at `dataSource.filter` on any page component, at `properties.filter` on the -`object-grid`, `object-metric`, `object-kanban`, `object-calendar`, `object-map`, `object-gantt`, -`object-tree`, `object-timeline`, `element:number` and `element:record_picker` blocks, and at -`properties.defaultFilters` on `object-grid`: - -| Stored | Becomes | -| --- | --- | -| `{ status: 'active' }` | `[{ field: 'status', operator: 'equals', value: 'active' }]` | -| `{ amount: { $gt: 100 } }` | `[{ field: 'amount', operator: 'greater_than', value: 100 }]` | -| `{ amount: { $gte: 1, $lte: 9 }, owner_id: 'u1' }` | three rules — they AND | -| `[['owner_id', '=', '{current_user_id}']]` | `[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` | -| `{}` | `[]` | - -Values are carried verbatim, value placeholders and date macros included. The operator comes -from the two tables the doors already use — a declared FilterCondition operator (`$gt`, `$nin`, -`$notContains`, …) folded through `normalizeFilterOperator`, and an AST infix spelling (`=`, -`!=`, `>=`, …) lowered through `parseFilterAST` — and every produced rule must parse at the -door, so a converted page re-saves cleanly. - -**Where it runs.** On every stored-row read (`applyConversionsToStoredItem` replays it), so -the metadata API already serves such a page in the rule-array form; `os migrate meta --stored --apply` -persists it; `os migrate meta --from 17` lists the edits for author sources. It is retired from -the authoring load path, so `defineStack` / `os validate` still refuse an author who writes the -record form and teach the rule array — no accept-set change. - -**What is left exactly as stored.** A filter carrying `$and` / `$or` / `$not` is never -flattened: the rule array only ANDs, and flattening `$or` or `$not` changes which rows the page -selects. So is any filter with a part that has no lossless rule spelling — a `null` value (the -renderer skips that key today, where a rule would test IS NULL), `$null` / `$exists`, an AST -`like` / `ilike`, an array or object comparand in equality position, an AST `and` / `or` group. -Conversion is all-or-nothing per filter: converting the mappable keys and dropping the rest -would widen the filter. And every filter — the binding's included — of a component whose rows -are **inline** (`data: { provider: 'value', … }`, a `data` array, or `staticData`) is left as -stored: the `object-map`, `object-tree`, `object-calendar` and `object-gantt` renderers match -that filter against their own rows in an in-memory data source that reads the record form but -excludes every row for a rule array, so a rewrite there would empty the block. The same filter -on a block that queries an object converts. Such a page keeps loading and rendering unchanged, -and its `filter` door refuses the form: at `dataSource.filter` on the page's next save; at a -block's `properties.filter` / `properties.defaultFilters` only as the component-props gate's -advisory finding (`os validate`, `os build`, `os lint`) — a re-save through the metadata API is -not refused there, measured — and for a combinator record that refusal no longer renders the -combinator as a field (`{ field: '$or', … }`); it names the combinator and says why no rule -spells it. -`os migrate meta --stored` lists each such filter left as stored as a TODO under its row. diff --git a/.changeset/17328-colspan-rule-withdrawn.md b/.changeset/17328-colspan-rule-withdrawn.md deleted file mode 100644 index 7647e1cdc63..00000000000 --- a/.changeset/17328-colspan-rule-withdrawn.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -fix(lint)!: `absolute-colspan-discouraged` is withdrawn — its premise was measured false in a browser, and the alternative it recommended measured worse than the thing it warned about (#17328) - - - -**BREAKING** — `@objectstack/lint` no longer exports `FORM_COLSPAN_ABSOLUTE`, and -`validateFormLayout` no longer emits the `absolute-colspan-discouraged` finding. A -TypeScript consumer that imported that constant (to suppress the rule, or to route it) -stops compiling on the import, and the compiler names the site — a more precise channel -than any release note. Authored metadata is untouched: `FormField.colSpan` is unchanged -and still valid. - -The rule fired on **every** authored `colSpan`, `colSpan: 1` included, and asserted a -rendering consequence: the form's column count is derived per surface (mobile 1 / modal 2 -/ page 3-4), so a fixed span "only aligns at one width". Measured in Chromium on a real -authored 3-column section at all three of the widths that sentence names (390 / 720 / -1700), that misalignment does not happen. The renderer emits one container-query-scoped -span class clamped to the section's declared column count, so the cell starts at a real -column boundary at every width and rendered overflow is 0px in every configuration — -including `colSpan: 4` in a 3-column section, the case that would overflow if the clamp -did not work. The clamp is precisely why the claim was false, and the rule's own file -already recorded the clamp a few lines above the claim. - -The hint was the sharper defect. It steered authors to `span: 'full'`, which compiles to -the same class as `colSpan: 4` (`@2xl:col-span-3`) and measures byte-identically: the rule -warned about one spelling and recommended the other, and they are the same thing. At the -modal width `span: 'full'` renders pixel-identical to authoring nothing at all, so an -author who complied was left worse off than one who ignored it. - -With no authored `colSpan` shape left that misbehaves there was nothing to re-ground, so -the rule is withdrawn rather than narrowed: `colSpan: 1` emits no class at all, a -`colSpan` within the column count renders exactly as authored, and one above it clamps. -Every test that pinned the rule's wording or its firing set was re-judged in place with -the reason recorded, never deleted, and each re-judged pin is paired with a live finding -on the same fixture so that a walk which stopped reaching the site could not pass as a -withdrawal. diff --git a/.changeset/17329-seed-settled-ipc-message.md b/.changeset/17329-seed-settled-ipc-message.md deleted file mode 100644 index 2d44c38e3ec..00000000000 --- a/.changeset/17329-seed-settled-ipc-message.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`os serve` now announces **`objectstack:seed-settled`** on its existing ipc channel when this boot's seeding has come to rest, and `os dev` forwards it to its own parent process when one holds the channel. A script that spawns a dev server can finally wait for the boot to finish without reading the child's output. - -`✓ Server is ready` is true about the HTTP server and says nothing about the app. Seeding races a soft budget (`OS_INLINE_SEED_BUDGET_MS`, default 8s) and past it finishes in the background, so the banner can be a minute ahead of the seed's own result — measured downstream at **82 seconds of silence after the banner, then 120 `ERROR` lines**. The same command on the same corpus settles before the banner on a machine where the seed fits its budget, so the defect is invisible on exactly the boxes that would have caught it. Everything that distinguishes the two cases arrives on the child's inherited stdio, and reading that costs the boot its TTY. - -- **The producer is not new.** `@objectstack/runtime` already declares every seed source and settles it at the moment its boot-time write is done, publishing the tally under `@objectstack/spec`'s `seed-settlement` contract. This is the hop outward: the CLI subscribes to two hooks the kernel already fires and reads a snapshot it already publishes. No service is registered and no tally is mutated — the contract is read-only by design. -- **Sent once, and never before `objectstack:listening`.** Seeding that settles during `runtime.start()` is latched and released after the bound port is published, so a parent that waits for the listening message and only then listens for the settle cannot miss it. -- ⛔ **Keyed on `inFlight`, not `pending`.** Multi-tenant replay and `skipSeedData` register a seed source and deliberately never run it, keeping `pending` above zero for the life of the process. A `pending`-keyed message would never be sent on those boots, and its absence would be indistinguishable from a boot still writing — the same ambiguity this closes, one level up. Those boots get the message with `suppressed` reasons attached instead, so a consumer can say *why* no rows landed. -- **Failure settles too.** A seed that failed has still come to rest; withholding there would recreate the hang. `ok` is a verdict on the per-source counts the boot recorded, and the message carries those counts. -- **The over-budget banner no longer omits seeding.** `Seeds:` is fed by outcomes recorded when a load *finishes*, so past the budget the row was ABSENT and the transcript was byte-identical to an app that declares no seeds — which is how the defect hid. It now reads `pending — N sources still writing`, with a line saying seeding continues in the background; suppressed sources are named rather than reported as pending. - -⛔ An ipc channel is **not** made a requirement of either command: `process.send` is undefined under an ordinary terminal boot, both sends are no-ops there, and no byte of that transcript changes. Nothing in the existing `objectstack:listening` publication moves. - -Note that `os dev` consumes `objectstack:listening` itself (it is how the bound-port readout and the MCP connect hint learn the real port) and relays only `objectstack:seed-settled`. Spawn `os serve` directly to receive both in one place. diff --git a/.changeset/17333-date-macros-header-adr-0053.md b/.changeset/17333-date-macros-header-adr-0053.md deleted file mode 100644 index 741f288f466..00000000000 --- a/.changeset/17333-date-macros-header-adr-0053.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`date-macros.zod.ts`'s module header states the ADR-0053 D-D upper-bound rule the platform implements, instead of the rule it replaced - -The header's "Out of scope" block told an author that on a `datetime` column -`<= {current_year_end}` **stops at midnight on the 31st**, and prescribed the -half-open `< {next_year_start}` as the fix. That is the pre-ADR-0053 reading. -The platform rule has been the opposite since #3777: a bare `YYYY-MM-DD` used -as an upper bound denotes the WHOLE day, compiled half-open to the next -calendar day. It is stated once, in -`packages/spec/src/data/calendar-day.ts` (ADR-0053 D-D), whose own operator -table reads: - -| Operator | A bare `YYYY-MM-DD` on a `datetime` column means | -|---|---| -| `$gte` / `$gt` / `$lt` | that day's `00:00:00.000` — already correct as written | -| `$lte`, a `$between` max, a `dateRange` end | the WHOLE day → compile `< nextUtcCalendarDay(day)` | - -and which `packages/spec/src/data/temporal-conformance.ts` pins cross-driver: -the case *"datetime: bare-day `$lte` keeps the whole final day"* expects -`d_mid` (09:15 on the boundary day) and `e_late` (21:40 on it) as members. - -**Why this header and not a note.** It is the doc comment on the vocabulary an -AI author reaches for, and it is the one place in the tree that says what a -`*_end` token does on the right-hand side of an operator. Both the old -prescription and the correct spelling parse, run and return rows, so nothing -downstream reports the mismatch — the author simply carries the wrong model -into every later filter. - -**What the correction does.** The load-bearing first clause is kept verbatim: a -`*_end` token IS the period's last calendar DAY. What follows now **cites** -`calendar-day.ts` rather than restating the rule, so the two statements cannot -drift apart again, and the half-open detour is refused by name for the reason -it is now wrong — the widening is already applied. - -⛔ No behaviour changes. The diff is comment lines only; no schema, accept set, -authorable key or published payload moves. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/spec`'s published `files[]` lists -`src/**/*.zod.ts`, so this file ships verbatim as source, and the header is the -first thing in it. - -The generated reference page `content/docs/references/data/date-macros.mdx` -carried the same sentence — it is rendered from this header and is marked -AUTO-GENERATED — and is regenerated here with -`pnpm --filter @objectstack/spec gen:schema && … gen:docs`. diff --git a/.changeset/17343-multi-valued-boolean-contains-membership.md b/.changeset/17343-multi-valued-boolean-contains-membership.md deleted file mode 100644 index b4953d65f39..00000000000 --- a/.changeset/17343-multi-valued-boolean-contains-membership.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -A `multiple: true` boolean column keeps its `$contains` membership filter - -A `multiple: true` field is stored as a JSON TEXT array, and on such a column -`$contains` is not a substring test — it is the MEMBERSHIP spelling, the one -operator #7398 left working there after refusing the equality family. The -declared-type gate added in #14079 fired on the boolean limb regardless of -storage shape, so a membership filter over a `multiple: true` `boolean` or -`toggle` column compiled to the always-false constant: - -``` -{ flags: { $contains: 'true' } } -- select * from `probe_tbl` where 1 = 0 (matched nothing) -+ select * from `probe_tbl` where `flags` GLOB '*true*' (matches the rows whose array holds it) -``` - -That is the fail-CLOSED direction: the query returns a `200` with no rows, -byte-identical to a filter that legitimately matched nothing, so an author sees -"no matching records" and doubts their data rather than the filter. Both -registry fills — `initObjects` and `registerExternalObject` — were affected, and -both are fixed, because the repair is at the predicate they share. - -The same shape on a `multiple: true` NUMBER was already correct (its registry is -filled `!field.multiple`), and #15683 spelled the equivalent carve-out for the -temporal limb at the predicate. This change spells it on the boolean limb, the -one that had neither. `booleanFields` itself is deliberately unchanged: it is a -read-coercion registry, and the three other seams that read it — the Postgres -aggregate cast, the presentation-kind door and `formatOutput`'s row pass — are -about "this column holds a boolean", which a multi-valued column still does. - -⚠️ Not a widening of the gate: a SCALAR `boolean` / `toggle` column still -answers the declared no-match for every positive text operator and `$notContains` -its exact complement, unchanged. What moves is exactly the JSON-column cell. diff --git a/.changeset/17369-organizations-entitlement-boundary-prose.md b/.changeset/17369-organizations-entitlement-boundary-prose.md deleted file mode 100644 index dda49285777..00000000000 --- a/.changeset/17369-organizations-entitlement-boundary-prose.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/verify": patch ---- - -Comment-only correction: the reason `bootStack`'s cross-tenant proofs stand in for `@objectstack/organizations` is now stated as the true one. - -Those doc comments said the enterprise multi-organization runtime was **cloud-private / not installable in this workspace**. ADR-0132 falsified that: the runtime is open core, Apache-2.0, and published on npm. The effect they describe has not changed, so the text now gives the reason that is actually load-bearing — **ADR-0132's entitlement boundary forbids any framework package DECLARING `@objectstack/organizations`** (`packages/plugins/organizations/src/no-framework-dependents.pin.test.ts`, its mechanical half: "Apps declare it; packages do not"), because the commercial repository ships a licence-gated subclass under the same package name. So `packages/verify` cannot depend on the runtime and cannot resolve it, the `'posture-only'` stand-in stays exactly what it was, and the proof that the real plugin walls tenants still lives in cloud's `security-enterprise` multi-organization integration test. - -⛔ **No behaviour, no dependency and no public surface moves.** `BootOptions.multiTenant` accepts and does the same things it did; the only shipped bytes that change are the doc comments carried into `dist/index.d.ts`. Apps that mount the runtime keep declaring it in their own `package.json`, which is and remains the supported wiring. diff --git a/.changeset/17385-chartconfig-liveness-drill.md b/.changeset/17385-chartconfig-liveness-drill.md deleted file mode 100644 index a3c8b9e8383..00000000000 --- a/.changeset/17385-chartconfig-liveness-drill.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -**Clause-②: no** — no schema key moves, no accept set widens or narrows, no export changes. This is the liveness ledger stating what the renderer actually does with an authored `chartConfig`, at one verdict per key instead of one blanket verdict for fourteen. - -`packages/spec/liveness/dashboard.json`'s `widgets.chartConfig` row is **drilled**: it now carries `children`, one status + evidence per `ChartConfigSchema` key, re-measured against this checkout's own `.objectui-sha` pin `53ded82bf7a4`. The row ships — `packages/spec` publishes `liveness/` whole — so this changeset is a measurement, not a convention: `npm pack --dry-run` puts `liveness/dashboard.json`, `liveness/README.md` and `liveness/state-counts.md` in the tarball (38 files under `liveness/`), and the fourth changed path, the undrilled-containers baseline under `scripts/`, is not in it (0 files under `scripts/`). - -Per-key verdicts, all pinned in the renderer repo: - -- **12 live.** Nine chrome keys are lowered onto the chart schema by `chartConfigPresentation`, one guard each — `title`, `subtitle`, `description`, `colors` (split two ways into the positional palette and the per-category map), `height`, `showLegend`, `showDataLabels`, `annotations`, `interaction`. `xAxis`, `yAxis` and `series` join them by a different route: `mergeAuthoredPresentation` merges their **presentation** onto the bindings the dataset selection derived, dropping exactly the two binding keys `ChartAxis.field` and `ChartSeries.name` so that series membership and the plotted column stay with the dataset. -- **2 dead.** `chartConfig.type` parses and does nothing on a dashboard widget — the widget's own `type` picks the chart family — and `chartConfig.aria` has no reader on either face: the chart implementation declares no `aria` prop and the ARIA injection reads the flat `ariaLabel` / `ariaDescribedBy` / `role`. Both are pinned as **negatives** by name in the renderer's own tests, which is what makes them re-askable rather than merely asserted. - -Neither `dead` verdict is acted on here. Recording a verdict is what feeds the ADR-0049 enforce-or-remove worklist; executing one moves a published accept set and is a separate, ruled piece of work. - -The drill also makes six containers one level further down visible for the first time (`xAxis`, `yAxis`, `series`, `annotations`, `interaction`, `aria` — 39 child keys). They are **recorded** in the shrink-only undrilled-containers baseline rather than drilled: fanning this row's verdicts down over them would manufacture verdicts with no evidence behind them, which is the one thing the drill rule forbids by name. diff --git a/.changeset/17385-dashboard-chart-config-structure-refused.md b/.changeset/17385-dashboard-chart-config-structure-refused.md deleted file mode 100644 index 01be6f5d454..00000000000 --- a/.changeset/17385-dashboard-chart-config-structure-refused.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': minor ---- - -**BREAKING** — a dataset-bound dashboard widget's `chartConfig` carries appearance only: `type`, `xAxis`, `yAxis` and `series` are refused by name, each refusal naming the dataset selection the intent belongs in. - -Clause-②: yes (narrowing) - -`DashboardWidgetSchema.dataset` is REQUIRED, so **every** dashboard widget is dataset-bound, and ADR-0021 already made the dataset the owner of the chart's structure: it decides which series exist and which column each one reads. `chartConfig` nonetheless declared `type` / `xAxis` / `yAxis` / `series`, and the two answers met with no rule between them. That was not merely inert. An authored `yAxis[].field` was a live MEMBERSHIP channel — the renderer synthesised a series from the authored axes when the chart declared none — so one authored axis could silently re-point a dataset-bound series at a different column while the chart still drew, which reads as a true statement about the data. Maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」, on options C+D together: state the ownership split in the protocol AND refuse the four keys by name. - -## FROM → TO - -| you wrote inside `chartConfig` (17.4 and earlier) | write instead | -| --- | --- | -| `type: 'line'` | `type: 'line'` on the WIDGET, beside `dataset` — the widget's own `type` is the chart family and it always won; nothing on this face ever read the chart config's | -| `xAxis: { field: 'stage' }` | `dimensions: ['stage']` on the widget — the dataset dimension the category axis plots | -| `yAxis: [{ field: 'amount' }]` | `values: ['amount']` on the widget — the dataset measures, one entry per mark. A second axis is a second measure, not a second axis declaration | -| `series: [{ name: 'amount' }]` | `values` (plus a second `dimensions` entry to split) — series membership follows the selection; an entry naming a measure outside it was already being ignored | - -**The one-line fix:** delete the four keys; the widget's `type` and its `dimensions` / `values` are the chart's structure. - -`os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. - -## What is NOT retired - -The keys stay authorable on `ChartConfigSchema` itself, and that is the half a blanket refusal would have broken. A react-tier `` binds inline rows with no dataset behind them, so its axes are the author's and are unchanged — `react-blocks.ts` still publishes all four in that block's `dataProps`. `ReportChartSchema` keeps its own `xAxis` / `yAxis`, narrowed to its bound dataset's dimension and measure names. The refusal lives on a new per-carrier `DashboardWidgetChartConfigSchema` (`ChartConfigSchema.extend(…)`, the `ReportChartSchema` spelling) precisely so it cannot reach those two. - -## What this costs, stated rather than discovered - -`xAxis` / `yAxis` / `series` carried presentation alongside the binding — axis titles, number formats, bounds, grid lines, log scale, and per-series labels, colours, stacking and mark types. Refusing the keys takes the presentation with the binding: a dataset-bound chart takes those from the dataset's own dimension and measure declarations, and `colors` on the chart config remains the palette channel. **The combo chart a dataset-bound widget could author through `series[].type` has no authoring channel on this face any more.** That capability loss is ruled, not incidental — the option that kept it was on the table and was not taken. - -## Accept-set movement, both directions - -Narrowing, on a dataset-bound widget: the four keys move from accepted to refused. **And one widening, which is forced by the ruling rather than chosen:** `ChartConfigSchema.type` is REQUIRED, so before this change a `chartConfig` without a `type` was refused as incomplete. Refusing `type` while keeping the bag authorable for appearance — which ruling item 1 requires in as many words — means absence must now be legal. So `chartConfig: { title: 'Revenue' }` on a dashboard widget moves from refused to accepted. That is why the declaration reads `yes (narrowing)` rather than `no`. - -## The retirement kit - -- **Four `retiredKey()` tombstones on the widget carrier**, registered as `ui/DashboardWidgetChartConfig:type` / `:xAxis` / `:yAxis` / `:series` under protocol 18. `tsc` types each key `never`, so every authoring site in a consumer's tree fails to compile before anything runs, and a value that reaches a parse raises the prescription rather than a bare unrecognized-key report. -- **The ADR-0087 pair.** The D2 conversion `dashboard-widget-chart-config-structure-removed` strips the four keys from stored dashboard widgets (dashboards only — reports and the react tier keep theirs); the D3 semantic entry `dashboard-widget-chart-config-structure-refused` carries the judgement, because moving what the keys MEANT into the dataset selection needs facts the widget does not hold — an authored axis field can name a dataset dimension the widget never selected. -- **The liveness rows stay and are regraded `dead`**, the `retiredKey` route's discipline: the tombstone keeps the key in the walked shape, so the row remains and records why. Three of them were graded `live` on their presentation half on 2026-09-12 and that measurement is recorded as overridden, not withdrawn. -- **`chart-config-missing` is withdrawn from `@objectstack/lint`.** It advised a `combo` widget with no `chartConfig` to declare `chartConfig: { series: [{ name, type }] }` — metadata the schema now refuses — and after the ruling there is nothing a `combo` author can do about the finding. The rule ID stays exported, so an existing `suppressWarnings: ['chart-config-missing']` entry keeps parsing. `chart-field-unknown` still fires on a legacy document and its hints now say delete-and-migrate instead of describing what the keys used to carry. - -## What an operator with a STORED dashboard sees - -A `sys_metadata` `dashboard` row written before this release can carry any of the four. Nothing breaks at read: the conversion replays on rehydration and strips them, so the row is served canonical, and `os migrate meta --stored --apply` rewrites the rows. ⚠️ The strip is the mechanical half only. A widget whose authored axes AGREED with its selection renders identically afterwards — that is the expected case. A widget that renders differently was relying on the membership channel this removes, which is the case the ruling was made about. - - diff --git a/.changeset/17409-scope-roots-baseline-docblock.md b/.changeset/17409-scope-roots-baseline-docblock.md deleted file mode 100644 index 76f54d123d3..00000000000 --- a/.changeset/17409-scope-roots-baseline-docblock.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/formula': patch ---- - -`SCOPE_ROOTS`'s docblock says it is a **baseline**, not a per-surface accept set, and points at where the per-surface verdict actually lives - -The exported `SCOPE_ROOTS` constant carried a docblock that made **a false statement about itself**. Its opening line read *"Namespace roots that a `record`-scoped CEL site may legitimately reference"* — which, read alone, is exactly the per-surface accept-set reading. Ninety lines below, the companion block asserted *"This list is a 'never faults' BASELINE, not a per-surface contract — **the doc-comment above says so**"*. The doc-comment above did not say so; it said close to the opposite. - -**This is not a docs nit, and the evidence is a card.** The accept-set reading is what a downstream seat took away, and it generated a cross-repo card filed against this package (this one) about a lint/runtime disagreement that is not a disagreement at all: the baseline declares a root, the per-surface gate refuses it, and both are correct. - -- **The opening line now states the contract it actually is**: the roots the strict check env declares, so that naming one is never itself a fault — and explicitly ⛔ *not* a claim that any surface **binds** the root. -- **It points at the per-surface authority by name**: `@objectstack/lint`'s `fieldRuleRootIssue`, judged against that surface's own closed `FIELD_RULE_BOUND_ROOTS` (`record` / `previous` / `parent`). A reader asking "may THIS surface reference this root?" is now sent one hop to the symbol that answers it, instead of reading the answer off this list. -- **It names `data` as the standing example** of a root this list declares and the field-rule surface does not bind — the two answers doing their separate jobs, ⛔ not something to repair by editing this list. -- **The self-reference is now true.** The companion block cites `SCOPE_ROOTS`'s own doc-comment, which now opens by saying exactly what the citation claims it says. - -⛔ **Zero behaviour change.** `SCOPE_ROOTS` keeps all **27** members, byte for byte — no member is added, removed or reordered, and ⛔ `app` is not added (objectstack#16420 closed `not_planned` on that and this does not reopen it). Narrowing was refuted by measurement rather than by preference: six `*.form.ts` metadata-form modules in this repo carry live `data.` predicates. The diff is comment lines only. - -**This publishes, which is why it is `patch` rather than `skip-changeset`.** `@objectstack/formula`'s `files[]` ships `dist`, and this TSDoc is emitted into the built declarations — measured on the built artifact at three readings: the new text's distinctive phrase present at 1 in both `dist/index.d.ts` and `dist/index.d.mts`, an untouched neighbouring sentence from the same docblock present at 1 as the lit control, and a fabricated phrase at 0 as the dark control. The companion block is a plain `/* */` comment attached to no declaration and reads 0 in `dist` — it is the half that does not ship, and the half that does is the half that was wrong. diff --git a/.changeset/17410-generate-reserved-word-barrel-refusal.md b/.changeset/17410-generate-reserved-word-barrel-refusal.md deleted file mode 100644 index 27e865a1cc3..00000000000 --- a/.changeset/17410-generate-reserved-word-barrel-refusal.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli)!: `os generate` refuses a name whose barrel alias no consumer could import by name (#17410) - -`os generate view class` exited **0** and wrote `export { default as class } from './class.view';`. That line parses — an ES module export clause admits a reserved word as a `ModuleExportName` — so both landed layers admitted it, each correctly by its own terms: the #16726 charset gate because every character of `class` is a lowercase letter, and the #16541 parse check because the bytes really are parseable TypeScript. The import side is not: `import { class } from './views'` needs an `ImportedBinding`, and a reserved word is not one. So the command reported success and produced a barrel entry nothing can name, with the failure deferred into the author's own file where it reads as their mistake. - -A third layer now stands behind those two. After the identifier is derived and before anything is written or previewed, the barrel alias is put through TypeScript **in the exact position a consumer must write it**, and the command refuses when the compiler will not take it — naming the constraint, showing the line that would have been written, and writing nothing. This delivers the #16726 ruling's own closing sentence, 「`os generate view class` is therefore refused at the door rather than emitting a barrel line that binds a reserved word.」, which the charset mechanism specified in that same ruling could not. - -⛔ **No third charset** — the #16726 ruling forbids one and none is added: no character is judged. ⛔ **Nothing is rewritten.** Emitting a non-reserved alias while keeping the authored name was the other option and it loses on the reasoning that already refused option B: it decouples the name the author wrote from the name that gets emitted, silently. So this refuses, and the name you author stays the name that lands. - -**What this narrows:** 46 names — the 36 always-reserved words (`class`, `new`, `enum`, `default`, `import`, …) plus the ten reserved because a module is automatically in strict mode (`let`, `yield`, `static`, `implements`, `interface`, `package`, `private`, `protected`, `public`, and `await`, reserved at a module's top level). Every one is charset-legal and every one used to reach `exit 0` for the six generators that suffix their `const` binding (`view`, `action`, `flow`, `dashboard`, `app`, `skill`). The seventh, `object`, binds the bare identifier, so the parse check already refused **some** of them there — but only the always-reserved ones: `os g object let`, `os g object yield` and `os g object static` also exited 0, because a strict-mode reservation is a semantic diagnostic and that check is syntactic. Pick a name that survives as an import binding — `os g view order_line` works, and binds `orderLine`. - -**This is an observable change to accepted input:** those 46 names exit **0** today and will exit non-zero after this lands. Every one of them produced a barrel entry no consumer could name, so this is the fix rather than a break — but if you script `os generate`, a name in that set now stops the command instead of writing an unusable file. - -**One durability note.** The refused set is decided by the TypeScript compiler, asked in position, rather than by a list this package keeps — which is why it is right in both directions today. The consequence is that a TypeScript upgrade can move it: a word that becomes reserved starts being refused, and a word that stops being reserved starts being accepted. Both are correct, neither is a regression, and neither is predicted by a changeset. - -**What this deliberately does NOT narrow:** contextual reserved words. `type`, `as`, `from`, `async`, `get`, `set`, `of`, `keyof`, `readonly`, `satisfies`, `infer`, `declare`, `namespace`, `using`, `accessor`, `undefined`, `arguments`, `eval` and the rest are legal import bindings, they generate today, and they still generate. Refusing one of them would break a name that works — the expensive failure direction, and the one a hand-written keyword list gets wrong. There is no keyword list here for exactly that reason: a list is simultaneously too narrow (it stops at the obvious 36 and ships the defect for the other ten, which a syntactic-only check cannot even see, because the compiler reports strict-mode reservations as semantic diagnostics) and too wide (it swallows the contextual set). The judge is the compiler, asked in position. - -⛔ Neither layer in front is relaxed or reordered. `os g object class` still meets the parse check's own diagnostic in the compiler's words, a name outside the charset still meets the schema's own pattern, and the new layer is asked last, so it can only narrow what all three would otherwise have admitted. - - diff --git a/.changeset/17416-packages-get-version-scope.md b/.changeset/17416-packages-get-version-scope.md deleted file mode 100644 index 627bd47da45..00000000000 --- a/.changeset/17416-packages-get-version-scope.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime): `GET /api/v1/packages/:id` honours `?version=` instead of silently ignoring it (#17416) - -The route accepted a `?version=` query parameter and the only surface serving it -never read the parameter. A caller asking for a version that is not installed -was answered `200` with the **installed** row, and nothing in the status, -headers or body distinguished that from a version-scoped read that actually -happened. - -The parameter is not hypothetical traffic: `ScopedEnvironmentClient.packages.get` -(`@objectstack/client`) declares `version?: string` and appends it, so the SDK -has been sending a parameter the runtime dropped. The handler that honoured it -— the REST registrar's twin of this route — was removed with the duplicate -response shape, and the dispatcher's `/packages` domain never had that read to -inherit. - -``` -FROM GET /api/v1/packages/com.acme.crm?version=99.0.0 (1.0.0 installed) - -> 200 { data: { manifest: { version: "1.0.0" }, … } } - -TO GET /api/v1/packages/com.acme.crm?version=99.0.0 - -> 404 { error: { message: "Package 'com.acme.crm' version '99.0.0' not - found — installed version is '1.0.0'" } } -``` - -**What does not change.** The unversioned read is untouched, down to the row and -the writability verdict it stamps — pinned as the lit control beside the new -assertions, because a green on only the scoped path would also pass with the -ordinary read broken. `?version=` naming the installed version is served -exactly as the unversioned read is, and so is `?version=latest`: the deleted -handler read `requested.value || 'latest'` and its store resolved `latest` to -the newest row, so "no version" and "`latest`" named one request there and name -one request here. An id the registry does not hold keeps its existing 404 -wording whether or not `?version=` rode along — a package that is not installed -cannot be at the wrong version. - -**This is request-side only.** The response shape is not touched, so the route -still answers with exactly one body shape; comparison is exact string equality -on the version, the same predicate the durable package store uses (`AND version -= ?`), so the two answers to "is this package at version v" cannot drift into -semver-range semantics at one of them. - -A repeated `?version=a&version=b` is no longer resolved by silently choosing -one — it is answered with a refusal naming what was seen. The repo's one rule -for a repeated single-valued parameter answers `400 VALIDATION_ERROR` and is -the right end state for this door too; it is not restated here, because the -helper that owns that rule and its message is not exported from -`@objectstack/rest`. diff --git a/.changeset/17424-liveness-depth-two-recursion.md b/.changeset/17424-liveness-depth-two-recursion.md deleted file mode 100644 index 70b1e002b30..00000000000 --- a/.changeset/17424-liveness-depth-two-recursion.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The liveness ledger's published README no longer declares a one-level drill — the walk follows a nested `children` map as deep as the ledger declares, and says so - -`check-liveness.mts` read `led.children[ck]` and never recursed into a child's -own `children`. A `children` map written at **depth two** was therefore accepted -by the file format and then ignored in silence: no evidence path resolved, no -key reported unclassified, no container reconcile, and no line of output saying -any of it was missing. Because the enforce-or-remove channel acts on this gate's -`dead` verdicts, a silently skipped subtree could retire a key that was alive. - -The walk now descends as far as the ledger nests, the reverse (orphan) direction -follows it down, and a drilled child that is itself a container owes the same -declared disposition — drilled, deferred or recorded — that its top-level peers -already owed. `MAX_DRILL_DEPTH` is a tripwire rather than the working limit: -every key below it is reported **UNCLASSIFIED**, which fails the gate, because a -depth limit the instrument does not announce would rebuild the same defect one -level lower. - -**No verdict moved.** Before and after: live 850, planned 10, dead 93, -experimental 5, live-elsewhere 1 — the full per-type `byStatus` map is -byte-identical. Nothing flipped to or from `dead`, so no retirement is in -question. What did move is the census the gate publishes about its own -completeness: 54 containers became visible at once, every one of them already -riding on a blanket verdict below a drilled container where a one-level walk -could not see it. Three are genuinely classified elsewhere (`app/navigation`'s -NavigationItem keys) and resolve as deferrals; the other 51 are recorded debt. - -**Why this carries a changeset rather than `skip-changeset`.** The tool, its -tests and its baseline all live under `packages/spec/scripts/`, which is absent -from the package's published `files[]` — measured at 0 entries in the packed -tarball, against `liveness/` ships at 38 as the lit positive control. But -`files[]` ships the `liveness` directory whole, and `liveness/README.md` is the -ledger's authoring contract: its "Granularity — drill one level" section is what -an author reads before writing a `children` map, and that sentence is now wrong. -The published bytes that change are that section, the depth rule that replaces -it, and the re-stated census. No ledger verdict file changed. diff --git a/.changeset/17425-retired-permission-residue-lint.md b/.changeset/17425-retired-permission-residue-lint.md deleted file mode 100644 index 38532254397..00000000000 --- a/.changeset/17425-retired-permission-residue-lint.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -feat(lint): `permission-retired-lifecycle-residue` — the retired `allowRestore` / `allowPurge` bits are now named at the authoring door (#17425) - -`ObjectPermissionSchema` accepts `allowRestore: false` / `allowPurge: false` as inert residue and strips them silently. That tolerance is #12840's class ruling and is unchanged here: the accept set does not move, no schema is touched, and every other value keeps the tombstone's loud refusal. - -The silence is deliberate — every artifact the published 17.x toolchain built has the retired default materialized in every permission entry, and a per-occurrence notice would be a storm. But `acceptRetiredDefaultResidue`'s own docblock names the channels that stay loud for authored sources — tsc `never`, `os migrate meta`, the ADR-0087 D2 conversion — and against a non-TypeScript author that list is one entry short. `tsc never` is a TypeScript channel. The conversion and `os migrate meta` are the same channel twice, and `permission-allow-restore-purge-removed` is declared `retiredFromLoadPath`, so it never fires while a stack loads. An author who writes the key in a JSON or YAML source and does not run the migration gets a clean parse and no signal at all — which is what a tombstone exists to prevent. - -`os validate`, `os build` and `os lint` now emit one advisory `warning` per carrying entry, on the raw pre-parse stack where the key is still present and still attributable to a line somebody wrote. The hint is the retirement's own prescription, read from the tombstone's published description rather than retyped, so it cannot drift from the parse-time wording the same author sees through the other door. - -It fires on the captured residue value and on nothing else: `true`, `"false"`, `0` and `null` are already refused at the parse with the prescription attached, and the surviving enforced lifecycle bit `allowTransfer: false` is not residue and is never named. - -New published exports on `@objectstack/lint`: `validateRetiredPermissionResidue`, `PERMISSION_RETIRED_LIFECYCLE_RESIDUE` and the `RetiredPermissionResidueFinding` type. Nothing is removed and no existing finding changes shape or severity. diff --git a/.changeset/17429-pin-bump-describe-corrections.md b/.changeset/17429-pin-bump-describe-corrections.md deleted file mode 100644 index 3023d825cdf..00000000000 --- a/.changeset/17429-pin-bump-describe-corrections.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Two published `describe` sentences that the `.objectui-sha` bump to objectui `87af769e9a3e` makes false are corrected against the new pin (#17429). - -`Clause-②: no` - -Both are claims about what the SHIPPED console renderer does, so the pin is what dates them — and both were re-read by executing the pinned tree, not by refreshing a sha. - -- `Dataset` measure `format`: the clause said "a datetime value ignores it". objectui#8352 lands inside the `53ded82bf7a4...87af769e9a3e` range and makes the datetime arm of `formatMeasureDate` honour the same two words the date arm does — `relative` through `formatRelativeDate`, `short` through `formatDateTime(v, { locale, style: 'compact' })`. A date PATTERN is still ignored on both arms, which is the half of the sentence that survives. -- `FormField.span`: the clause said only the widest container-query tier's class is emitted, so a `'full'` field took one cell of two at the 720px modal width (objectstack#17328). objectui#9244 / objectui#9253 (objectui `bd09957380`) are also inside the range: `spanLadderFor` now emits one clamped col-span class per multi-column tier, so `'full'` is the whole row at every multi-column tier. The `'auto'` half — textarea, markdown, html, richtext and repeater — re-measured unchanged at the new pin. - -No key, default, enum member or export moves: the same authored metadata is accepted and refused as before, and `content/docs/references/ui/{dataset,view}.mdx` are regenerated from these two sentences. diff --git a/.changeset/17445-view-binding-calendar-fallback-corrected.md b/.changeset/17445-view-binding-calendar-fallback-corrected.md deleted file mode 100644 index 614617e9cb6..00000000000 --- a/.changeset/17445-view-binding-calendar-fallback-corrected.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`view/layout-without-binding` — the `calendar` warning no longer tells an author the renderer falls back to `start_date` / `end_date`, because objectui deleted those floors; it names the refusal screen the renderer shows instead, and the key that clears it (#17445). - -Two carriers asserted the same deleted behaviour: `VIEW_BINDING_BLOCKS`' calendar row in `src/kernel/functional-completeness.ts` (stated as *measured*, against "the built console 17.2.0") and the body of the warning `checkViewCompleteness` emits on that route. Re-measured on objectui `main` at `0cf2d6644` (2026-09-21), both halves of the path a `type: 'calendar'` list view takes: - -- `packages/plugin-list/src/ListView.tsx`, `case 'calendar'` — the two literal floors are gone (objectui#7029); the branch restates only bindings the view declared. -- `packages/plugin-calendar/src/ObjectCalendar.tsx` — `getCalendarConfig` resolves `null` with neither a `calendar` block nor a flat `startDateField`, and the component renders its "Calendar configuration required" refusal screen, which names `startDateField` (objectui#8170 corrected that screen: `titleField` is not required). - -So the old body was wrong twice — there is no fallback to literal field names, and the failure is not silent. What it was right about is the remedy, and that is the half the new body keeps: it names `calendar.startDateField`, `CalendarConfigSchema`'s one required key, and records that the event title resolves through the ADR-0079 display-name chain when `titleField` is omitted. The `fix` hint is unchanged. - -- **The message is now per type.** `VIEW_BINDING_MESSAGE` carries an entry for a type whose measured outcome is not the generic literal-fallback sentence; the other five types receive the generic body unchanged. A type that stops flooring gets an entry, never a reworded universal — the two carriers drifted apart once, and the map is what makes correcting both one edit. -- **No severity moves — the severity is already ruled.** #16577 ruled **B** on 2026-09-11 (comment `5634033966`, card closed `completed`): the `type: 'calendar'` route stays warning-class under ADR-0078 §1, and it stays there *because* both doors are loud — loud at `os validate` (this warning) and loud at render (objectui#7029 deleted the `start_date` / `end_date` floors; `getCalendarConfig` returns `null` and the named refusal screen is reachable). That is exactly the premise re-measured here, so the corrected row is the evidence the standing ruling rests on, not a change whose severity consequence is pending. -- ⚠️ **The same reading found three sibling rows stale, and they are recorded rather than corrected** — out of this card's scope, and each changes what its row's severity rests on: `gantt`'s four floors are gone (objectui#7070, objectui#7499) and `ObjectGantt` refuses; `timeline`'s `created_at` floor is gone (objectui#7070) while its `titleField || 'name'` stands; `map`'s `locationField || 'location'` is gone on both faces (objectui#8169). The table now carries that re-measurement note so the three are not reused as current fact, and `kanban` / `tree` were re-read at the same ref and still say what they say. - -No schema moved, no export moved and no accept set moved: this corrects prose and one warning string. `Clause-②: no` diff --git a/.changeset/17456-prototype-fallthrough-guards.md b/.changeset/17456-prototype-fallthrough-guards.md deleted file mode 100644 index cbe2b59b004..00000000000 --- a/.changeset/17456-prototype-fallthrough-guards.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): three more lookups refuse an off-vocabulary key instead of handing back an `Object.prototype` member - -`BASE_ALIASES` / `DIALECT_ALIASES` (`canonicalizeSqlType`), -`DEFAULT_VALUE_TOKEN_SUGGESTIONS` (`suggestDefaultValueToken`) and -`CONTEXT_TOKEN_SUGGESTIONS` (`classifyFilterToken`) are plain object literals, so -all three inherit `Object.prototype`, and every lookup into them was a bare -index. Measured by importing the BUILT artifact (`dist/data/index.mjs`) on the -repo's Node 22 baseline (v22.22.2) and driving each function — the same way the -two landed siblings in this family were measured — over a fixed population of -five: `constructor`, `toString`, `valueOf`, `__proto__` and a plain unknown word. - -| call | before | after | -|:--|:--|:--| -| `canonicalizeSqlType('varchar')` | `'text'` | `'text'` — unmoved | -| `canonicalizeSqlType('timestamptz', 'postgres')` | `'datetime'` | `'datetime'` — unmoved | -| `canonicalizeSqlType('constructor')` | the `Object` **function**, out of a signature that admits only `CanonicalSqlType` string literals | `'unknown'` | -| `canonicalizeSqlType('constructor', )` | the `Object` **function** | `'unknown'` | -| `canonicalizeSqlType('__proto__')` | `'array'` | `'array'` — unmoved; the array-notation rule answers ahead of either table | -| `canonicalizeSqlType('toString' / 'valueOf' / 'nope')` | `'unknown'` | `'unknown'` — unmoved | -| `suggestFieldTypeForSqlType('constructor')` | **`TypeError: Cannot read properties of undefined (reading 'suggested')`** | `undefined` | -| `isCompatible('constructor', 'text')` | **`TypeError: … (reading 'exact')`** | `'lossy'` | -| `suggestDefaultValueToken('currentuser')` | `'current_user'` | `'current_user'` — unmoved | -| `suggestDefaultValueToken('constructor')` | the `Object` **function** | `undefined` | -| `suggestDefaultValueToken('__proto__')` | `Object.prototype` — an **object** | `undefined` | -| `classifyFilterToken('{current_user}').suggestion` | `'current_user_id'` | `'current_user_id'` — unmoved | -| `classifyFilterToken('{constructor}').suggestion` | the `Object` **function**, in a field declared `ContextToken` | `undefined` | -| `classifyFilterToken('{__proto__}').suggestion` | `Object.prototype` | `undefined` | - -The two `TypeError` rows are the sharpest consequence and were not previously -recorded: a non-`CanonicalSqlType` reaches `CANONICAL_TO_FIELD[canonical]`, which -is `undefined`, so both published sibling accessors threw on the member read -rather than merely returning something off-contract. `canonicalizeSqlType`'s -`rawType` comes off live database introspection, which is where an -attacker-free, entirely accidental `constructor` actually comes from. - -`classifyFilterToken`'s half is the one a type-checked consumer meets: the -declared `suggestion?: ContextToken` was a compile-time guarantee that was false -at runtime, and nothing in the type system would ever have flagged it. Its -wrapped-token regex captures `[^{}]+` — anything but braces — so the reachable -key set is not the identifier-shaped one; what bounds it is the `toLowerCase()`, -which leaves exactly the lower-case-stable prototype members (`constructor`, -`__proto__`) namable today. `toString` / `valueOf` were quiet by that casing -accident alone, not by a guard. - -All three sites now go through an `Object.prototype.hasOwnProperty.call` check -returning each function's own already-declared refusal value — `'unknown'`, -`undefined`, and an absent `suggestion` respectively. No declared signature -changes. This narrows and widens nothing an author can reach: every legal -spelling is an own key of its table, so nothing accepted before is refused now, -and only answers that were never inside the declared return types move. - -A null-prototype table was the other available shape and is not taken, for the -reason the two landed siblings measured rather than assumed: a `__proto__: null` -object literal does not type-check against the `Record<…>` annotation at all -(TS2353), and the `Object.assign(Object.create(null), …)` spelling that does -compile silently costs that annotation's exhaustiveness check (TS2741 stopped -firing for a table missing a member). A quiet failure is worse than a loud one. diff --git a/.changeset/17464-knowledge-source-docblock-runtime-registration.md b/.changeset/17464-knowledge-source-docblock-runtime-registration.md deleted file mode 100644 index 73f84626954..00000000000 --- a/.changeset/17464-knowledge-source-docblock-runtime-registration.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`KnowledgeSourceSchema`'s docblock stops claiming it is stored as metadata "exactly like a view or a flow", and says where a knowledge source actually lives - -The docblock above `KnowledgeSourceSchema` declared, verbatim: - -> Canonical KnowledgeSource. Stored as metadata, versioned, and -> environment-scoped exactly like a view or a flow. - -None of the three is true, measured on the tree this changeset lands on: - -- `listMetadataTypeSchemaTypes()` returns **26** governed metadata types and - **none is knowledge-shaped**. Controls that fire: `view`, `flow`, `skill`, - `agent` and `tool` are all present; a `zzz_nonsense` dark control is absent. -- `ObjectStackDefinitionSchema` has **44** top-level keys, none knowledge-shaped - (controls present: `skills`, `agents`, `tools`, `views`, `flows`). -- `defineStack({ knowledgeSources: [...] })` is refused with the **generic** - unrecognized-top-level-key message — byte-identical to the message for - `zzz_nonsense`. Lit control: `defineStack({ skills: [] })` is - accepted on the same base, so the probe does find an authoring route for a - type that has one. - -So an author who followed the sentence reached for a mounting that does not -exist and got a rejection that pointed nowhere — the authoring trap, not a -wrong example. - -**The prose was the outlier, not the schema.** No ADR in this repo mentions -`KnowledgeSource` at all, and the rest of the contract is already consistent: -`IKnowledgeService` declares `registerSource` / `unregisterSource` / -`listSources` / `getSource`, `KnowledgeServicePlugin` takes a `sources` option -at kernel wiring and calls `registerSource` for each, and the implementation -holds them in a process-lifetime `Map`. The `agent.knowledge` liveness row says -the same thing from the other side — *"restrict retrieval at the -knowledge-service/source level; describe grounding in `instructions`"*. - -The replacement docblock states what the schema is (the shape of a runtime -registration), names both routes a source actually arrives by, and says the -retrieval restriction is per-source at the service level. - -⛔ No behaviour, no key and no accept set changes: the diff is one docblock. -Running `gen:schema` and `gen:docs` afterwards produced no artefact change — -`content/docs/references/ai/knowledge-source.mdx` mirrors the file-level header -docblock, not this per-schema one. - -**Why this is not `skip-changeset`.** `@objectstack/spec`'s published `files[]` -ships `dist` *and* `src/**/*.zod.ts`, so this text is published twice over: the -old sentence was measured in the built `dist/knowledge-document.zod-*.d.ts` and -`.d.mts` (1 occurrence each) before the edit, and the source file is shipped -verbatim. Both move. diff --git a/.changeset/17469-multiple-non-capable-type-refused.md b/.changeset/17469-multiple-non-capable-type-refused.md deleted file mode 100644 index f1c4d3dc817..00000000000 --- a/.changeset/17469-multiple-non-capable-type-refused.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/driver-sql": minor ---- - -fix(spec)!: `multiple: true` is refused on every type outside the multi-capable set, and driver-sql derives JSON-column storage from the spec predicate (#17469) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention: `major` is refused by `check-changeset-no-major`, and -breaking-ness is carried by this banner plus the ADR-0087 disposition). - -Two definitions of "multi-valued" disagreed, and the user saw the disagreement as -a `400`. - -- `FieldSchema` accepted `multiple: true` on **any** type. -- `@objectstack/driver-sql`'s `isJsonField` read the flag raw — - `JSON_COLUMN_TYPES.has(type) || !!field.multiple` — and built a **JSON array - column** for it. -- `isMultiValueField` — the published spec predicate consumers shape queries from - — answered **"not multi-value"** for that same field, because `master_detail` / - `tree` / `text` are outside `MULTI_CAPABLE_TYPES`. - -So a related list composed `=` against a JSON array column, and the driver refused -the equality family there with a `400`. - -In business terms: `multiple` means "this cell holds several values at once", and -that has meaning only on multi-select, multi-record / multi-user and multi-file -fields — exactly what the spec already declares. A child record with several -masters, a tree node with several parents, or a text box holding several texts has -no meaning on any mainstream platform. The declaration was accepted silently, the -UI rendered a single value, the database built a JSON array column, and the -related list answered the user a 400. - -FROM → TO, for metadata that used to parse and now fails: - -```ts -// FROM — parsed, stored a JSON array, rendered single, answered `=` with 400 -{ type: 'text', label: 'Aliases', multiple: true } -{ type: 'master_detail', label: 'Parents', reference: 'account', multiple: true } -{ type: 'tree', label: 'Parents', reference: 'category', multiple: true } - -// TO — pick the type that actually holds several values… -{ type: 'tags', label: 'Aliases' } // several free-form strings -{ type: 'lookup', label: 'Parents', reference: 'account', multiple: true } // several related records - -// …or drop the key, if the cell really holds one value. -{ type: 'text', label: 'Alias' } -{ type: 'master_detail', label: 'Parent', reference: 'account' } -``` - -The refusal names the field, its type and the alternative, on the `multiple` path. -`radio` keeps its own narrower 2026-08-22 message (#11437); the two never -double-fire. - -**`MULTI_CAPABLE_TYPES` and `isMultiValueField` are untouched**, deliberately: a -field that was already multi-valued by that predicate keeps its declaration, its -storage and its read path byte-identically. What moved is which declarations can -be newly authored, plus the storage decision for the shapes that are now refused. - -**Storage change (`@objectstack/driver-sql`)**: every site that asked -`field.multiple` the question "is this value multi-valued" now asks -`isMultiValueField` — **eighteen expressions across two files**, not one. The -file's own header already called `JSON_COLUMN_TYPES` membership "owned by -`@objectstack/spec`"; that sentence is now true for the `multiple` half too. - -- `sql-driver.ts` — the DDL writer (`createColumn`'s multi-value short-circuit), - the read-side deserializer (`isJsonField`, both limbs), the `varchar` width - mirror (`varcharColumnChars`), the cross-field comparison class - (`crossFieldComparisonClass`), the four scalar registries filled by BOTH - `registerObjectMetadata` and `registerExternalObject` (`mediaFields`, - `booleanFields`, `numericFields`, `numericValueFields`), and the two MySQL - temporal-widening candidate sets. -- `schema-drift.ts` — the differ's `fieldHasColumn`, its `declaresJsonColumn` - disjunct and its `declaresArray` test, which #15771 bound to the writer's - predicate and which a pin test holds equal to it. - -Only one of those was named in the ruling; aligning it and leaving seventeen -would have re-opened #11535 in reverse — the DDL writing a JSON column that the -read-side deserializer no longer recognises. A column whose field is multi-valued -by the spec predicate behaves exactly as before; the shapes that change are the -ones the schema now refuses at the entrance. - -⛔ Three `field.multiple` reads are deliberately NOT aligned: the three that -interpolate `', multiple'` into an `uncompilableFieldReferenceError` message. -They echo what the author DECLARED back to them; they do not ask whether the -value is multi-valued (the verdict there comes from `crossFieldComparisonClass`, -which is aligned). - -⚠️ **Two consequences worth reading before you upgrade.** - -1. A **stored** field carrying `multiple: true` on a non-capable type has no - lossless conversion — its column was physically built as a JSON array. The - ADR-0087 semantic entry `field-multiple-non-capable-type-refused` emits the - structured TODO naming the object, field and type; migrating the data is the - author's judgment call, and the entry states how to prove it. -2. `isMultiValueField` reads the **authorable** `FieldType` vocabulary. A driver - -internal column-type alias (`string` / `integer` / `int` / `float` — the - introspected-column spellings) is not a `FieldType`, so a hand-declared - external object that puts `multiple: true` on one of those no longer gets a - JSON column. Declare such a column as `object` or `array` (both are - `JSON_COLUMN_TYPES` members and unchanged), or as the authorable type it - really is. -3. `multiple: true` on `boolean` / `toggle` / `number` / `currency` / `percent` / - `date` / `datetime` / `time` **ceases to be a supported shape end to end**, as - a consequence of the entrance refusal above. Such a column is no longer a JSON - column, so it is no longer excluded from the scalar read-coercion registries - and the declared-type text-operator gate (`isNonTextColumn`) applies to it: a - `$contains` against one answers the declared no-match rather than a JSON - membership test. Stored data in that shape is the ADR-0087 entry's subject. diff --git a/.changeset/17475-record-picker-filter-docblock.md b/.changeset/17475-record-picker-filter-docblock.md deleted file mode 100644 index bc9a24292c9..00000000000 --- a/.changeset/17475-record-picker-filter-docblock.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`element:record_picker`'s `filter` docblock now says what the `object-*` blocks actually declare - -The docblock on `ElementRecordPickerPropsSchema.filter` (anchor: -`Filter rules narrowing which records the picker offers`) carried a -parenthetical claiming *"the four `object-*` blocks declare `filter` as -`z.unknown()`, no orthography at all"*. Measured on the file itself: there is no -`filter` key anywhere in `packages/spec/src/ui/component.zod.ts` declared -`z.unknown()` — zero occurrences, against 61 occurrences of `z.unknown()` in the -same file on the same instrument, so the zero is a reading and not a broken -matcher. All eight Zod `filter` declarations in the file are -`z.array(ViewFilterRuleSchema).optional()`; the one remaining `filter:` line is a -`KeySetGuidance` prose entry, not a declaration. - -The `object-*` family in `ComponentPropsMap` has **six** entries. **Four** of -them carry a `filter` door — `object-grid`, `object-metric`, `object-kanban`, -`object-calendar` — and all four declare `z.array(ViewFilterRuleSchema)`. The -other two, `object-form` and `object-master-detail-form`, declare no `filter` -key at all. The corrected parenthetical states both numbers and names all six, -and keeps the `#15449` citation, which is accurate as provenance for when those -four doors moved onto the array form. - -**Why this is worth a patch rather than a silent tidy.** The sentence sat in the -one docblock that tells an author what the sibling `filter` doors accept, and it -told them those doors accept anything. The record form it thereby invited — -`{ field: { $eq: ... } }`, the MongoDB-style shape this very docblock says the -picker moved OFF — is refused at parse by all four. Prose only: no declaration -moves and no accept set changes. diff --git a/.changeset/17487-confirmation-gate-prescriptions-present-tense.md b/.changeset/17487-confirmation-gate-prescriptions-present-tense.md deleted file mode 100644 index d5c75e5948b..00000000000 --- a/.changeset/17487-confirmation-gate-prescriptions-present-tense.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the three shipped confirmation-gate prescriptions state the gate in the present tense — they were denying a door that exists (#17487) - -Clause-②: no - -No accept-set change and no export moves. `ToolSchema` still refuses -`requiresConfirmation` with a located parse error, `ActionSchema` still accepts -`ai.requiresConfirmation` in both directions, and `check:authorable-surface` / -`check:api-surface` are byte-identical across this diff. What moves is text. - -Three customer-facing prescriptions were written while the runtime confirmation -door was a separate, unlanded change, and each said so in the present tense. The -door has since landed on `main` — `actionConfirmationRefusal`, called pre-dispatch -by `invokeBusinessAction` in `@objectstack/runtime`, with the `confirm` member -grown on the MCP `run_action` tool in the same change. From that moment the -published prose DENIED a door that exists, and it denied it in the dangerous direction: an author who -reads it concludes the safety flag stops nothing and either arranges a human in -the loop some other way or stops setting the flag — losing the gate exactly when -it starts working. That is the ADR-0049 false-compliance defect with the sign -flipped. - -**The three carriers**, all of them shipped text rather than comments: - -1. the `requiresConfirmation` entry of `TOOL_RETIRED_KEY_GUIDANCE` - (`ai/tool.zod.ts`), which reaches consumers as the parse error on the - `.strict()` `ToolSchema` — the one channel every consumer bumping - `@objectstack/spec` is guaranteed to hit; -2. the ADR-0087 D3 entry's `replacement`, and -3. its `acceptanceCriteria` — what `spec-changes.json`, - `docs/protocol-upgrade-guide.md` and `os migrate meta` project to consumers. - -FROM → TO, on the sharpest of the three (the acceptance criterion): - -``` -was: Do NOT try to "prove the gate" by invoking the operation without the - confirmation member: ... before that ships the call is not refused, it - RUNS the destructive operation. -now: ... that gate is PERFORMED: invoking the operation over an AI-exposed - door without the confirmation member is REFUSED with - ACTION_CONFIRMATION_REQUIRED (428) and nothing runs, so that call is a - real check you can make rather than a destructive experiment. -``` - -**The corrections carry the door's BOUNDS, because over-promising here is the -same defect in the other direction.** Each prescription now states, as the door -itself declares them: the refusal is `ACTION_CONFIRMATION_REQUIRED` / 428 naming -the action and the member `confirm: true`; it is a GATE, not a queue — nothing -is parked and a refused call did not run, no record read and none written; the -enforced set is the doors that enforce the author's `ai.exposed` opt-in, today -the action door reached from the MCP `run_action` tool, while REST `/actions` is -not `ai.exposed`-gated and sits outside the gate; only the author's declared -`ai.requiresConfirmation: true` refuses, while the wider listing heuristic -advises and never refuses; and `confirm: true` is an unverifiable caller claim, -so the gate makes FORGETTING loud without proving a human. - -`ai/tool-confirmation-prescription-tense.pin.test.ts` is the tie that was -missing the first time: it reads the three shipped strings AND the runtime door, -so a prescription that re-acquires a not-yet-shipped denial fails, and a door -that is removed, narrowed off the DECLARED flag, unhooked from -`invokeBusinessAction`, or widened onto REST `/actions` fails naming both files. -The denial predicate is fed the three retired sentences verbatim, so it cannot -pass by the prose merely falling silent. - -**On release ordering.** The door ships in the same release this correction -does: the runtime changeset that carries it (`action-confirmation-gate-enforced`) -is still pending alongside this one, and one `changeset version` run consumes -both. A release cut before this lands is the failure this card exists to end — -the runtime refusing calls while the published spec text tells authors the flag -stops nothing. diff --git a/.changeset/17493-node-door-refusal-residues.md b/.changeset/17493-node-door-refusal-residues.md deleted file mode 100644 index e75807c61c1..00000000000 --- a/.changeset/17493-node-door-refusal-residues.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): the structural-condition ruling and the ADR-0087 entry both name the NODE slot (#17493) - -Two places in `packages/spec` still described the world as it was before the -blank structural condition became a defect. Neither changes behaviour: this is -the notification half of a refusal that has already shipped. - -**The ADR-0087 D3 entry `flow-edge-condition-evaluated-slot-source-required` -named only the edge key.** Its `surface` and `acceptanceCriteria` told a -consumer replaying the chain to sweep `edges[].condition` and nothing else — -so a deployment carrying a blank `config.condition` on a flow node was never -told to look, even though `AutomationEngine.registerFlow` refuses it since -#17322 and `objectstack validate` since #17495. Both fields now name both -structural slots, the node key's own locator -(the phrase the structural pass builds, e.g. `node 'gate' (start) condition`) is -stated beside the edge's `flows.N.edges.N.condition`, and the sweep carries the -warning that removing a `condition` from a `start` node opens the trigger gate -rather than preserving it. The entry's `id`, `replacement` and `reason` are -untouched, and no new entry is added: this is one decision reaching its second -slot, not a second decision. - -**`structuralConditionRefusal`'s docblock stated a ruling that had become -false.** It admitted a whitespace-only string on the ground that such a -condition "is consistent on both sides and is ruled correct, not a defect" — -the ground #15807 removed at the edge door and #17322 ruled on. The admission -itself is unchanged and still correct, because this function answers the SHAPE -question only and the blank is refused beside it by the imported -evaluated-slot rule; what the docblock now records is which card removed the -ground, which door each refusal lives at, and why the two refusals are kept -distinct. - -It also records, without answering, the question one slot over: the ledger -`predicate` slots (`config.conditions[].expression`, -`screen.fields[].visibleWhen`) still admit a whitespace-only string, pinned as -correct by #15572 on the same ground. Narrowing them re-judges that pin and -moves a published accept-set, so it is a ruling and stays open on #17493. diff --git a/.changeset/17493-predicate-slot-blank-refused.md b/.changeset/17493-predicate-slot-blank-refused.md deleted file mode 100644 index a05ca2f4367..00000000000 --- a/.changeset/17493-predicate-slot-blank-refused.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-automation': minor -'@objectstack/lint': minor ---- - -fix(spec)!: a blank string in a flow node's predicate slot — a `decision` branch `expression`, a screen field `visibleWhen` — is refused at authoring (#17493) - -Clause-②: no (narrowing) - - - -**BREAKING** — an accept-set narrowing on two authored flow-node slots, shipped as -`minor` under the launch-window convention (`check-changeset-no-major` refuses -`major` until GA; breaking-ness is carried by this banner and the ADR-0087 -disposition above, not by the level). - -**What changed.** A `decision` node's `config.conditions[].expression` and a -`screen` node's `config.fields[].visibleWhen` are declared bare CEL text. A string -that is blank after trimming (`''`, `' '`, a tab or a newline) used to be -accepted there by `FlowSchema.parse`, `AutomationEngine.registerFlow` and -`objectstack validate`, and was then read as "no predicate": the evaluator answers -a blank decision predicate `false`, so that branch was not taken, and nothing said -so. It is now refused at those doors — by `FlowSchema.parse` with a `custom` issue -anchored at the slot (for example `nodes.1.config.conditions.0.expression`), and -by `registerFlow` and `objectstack validate` through that same parse — with a -message that leads with the published `PREDICATE_SLOT_STRING_REFUSAL` sentence, -the one these slots already answered with for a non-string value. Where such a -value already sits, the whole flow is refused: registered from the metadata -registry or `sys_metadata` at boot, it is skipped with a -`failed to register flow` warn naming it while the flows beside it register; a -`defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole -stack; an artifact file is refused whole at load. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `conditions: [{ label: 'high', expression: ' ' }]` on a `decision` node | the predicate you meant — `{ label: 'high', expression: 'record.amount > 10000' }` — or, to keep what the blank did, `expression: 'false'` | -| `fields: [{ name: 'reason', visibleWhen: '' }]` on a `screen` node | the predicate you meant — `visibleWhen: "status == 'rejected'"` — or, to keep what the blank did, drop the `visibleWhen` key | - -**One-line fix:** write the predicate, or keep what the blank did — `'false'` on -a decision branch (the value the blank evaluated to), no `visibleWhen` on a -screen field (a blank one was read as absent). ⚠️ Do not drop a decision's only -branch: the node then routes by its out-edges alone, and the out-edge that branch -labelled is no longer held back. A blank structural `condition` is another case — -see the `flow-edge-condition-evaluated-slot-source-required` migration entry. - -**Unchanged.** A non-blank predicate parses, registers and validates as before; -a non-string in these slots keeps its existing refusal at `registerFlow` and -`objectstack validate`; `edges[].condition` and a node's `config.condition` keep -their own rule and sentence (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`); and -`AutomationEngine.evaluateCondition` still answers a blank predicate `false` for -a caller that reaches it directly. The `PREDICATE_SLOT_STRING_REFUSAL` constant -keeps its name and now also names the blank string, so code matching the -constant rather than a copy of its text is unaffected. diff --git a/.changeset/17499-groupbyfield-non-padded.md b/.changeset/17499-groupbyfield-non-padded.md deleted file mode 100644 index c5d50710a93..00000000000 --- a/.changeset/17499-groupbyfield-non-padded.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: `groupByField` refuses a padded field name on kanban, gantt and timeline instead of handing the renderer a lookup that always misses (#17499) - -**BREAKING** — an accept-set narrowing on three published authoring keys. `KanbanConfigSchema.groupByField` (**required**), `GanttConfigSchema.groupByField` and `TimelineConfigSchema.groupByField` were bare `z.string()`, so `' stage'` was valid authored metadata; all three are now refused at parse. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings, the same as the sibling axis in #17360. Stored metadata carrying a padded `groupByField` now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `ui-list-view-groupbyfield-padded-refused`. - -## What was wrong - -The padded name never failed anywhere. It failed to *group*. - -These three keys name a field the consumer looks up on **every row, by that name**. Measured in objectui at `dda8f3815`: the kanban board resolves its lane as `laneField = groupByField || groupField || detectStatusField(objectDef)` and buckets cards by `card[laneField]`; `ObjectGantt`'s `groupByAccessor` splits the name on `.` and walks the backing record (`resolvePath(task.data, field)`); the timeline groups its rows the same way. The server answers under the unpadded name, so a padded spelling reads `undefined` on every row and the board collapses into one `Uncategorized` lane — the gantt and the timeline into one ungrouped bucket — holding every record. - -That is a silent wrong answer that reads as a true statement about the data: a user looking at one giant lane cannot tell it apart from a dataset where the field genuinely is empty. Nothing weaker than a parse refusal is honest about it. - -`packages/lint`'s `validate-list-view-field-refs` already calls this consequence out for `kanban.groupByField` (*"collapses every card into the uncolumned bucket"*), and grades that position `error` — but that rule only runs where an app is validated against its object definitions. The producer accepted the value regardless, which is the hole this closes. - -## What it does now - -Each of the three carries the **non-padded** pattern — no leading and no trailing whitespace — and the refusal is addressed to the offending key (`kanban.groupByField`, `gantt.groupByField`, `timeline.groupByField`), names the offending spelling verbatim so the whitespace an author cannot see in an editor is visible in the message, and carries the name to write instead. - -⛔ **Not a `.trim()`.** A trimming schema makes `' stage'` and `'stage'` silently equivalent, which is the consumer-tolerance direction AGENTS.md #0.1 refuses: the padded spelling is a mistake the author should be told about, not a dialect the producer quietly normalises away. On the **required** kanban key this is sharper than on the sibling axis — an author cannot withdraw the value by omitting the key, so a normalising producer would be the author's only feedback channel and it would say nothing. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `kanban: { groupByField: ' stage' }` | `kanban: { groupByField: 'stage' }` | -| `gantt: { groupByField: 'owner ' }` | `gantt: { groupByField: 'owner' }` | -| `timeline: { groupByField: 'team\n' }` | `timeline: { groupByField: 'team' }` | - -The remedy is always the same: write the field name exactly as the object declares it and the server answers under. If a board has been silently showing one `Uncategorized` lane, re-authoring the name is also the fix for that. - -## Scope — what is deliberately NOT narrowed - -- **The empty string is unchanged.** It still parses, exactly as before, on all three keys. This narrowing exists for the **silent** case; widening the pattern to catch `''` would be a second, undeclared narrowing riding on this one. -- **This is not the snake_case machine-name grammar.** `packages/spec` spells `/^[a-z_][a-z0-9_]*$/` inline for object, field and tool **names**, and these keys deliberately do not take it: a `groupByField` holds a field **reference**, and a dotted relationship path (`owner.name`) is an in-tree spelling of one — `packages/lint`'s `validate-list-view-field-refs.test.ts` carries `kanban: { groupByField: 'owner.name' }` in a case asserting no findings. -- **The sibling axis `grouping.fields[].field`** already landed this rule in #17360 / PR #17498; this change reuses that pattern rather than declaring a second one. - -## Who is affected, measured - -Every `groupByField` spelling in this repo parses unchanged. Harvested across every `.ts` / `.tsx` / `.mdx` / `.json` / `.mjs` outside `node_modules`: **14 distinct literals, zero of them padded** (`'warning'` / `'error'` are severity-map values in `packages/lint` and `''` is prose inside a completeness hint, so neither is an authored name). Nothing in the tree reddens, and no fixture had to be rewritten to keep it green. - -Outside the repo, only metadata that was already grouping wrongly is affected: a padded `groupByField` has never produced a correct board, gantt or timeline on any renderer. - -Clause-②: no (narrowing) — no key is added, removed or renamed, no exported symbol moves (`check:api-surface` clean with no regeneration), and no registry row is added. The accept set narrows back to what the key's description already claimed. - - diff --git a/.changeset/17502-served-schema-drops-unauthorable-columns.md b/.changeset/17502-served-schema-drops-unauthorable-columns.md deleted file mode 100644 index b11d0946ab1..00000000000 --- a/.changeset/17502-served-schema-drops-unauthorable-columns.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol): `GET /meta/types` stops publishing properties no instance can satisfy (#17502) - -The served JSON Schema advertised the `retiredKey()` tombstones alongside the -live keys. `retiredKey()` keeps a removed authorable key declared on purpose — -the removal has to be audible — and `z.toJSONSchema` renders that tombstone as -a property node, `{ "description": "[REMOVED] ", "not": {} }`. - -`not: {}` is the JSON Schema spelling of "no instance validates", so a consumer -that reads the subschema is told the truth. A consumer that reads the KEY SET is -not: Studio builds a repeater's column headers from -`items.properties[k].title ?? k`, so a tombstone inside a row shape became a -column an author was invited to fill and `saveMetaItem` then refused. - -`toJsonSchemaSafe` now drops every property whose subschema admits no instance -before it serves or caches the document — structurally, by asking the JSON -Schema question, never by matching the `[REMOVED] ` description prefix, which -would put a second hand-written spelling of "this is a tombstone" in a consumer. -A property that admits nothing and is `required` is kept: dropping it would turn -"this object admits nothing" into "this object admits anything". - -Measured over the whole served registry at `74eaab8614`, this change's merge -base (`@objectstack/spec` SOURCE at 17.4.0, plus the retirements unreleased at -that sha — not the published release): 80 such nodes across 16 types — a -reading taken at that tree, not a standing invariant; it moves as retired keys -land or age out. - -**Nothing is un-retired, and no prescription CHANNEL is destroyed.** The removal is a -property of ONE emitter. `tsc` still types the key `never`, the parse still -refuses it with the prescription byte for byte, `packages/spec`'s -`authorable-surface/` ratchet still lists every retired key as `[RETIRED]`, and -the generated reference pages still print the full prescription in the -description column of a `never`-typed row. What this drops is a fourth copy, on -the one surface whose documented job is to describe what an author MAY write. - -**What an author stops being offered, stated as a class.** A tombstone became -visible wherever a renderer derives its field or column list from the served KEY -SET and reads the subschema for nothing but a label — so the retired key arrived -as an editable input, or as a repeater column, that the publish door then -refused. Three mechanisms put one in front of an author, and one retired key can -reach it through more than one of them: - -- **the flat, schema-driven fallback**, for a served type that carries no - `*.form.ts` layout: its field list *is* the served `properties` map, and a - nested object renders recursively, so a tombstone at any depth becomes a field - with the `[REMOVED] ` prescription as its help text; -- **repeater rows**, whose column headers are `items.properties[k].title ?? k` — - the carrier this card was filed on; -- **server-field grafting**, where an inspector merges the server's top-level - properties into a trailing "More fields" section: a key the UI's own bundled - spec predates is offered *because* the served document is the only place it is - known from. - -No count of the affected sites is given, on purpose. Which nodes reach an author -depends on the renderer and on the Console build this repo pins, so any number -written here would be false at the next pin bump. The invariant is the class: the -served document stops offering what the publish door refuses, and every retired -key keeps the full prescription on its generated reference page. A repeater -column loses no text either way — the row-cell renderer has no `description` -branch — so there the removal only withdraws the offer. diff --git a/.changeset/17505-dashboard-repeater-row-titles.md b/.changeset/17505-dashboard-repeater-row-titles.md deleted file mode 100644 index c18fb71613d..00000000000 --- a/.changeset/17505-dashboard-repeater-row-titles.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`dashboard.widgets[]` (17) and `dashboard.globalFilters[]` (10) — every authorable row property of these two repeaters now carries a JSON Schema `title`, so Studio's property-panel table prints an authoring label instead of the raw machine key (#17505). - -`Clause-②: yes` — no authorable key moves, but each row property gains a `title` node in the emitted JSON Schema, which is a published artifact. - -Studio renders a `type: 'repeater'` field as a table whose column headers read `items.properties[k].title ?? k` off the schema derived by `z.toJSONSchema(...)`. With no `title` the fallback arm runs in **every** locale, English included, so the maker saw `requiresService`, `filterBindings` and `optionsFrom` inside an otherwise translated panel. That is a missing authoring label in the contract, not a translation gap — the English default has to live on the schema, because `resolveMetadataFormSchemaTitles` only ever REPLACES a `title` that is already there. - -- **Mechanism unchanged** — this applies the one ruled in #16458 and already landed on `dashboard.header.actions` and on the `ai/skill`, `ui/report` and `ui/page` carriers: `.meta({ title })` on the zod item schema, beside the existing `.describe()` rather than in place of it. -- **The debt record is deleted, not suppressed.** `repeater-item-titles.test.ts` keeps an exact, shrink-only ledger: a carrier in it must still be untitled, so paying a debt and leaving the entry behind is as red as never paying it. Both `dashboard:*` entries are gone from that set; five remain (`field:options`, `object:fields.options`, `view:columns`, `view:sort`, `view:tabs`). -- ⛔ **No tombstone was titled.** The five `retiredKey()` keys on this row (`actionUrl`, `actionType`, `actionIcon`, `responsive`, `aria`) declare their keys unwritable; an authoring label would advertise them as writable. All five still emit `title: undefined` in both `io: 'input'` and `io: 'output'`, and the sibling control in `dashboard.test.ts` was re-pointed onto one of them so the rule is now pinned rather than assumed. - -Measured through the platform's own predicate (`z.toJSONSchema` over `getMetadataTypeSchema`, `io: 'input'`), not by regexing source: `dashboard:widgets` untitled 17 → 0 and `dashboard:globalFilters` untitled 10 → 0, with all twenty other repeater carriers unchanged in the same run. diff --git a/.changeset/17506-select-option-row-titles.md b/.changeset/17506-select-option-row-titles.md deleted file mode 100644 index 861d12bcdc6..00000000000 --- a/.changeset/17506-select-option-row-titles.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`SelectOptionSchema`'s six row properties carry a JSON Schema `title`, so Studio's property panel stops printing raw machine keys as the column headers of a field's `options` table (#17506). - -Clause-②: no - -Studio renders a `type: 'repeater'` form field as a table whose column headers read `items.properties[k].title ?? k` off the JSON Schema derived from the metadata type schema. `SelectOptionSchema` carried no `title` on any row property, so the fallback arm ran and the maker saw `label` / `value` / `description` / `color` / `default` / `visibleWhen` inside an otherwise translated panel — **in every locale, English included**. Titles are hard-coded English by design: `system/translation.zod.ts` states that a row property renders from `items.properties[k].title`, and `resolveMetadataFormSchemaTitles` only ever REPLACES a title that is already there, so an untitled property has no layer for a translation to overlay. - -- **One edit clears two carriers.** `field:options` and `object:fields.options` resolve to the *same* `SelectOptionSchema` object — `FieldSchema.options` is `z.array(SelectOptionSchema)` and `object.fields` is a `z.record(..., FieldSchema)` of that same `FieldSchema` — verified by object identity (`===`) against the schemas `getMetadataTypeSchema('field')` and `getMetadataTypeSchema('object')` actually return, with `FormSelectOptionSchema` as the firing control that the probe can tell two schemas apart. Both entries are deleted from the shrink-only `repeater-item-titles` ledger in the same change; `object.zod.ts` needed no edit. -- **Nothing the schema accepts or refuses moved.** `.meta({ title })` is presentation metadata: the generated `authorable-surface/` artifacts are byte-identical, and the pinned accept/refuse suites for this shape (`editability-boundary`, `visible-when-alias-guidance`, `form-select-option`, `evaluated-slot-population`) pass unchanged. -- **The form-view face inherits the titles for free.** `FormSelectOptionSchema` is a shape-level Omit that reuses the same property schema instances, so the five keys it keeps arrive titled too, and its `default`-refusal is untouched. diff --git a/.changeset/17507-view-repeater-row-titles.md b/.changeset/17507-view-repeater-row-titles.md deleted file mode 100644 index ff898771d09..00000000000 --- a/.changeset/17507-view-repeater-row-titles.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The row properties of the `view.columns`, `view.tabs` and `view.sort` repeaters carry a JSON Schema `title`, so Studio's property panel stops printing raw machine keys as the column headers of those three tables (#17507). - -Clause-②: no - -Studio renders a `type: 'repeater'` form field as a table whose column headers read `items.properties[k].title ?? k` off the JSON Schema derived from the metadata type schema. None of the 25 row properties of the three view repeaters carried a `title`, so the fallback arm ran and the maker saw `field` / `width` / `isDefault` / `order` in every locale, English included. - -- **`view.columns`** — the 14 row properties of `ListColumnSchema` (`Field`, `Label`, `Width (px)`, `Alignment`, `Hidden`, `Sortable`, `Resizable`, `Wrap Text`, `Renderer Type`, `Pinned`, `Summary`, `Prefix`, `Primary Link`, `Click Action`). -- **`view.tabs`** — the 9 row properties of `ViewTabSchema` (`Name`, `Label`, `Icon`, `List View`, `Filter`, `Display Order`, `Pinned`, `Default Tab`, `Visible`). -- **`view.sort`** — the list view's INLINE `{ field, order }` sort entry (`Field`, `Direction`). It is not the shared `SortItemSchema`, so titling that schema never reached this table; the titles mirror it. -- **Nothing the schema accepts or refuses moved.** `.meta({ title })` is presentation metadata: the generated `authorable-surface/` artifacts are byte-identical. The repeater-title ledger loses its last three entries and is now empty, so every repeater a form declares is fully titled and a new untitled one fails its own PR. diff --git a/.changeset/17508-repeater-row-property-localisation.md b/.changeset/17508-repeater-row-property-localisation.md deleted file mode 100644 index ac843f6b273..00000000000 --- a/.changeset/17508-repeater-row-property-localisation.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/spec': minor ---- - -Studio's property-panel repeater tables name their columns in the author's own language: every repeater enumerates its row properties in the owning `*.form.ts`, and all four platform catalogs carry a translated name for each one - -Clause-②: no - -A `type: 'repeater'` renders as a table whose column heads come from the form's declared row children when it declares any, and from the served JSON Schema `items.properties[k].title` when it does not. `os i18n extract` only emits a `metadataForms..fields['.']` key for a **declared** child, so a repeater that enumerated none had no localisation channel at all — #17232 (PR #17500) authored English titles on thirteen item schemas, #17505 and #17506 on four more, and every one of those column heads reached a Chinese, Japanese or Spanish author in English. - -Both halves land together, because either alone is a half-state: 112 row properties across fifteen repeaters are now enumerated, each with a `label` equal to the item schema's own `.meta({ title })`, and the `en` / `zh-CN` / `ja-JP` / `es-ES` catalogs gain a leaf for each. Nothing in the accept set moves — the same author input parses identically before and after, and no row child declares a `type`, so the row widgets stay schema-derived. - -Terms reuse the word each catalog already uses for the concept (`Label` → 显示名称 / 表示名 / Etiqueta, `Filter` → 筛选 / フィルター / Filtro, `Timeout (ms)` → 超时(毫秒)/ タイムアウト(ms)/ Tiempo de espera (ms)), and `field.options.*` mirrors its `object.fields.options.*` twin verbatim. - -`page.variables.source` is the one existing string that moves. Its children were enumerated without labels, so the extractor emitted the humanized path `"Source"` as the English source and the bundle overlay then wrote that over the schema's authored `"Written By"`. The form now declares the label, the `en` leaf becomes `Written By`, and its three translations are re-authored with it (写入组件 / 書き込み元 / Escrito por). - -`view.columns` / `view.sort` / `view.tabs` are untitled and enumerate no children — they are #17507's, and are untouched here. `object.fields.options` stays the curated four-key subset its reconciliation-ledger entry declares. diff --git a/.changeset/17511-i18n-extract-region-screens.md b/.changeset/17511-i18n-extract-region-screens.md deleted file mode 100644 index add44306647..00000000000 --- a/.changeset/17511-i18n-extract-region-screens.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os i18n extract` reaches a `screen` node nested inside an ADR-0031 flow region - -`walkScreenFlows` (`packages/cli/src/utils/i18n-extract.ts`) iterated -`flow.nodes` flat, so a `type: 'screen'` node inside a region — -`loop.config.body`, `parallel.config.branches[].nodes`, -`try_catch.config.try` / `.catch`, nesting arbitrarily — was never reached. It -emitted **no** `flows.NAME.screens.NODE_ID.title` / `.fields.*` skeleton entry -and **no** coverage row. - -**Why that pairing is the defect and not just a missing translation.** A nested -wizard step is a real screen: the executor pauses on it and the client receives -its `ScreenSpec.nodeId`, so `translateFlow` overlays the bundle onto it and the -key is live. With no entry emitted, a translator was never shown the key AND -`os lint` / `pnpm check:i18n-coverage` had no row to demand — the gap was -invisible to the mechanism built to report gaps. A green i18n gate on a tree -whose nested steps render source-locale text was green because the surface was -unreachable, not because the app was translated. - -The node universe now comes from a region-aware descent that reads the one -shared declaration of WHERE a region lives, `FLOW_REGION_SLOTS_BY_TYPE` from -`@objectstack/spec/automation` — the same table `packages/lint`'s -`walkFlowNodes` reads. No local copy of the slot list is introduced: a second -region table in a fourth package is the very shape this defect is an instance -of. - -**Depth deliberately does not enter the key.** Entries stay -`flows.NAME.screens.NODE_ID.*` at every depth, because `lookupFlowScreenCopy` -is keyed by node id alone and the bundle schema knows nothing about depth; a -region path segment would offer a key nothing resolves. A node id repeated at -two depths therefore addresses one bundle slot and collapses to a single entry -(first emission wins, outer before inner) — one slot can serve only one string, -and the resolver overlays that string onto both nodes. - -Seeding is unchanged and applies at every depth: a screen `title` falls back to -the node `label` (what `ScreenSpec.title` draws), and a field `label` falls back -to its `name` as a *derived* seed, so the skeleton stays usable while the -coverage gate demands no translation of a string nobody authored. - -⛔ No authorable key, bundle shape or export moves — an author who wrote a -nested screen now gets scaffolding and a coverage row where both were silently -absent. Existing keys are byte-unchanged. diff --git a/.changeset/17516-permission-set-collision-diagnostic.md b/.changeset/17516-permission-set-collision-diagnostic.md deleted file mode 100644 index debf72fa9c6..00000000000 --- a/.changeset/17516-permission-set-collision-diagnostic.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -A **permission-set name collision now reaches the author**. When a package declares a permission set whose name a *different* package already owns, `bootstrapDeclaredPermissions` refuses to write into that row — correct under ADR-0086 D4, and unchanged — but the refusal is no longer invisible (#17516). - -Measured on the pre-change tree, with a collision seeded and **no logger passed**: - -``` -skippedForeign = 1 (the entire declared set was dropped) -author-visible console lines = 0 (log, info, warn, error, debug — all five) -diagnostic records on outcome = undefined -``` - -The branch reported through `logger?.warn?.(…)` — optionally chained **twice** — so a caller that passed no logger produced no output at all, and a package's whole declared permission set vanished with one internal counter incremented. The comment there said *"refuse loudly"*; nothing about it was loud. Same case after the change: - -``` -skippedForeign = 1 (unchanged — the skip is not what was wrong) -author-visible console lines = 1 warn: [security] [permission_set_name_collision] … -diagnostic records on outcome = 1 { name, declaredBy, ownedBy, message, fix } -``` - -- **It prints with no sink injected.** `reportPermissionSetNameCollisions` falls back to `console.warn`, per the #10556 ruling that silent-by-declaration is rejected — an injected host sink still replaces it rather than printing beside it. The call keeps the receiver (a property-access call, never a detached `logger.warn ?? console.warn`), so a class-based host sink does not throw. -- **The refusal is also readable without a log.** `PermissionSeedOutcome` gains an optional `collisions` array carrying one diagnostic per dropped set — absent, never `[]`, when the pass hit none. A counter with no record is what made the drop undiagnosable. -- **One derivation, so two doors cannot drift.** `permissionSetNameIsForeign`, `permissionSetNameCollisionDiagnostic` and `formatPermissionSetNameCollisionDiagnostic` are exported from the package entry so a compile-time door consumes them rather than re-deriving the predicate or re-spelling the wording — the shape #14553 established for `navigationContributions`. ⚠️ Only the **runtime** door ships here; the compile-time door (`os build` / `os validate`) lives in another package and is not part of this change. -- **A stable, greppable token**, `permission_set_name_collision`, is stamped as `event` on every report. It is a snake_case data value, not an ADR-0112 error code: it is never routed to `error.code` and never reaches a wire refusal, the same discrimination the sibling `position_name_fold_grant` token already makes in this package. -- **The branch comment's premise is corrected.** It claimed package-namespaced object api names make set-name collisions a packaging bug rather than a merge case. **ADR-0130 D1 falsifies that** — N packages may co-own one namespace — so a collision is a legal configuration that gets *more* common, not an error that should never happen. The diagnostic's `fix` text names both legal resolutions. - -⛔ **No wire byte moves and no skip changes.** The foreign row is still never written; `skippedForeign` still counts it; the ADR-0086 P2 publish materializer still returns its existing `permission set name is owned by another package` failure text. A non-colliding pass stays completely silent on all five console channels, asserted over a pass that really does seed and re-seed. diff --git a/.changeset/17518-inert-json-package-body-stages.md b/.changeset/17518-inert-json-package-body-stages.md deleted file mode 100644 index 5ce069b34f8..00000000000 --- a/.changeset/17518-inert-json-package-body-stages.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -A package body now has a declaration at every stage it really passes through: `ArtifactStagePackageBodySchema` and `RecordStagePackageBodySchema` join `AssembledPackageBodySchema`, and the installed-package read rows are declared against the record stage instead of two `z.unknown()` holes (#17518). - -ADR-0130 D4 says an artifact is inert JSON — "a plugin written inside `packages[i].manifest` could never be constructed by a loader, so a reader that resolved it there would register garbage where it used to skip in silence". Of `AssembledPackageBodySchema`'s 55 members exactly two declare that they accept a callable: `functions`, whose entry union opens with `z.function()`, and `hooks`, whose `handler` carries a `z.custom()` branch. One unrepresentable member costs every embedder its whole JSON Schema, which is why `api/ListInstalledPackagesResponse` and `api/GetInstalledPackageResponse` could only carry the body with both keys written `z.unknown().optional()` — accepted without being checked, as that file's own docblock said. - -- **⛔ The assembled body is untouched, and that is the point.** Those callables are LIVE on the stage it declares itself for: `composeStacks(stacks, { manifest: 'preserve' })` builds exactly such a body and the load path registers it, and `stack.zod.ts` states the invariant that binds the two. Narrowing in place would refuse a published composition function's own output. The two JSON stages are declared BESIDE it instead. -- **Artifact stage** — what `objectstack build` writes: `functions` entries are the lowered spellings (a bare handler ref, or `FlowFunctionLoweredDeclarationSchema`), `hooks[].handler` is a string. **Record stage** — what `SchemaRegistry.installPackage` stores: the artifact stage with `functions[].handler` OPTIONAL, in both the map-record and the array form. That single difference is the whole distance between the two: `build` mints a ref for every callable, while `toRecordManifest`'s structural projection DROPS the callable and mints nothing in its place, so a record states what each function is named and what it declared with `handler` absent where the callable was. Measured: both bodies convert under `z.toJSONSchema` over the whole body, where the assembled body still does not. -- **`FlowFunctionLoweredDeclarationSchema` is exported** from `@objectstack/spec/automation`, with its `FlowFunctionLoweredDeclaration` / `…Parsed` aliases. It was a module-local `const`, and `export * from './flow-function.zod'` only re-exports what is already exported — so `unemitted-schemas.baseline.json`'s reason for `Automation.FlowFunctionDeclarationSchema`, which says the lowered record "is the serialisable half … and it publishes normally", pointed at a schema no consumer could reach. It publishes now: `automation/FlowFunctionLoweredDeclaration` is in the schema manifest. -- **`effect` is READ, not minted.** `FlowFunctionDeclarationSchema.effect` is `FlowFunctionEffectSchema.default('pure')` — a default, not a requirement — and the array member's is `.optional()` with no default. Both JSON stages inherit each form's optionality by deriving from it rather than restating it. -- **⚠️ What narrows, stated plainly**: on the two installed-package responses, `functions` and `hooks` move from `unknown` (accepts anything) to their declared JSON shapes. No row the doors really serve is withdrawn — measured through the real `SchemaRegistry.installPackage` on the shape `examples/app-showcase` ships, on the array form, and on the already-lowered body an artifact boot installs. Every other key, `objects` included, is checked exactly as before, and both stages still refuse an authoring glob and an unknown key. -- One correction in the same edit: `package-api.zod.ts` said those two members were also why `ArtifactPackageSchema` and `ObjectStackDefinitionSchema` publish no JSON Schema. They are not — `src/stack.zod.ts` is not one of the subpath namespaces `build-schemas.ts` walks, so neither is ever reached by the emit loop. diff --git a/.changeset/17518-registry-record-reports-every-function.md b/.changeset/17518-registry-record-reports-every-function.md deleted file mode 100644 index 1f532c9932d..00000000000 --- a/.changeset/17518-registry-record-reports-every-function.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -`GET /packages` reports every function a package declares. A bare callable `functions` entry is normalised to the declared form at the assembly boundary, so the registry record no longer drops it (#17518). - -`SchemaRegistry.installPackage` stores `toRecordManifest(manifest)`, a structural JSON projection whose rule is "a live object reached the record" and deliberately ⛔ not a key denylist. That rule treated the two authored `functions` spellings unequally through no fault of its own: a DECLARED entry (`{ handler, effect: 'writes' }`) is a plain object, so it survived with its callable dropped, while a BARE callable entry IS the callable, so the whole key vanished. `examples/app-showcase` ships one of each, so a package declaring two functions was reported as declaring one — a machine-readable read door under-reporting by construction. - -- **The repair is at the assembly boundary, ⛔ not in the projection.** `installPackage` makes the two spellings structurally equal before projecting, so the structural rule is untouched and no key name is special-cased. The projection then leaves `{ effect }` for both. -- **⛔ No ref is minted.** `objectstack build` mints refs with `uniqueName(base, taken)` and dedupes by function identity, so a ref minted in the registry is not guaranteed to be the one `build` mints — a record could assert a handler that resolves in no sibling module. An absent `handler` is the honest statement "declared here, not serialisable", which is exactly what `@objectstack/spec`'s new `RecordStagePackageBodySchema` declares. -- **⛔ No entry is dropped**, either: under-reporting by design was the other arm, and it also throws away the `effect` declaration, the one half that survived. -- The caller's manifest is never mutated — `ObjectQL.registerApp` and the hook binder read the live callables off that object — and a copy is made only when an entry really needed rewriting. The ARRAY form is untouched: its entries are objects carrying their own `name`, so the projection already kept them. diff --git a/.changeset/17527-metadata-stats-package-fold.md b/.changeset/17527-metadata-stats-package-fold.md deleted file mode 100644 index 1efcc2a2734..00000000000 --- a/.changeset/17527-metadata-stats-package-fold.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os validate`, `os build` and `os info` count the objects an ADR-0130 D4 / option-B project actually declares, so `--strict` stops refusing a conforming stack - -`collectMetadataStats` — the one reader behind the metadata summary all three -commands print — counted every collection at the **top level only**. On an -option-B project (every definition inside `packages[]`, none flattened up) the -summary reported `Data: 0 Objects`, and `os validate` raised -`No objects defined — this stack has no data model` on a stack that declares a -data model. - -Under `--strict` that warning is not cosmetic. Measured through the real -binaries on the card's repro, before: - -``` -os validate exit 0 Data: 0 Objects - ⚠ No objects defined — this stack has no data model - ⚠ No apps or plugins defined — this stack may not do much -os validate --strict exit 1 ✗ Strict mode: warnings treated as errors -os build exit 0 Data: 0 Objects -os info exit 0 Data: 0 Objects -``` - -and after, on the same stack: - -``` -os validate --strict Data: 1 Objects 2 Fields - ⚠ No apps or plugins defined — this stack may not do much -``` - -A conforming project that also declares an app now exits **0** where it exited -**1**. - -**The fix reuses the existing fold, and that is what keeps the count a union.** -`authoringRuleUnionStack` (`utils/stack-collections.ts`) is this package's one -resolution rule for a package-owned collection, and it is strictly additive: a -key the top level already carries wins, because in today's additive shape that -array already *is* the union. So an object reachable from both the top level and -a `packages[]` entry is counted once, never twice — a corrected number that -over-counts would be the same defect with the opposite sign. - -**One behaviour change beyond the counts, in `os info` only.** The fold resolves -package order through `resolveArtifactPackageOrder`, whose ADR-0112 refusals are -deliberately not swallowed. `os validate` and `os compile` already drove that -seam on the same config above their summary call, so they are unchanged; `os -info` did not, and now reports a stack whose `packages[]` repeats a package id -as a named `422` (`DUPLICATE_ARTIFACT_PACKAGE`) instead of printing -`Data: 0 Objects` for an artifact it could not read. - -⛔ No authorable key, spec schema or published export moves. A stack whose top -level carries its collections — every stack the platform emits today — gets a -byte-identical summary: the seam returns it by identity. diff --git a/.changeset/17528-lint-handwritten-checks-package-fold.md b/.changeset/17528-lint-handwritten-checks-package-fold.md deleted file mode 100644 index 23b3a829ee0..00000000000 --- a/.changeset/17528-lint-handwritten-checks-package-fold.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os lint`'s own rubric and `os lint --score` judge the stack an ADR-0130 D4 / option-B project actually declares, instead of reporting `✓ All checks passed` on a stack they never opened - -`lintConfig` runs two families: the shared author-time rule registry and -`os lint`'s **own** hand-written checks — naming, labels, empty field maps, the -intra-package duplicate advisory, hook-body lowering and the data-model -conventions. The registry learned to resolve `packages[]` earlier; the -hand-written family and `scoreMetadata`, which reaches the same function, still -read the **top level only**. On an option-B project (every definition inside -`packages[]`, none flattened up) they were handed an empty stack. - -Measured through the real binary, on one object authored two ways — the same -metadata, differing only in where it is declared: - -``` -packages[] os lint exit 0 ✓ All checks passed - Metadata quality: 100/100 (A) - -top level os lint exit 0 ⚠ Label "order" should start with an uppercase letter - convention/label-case at objects[0].label - ℹ Object "ob_order" has no nameField and no name-like field … - object/missing-name-field at objects[0].fields - Metadata quality: 96/100 (A) -``` - -and after, on the same two projects: - -``` -packages[] os lint exit 0 ⚠ convention/label-case at objects[0].label - ℹ object/missing-name-field at objects[0].fields - Metadata quality: 96/100 (A) - -top level os lint exit 0 — byte-identical to before -``` - -The score is the sharper half. `100/100 (A)` with every count at zero is -byte-for-byte the verdict a genuinely clean project gets, on a rubric that had -judged nothing — the same indistinguishability a swallowed linter crash used to -produce, arriving through the input instead. - -**The fix folds once, at `lintConfig`'s entry, with the existing helper.** -`authoringRuleUnionStack` (`utils/stack-collections.ts`) is this package's one -resolution rule for a package-owned collection and it is present-wins: a key the -top level already carries wins, because in today's additive shape that array -already *is* the union. So a multi-package artifact is judged once, never twice, -and a stack whose top level carries its collections — every stack the platform -emits today — is returned by identity and lints byte-identically to before. - -**This does not change what `scoreMetadata` scores.** It already scored the whole -project: its schema half reports `packages.0.manifest.objects.0: …` on an -option-B stack with no fold anywhere, and on today's additive multi-package shape -its lint half already read the flattened union across every package. The fold -makes the option-B shape agree with the additive one. - -⛔ No authorable key, spec schema, published export or accept set moves. -`os build` rejects and accepts exactly what it did; `os lint`'s own `error` -severity remains a lint verdict, not a publish gate. diff --git a/.changeset/17534-manifest-id-reverse-domain.md b/.changeset/17534-manifest-id-reverse-domain.md deleted file mode 100644 index 836eb956b5d..00000000000 --- a/.changeset/17534-manifest-id-reverse-domain.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/cli": minor -"create-objectstack": minor ---- - -feat(spec)!: `manifest.id` enforces the reverse-domain rule its registry face already had (#17534) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the repo's convention: `major` is refused by `check-changeset-no-major`, and -breaking-ness is carried by this banner plus the ADR-0087 disposition): -`ManifestSchema.id` was `z.string()` and accepted any string. It now enforces -reverse-domain notation — the same rule `PackageSchema.manifestId` has always -carried, now declared once and referenced from both sites so the two cannot -drift again. - -Two declarations named one identity and disagreed. The registry enforced the -shape; the key an author actually writes did not. So a package scaffolded, -validated, built and booted with an id the publish path would refuse, and the -author met the rule for the first time at the most expensive possible moment. - -FROM → TO, for metadata that used to parse and now fails: - -```ts -// FROM — accepted by defineStack, refused at publish -defineStack({ manifest: { id: 'my_app', /* … */ } }); -defineStack({ manifest: { id: 'com.acme.my_app', /* … */ } }); - -// TO — dot-separated lowercase segments; hyphens inside a segment, never underscores -defineStack({ manifest: { id: 'com.example.my-app', /* … */ } }); -defineStack({ manifest: { id: 'com.acme.my-app', /* … */ } }); -``` - -The refusal carries the repair rather than restating the rule: it names the key, -echoes the value, shows both documented examples, and — having first checked the -candidate against the pattern itself — suggests `com.example.blank` for a bare -word and `com.dogfood.flow-fixture` for a value whose only fault is an -underscore. A suggestion it cannot verify it does not make. - -⚠️ **Changing an id is a republish, not an edit.** An id is an identity: the -registry addresses a package by `manifest_id`, an installed row is keyed on it -and a dependent declares it. Before renaming, confirm nothing still addresses -the old value. That is why this ships as an ADR-0087 **semantic** entry -(`manifest-id-reverse-domain-required`) with a structured TODO and no automatic -rewrite — `objectstack migrate meta` will not rename an id for you. - -`manifest.namespace` is unchanged and still admits underscores, so the two are -derived from a project name under different rules and neither is the other. Both -scaffolders were producing ids the new rule refuses and both now derive a -conforming one: the bundled `create-objectstack` template ships -`com.example.blank` and interpolates `com.example.` in kebab form, -and `os init` derives its id from the project name instead of interpolating the -snake_case namespace (`os init my-app` produced `com.example.my_app`). - -## ⚠️ One consent path reverses direction: fail-OPEN → fail-CLOSED - -Narrowing `manifest.id` also narrows the **accept set of the artifact load -path**, and on one route that is a **fail-OPEN → fail-CLOSED reversal on a -consent/permission path**. Stating it explicitly because a reversal in that -direction is owed a named direction and a named population, however small the -population turns out to be. - -**What changed.** `AssembledPackageBodySchema` extends `ManifestSchema`, so the -artifact package entry schema now carries this rule too. An assembled package -whose `manifest.id` is `''` used to parse: `artifactPackageId` is -`manifest.id || manifest.name`, so such a package was carried under its `name`, -while an install-time `grantedPermissions` record keyed by `''` matched no -carried package and was registered nowhere. The package loaded **with no -consent record at all** — reported as unbound, warned about, and otherwise -allowed to run. That is the fail-OPEN half. Such an entry is now refused -outright (`INVALID_ARTIFACT_PACKAGE_ENTRY`, 422) and the artifact does not -materialize at all — fail-CLOSED. - -**Who is affected: artifacts carrying `manifest.id: ''`, and they were already -half-broken in both directions.** - -- They could never be **published**: the registry face - (`PackageSchema.manifestId`) has carried this exact pattern all along — the - same regex literal, now the shared `MANIFEST_ID_PATTERN` — so the publish path - has always refused them. -- Their granted-permissions **consent already did not apply**: a record keyed by - `''` bound to nothing, silently, on every load. - -⇒ For that population this converts a silent, already-ineffective consent -binding into an explicit refusal that names `manifest.id`. Nobody who could -publish an artifact loses the ability to load it; what they lose is a shape that -only ever half-worked. - -⛔ This is the **artifact package door** refusing a malformed id, **not** the -permission enforcer acquiring teeth. The install-time granted permission set is -still registered and not enforced (#17147) — nothing on the tree queries that -registry, and the repo-wide pin asserting so is unchanged and still green. diff --git a/.changeset/17536-client-packages-read-doors-either-stage.md b/.changeset/17536-client-packages-read-doors-either-stage.md deleted file mode 100644 index bed084fd43a..00000000000 --- a/.changeset/17536-client-packages-read-doors-either-stage.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): the four `packages` READ members declare the stage their door is declared at (#17536) - -Clause-②: yes - -**BREAKING** for TypeScript consumers — a published TYPE-surface WIDENING, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition below, never by the level). No runtime behaviour changes, and none is possible here: only a declaration moved, and the values these four methods resolve to are the values they have always resolved to. - -`ObjectStackClient.packages.list` / `.get` and their `ScopedEnvironmentClient` twins returned `InstalledPackage` — the AUTHORING manifest stage, imported from `@objectstack/spec/kernel`. Since PR #17517 both read doors have been declared at EITHER stage: `ListInstalledPackagesResponseSchema.packages` is `z.array(InstalledPackageAtEitherStageSchema)` and `GetInstalledPackageResponseSchema.data` is that same schema. ⇒ A response the server is declared able to send was one this SDK's own types said could not arrive. - -`packages/spec` is the one contract between producers and consumers, and `packages/client` is a consumer of it, so the consumer's declaration is what moves. All four now declare `InstalledPackageAtEitherStage` from `@objectstack/spec/api`. - -**What the union is.** It is a union over the two manifest stages — `InstalledPackageSchema` and `AssembledInstalledPackageSchema` — which differ in exactly one key, `manifest`. Every other member of the row (`id`, `name`, `version`, `status`, `enabled`, `installedAt`, …) is common to both branches and reads exactly as it did, so code that reads only those members needs no change at all. - -**Where the RUNTIME and the TYPE disagree, measured at this head.** The runtime schema is the strict half: `InstalledPackageAtEitherStageSchema.safeParse(row)` answers `success: false` for a row whose `manifest` belongs to neither stage — measured on a `{ bogus: 1, objects: 'not-even-an-array' }` manifest and on an empty `{}` one. The published TYPE is NOT that strict: on the assembled branch `manifest` is declared `Record`, so both of those same rows COMPILE against the declared return type. ⛔ Do not read this widening as a type-level guarantee about `manifest` — the guarantee is the parse's. The type-level tolerance is a known gap, tracked as **#19324**; its root cause is the deliberate `z.ZodType, …>` annotation at `packages/spec/src/stack.zod.ts:1283` (#14513 — TS7056 and a declaration-chunk ceiling), and it is ⛔ not this change's to fix. It is recorded as a pin in `packages/client/src/return-type-precision.test.ts`, which reddens the day the gap closes. - -**What a consumer does, concretely.** A member read off `manifest` on the union arrives as `unknown` (measured: `pkg.manifest.objects` is `unknown`). ⇒ a caller that reaches INTO `manifest` narrows by PARSING the row with a `packages/spec` schema and reading the parse's output: - -```ts -import { AssembledInstalledPackageSchema } from '@objectstack/spec/api'; -import { InstalledPackageSchema } from '@objectstack/spec/kernel'; - -const row = await client.packages.get(id); -const parsed = AssembledInstalledPackageSchema.safeParse(row); -if (parsed.success) { - // parsed.data.manifest — the ASSEMBLED stage, object definitions -} else { - const authoring = InstalledPackageSchema.parse(row); - // authoring.manifest.objects — the AUTHORING stage, glob strings -} -``` - -⛔ Do NOT narrow with `Array.isArray(pkg.manifest.objects)`, or with any other structural guess. It separates the stages on NEITHER level: at the type level `pkg.manifest` is the same union inside both branches of that `if`, and at runtime BOTH stages' `objects` are arrays — `z.array(z.string())` at the authoring stage against `z.array(ObjectSchema)` at the assembled one. (Measured: a row carrying no `objects` at all parses as either stage, which is the right answer for it — the key such a guess would read is not there.) - -In this repository the whole consumer cost is zero sites outside `packages/client` itself: no other workspace package calls either read member. - -**The WRITE members did not move** and stay declared at the authoring stage — all four of them: `install`, `enable`, `disable`, `update` still answer `Promise`. `install` answers the row its own request contract produced (`PackageInstallRequestSchema` declares `manifest: ManifestSchema`), and PR #17517 moved the read doors alone. That asymmetry is the measurement, not an oversight, and it is pinned. - -The type is reached the same way `InstalledPackage` always was, from `@objectstack/spec` rather than re-exported here: this SDK has never re-exported the package row, and this change does not start. - - diff --git a/.changeset/17541-resume-door-consults-inspection.md b/.changeset/17541-resume-door-consults-inspection.md deleted file mode 100644 index ca026b6dbf6..00000000000 --- a/.changeset/17541-resume-door-consults-inspection.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/runtime': minor ---- - -the resume door's `repairable` is answered by the engine on the exits that stamp no status — `IAutomationService` declares the read-only `inspectConsumedSuspension` (#17541) - -Clause-②: yes (widening) - -The resume route's `400 FLOW_FAILED` details computed `repairable` as the single -expression `status === 'stranded'`. That word is stamped on exactly one exit — -the run that consumed its OWN pause and then threw downstream. The subflow -DELEGATION exit stamps nothing on purpose: a caller resumes the PARENT, the -signal is forwarded down, the child strands, and the parent frame answers -`{ success: false, error, durationMs }`, because nothing re-arms an ancestor by -resuming it and stamping `'stranded'` there would send an operator to retry a -recovery that cannot succeed. - -Since the nested-chain restore landed, that parent's consumed pause IS -journalled and one `restoreConsumedSuspension(parentRunId)` re-arms the whole -chain leaf-first. So the wire answered `repairable: false` about a run the -operator verb WILL repair, and a client written exactly as the reference page -instructs closed it as terminal. Measured through the HTTP route, before and -after, on the same parked delegation: - -```json -before 400 { "error": { "code": "FLOW_FAILED", - "details": { "runId": "run_…", "repairable": false } } } -after 400 { "error": { "code": "FLOW_FAILED", - "details": { "runId": "run_…", "repairable": true } } } -``` - -…while at that same instant the engine answered -`inspectConsumedSuspension(runId) → { repairable: true, witness: 'journal' }` -and `restoreConsumedSuspension(runId) → { restored: true, chain: [child, parent] }`. - -**`@objectstack/spec` — additive, `minor`.** `IAutomationService` declares the -optional read-only member `inspectConsumedSuspension(runId)`, which -`AutomationEngine` already implements publicly: would the restore verb have a -consumed suspension to put back for this run? It re-arms nothing and reads the -same two witnesses that verb reads, so what it calls repairable IS what that -verb restores. The declared result is deliberately narrower than the -implementation's, the way `restoreConsumedSuspension`'s already is — `reason` is -typed as the string the implementation answers, not as an enumeration this -contract would have to keep in step, and the engine's wider type satisfies it -under `implements`. `ResumeFailureDetailsSchema.repairable`'s `.describe()` is -rewritten to the truth and the generated reference page regenerated with it. No -key is added, renamed or retired on any wire schema. - -**`@objectstack/runtime` — the door.** On a `400 FLOW_FAILED` whose result -carries a `status`, that stamp still decides, and the engine is not consulted at -all. On a result that carries none, the door asks the declared member and relays -its `repairable`. Both ways of not getting an answer are FAIL-CLOSED: a service -that declares no inspection member answers `false` exactly as it did before, and -an inspection that REJECTS (a store it could not read) answers `false` and says -so once at `warn` — an unreadable store is UNKNOWN, not "nothing to restore", -and it is never allowed to replace the `400` the caller asked for with a `500`. - -⛔ The fence is untouched: a cascade-failed ancestor is still never STAMPED -`'stranded'`. Its repairability is carried by the journal and REPORTED by the -inspection, which is exactly why the door asks instead of reading a word. ⛔ And -no new `AutomationResult.status` member is minted for this exit — there is -nothing new for a client to learn, and `details.repairable` is the member a -client was already told to branch on. diff --git a/.changeset/17551-dataset-selection-schema.md b/.changeset/17551-dataset-selection-schema.md deleted file mode 100644 index 71bba028484..00000000000 --- a/.changeset/17551-dataset-selection-schema.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/rest": patch -"@objectstack/service-analytics": patch ---- - -`DatasetSelectionSchema` — the ADR-0021 dataset selection is a Zod declaration now, and `POST /api/v1/analytics/dataset/query` parses the whole selection against it (#17551). - -`DatasetSelection` was a TypeScript **interface** with no Zod schema anywhere in the repo. PR #17548 doored that route, but only over the **seven** members the selection shares with `AnalyticsQuery`; the other **four** — `runtimeFilter`, `dateGranularity`, `compareTo`, `totals` — were declared in TypeScript, published in the api-surface, and enforced by nothing on the wire. The measured consequence is #17550: `compareTo: { kind: 'nonsense' }` came back as a previous-period comparison under an ordinary **200**, a number a dashboard renders and a person reads as fact. - -- **One declaration, in `packages/spec`.** `DatasetSelectionSchema`, `DatasetCompareToSchema` and `DatasetTotalsSchema` are authored in `api/analytics.zod.ts`, beside the `AnalyticsQueryRequestSchema` the sibling routes parse. `@objectstack/spec/contracts` now **re-exports** the `DatasetSelection` and `DatasetCompareTo` types from that schema instead of declaring interfaces of its own — the same move `AnalyticsQuery` made in #4538, taken here before a mirror could drift. -- **A transcription, not a new contract.** The seven shared members are read straight off `AnalyticsQuerySchema.shape`, so the claim that the two agree is structural rather than a hand-written list; the four dataset-only members are the already-published TypeScript members made executable. No member is added and nothing the interface permitted is refused. -- **Refusals carry a prescription.** An unrecognised `compareTo.kind` answers the sentence `datasetCompareKindRefusalMessage` builds — what arrived, the two windows the executor implements, what to do — and `@objectstack/service-analytics`' `shiftRange` now raises that same sentence with its own origin clause, so one condition keeps one wording. An unknown key is named, echoed and pointed at the canonical spelling (`where` → `runtimeFilter`, `granularity` → `dateGranularity`), and the retired `{ offset }` arm and the pre-#5011 bare-string form each carry their rewrite. -- ⚠️ **What narrows on the wire**, so an upgrading caller can look for it: a selection member whose value the published interface never permitted now answers `400 VALIDATION_FAILED` with `details.fields[]` instead of travelling into the executor. Measured against the sibling route spelling for spelling, `runtimeFilter` now behaves exactly as `/analytics/query`'s `where` does — three structurally-malformed filter spellings (`{ $or: 'x' }`, an `$or` branch that is not a filter object, `{ $not: 5 }`) are refused at the schema on both routes, and the four semantic ones (`{ stage: {} }`, `{ amount: { $between: [10] } }`, `{ $nor: […] }`, `{ $or: [] }`) still pass both and are answered deeper. The dataset route was the looser of the two; it is not any more. -- **No valid selection changes.** Every in-repo specimen and all five `@object-ui` call sites that build a selection today still pass, pinned in both packages; the route still forwards the caller's object to the service by identity, never a parse output, and the schema carries no default or transform that could override the engine's own timezone resolution chain. diff --git a/.changeset/17556-app-contributed-first-run-credentials.md b/.changeset/17556-app-contributed-first-run-credentials.md deleted file mode 100644 index 17c7447776a..00000000000 --- a/.changeset/17556-app-contributed-first-run-credentials.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/cli': minor ---- - -feat(spec, cli): an application contributes its own first-run credentials to the development boot banner — `devHint` / `devLogins[]` (#17556) - -Clause-②: yes (widening) - -## What an operator sees - -`os dev` seeds a platform admin on an empty DB, and the banner prints it as the only -credential a first-run operator is handed. #17081 made that line honest about what the -account *cannot* see; it could not name an account that *can*, because the platform does -not know an application's audiences. Measured on a downstream app, of five personas the -four it seeds each rendered their navigation group and the one the banner printed -rendered none — and the operator read the empty shell as a broken product. - -Two new top-level keys on the stack definition close that. Declaring either adds a block -BENEATH the seeded-admin lines, on a development boot only: - -``` - 🔑 Dev admin: admin@objectos.ai / admin123 - seeded on empty DB · dev only — do not use in production - platform admin — Setup, Studio and every record, but NO app-declared capability, so - an app that gates navigation on requiredPermissions may show it an empty menu; grant - it a permission set under Setup → Users, or sign in as an account your app seeds - - 👥 App logins: 2 declared by this app - Hiring admin — admin@quillstone.example / demo1234 - Job seeker — candidate01@mail.example / demo1234 - declared in this app's `devLogins` · dev only — the platform seeded none of them - - 💡 App hint: run `pnpm seed:demo` first, then sign in as the Hiring admin -``` - -## What is writable that was not - -The top-level stack door has been strict since #8687, so before this both spellings were -an `unrecognized_keys` refusal. The accept set gains exactly: - -- **`devHint?: string`** — one sentence printed under the credential block. Composes as - `'single'`: two stacks declaring different hints is a composition error naming the key, - never a silent last-wins. -- **`devLogins?: DevLogin[]`**, where `DevLogin` is `{ email: string; password?: string; - label?: string }`, closed against unknown keys from birth. Composes as `'concat'`, so - composing two applications keeps both publishers' personas. An artifact ENVELOPE key - like `plugins` / `devPlugins`: it stays at the top level and is refused inside - `packages[].manifest`, because the banner's only reader looks at the top level. - -`DevLoginSchema` / `DevLogin` / `DevLoginParsed` are exported from -`@objectstack/spec/system`. Nothing is renamed, nothing is retired, and no value that -parsed before is refused now. - -## Three properties worth knowing before you author one - -- **Declaring is not seeding.** An entry CREATES NOTHING: it names an account the - application seeds by other means (`data` fixtures, `onEnable`, its own script) so the - banner can point at one that shows something. An entry naming an unseeded account - prints a credential that will not work, exactly as a README line would — which is why - the banner says the application declared it. -- **Additive, never a replacement.** The seeded-admin block still prints, unchanged and - first. An application-controlled key able to suppress a platform disclosure would let - an app hide a live credential the operator was just handed. -- **Development only, and scrubbed.** The block renders only under `os dev`, - `objectstack serve --dev` or `NODE_ENV=development`; any other boot is byte-identical - to one declaring nothing. The values are author-controlled text reaching a terminal, so - every C0/C1 control byte is replaced with U+FFFD before printing — an escape sequence - in a hint cannot erase the rows above it or repaint a forged `🔑 Dev admin` row. ⚠️ - Whatever is written here is committed to the application's repository and printed to a - terminal: it is a development fixture, never a real secret. diff --git a/.changeset/17560-selecting-aggregate-field-type-refused.md b/.changeset/17560-selecting-aggregate-field-type-refused.md deleted file mode 100644 index a7a4d428fb7..00000000000 --- a/.changeset/17560-selecting-aggregate-field-type-refused.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -"@objectstack/service-analytics": minor -"@objectstack/spec": minor ---- - -feat(service-analytics)!: `min` and `max` are judged by the aggregate × field-type table too — all 74 refused pairs answer `400 DATASET_INVALID` through one compile door (#17560) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface, and the last -one this table owed. A dataset measure pairing `aggregate: 'min'` (or `'max'`) with any -of the **37** field types outside the numeric, temporal and boolean classes — for example -`text`, `select`, `lookup`, `autonumber`, `json`, `multiselect`, `file`, `location`, -`vector` or `formula`; the ADR-0087 entry registered below carries the full list — used to -compile and reach the backend; it is now refused by -`compileDataset` with `DATASET_INVALID` / **400** before any query is built. Shipped as -`minor` under the repo's launch-window convention for accept-set narrowings. - -⛔ This changeset adds no rows to any table and restates none. The verdict is -`AGGREGATE_FIELD_TYPE_COMPATIBILITY`'s — the one table `@objectstack/spec` declared in -#16353 under the director ruling of decision batch #59 ("both legs, table in spec") — -read through `isAggregateCompatibleWithFieldType`. - -## What was wrong - -The table refused these 74 pairs from the day it was declared, and **four declarations -gave three different answers about them**: - -| declaration | what it said about `min` × `text` | -|---|---| -| `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (spec) | refused | -| `dataset-compiler`'s compile leg | never judged — `if (!DERIVING_AGGREGATES.has(aggregate)) return;` | -| `measureResultType` (service-analytics, #15768) | a supported `'string'` result | -| two shipped test files, in prose | "ruled C — the table is to be AMENDED to accept it" | - -Driven through the real service door before anything was written, `min` / `max` over 13 -sampled refused pairs all compiled and emitted SQL, with `avg` × `datetime` as the -firing control (refused, `DATASET_INVALID` / 400, no SQL) — so the zero was a reading of -the tree rather than of a blind harness. - -The fourth row had nothing behind it. The card it cited (#17513) is closed as a -duplicate carrying zero rulings, and the one recorded ruling on this table says the -opposite. ⇒ The director ruling of decision batch #127 (2026-09-13) settled all three -sub-questions in one pass, because one shared fixture drove members of both halves: - -1. **the string classes** (42 pairs) stay refused, as batch #59 ruled — ⛔ the table is - not amended; -2. **the non-string classes** (32 pairs) are refused **and enforced**; -3. **`formula`** is refused on the table's own storage ground — it is VIRTUAL in SQL - storage, no column is emitted, so no aggregate can be lowered to it whatever - `returnType` says. - -The divergence is real, and for these two aggregates it is the **ORDER** rather than the -arithmetic: string order is collation-dependent, so two backends answer two different -"smallest" values for one metadata document, and `min(jsonb)` does not exist on -PostgreSQL at all. - -## What changed - -- **`dataset-compiler`**: the scope condition is gone. `assertAggregateFieldTypeCompatible` - judges all six `AggregationFunction` members against the table, through the same - `DATASET_INVALID` / 400 door. The refusal message names the divergence its own - aggregate class really has (`min` / `max` SELECT a stored value and diverge on order; - `sum` / `avg` DERIVE a number and diverge on arithmetic) and prescribes accordingly. -- **`measureResultType`** asks `isAggregateCompatibleWithFieldType` before it answers, so - the rule and the table agree **by construction**. Its `STRING_SOURCE_FIELD_TYPES` - branch and its `formula` branch are retired with them; `min` / `max` over the temporal - class still answers `'time'`, unchanged. -- **`AnalyticsServiceConfig.sourceFieldMeta`** no longer declares `returnType`. It was - carried (#16236) for one reader — the retired `formula` branch — and a declared input - nobody consumes is the declared-not-enforced shape Prime Directive #10 refuses. - - ⚠️ **That key was never released, so against every published version this removal is a - no-op.** #16236 is still a pending changeset in the same release window as this one; - the last published entry (17.4.0) says in as many words that `FieldSchema.returnType` - "is not on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape". The key was - therefore added and removed inside one window and no published tarball ever carried it. - - **Host fix, one line:** drop `returnType` from whatever your `sourceFieldMeta` returns. - You do not have to — the hook is a function RETURN position, so an extra key is not an - excess-property error and is simply ignored at runtime — but keeping it declares an - input nothing reads. Hosts on `AnalyticsServicePlugin` need no change at all: the plugin - stopped relaying the key in this same change. - -## FROM → TO, and the one-line fix - -| you wrote | write instead | -|---|---| -| `{ aggregate: 'min' \| 'max', field: }` | `count` / `count_distinct` if you were counting; a **sort** on the list/report if you wanted the first or last RECORD | -| `{ aggregate: 'min' \| 'max', field: }` | store the quantity you meant as a numeric or temporal field and aggregate that | -| `{ aggregate: 'min' \| 'max', field: }` | a formula emits no column; aggregate the stored field the formula reads, or persist the computed value | - -⚠️ **Untouched:** those field types used as a **DIMENSION** (grouping, labelling, -bucketing, filtering), `count` / `count_distinct` over any type, `min` / `max` over the -numeric, temporal and boolean classes, and every `sum` / `avg` row #16778 and #16099 -already settled. The refusal also still stands down rather than guessing wherever the -declared type cannot be resolved: no `sourceFieldMeta` wired, an unknown field, or a -`relationship.field` path whose column lives on a joined object. - -⚠️ The hand-migration prescription ships as the ADR-0087 semantic TODO registered above, -which names the measure and the field type per affected pair — no lossless conversion -exists, because nothing can compute "the smallest text value" in a way every backend -agrees on. diff --git a/.changeset/17562-initial-failure-history-guard.md b/.changeset/17562-initial-failure-history-guard.md deleted file mode 100644 index bd20f4bcdd3..00000000000 --- a/.changeset/17562-initial-failure-history-guard.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A run that genuinely failed is still answered in the declared shape when its own terminal run-history write throws (#17562) - -`AutomationEngine.execute()` and `executeWithoutRetry()` each ended their node-failure `catch` with an unguarded `recordLog({ status: 'failed' })`. That `catch` **is** the handler for node failures and there is no outer one, so a throw out of the history write escaped the method entirely and left `execute()` a **rejected promise**, where its declared return type is an `AutomationResult`. This is the failure-arm half of the completion-path guard shipped just before it, and the same shape already landed on the resume path's failure arm in 17.4.0. - -**What is lost is the shape, not the verdict.** The run really did fail, so nothing misleads an operator: there is no false `failed` and no double run. But a caller that branches on `{ success: false, status: 'failed' }` gets an exception instead, so the transport's `status` arm is bypassed and `errorMessage` (the author's failure text) and `summary` (how far the run got before dying) never arrive — a REST route or SDK caller sees a 500-class throw for a run that had a perfectly good failure envelope waiting, and the node's own error text is replaced by the history driver's. - -Reproduced with a control, the identical flow and the identical node failure differing only in the store: - -``` -store = SYNC-THROW -> {"kind":"threw","error":"run-history driver refused the terminal row"} -store = HEALTHY (control) -> {"kind":"returned","status":"failed","error":"work blew up"} -``` - -**What can throw there is a host surface, not in-repo code** — the same two statements the completion-path fix names: the default-on run-summary line `logger.info(line, meta)`, which calls a host-injected `Logger` and needs no store at all; and `store.recordTerminal(record)` throwing **synchronously**, before it returns a promise, which the `void write.catch(...)` beneath that call cannot see. Both stores shipped in this package are `async` and cannot do it, but `SuspendedRunStore` is an exported interface whose `recordTerminal` is optional, so a host store is unconstrained. - -What changes: - -- **Each failure-path history write is guarded at its own call site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. The caller now receives the envelope it was always promised — `success: false`, `status: 'failed'`, the **node's** own text in `error`, the flow's `errorMessage`, and a `summary` recomputed by the same pure function `recordLog` runs first. -- **The retry budget survives the loss.** On the retry path the throw used to reject out through the retry loop and `execute()` both, ending the run early; the remaining attempts now run as the author's policy says. -- **The swallowed failure is reported once per abandoned write at `error`**, with the consequence and the fix in the first line: the run failed, its terminal row never landed, nothing retries it, and the caller *was* told the run failed so nothing needs re-driving. The thrown text rides the structured slot. - -⛔ No `catch` arm's meaning is widened: the suspend arm, the input-schema refusal and the retry strategy branch are untouched, and a genuine node failure against healthy sinks is answered exactly as before. diff --git a/.changeset/17579-approver-type-manager-describe.md b/.changeset/17579-approver-type-manager-describe.md deleted file mode 100644 index d4e9fb9879f..00000000000 --- a/.changeset/17579-approver-type-manager-describe.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`ApproverType` qualifies `manager` in its `.describe()` instead of offering it as a bare allowed value - -`ApproverType` carried **no** `.describe()` at all, so the generated reference -page rendered `## ApproverType` with nothing but an `### Allowed Values` list: -`manager` — the one rung an author cannot operate on a stock install — read -exactly like the nine members that work. `{ type: 'manager' }` resolves -`sys_user.manager_id`, and that column still has no product write surface -(re-measured on this tree: the identity write guard's managed-update whitelist -for `sys_user` is `{name, image, locale}`; the column carries `readonly: true`; -no `packages/plugins/plugin-auth` source writes it). An author who chose it got -a chain that passed `validate` and `lint` and then stalled on its first -submission. - -The new describe says what is true about `manager` and **points** at the remedy -rather than restating it: `MANAGER_ONLY_REMEDY` / `MANAGER_ONLY_ROUTES` in -`packages/lint/src/validate-approval-approvers.ts` remain the single -authoritative copy of the population routes, and that file's `DEPENDENCY` -docblock now names this new string among the lines that go stale if the column -ever gains a write surface. A pointer cannot drift into disagreement with what -it points at, which is why no third copy of the 667-character remedy was added. - -⛔ No member is added, removed or renamed, and no behaviour changes: the enum's -accept set is byte-identical and `check:api-surface` is green on the rebuilt -`dist/*.d.ts`. - -**Why this ships, and why `patch`.** `@objectstack/spec`'s published `files[]` -carries `dist`, `json-schema` and `src/**/*.zod.ts`, and the new string is -measured in all three on the built tree — `dist/automation/index.js` and -`.mjs` (2 files, against a lit control of an existing describe from the same -module, also 2), four `json-schema/` documents (`ApproverType.json`, -`ApprovalNodeApprover.json`, `ApprovalNodeConfig.json`, `objectstack.json`) and -the shipped `approval.zod.ts` source. Prose only, no surface widening ⇒ -`patch`. - -The `packages/lint` half is a docblock comment and is deliberately **not** -graded: that package publishes `dist` only, and the new sentence is absent from -it (0 files) while a runtime string from the same source file is present in 4 -and a pre-existing comment from the same docblock is absent in 0 — so comments -are stripped by construction and nothing published moves there. diff --git a/.changeset/17584-references-refusal-front-load-remedy.md b/.changeset/17584-references-refusal-front-load-remedy.md deleted file mode 100644 index ccb794c89a7..00000000000 --- a/.changeset/17584-references-refusal-front-load-remedy.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-protocol): the `/references` refusal front-loads its ADR-0110 D3 prescription, so the #5423 bound cannot cut the remedy (#17584) - -`GET /api/v1/meta/:type/:name/references` refuses an unanswerable target type -(`field`, addressed by the composite key `.` that no reference -site can hold) with a prescriptive 501: it names the question that IS -answerable, `GET /api/v1/meta/object//references`. That clause is the -half ADR-0110 D3 exists to deliver — the admin "Used by" panel renders an empty -answer as *"Nothing in the metadata graph points at this item. Safe to delete."* -to an operator whose next click is a delete. - -Since #16146 the refusal crosses the REST boundary through the shared #5423 -bound (`CLIENT_MESSAGE_MAX`, 500 characters), which truncates the **tail**. The -sentence back-loaded the prescription and interpolates the object name twice, so -it grew about three characters per character of name and the remedy was the -first thing a long name cost. Measured through the real route on the unrepaired -sentence: a 37-character object name beside a 37-character field name composed -502 characters and arrived as `…/api/v1/meta/object//referenc…` — the -opener still readable, the URL cut mid-path, an instruction that 404s if -followed. `crm_opportunity_line_item_snapshot_v2` is 37 characters, and nothing -caps a metadata name near that (the ceiling is the storing column's -`maxLength`; the widest is `sys_metadata.name` at 255). - -The clauses are re-ordered so truncation costs the **explanation** instead. No -behaviour moves: the refusal decides exactly what it decided before, the same -`NOT_IMPLEMENTED` / `501` / `refusal` declaration is raised for exactly the same -targets, and the bound is untouched. Callers matching on the message's opening -words will see the new order; matching on `error.code` is unaffected. - -FROM: `References to a 'field' item cannot be computed. … Ask the owning object -instead: GET /api/v1/meta/object//references.` -TO: `Ask the owning object instead: GET /api/v1/meta/object//references. -References to a 'field' item cannot be computed, because …` diff --git a/.changeset/17586-multi-valued-boolean-read-inversion.md b/.changeset/17586-multi-valued-boolean-read-inversion.md deleted file mode 100644 index 224c0e3fb20..00000000000 --- a/.changeset/17586-multi-valued-boolean-read-inversion.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -A `multiple: true` boolean/toggle column reads back as its stored array, not as a single inverted `true` - -`formatOutput` runs its `jsonFields` pass first, which `JSON.parse`s the cell -into a real array, and then its `booleanFields` pass did -`data[field] = Boolean(data[field])`. Every non-empty array is truthy, so a -`multiple: true` `boolean`/`toggle` column presented a single `true` whatever -the array held — a stored `[false]` read back as **`true`**, the opposite of -what is stored, with no error anywhere. `readPresentationKind` hands the same -presenter to the `aggregate()` / `distinct()` doors, so the collapse was not -confined to the row-read door. - -**Fixed at the registry fill.** `&& !field.multiple` is the condition the three -neighbouring pushes in both registration blocks already carry (`mediaCols`, -`numericCols`, `numericValueCols`); `booleanCols.push(name)` was the single -omission, in **both** fills (`registerExternalObject` and -`registerManagedObjectMetadata`). A `multiple: true` boolean/toggle is a JSON -column here, and its array is written faithfully — only the read collapsed it. - -**What moves for a caller.** A `find()` / `aggregate()` / `distinct()` read of a -`multiple: true` `boolean` or `toggle` column now returns the stored array of JS -booleans (`[false]`, `[true, false]`) where it previously returned `true`. Code -that consumed the old scalar was reading a value that did not reflect storage — -including for an all-`false` array. Scalar `boolean`/`toggle` columns are -unchanged and keep their stored-`1`/`0` → JS `true`/`false` coercion; the -`multiple: true` number and `tags` classes were already correct and do not move. diff --git a/.changeset/17590-contains-membership-per-dialect.md b/.changeset/17590-contains-membership-per-dialect.md deleted file mode 100644 index 60db1bf1db6..00000000000 --- a/.changeset/17590-contains-membership-per-dialect.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/driver-sql': minor -'@objectstack/spec': minor ---- - -`$contains` on a multi-valued / JSON column is a MEMBERSHIP test, compiled per dialect so SQLite, MySQL and PostgreSQL answer the same rows. - -`$contains` is the membership spelling on a `multiple: true` field or a `JSON_COLUMN_TYPES` member — the one operator that kept working on a JSON column after the scalar-comparison family was refused there, and the spelling that refusal's own message prescribes. It was lowered like any other text operator, so each backend was asked about the SERIALIZATION rather than about the members, and the three answered three different things: SQLite matched a substring of the stored array text, MySQL coerced its `json` column for `LIKE` and matched the same substring, and PostgreSQL raised SQLSTATE 42883 (`operator does not exist: json ~~ text`) — a `DATABASE_ERROR` 500 for a filter the spec accepts. - -`driver-sql` now compiles a real membership construct per dialect: `jsonb` containment on PostgreSQL, `JSON_CONTAINS` on MySQL, a `json_each` scan on SQLite. `$notContains` moves with it as its exact complement. - -**Behaviour change on SQLite and MySQL, in the narrowing direction.** Where the substring reading matched ACROSS element boundaries it no longer does: `{ tags: { $contains: 'red' } }` stops answering a row whose only tag is `redwood`, and `{ nums: { $contains: '1' } }` stops answering a row holding `[10, 21]`. Those rows were wrong answers, not a contract — a filter that needs the old reading is asking for a substring search over a serialization and should be written against a scalar column. On PostgreSQL the same filters change from a 500 to the member rows. - -Unchanged: `$contains` on a scalar string column is still the case-sensitive substring test, and the rest of the text family (`$startsWith`, `$endsWith`, `$icontains`, `$like`, `$ilike`) keeps the lowering it had on every column. - -`packages/spec`'s `StringOperatorSchema` docblock — published source — now states the membership reading and records, per face, which runtimes answer it. diff --git a/.changeset/17594-step18-element-node-todo.md b/.changeset/17594-step18-element-node-todo.md deleted file mode 100644 index bf81d563755..00000000000 --- a/.changeset/17594-step18-element-node-todo.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the 17 → 18 chain now NAMES the bare `element:filter` / `element:form` node it leaves behind, instead of ending schema-invalid in silence (#17594) - -`element:filter` and `element:form` were retired whole at element grain, and the -two ADR-0087 D2 conversions that carry the retirement — `element-filter-removed` -and `element-form-removed` — strip every authorable key and **deliberately leave -the bare component node**: deleting an authored page node changes a page's -layout, which a mechanical conversion must not decide. That residue was inert -until both names joined `RETIRED_PAGE_COMPONENT_TYPES` and the parse began -refusing them by name — at which point deleting the node stopped being optional -and became a required step of the upgrade. - -The chain never said so. Measured on a stack carrying both nodes, before this -change: - -``` -os migrate meta --from 17 --to 18 - - --json schemaValid: false - human path "Migrated stack does not yet pass schema validation — - resolve the manual changes above" - the 115 step-18 todos 0 name `element:filter`, `element:form`, - `ElementFilter` or `ElementForm` -``` - -ADR-0087 D3 requires a structured TODO "rather than silence" for a migration -step that cannot be expressed declaratively, and this is one: only the author -knows what their region should hold once the node is gone. The new -`element-filter-and-form-node-refused` semantic entry supplies it — surface, the -two replacements (`userFilters` for the filter, the object-bound `object-form` -block for the form) and an `os validate`-clean acceptance criterion — so -`os migrate meta` and the generated upgrade guide both name the thing to delete. - -⛔ Nothing about either conversion's behaviour changes: they still strip the keys -and still leave the node, and no node is deleted for the author. - - diff --git a/.changeset/17596-daterange-array-arity.md b/.changeset/17596-daterange-array-arity.md deleted file mode 100644 index 0bf904421f4..00000000000 --- a/.changeset/17596-daterange-array-arity.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/driver-memory': patch ---- - -`dateRange`'s array arm has ONE arity everywhere: a two-element window, or the ADR-0112 refusal (#17596) - -The shared conformance kit -(`analyticsDateRangeConformanceFindings`) had exactly one array case — a -two-element window — so the ARITY of the array arm was governed nowhere and -every analytics face was free to invent a meaning for `dateRange: -['2026-01-01']`. Four faces in one package had invented three (#17124), and a -fifth — `driver-memory`'s cube face — had invented a fourth. - -**The kit** now exports `ANALYTICS_DATE_RANGE_NOT_A_WINDOW` and holds every -registered face to the rule the `service-analytics` faces already carry: an -array that is not two non-empty string bounds is refused with -`ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400. No new rule was invented for it, and -the existing two-element window case is untouched — it is this case's control, -so "refuse every array" cannot pass. - -**`driver-memory`** now answers that refusal instead of dropping the window. -MEASURED end to end over four rows spanning 2020…2099: `['2026-01-01']`, `[]` -and `['2026-01-01', '2026-01-31', '2026-02-01']` each emitted a pipeline -byte-identical to one with **no `dateRange` at all** — every row selected, the -"plot all of history" failure #3650 was filed about — and `[null, null]` -compared instants against the string `'null'` and selected none. - -**Levels.** `@objectstack/core` is `minor`: it gains a new exported symbol on -its index (`ANALYTICS_DATE_RANGE_NOT_A_WINDOW`), and a purely additive widening -of a published package's public surface takes at least `minor` whatever the -commit type says. `@objectstack/driver-memory` is `patch`: its public surface is -byte-unchanged — no new export, no new accepted key or value. Its behaviour does -change, from selecting every row to refusing with `400 -ANALYTICS_DATE_RANGE_UNRECOGNIZED`, and that is a `patch` because the old -behaviour was a defect and never a contract: the spec's own refusal wording -already said an explicit window is the two-element array, and the #16322 -migration table already told authors to write a single day as two bounds. A -release that stops answering a shape the contract never admitted is a fix, not a -feature — and the shapes it now refuses had no correct answer to lose. - -**If you wrote a one-element array**, write both bounds: `['2026-01-01']` -becomes `['2026-01-01', '2026-01-01']`, which selects exactly that day on every -face and did so before this change too. The refusal names the shape that -arrived, the two-element contract and that spelling. diff --git a/.changeset/17598-analytics-date-range-two-bound-window.md b/.changeset/17598-analytics-date-range-two-bound-window.md deleted file mode 100644 index bfc2e258f51..00000000000 --- a/.changeset/17598-analytics-date-range-two-bound-window.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor -"@objectstack/types": patch -"@objectstack/rest": patch ---- - -fix(spec)!: `timeDimensions[].dateRange`'s array arm is exactly two string bounds, and each refusal ORIGIN gets a true sentence (#17598; ruling A, decision batch #117 item 3) - - - -**BREAKING** accept-set narrowing at `timeDimensions[].dateRange` — shipped as -`minor` under this repo's launch-window convention for breaking changes -(`scripts/check-changeset-no-major.mjs`), above the `patch` floor the `fix` -commit type sets, and the same grade the one comparable precedent took: the -STRING-arm closing on this same schema is #16041, and it shipped -`"@objectstack/spec": minor` (`packages/spec/CHANGELOG.md` 17.4.0, under Minor -Changes). ⚠️ Its driver half #16322 declares `"@objectstack/spec": patch`, but -that entry is — in that changeset's own words — "a `PROVENANCE_WAIVERS` row -only", not an accept-set narrowing, so it is not a grade this one is measured -against. The maintainer -ruling calls it a "major changeset"; under the launch window that phrase maps to -the protocol MAJOR the migration registers against (18), not to the changeset's -bump level, which `scripts/check-changeset-no-major.mjs` reserves. The semantic -prescription is registered under protocol major 18 as -`analytics-date-range-array-two-bounds-required`. - -### What changed - -`AnalyticsDateRangeSchema`'s array arm was `z.array(z.string())` with **no length -constraint**, so `['2026-01-01']`, `[]` and `['a', 'b', 'c']` were schema-valid. -It is now `z.tuple([z.string(), z.string()])` — a tuple rather than a length -refinement, so the arity is stated to the author's compiler before any parse runs. -Preset names, two-bound windows and an absent `dateRange` parse byte-identically -to before. - -`analyticsDateRangeRefusalMessage(input)` becomes -`analyticsDateRangeRefusalMessage(input, origin)`, where `origin` is `'schema'` or -`'runtime'` and is **required** — there is deliberately no default. - -### Migration: FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `dateRange: ['2026-01-20']` | `dateRange: ['2026-01-20', '2026-01-20']` — a single day is that day as both bounds, the shape the shipped #16322 table already prescribes | -| `dateRange: []` | no conversion. An empty array names no window: write the two bounds the widget was meant to show, or omit `dateRange` (it is optional, and absent means the query is not time-bounded) | -| `dateRange: ['a', 'b', 'c']` | no conversion. Decide which two bounds you meant and write them | -| `analyticsDateRangeRefusalMessage(value)` | `analyticsDateRangeRefusalMessage(value, 'schema')` at a parse door, `…(value, 'runtime')` past one | - -`os migrate meta --from 17` emits the first three as a structured TODO rather than -rewriting them: rewriting a one-element array to the same day twice at load would -be the platform deciding, silently, that the author meant one day rather than a -window whose end they forgot, and for the other two shapes there is nothing to -decide from. - -### Why it is not a new class of breakage - -Since PR #17593 all four analytics faces (`ObjectQLStrategy`, `NativeSQLStrategy`, -the draft-preview evaluator, `DatasetExecutor.runCompare`) already refused anything -that is not exactly two bounds with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`, so -every stored range this narrowing refuses was **already failing at query time**. -The contract door was looser than every reader behind it; this moves the refusal -to authoring time and states it accurately. Blast radius is the WIDGET, not the -page: a stored dashboard carrying a now-refused range loses that widget with the -refusal shown and still loads. - -### The wording half - -The shared sentence ended `"Refused at the schema"` and described every refused -array as `"received an array with a non-string bound"`. For a one-element window -refused by a face **both clauses were false** — every bound present is a string, -and it was refused past the schema, not at it — which is why -`@objectstack/service-analytics` had to overwrite the message rather than reuse it, -leaving one condition with two wordings. The origin is now a parameter and the -`received …` clause names the arity and the bad bound separately, so the sentence -is true for each origin both before and after the arm narrows. - -The same rule reaches the WIRE. Narrowing the arm to a tuple gave the union a -second voice: its arm answers `Too small: expected array to have >=2 items` for -the very arity the prescription just prescribed, and the ADR-0114 union -expansion emitted both as `fields[]` entries on `POST /analytics/query` and -`POST /analytics/dataset/query`. `fieldsFromZodIssues` (`@objectstack/types`), -the one mapper both doors report through, now drops the branch issues that land -at the union's OWN path for this refusal — recognised structurally through -`isAnalyticsDateRangeRefusalIssue`, never by message prose. A refusal that names -a DEEPER position keeps it: `dateRange: ['2026-01-01', 3]` still reports -`timeDimensions.0.dateRange.1`, because WHICH bound is not a string is a -location the prescription does not carry. Every other union expands exactly as -before. Client-visible effect: one `fields[]` entry for an arity refusal instead -of two, with the prescriptive one kept. diff --git a/.changeset/17610-notification-dispatcher-idle-cost.md b/.changeset/17610-notification-dispatcher-idle-cost.md deleted file mode 100644 index 90466f3bb0b..00000000000 --- a/.changeset/17610-notification-dispatcher-idle-cost.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -`NotificationDispatcher` reaps once per tick instead of once per claim, backs off while the outbox is idle, and `emit()` wakes it (#17610) - -**What an idle dispatcher cost.** Against an EMPTY `sys_notification_delivery` outbox every tick walked `partitionCount` partitions (default 8) and ran `claim()` and `claimDigest()` in each — and each of those opened with the environment-wide visibility-timeout reap before its candidate SELECT. Measured on a real `ObjectQL` + `SqlDriver`: **32 statements a tick, 16 of them the identical reap UPDATE**, on a fixed 500 ms interval that never let up, one loop per warm kernel. On remote Turso every statement is an HTTP round trip. - -**Now:** - -- **The reap runs once per tick**, before any claim — an idle tick is `1 + 2 × partitionCount` = 17 statements. Its predicate names no partition, so one run returns every claim that had expired when the tick began; a claim that expires during the tick is returned by the next one. A crashed node's `in_flight` rows are still recovered within one tick of `claimTtlMs` passing, and a claim is still never re-taken before its TTL. -- **The loop backs off while idle.** Every tick that claims nothing doubles the delay to the next, from `intervalMs` up to `maxIdleIntervalMs` (default 30 s; `MessagingServicePlugin` option `dispatchMaxIdleIntervalMs`). A tick that claims work snaps back to `intervalMs`. With the defaults, ten idle minutes are 24 ticks instead of 1,201. -- **`emit()` wakes the dispatcher.** `MessagingService.setOutbox(outbox, { onEnqueued })` fires once per `emit()` that enqueued at least one delivery; the plugin points it at the new `NotificationDispatcher.wake()`, which ticks immediately — or once more, right after a tick already in flight. - -**Latency bound.** A notification emitted in the process that runs the dispatcher goes out on the tick `wake()` starts, no later than before. While idle, work nobody announces is noticed within one backed-off interval, at most `maxIdleIntervalMs` (30 s by default): a deferred delivery coming due (retry schedule, quiet hours, digest window), a row enqueued by a process that does not run this dispatcher, and a crashed node's expired claim (recovered within `claimTtlMs` + `maxIdleIntervalMs`). Set `dispatchMaxIdleIntervalMs` to `dispatchIntervalMs` to keep the fixed interval. - -**Contract additions — all optional, nothing to change on upgrade.** `INotificationOutbox` gains an optional `reap(opts: ReapOptions)` — the visibility-timeout recovery `claim()` / `claimDigest()` already open with, as a method of its own — and `ClaimOptions` gains an optional `skipReap`. Both built-in stores (`SqlNotificationOutbox`, `MemoryNotificationOutbox`) implement them. A custom outbox without `reap()` keeps working as it is: the dispatcher probes for the method and, when it is absent, lets each claim reap as before — correct, at the old per-claim cost; implementing `reap()` and honouring `skipReap` is what earns the once-per-tick cost. Direct callers of `claim()` / `claimDigest()` are unaffected: without `skipReap` they reap exactly as before. Also new: `NotificationDispatcher.wake()`, the dispatcher's `maxIdleIntervalMs` option, and `MessagingService.setOutbox`'s optional second argument. diff --git a/.changeset/17611-terminal-delivery-retention.md b/.changeset/17611-terminal-delivery-retention.md deleted file mode 100644 index af9c4126e65..00000000000 --- a/.changeset/17611-terminal-delivery-retention.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -`sys_notification_delivery` reaps its terminal-failure rows after **7 days** instead of 90 (#17611) - -**⚠️ Operational consequence, stated plainly: `dead` and `suppressed` delivery rows are now deleted 7 days after they were created.** Any report, SLA reading, dashboard or manual investigation that consulted them — "which notifications failed to send, and why" — must now read inside that window. Before this change those rows survived for 90 days. Nothing else about the table changes: `pending`, `in_flight` and `success` rows keep the same 90-day window they have always had, and no row is reaped sooner than before except the two terminal-failure statuses. - -**What was wrong.** Fan-out writes one delivery row per `(event × recipient × channel)`. A tenant with no transport configured for one of those channels dead-letters that channel's row on its **first** attempt, and every `notify` writes another one. Measured on a production tenant: 2,876 `email`/`dead` rows against 2,876 `inbox`/`success` rows, `max(attempts) = 1`, zero pending, growing +316 rows/day. Those rows carry no work — nothing ever claims, retries or acks them again — but they sat in the table the dispatcher's claim query reads on every hop for the full 90-day window, so the cost of every claim rose linearly with time. - -**The change** is one declaration on the object, using spec keys that already ship and are already consumed by the platform Reaper: - -```ts -lifecycle: { - class: 'telemetry', - ttl: { field: 'created_at', expireAfter: '90d' }, - retention: { - maxAge: '7d', - onlyWhen: { status: { $in: ['dead', 'suppressed'] } }, - }, -}, -``` - -`retention.onlyWhen` scopes the short window to the terminal-failure statuses — the same shape `sys_job_queue`, `sys_automation_run` and `sys_upload_session` already declare. No channel interface member, no new status value, no change to fan-out. - -The `ttl` leg is not new behaviour: it restates the 90-day bound the object has always declared. `lifecycle.retention` is a single block, so scoping it to terminal rows would otherwise have left `pending` / `in_flight` / `success` with **no age bound at all** — unbounding the larger half of this table's growth on the very change that exists to bound it. Both legs run: `LifecycleService.reapObject` takes `ttl` and `retention` in independent branches. `success` is deliberately outside the scope; delivery history stays at the table window. - -**If you override this object's lifecycle windows through the `lifecycle` settings namespace, re-read your configuration.** `retention_overrides.maxAge` for `sys_notification_delivery` used to move the whole table's window; it now moves the **terminal-failure** window only, and `expireAfter` moves the table window. An override left in place keeps parsing and keeps applying — to a narrower set of rows than it did before. - -**⚠️ This is worth nothing where the Reaper does not run.** The whole benefit is delivered by `LifecycleService`, which `OS_LIFECYCLE_DISABLED=1` or the plugin switch turns off. A deployment with lifecycle disabled kept these rows forever before this change and keeps them forever after it; a declaration is not a sweeper. Check that the Reaper is enabled before reading this entry as a bound on your table. diff --git a/.changeset/17612-db-queue-idle-backoff.md b/.changeset/17612-db-queue-idle-backoff.md deleted file mode 100644 index 293dc86ed9c..00000000000 --- a/.changeset/17612-db-queue-idle-backoff.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/service-queue": minor -"@objectstack/service-messaging": patch ---- - -`DbQueueAdapter` backs off while `sys_job_queue` is idle instead of polling flat at 1 s, and the loop that does it is now published from `@objectstack/core` as `DispatchLoop` (#17612). - -A registered-but-idle queue issued **3600 candidate reads an hour, per queue**, whatever was in the table — on a remote driver, 3600 HTTP round trips an hour of pure idle cost. Measured over one simulated idle hour on the engine boundary the adapter really talks to: **3601 reads before, 124 after**, with the flat-poll number re-measured on the same harness as a control so the new one is a reading about the backoff rather than about a loop that stopped ticking. - -- **One mechanism, not a third copy.** The idle-backoff loop was written for `NotificationDispatcher` (#17610), shared with `HttpDispatcher` (#17623), and lived unexported inside `@objectstack/service-messaging`. `DbQueueAdapter` was the third polling worker needing it. It moves to `@objectstack/core` — the package all three already depend on — because it is a timing primitive owned by neither the messaging domain nor the queue domain, and having `service-queue` depend on `service-messaging` to reach it would invert the dependency direction. **New export from `@objectstack/core`: `DispatchLoop`, `DispatchLoopOptions`, `DEFAULT_MAX_IDLE_INTERVAL_MS`.** -- **Nothing published moved.** `@objectstack/service-messaging` exports only its `index`, which never carried the loop; its two dispatchers now import it from `@objectstack/core` and its own surface is byte-unchanged. -- **New option `DbQueueAdapterOptions.maxIdleIntervalMs`** (default 30 s). Each tick that claims nothing doubles the delay to the next from `pollIntervalMs` up to this ceiling; anything claimed, and every wake, snaps it straight back. **Setting it at or below `pollIntervalMs` restores the flat poll exactly.** -- ⚠️ **What the backoff costs, and what it does not.** Work published through this adapter now wakes the loop, so a due `publish()` and `replay()` are picked up at the base interval as before — the ceiling is never on their latency path. What it does cost is up to `maxIdleIntervalMs` of extra latency on work this process was never told about: a row another node wrote, a deferred row coming due, a crashed worker's lease expiring. A deferred `publish()` deliberately does **not** wake the loop, since that tick would claim nothing and would throw the backoff away. diff --git a/.changeset/17612-job-queue-claim-index.md b/.changeset/17612-job-queue-claim-index.md deleted file mode 100644 index b52db308050..00000000000 --- a/.changeset/17612-job-queue-claim-index.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/platform-objects": patch -"@objectstack/service-queue": patch ---- - -`sys_job_queue`'s claim path no longer sorts the whole queue on every poll, and a job's due time is now a SQL predicate instead of a filter applied after `LIMIT` (#17612). - -`DbQueueAdapter.claimBatch` — the 1s poll every `DbQueueAdapter` runs — read the queue as `WHERE queue = ? AND status = 'pending' ORDER BY priority ASC, scheduled_for ASC`, while `sys_job_queue` declared `['queue','status','scheduled_for']`. The sort's **first** key, `priority`, was in no declared index at all, so the equality prefix seeked and the planner then built a sorter over every pending row in the queue, every tick. Measured on both Turso faces: - -``` -SEARCH sys_job_queue USING INDEX idx_sys_job_queue_queue_status_scheduled_for (queue=? AND status=?) -USE TEMP B-TREE FOR ORDER BY -``` - -- **The declared index becomes `['queue','status','priority','scheduled_for']`**, replacing `['queue','status','scheduled_for']` — the table still declares three. The full-queue sort is gone on both faces; what remains is a sorter bounded to rows tying on the whole indexed prefix, because a paged read carries one ORDER BY term the caller never writes — the unique tie-breaker of the deterministic-paging contract (ADR-0053 D-A1), here `id`. ⛔ That last term is deliberately **not** closed by appending `id` to the index: `id` is an unbounded `Field.text`, and a text column a declared index keys on without a `maxLength` makes MySQL reject the index DDL outright (`check:keyed-text-bounds`, ER_BLOB_KEY_WITHOUT_LENGTH). -- **Due-ness moved into `where`** as `$or: [{ scheduled_for: null }, { scheduled_for: { $lte: now } }]`, the same shape `SqlOutboxStore.claim` uses. It had been a JS filter applied to rows `LIMIT` had already chosen, so a window full of not-yet-due high-priority jobs hid already-due work behind it indefinitely: at the default `batchSize: 10` (candidate window 30), 30 future-dated `priority: 1` rows plus one due `priority: 100` row claimed **0** per poll, forever. It now claims 1. -- **`priority` still decides claim order.** The alternative — dropping it from the sort — would have left a declared, documented field (`Lower = higher priority`) with no runtime effect at all. -- ⚠️ **On an existing database the superseded index is not dropped.** The retrofit adds `idx_sys_job_queue_queue_status_priority_scheduled_for` and leaves `idx_sys_job_queue_queue_status_scheduled_for` in place (measured: 3 indexes before, 4 after, no row touched), so a provisioned table carries one redundant index until an operator drops it through the migrate-plan path. A freshly created table gets three. diff --git a/.changeset/17620-action-engine-delete-nullish-id.md b/.changeset/17620-action-engine-delete-nullish-id.md deleted file mode 100644 index f02e242349e..00000000000 --- a/.changeset/17620-action-engine-delete-nullish-id.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -`ActionEngineFacade.delete` refuses a nullish id instead of silently skipping it - -**Who this is for: untyped hosts.** A JS host, or a `registerAction` handler -whose context slot is still `(ctx: any)`, can hand `ctx.engine.delete()` a -nullish id — `delete('todo_task', null)`, or an array with a hole in it. Until -now the arm dropped that element on the floor: nothing refused it, nothing -warned, and the call **resolved as though the row had been deleted**. A silent -no-op on a destructive verb is the one failure an untyped caller has no way to -detect, which is why it is worth a line in your changelog rather than a shrug. - -**What changes.** Every id now reaches the engine as written, and the engine's -own delete-dispatch predicate refuses a `where.id` that is not a truthy scalar: -the call rejects with `Delete requires an ID or options.multi=true` where it -used to resolve in silence. In the array form the refusal stops the loop where -the declared member doc already said a failure stops it — ids before the -nullish element are deleted, ids after it are untouched. - -**If a host was leaning on the old behaviour**, filter before you call: - -```js -const ids = candidates.filter((id) => id != null); -if (ids.length > 0) await ctx.engine.delete('todo_task', ids); -// `delete nothing` is the EMPTY ARRAY (it resolves, deleting nothing) — -// never a null id. An empty array is contract; a nullish id never was. -``` - -⛔ **No declaration moves, and this is not a correction of the `string | string[]` -widening that shipped just before it.** That declaration is accurate: it takes a -single id or an array of them, and under it **no typed caller could ever reach -the skipped branch** — the accept set it publishes has never admitted nullish. -The array form, its per-row semantics, its ordering and its empty-array case are -all unchanged and pinned as controls. What moves is only the runtime's -undeclared tolerance for a value three separate statements already excluded: the -published type, the member's own doc comment, and the spec-side pin that reads -«"delete nothing" is the EMPTY ARRAY, never a null id». diff --git a/.changeset/17621-metadata-protocol-live-postgres-arm.md b/.changeset/17621-metadata-protocol-live-postgres-arm.md deleted file mode 100644 index dbb014223b7..00000000000 --- a/.changeset/17621-metadata-protocol-live-postgres-arm.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Execute the read-probe's PostgreSQL catalog arm against a live server, closing the one dialect this package pinned as text and never ran. - -`read-probe.ts` compiles one non-raising table-presence arm per dialect family. Two were executed against something real — SQLite end to end through a real `SqlDriver`, MySQL on the live server the `Temporal Conformance` job provisions. The PostgreSQL arm (`SELECT 1 WHERE to_regclass('""') IS NOT NULL`) was pinned character-for-character against all four knex client spellings and run nowhere: this package had no live-PG harness, no `pg` dependency, and its CI step supplied `OS_TEST_MYSQL_URL` alone while filtering vitest to `live-mysql`. - -A text pin cannot close that gap, because the failure this module is fenced against is an arm mis-compiled for one dialect: it raises, the `catch` that exists for the expected miss swallows it, and a stored-row data repair silently becomes a no-op. Whether `to_regclass` answers ZERO ROWS rather than raising is a claim about PostgreSQL, not about this repo's string concatenation. `seed-tenancy-backfill.live-postgres.test.ts` now runs every statement the migration builds, both presence directions with the refusal control beside them, the search-path scoping the arm depends on, and the whole backfill end to end — on a live server, in its own derived schema. Ablated (the Postgres arm re-compiled to MySQL's `DATABASE()` form), six of its seven cases go red, reporting `verdict: 'unreadable'` with `detail: "function database() does not exist"` — the exact shape the fence exists to keep out of `'absent'`. - -Grade: `patch`, measured rather than defaulted. Not `minor` — no new export, no widened accept-set, no runtime behaviour change of any kind. Not `skip-changeset` either, and that is the measurement worth recording: `dist/` is byte-untouched (grepped for this change's markers: zero hits, against a positive control that hits `dist/index.js` and `dist/index.cjs`), but `package.json` is one of the 27 files `npm pack` ships, and it now carries `pg` and `@types/pg` in `devDependencies`. `skip-changeset` is for a diff that publishes nothing from a released package; this one publishes two manifest lines a consumer never installs, which is still publishing. diff --git a/.changeset/17623-http-dispatcher-idle-cost.md b/.changeset/17623-http-dispatcher-idle-cost.md deleted file mode 100644 index 08d3d5a130b..00000000000 --- a/.changeset/17623-http-dispatcher-idle-cost.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -`HttpDispatcher` reaps once per tick instead of once per partition, backs off while `sys_http_delivery` is idle, and `enqueueHttp()` / `redeliverHttp()` wake it (#17623) - -**What an idle dispatcher cost.** Against an EMPTY `sys_http_delivery` outbox every tick walked `partitionCount` partitions (default 8) and ran `claim()` in each — and each claim opened with the environment-wide visibility-timeout reap before its candidate SELECT. Measured on a real `ObjectQL` + `SqlDriver`: **16 SQL statements a tick, 8 of them the identical reap UPDATE**, on a fixed 500 ms `setInterval` that never let up, one loop per warm kernel. It is the shape #17610 removed from `NotificationDispatcher`, still running beside it. On remote Turso every statement is an HTTP round trip. - -**Now:** - -- **The reap runs once per tick**, before any claim — an idle tick is `1 + partitionCount` = 9 statements. Its predicate names no partition, so one run returns every claim that had expired when the tick began; a claim that expires during the tick is returned by the next one. A crashed node's `in_flight` rows are still recovered within one tick of `claimTtlMs` passing, and a claim is still never re-taken before its TTL. -- **The loop backs off while idle.** Every tick that claims nothing doubles the delay to the next, from `intervalMs` up to `maxIdleIntervalMs` (default 30 s, the notification dispatcher's default). A tick that claims work snaps back to `intervalMs`. With the defaults, ten idle minutes are 24 ticks and 216 statements instead of 1,201 ticks and 19,216. -- **`MessagingServicePlugin`'s `dispatchMaxIdleIntervalMs` sets the ceiling for both dispatchers**, the way `dispatchIntervalMs` and `partitionCount` already govern both. -- **Writes in this process wake the dispatcher.** `MessagingService.setHttpOutbox(outbox, { onEnqueued })` fires after an `enqueueHttp()` that enqueues a delivery — not one that parks an undeliverable record, which is `dead` on arrival — and after a `redeliverHttp()`. The plugin points it at the new `HttpDispatcher.wake()`, which ticks immediately, or once more right after a tick already in flight. - -**Latency bound.** A delivery enqueued or redelivered in the process that runs the dispatcher goes out on the tick `wake()` starts. While idle, work nobody announces is noticed within one backed-off interval, at most `maxIdleIntervalMs` (30 s by default): - -- a retry coming due is attempted less than `min(its delay + intervalMs, maxIdleIntervalMs)` late, because the backoff restarts from `intervalMs` at the attempt that scheduled it; -- a row enqueued by a process that does not run this dispatcher; -- a crashed node's expired claim, recovered within `claimTtlMs` + `maxIdleIntervalMs` (about 35 s at defaults, where it was about 5.5 s). - -Set `dispatchMaxIdleIntervalMs` to `dispatchIntervalMs` to keep the fixed interval. - -**Contract additions — all optional, nothing to change on upgrade.** `IHttpOutbox` gains an optional `reap(opts: HttpReapOptions)` — the visibility-timeout recovery `claim()` already opens with, as a method of its own — and `HttpClaimOptions` gains an optional `skipReap`. Both built-in stores (`SqlHttpOutbox`, `MemoryHttpOutbox`) implement them. A custom outbox without `reap()` keeps working as it is: the dispatcher probes for the method and, when it is absent, lets each claim reap as before — correct, at the old per-claim cost. Direct callers of `claim()` are unaffected: without `skipReap` they reap exactly as before. Also new: `HttpDispatcher.wake()`, the dispatcher's `maxIdleIntervalMs` option, the `HttpReapOptions` type, and `MessagingService.setHttpOutbox`'s optional second argument. - -**One loop, not two copies.** The timer loop — idle backoff, collapsing wakes into one follow-up tick, `stop()` — moved out of `NotificationDispatcher` into a module both dispatchers share. `NotificationDispatcher`'s behaviour and public surface are unchanged; its #17610 tests pass as they were. diff --git a/.changeset/17625-api-root-is-the-discovery-route.md b/.changeset/17625-api-root-is-the-discovery-route.md deleted file mode 100644 index ee2057d3c90..00000000000 --- a/.changeset/17625-api-root-is-the-discovery-route.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime): the API root is the discovery route, under a second spelling — a gated session's `GET ${prefix}/` reaches discovery again (#17625) - -`HttpDispatcher.dispatch()` strips one trailing slash, so both root spellings it -accepts collapsed onto the empty string: `${prefix}/` arrives as `/` and -`${prefix}` arrives as `` (the MSW / base-URL-stripped form). Only the discovery -branch at the foot of the method knew that empty string meant the API root. The -ADR-0069 authentication-policy gate, which runs far above it, did not. - -That disagreement was invisible while `isAuthGateAllowlisted` answered `true` -for a falsy path. objectstack#7898 made the predicate fail-closed at the source -— exemption is now something a path EARNS by naming an allow-listed route — and -the bare-root discovery request started answering 403 for a session carrying an -`authGate` posture (expired password, required MFA): - -``` -FROM GET ${prefix}/ (session with user.authGate) -> 200 discovery document -TO GET ${prefix}/ (session with user.authGate) -> 403 PASSWORD_EXPIRED // regression -NOW GET ${prefix}/ (session with user.authGate) -> 200 discovery document -``` - -**Normalising the root to `/` is measured insufficient and is not what landed.** -`isAuthGateAllowlisted('/')` is `false` — a segment-less path matches no -`ALLOW_ROUTES` entry — and the discovery branch tests `/discovery` or the empty -string, neither of which `/` satisfies. `'' -> '/'` therefore relocates the 403 -rather than removing it. Both legs are pinned upstream in -`packages/core/src/security/auth-gate.test.ts` ("does not exempt the dispatcher -bare-root `cleanPath` — step 2 is #17625"). - -The root is canonicalised to `/discovery` instead — the route it has always -served — read from one constant by both the canonicalisation and the branch that -serves it, so the two cannot drift into a third disagreement about what the -empty path means. - -**⛔ No allow-list was widened and `packages/core` is untouched.** The only input -whose gate answer moves is the API root, and it gains exactly the exemption -`/discovery` already carried, by BEING that route — no new information is -reachable, since `/discovery` was already exempt and already outside the -project-membership skip check. A caller that reaches the gate with no path at -all is still refused at the predicate, and the pathless case stays declared -where it lives (`shouldDenyAnonymous`) rather than re-derived at this seam. - -**What does NOT change.** `${prefix}` with no trailing slash keeps serving the -same document; the named `/discovery` route is untouched; the -environment-scoped root `${prefix}/environments/` keeps its own answer, -which matched no allow-listed route before objectstack#7898 either. `//` strips -to `/`, not to the empty string, so it is not the root and is not canonicalised. - -**Why `minor` on a change whose commit type is `fix`.** The two are independent -and the floor is mechanical, not editorial: this PR's clause ② is declared -affirmative, and the maintainer's ruling of 2026-09-04 (decision batch #35, on -objectstack#15294) puts an affirmative clause ② on a package whose -`packages/**/src/**` the diff moves at AT LEAST `minor` — *the commit type may -raise a bump but never lower it below what the act requires*, written out under -"WHICH LEVEL" in the `Check Changeset` step of -`.github/workflows/pr-automation.yml`. ⛔ So the reading that this is "a 403 that -should be a 200, therefore a patch" is an argument about INTENT and does not -reach the level: the act re-admits an input class the merged tree refuses, on an -authorisation surface, and that is what the level grades. The commit type stays -`fix(runtime)`, because the type describes the act and the level prices it. - -**ADR-0087 disposition: no ledger entry is owed and no marker is required.** -This changeset declares no breaking change, which is the only condition under -which `check:adr-0087-registration` demands a disposition marker. On the -substance: no ADR-0087 shape surface moved — the diff touches one -`packages/runtime` transport file and its sibling test, no `*.zod.ts`, no -`packages/spec/**`, no `packages/spec/src/contracts/**` entry and no object -definition — so `objectstack migrate meta` has nothing to reach, and no -authorable metadata key, accept set or stored shape changes. Nor is this an -ADR-0087 conversion-layer entry: nothing lenient is being accepted from a -metadata producer. One transport's two spellings of its own route are being -reconciled to the route's own name, which is the opposite direction — a dialect -removed, not tolerated. diff --git a/.changeset/17631-requires-feature-blank-source.md b/.changeset/17631-requires-feature-blank-source.md deleted file mode 100644 index 45e90525f1e..00000000000 --- a/.changeset/17631-requires-feature-blank-source.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): `requiresFeature` refuses a blank-`source` CEL `visible` instead of composing a predicate that can never parse (#17631) - -Clause-②: no - -`lowerRequiresFeature` lowers the `requiresFeature: ''` sugar into the canonical `visible` CEL predicate, and its own docblock states the ADR-0078 rule it enforces: a composition that could never take effect is a loud parse error, not a silent one. The guard that enforced it tested the TYPE of `source` (`typeof existing.source !== 'string'`), so a whitespace-only `source` — legal on `ExpressionSchema`, which is the persistence contract and whose `min(1)` whitespace clears — passed it and the gate was composed AROUND a blank operand: - -``` -visible: { dialect: 'cel', source: ' ' } + requiresFeature: 'organization' - → { dialect: 'cel', source: '( ) && features.organization != false' } -``` - -That predicate parses on no scope at all (`celEngine.evaluate` answers `kind: parse`, `Unexpected token: RPAREN`), so at render the gate faults instead of gating: fail-soft surfaces show the element regardless of the flag, fail-closed surfaces hide it regardless of the flag. Either way the flag decides nothing — the parses-clean-changes-nothing arrival the guard exists to reject, produced by the guard's own composition step. - -The lowering now refuses a `source` that is blank after trimming, on the same leg as the AST-only refusal one line above, with a refusal that names the composition it would have produced and both exits (drop the blank `visible` and the sugar emits the gate alone; or write the predicate the gate should compose with). The notion of blank is `source.trim()` — the one the engine's own helpers apply — so a `source` that is merely padded around real text still composes verbatim. - -- **Refused at the producer, not tolerated at a consumer.** No renderer gains a fallback for the unparseable predicate; the lowering stops emitting it. -- **Both slots that compose the sugar inherit it** — `ActionSchema.visible` and `ActionParamSchema.visible` — because the rule lives in the shared lowering rather than in either slot's declaration. -- **`ExpressionSchema` / `ExpressionInputSchema` are NOT narrowed.** They remain the persistence contract, and a blank-`source` `visible` with no `requiresFeature` beside it still parses exactly as before. What is refused is the COMPOSITION, which is the thing that could never work. -- **Nothing that functioned stops functioning.** The only authoring this refuses is one whose output faulted at CEL parse on every scope, so the migration is the refusal's own prescription and there is no working shape to port. diff --git a/.changeset/17634-http-ack-claim-credential.md b/.changeset/17634-http-ack-claim-credential.md deleted file mode 100644 index 738a2744bc5..00000000000 --- a/.changeset/17634-http-ack-claim-credential.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -`IHttpOutbox.ack()` takes an optional third argument, the claim credential, and `HttpDispatcher` now always passes it (#17634). A late ack from a claim the visibility-timeout reap had taken back — a send that outran `claimTtlMs` while another dispatcher re-claimed the row — used to write its outcome by row id over that dispatcher's live attempt: a delivery still in progress could be marked `dead`, or one attempt's outcome overwrite another's. Handed the credential, `SqlHttpOutbox` and `MemoryHttpOutbox` perform the compare-and-set `INotificationOutbox.ack()` has performed since #11859: the outcome is written only while the row is still `in_flight` under the same (`claimedBy`, `claimedAt`) pair `claim()` stamped on it. A lost claim writes nothing and throws the new `HttpAckError` (`DELIVERY_NOT_ELIGIBLE`, the code this package already raises for a delivery row in the wrong state); the dispatcher logs `http-dispatcher: ack refused, claim no longer held`, carries on with the rest of its batch, and whoever holds the row re-drives the delivery. - -Nothing written against the two-argument `ack(id, result)` has to change. An `IHttpOutbox` implementation that does not read the third argument compiles and works as before, and a caller that does not pass it gets the by-id write it always got — that arity is deprecated, because it checks no ownership. New exports: `HttpClaimCredential` and `HttpAckError`. A subclass that overrides a built-in store's `ack()` should forward the third argument to `super.ack()`, or its dispatcher acks keep the old unchecked write. diff --git a/.changeset/17639-distinct-backend-fault-envelope.md b/.changeset/17639-distinct-backend-fault-envelope.md deleted file mode 100644 index 4a83d93c09b..00000000000 --- a/.changeset/17639-distinct-backend-fault-envelope.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -`distinct()` answers a backend refusal with the ADR-0112 envelope instead of leaking the dialect's own error - -`SqlDriver.distinct` awaited its query builder bare — no `try`/`catch`, no -envelope — so any refusal the statement raised left the driver as the backend's -own object: a raw SQLSTATE in `code`, `status` **undefined**, and the compiled -statement as the message. `@objectstack/rest` builds a wire status from the -envelope, so an error carrying no `status` and a `code` that is a raw SQLSTATE -is on no list it reads: an ordinary caller shape — *list the distinct values of -this column* — surfaced as an UNHANDLED server fault rather than a declared -`DATABASE_ERROR` 500. - -Measured on live PostgreSQL 16.13: this driver stores every `multiple: true` -column as `json`, and PostgreSQL's `json` defines no equality operator, so -`SELECT DISTINCT` over one is refused — -`code=42883 status=undefined`, `msg=select distinct "toggles" from "…" - could -not identify an equality operator for type json`. Class-wide across every JSON -column (`toggle`, `boolean` and `number` with `multiple: true`, and `tags`), -with a scalar `boolean` column in the same table answering normally. - -The third read door now routes through the same terminal -`backendStatementFault` that `find()` and `count()` have used since -objectstack#8931 and `aggregate()` since objectstack#11455: one catalogued -code, one status, the dialect's own text written to the server log for an -operator and withheld from the caller, and the original error kept as a -non-enumerable `cause` so `isMissingTableError` still reads through it. - -⛔ No new export, no new error code, no new envelope field, and the accepted -input set does not move: `status` and `code` are fields this envelope already -declares. ⛔ This does not make `distinct()` ANSWER over a `json` column — the -call fails either way; what changes is whether the failure is classified. -Whether such a column should support a distinct read belongs with -objectstack#17590. diff --git a/.changeset/17645-sdui-parser-lockstep-port.md b/.changeset/17645-sdui-parser-lockstep-port.md deleted file mode 100644 index 7c171fddfc4..00000000000 --- a/.changeset/17645-sdui-parser-lockstep-port.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/sdui-parser": minor ---- - -The save gate now stamps `inert-quick-add` and `member-type-mismatch`, the two diagnostics that existed only in objectui's copy of this parser — so a page no longer saves clean here and renders with a different verdict there (#17645). - -The two copies of this parser owe each other one thing: byte agreement on the accepted grammar and on diagnostic codes. This copy runs the **save gate** and objectui's runs the **renderer**, so a code on one side only is a dialect — the author gets one reading when they save and another when the page draws, which is surface-dependent and therefore reaches them as intermittent. Measured at the ported revision: objectui stamped 26 codes, this copy stamped 24, and the missing two were exactly these. - -- **`inert-quick-add`** (warning) — `quickAdd` on `` reaches no control. The Quick Add button is gated on **both** `quickAdd` and an `onQuickAdd` handler, and `onQuickAdd` takes a function, which no page on this tier can write (this tier parses, it never executes) and which the board substitutes none of its own for. It **replaces** the `unknown-prop` this copy used to emit for the key, which was false against the contract: `ComponentPropsMap['object-kanban']` publishes `quickAdd`, so an author who checked the spec found the warning contradicted and kept a key that will never do anything. Asked ahead of the declaration lookup on purpose — the claim is about the render path, so declaring the key must not silently disarm it. A falsy value and an unevaluated braced expression are deliberately untouched. -- **`member-type-mismatch`** (warning; `error` when an `enum` arm is present) — the coarse type check one level down, over the member kind an input declares. This brings the `ManifestInput.of` key and its three readers with it: the validator, the serializer's canonicalization, and the codegen's element type. `of: 'string'` on an array input now types the members `string[]` in the generated `.d.ts` instead of `unknown[]`, and a member no declared arm accepts draws **one** diagnostic naming every offending position rather than one per member. - -**Nothing published changes shape for an input that declares no `of`.** The key is absent-means-undeclared: the validator checks no member, the codegen emits the unnarrowed element type, and `manifestFromConfigs` publishes no `of` at all, so an entry written before the key existed serializes byte-identically. Measured on the tracked `sdui.manifest.json`: 0 of 339 inputs declare `of`, and the artefact regenerates to the same sha256 across this change. - -⚠️ **Both new codes are diagnostics, not a new red gate.** Each is a warning, so `compile().ok` — the save gate's pass/fail — is unchanged, and a page that saves today still saves. Escalating an inert authored key to `error` is a separate question and belongs at the save gate, not here. diff --git a/.changeset/17648-connect-agent-account-path.md b/.changeset/17648-connect-agent-account-path.md deleted file mode 100644 index 9031923f0df..00000000000 --- a/.changeset/17648-connect-agent-account-path.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -Point Connect-an-Agent instructions at the Account door too, so a non-admin is told a path they can actually take - -#17646 made the Connect-an-Agent page reachable for every signed-in user by -adding a second `navigationContributions` entry into the **`account`** app's -`grp_account_developer` group. It deliberately did **not** ungate Setup — that -was measured to expose 14+ unrelated Setup surfaces — so the same principal -still gets `403 PERMISSION_DENIED` on `GET /api/v1/meta/apps/setup`. - -The shipped instructions never moved. The stdio transport's refusal message and -this package's README both said *"Setup → Connect an Agent"*, naming the one app -a non-admin cannot open — read, in the refusal's case, at exactly the moment the -user is stuck. Both now name **both** doors: **Account → Developer** for any -signed-in user, **Setup → Connect an Agent** for platform admins. The Setup -entry is unchanged and stays where admins already look. - -Text only — no behaviour, no gate, no authorization change. diff --git a/.changeset/17667-packages-query-contract.md b/.changeset/17667-packages-query-contract.md deleted file mode 100644 index 12b83dd2072..00000000000 --- a/.changeset/17667-packages-query-contract.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): the `/packages` doors declare the query parameters they execute, and stop declaring the two they never did (#17667) - -`GET /api/v1/packages` diverged from its own declared request contract in BOTH -directions, on the same door, with the same `200`. This aligns the declaration -with the reads, per the maintainer-approved ruling of 2026-09-13 (decision batch -#126 item 1, route 2 of three). - -**BREAKING** — `limit` and `cursor` no longer parse on -`ListInstalledPackagesRequestSchema`, and `limit`'s `.default(50)` is gone with -them. Both were declared here and read by nothing: the serving door filters on -`status` / `type` / `enabled` and then returns every remaining row, so no page -was ever withheld and no continuation token was ever minted. The response half's -`nextCursor` has never been emitted, so a caller looping "until the cursor runs -out" re-read the first and only page forever, with no error and no `400`. - -``` -FROM ListInstalledPackagesRequestSchema.parse({}) - -> { limit: 50 } // a cap the server has never applied - ListInstalledPackagesRequestSchema.parse({ limit: 1, cursor: 'x' }) - -> { limit: 1, cursor: 'x' } // both dropped on the wire, 200, every row - -TO ListInstalledPackagesRequestSchema.parse({}) - -> {} // no window is declared, because none exists - ListInstalledPackagesRequestSchema.parse({ limit: 1 }) - -> throws: '`limit` / `cursor` were removed from GET /api/v1/packages in - @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) …' -``` - -**Read the removed default, not just the removed key.** `limit` carried -`.default(50)`, so a reader of the published schema — an SDK, codegen, an AI -client — was entitled to believe an unparameterised list is capped at 50 rows. -It has never been capped at all. Nothing parses a query string through this -schema, so that default has never been stamped onto anything; there is nothing -to send instead and nothing to restore. **A client that sized a buffer or a -page control to the declared 50 should size it to the installed set instead** — -which is a bounded table of tens of rows, which is also why paging was removed -rather than implemented. - -Both keys are `retiredKey()` tombstones rather than deletions: the schema is not -`.strict()`, so a bare deletion would have made Zod silently strip whatever a -generated client kept sending — a clean parse and a parameter that never takes -effect, which is this defect re-created one layer down (ADR-0104). Writing -either key is now a `tsc` error and a parse error carrying the prescription. - -**The other direction, and nothing on the wire changes for it.** Three query -parameters the doors already executed were declared by no request schema, so -they were invisible to anything generated from the contract: - -| door | parameter | now declared on | -|---|---|---| -| `GET /api/v1/packages` | `type` — exact match against `manifest.type` | `ListInstalledPackagesRequestSchema` | -| `GET /api/v1/packages/:id` | `version` — exact installed-version scope; `latest` reads the installed row | `GetInstalledPackageRequestSchema` | -| `DELETE /api/v1/packages/:id` | `keepData` — keep object tables, remove metadata only | `UninstallPackageApiRequestSchema` | - -No accept set moves: the doors served all three before and serve them -identically now. `overwrite`, the fourth parameter the ruling named, was already -declared on `PackageInstallRequestSchema` and needed nothing. - -**`hasMore` stays the constant `false` it already was, and is now true by -construction rather than by coincidence**: with no `limit` and no `cursor` to -ask with, nothing can request a page, so there is never a next one to announce. - -Clause-②: yes - - diff --git a/.changeset/17670-colspan-span-measured-behaviour.md b/.changeset/17670-colspan-span-measured-behaviour.md deleted file mode 100644 index 8dccfd06431..00000000000 --- a/.changeset/17670-colspan-span-measured-behaviour.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -docs(spec): `FormField.colSpan` and `FormField.span` describe their measured behaviour — the two claims browser measurement falsified are gone (#17670) - -Both `.describe()` strings ship inside the published package (`src/**/*.zod.ts`, `dist`, `json-schema`) and they generate the public `content/docs/references/ui/view.mdx` tables, so what they assert is what every reader of the API reference — human or AI — is told the renderer does. Two of those assertions were measured false in Chromium at all three surface widths (#17328, `absolute-colspan-discouraged` withdrawn on the same evidence): - -- `colSpan` was described as "fragile … a fixed span only lines up at the width the author imagined". It is not. The renderer clamps the span to the form grid's column count, so the cell starts at a real column boundary at every width; rendered overflow was 0px in every configuration measured, including `colSpan: 4` in a 3-column section — the case that would overflow if the clamp did not work. The old text contradicted its own next sentence, which already stated the clamp. -- `span: 'full'` was described as "whole row at any column count". It is not. It resolves to the form grid's full column count, and at the `.objectui-sha` pin `53ded82bf7` the renderer emitted only the widest tier's class (`@2xl:col-span-3` for a 3-column grid — the identical class `colSpan: 4` emits), so at the 2-column modal width it took one cell of two, not the row — in the single 3-column section #17328 measured, pixel-identical to authoring nothing at all. - -Each key now states what it actually does. **The preference between the two keys is removed, not reversed** — `[legacy — prefer `span`]`, `Prefer `span`.` and `Prefer this over the absolute `colSpan`.` are gone, and nothing replaces them. Both spellings rest on the falsified claim, and the measurement puts the recommended one on the wrong side of it; the renderer question behind it — `span: 'full'` not spanning the row at intermediate container widths — was answered on the objectui side by objectui#9253 (commit `bd09957380`, 2026-09-12, part of objectui#9244), which emits one clamped col-span class per multi-column tier. That fix is unreleased at this repo's pin (`@object-ui/components` 17.6.0 at both, 0 tags contain the commit), so the text above anchors the pin state and this PR does not move `.objectui-sha`. - -Nothing an author writes moves. Both keys are unchanged, both still parse, every stored form view keeps its shape and its rendering, and no validation, default or emitted class changes. This is a correction to what the package says about itself. diff --git a/.changeset/17672-repeated-version-400-reachability.md b/.changeset/17672-repeated-version-400-reachability.md deleted file mode 100644 index de972866dbd..00000000000 --- a/.changeset/17672-repeated-version-400-reachability.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -'@objectstack/rest': minor -'@objectstack/runtime': patch ---- - -fix(runtime): a repeated `?version=` on `GET /packages/:id` is refused `400 VALIDATION_ERROR` in the repo's one message, and `@objectstack/rest` publishes the rule that owns it (#17672) - -`GET /api/v1/packages/:id?version=a&version=b` answered **`404`**, with a second -sentence written at that door. This repo already had a landed answer for exactly -that condition on exactly that route — `400 VALIDATION_ERROR` in the ADR-0112 -nested body (#6307) — and one implementation of it, `refuseRepeatedQueryParams` -/ `repeatedQueryParamMessage` in `packages/rest/src/query-multiplicity.ts`, -whose header is the authority on the rule. - -Driven before the change, one host, three refusals: - -``` -GET /packages/com.acme.crm?version=a&version=b -> 404 RESOURCE_NOT_FOUND -GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND -GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND -``` - -A client branching on the answer could not tell "your request named the -parameter twice" from the two genuine not-founds. After: - -``` -GET /packages/com.acme.crm?version=a&version=b -> 400 VALIDATION_ERROR -GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND -GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND -``` - -The body is the dispatcher's declared envelope — -`{ success: false, error: { code: 'VALIDATION_ERROR', message, httpStatus: 400 } }` -— with `VALIDATION_ERROR` derived by `buildApiError` from -`standardErrorCodeForHttpStatus(400)`, the standard catalog's member for 400. -⛔ Nothing in `packages/spec` moves. - -**What was actually blocking this was reachability, not judgement.** -`@objectstack/rest` declares exactly one export subpath and that module was not -on it, so #17668 could neither call the rule nor (correctly) copy it, and -shipped the `404` with its own sentence instead. The barrel now publishes -`repeatedQueryParamMessage` and `refuseRepeatedQueryParams`, and the dispatcher -domain calls the message function — so the sentence a caller is told for a -repeated parameter is the same one on every door that carries the rule, ⛔ never -a second copy that drifts. - -⚠️ The two published symbols are not interchangeable across a package boundary, -and the barrel entry says so. `repeatedQueryParamMessage` is the portable half: -a pure function of two primitives. `refuseRepeatedQueryParams` writes the bare -ADR-0112 body onto a `res`, which suits handlers of that shape and ⛔ not a -runtime dispatcher domain — measured, its body fails that surface's -`BaseResponseSchema` with `success is missing, must be a boolean`. - -**Not a breaking change, measured rather than assumed.** The `404` it replaces -was introduced by #17668 (`1a25f4a8d`), which is not an ancestor of -`@objectstack/runtime@17.4.0` (exit 1; two control commits from that tag's own -history answer exit 0 on the same predicate, in a checkout -`--is-shallow-repository` reports `false`). It has never been published, so no -released consumer can have branched on it. Everything else about the door is -unchanged: `?version=` and `?version=latest` still serve the -installed row, an absent version and an unknown id still answer `404`, and a -one-element array is still one occurrence. - -Also corrected, on the module that owns the rule: its header said the -dispatcher's `/packages` domain "reads no `version`" — load-bearing prose, -since it is part of why the rule needs only one home. That stopped being true -when #17668 landed. The paragraph now states what is true, which is that the one -home did not move and now serves two doors. diff --git a/.changeset/17676-package-registry-docblock-capability-name.md b/.changeset/17676-package-registry-docblock-capability-name.md deleted file mode 100644 index c382c366b1d..00000000000 --- a/.changeset/17676-package-registry-docblock-capability-name.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -`installPackage`'s docblock no longer names `marketplace` as the capability that governs package persistence — after #17676 ruling A′ item 1 that half is `package-registry`, and `marketplace` names only the optional catalogue (#17676). - -Clause-②: no - -The shipped note read *"when the `package` service is absent (e.g. the `marketplace` capability is off)"*. That parenthetical was accurate when it was written and stopped being accurate when the carve-out landed: `packages/spec/src/kernel/platform-capabilities.ts` now carries `marketplace` and `package-registry` as two tokens, with the `sys_packages` container and its boot hydration under the second — a core capability mounted always — and browsing left under the first. A consumer reading this docblock in an editor, out of `dist/index.d.ts`, was being pointed at the wrong switch. - -- **⛔ No behaviour moves, and this is not the card's defect being repaired.** The in-memory-only branches in `installPackage` and `updatePackage` are byte-identical. Ruling A′ item 2 keeps them deliberately, as the documented degraded path for reduced hosts — a host that mounts no provider must still be able to install a package for the life of its process — so the note now says that too, rather than leaving the branch reading like an oversight. #17676 stays open. -- **The note also records what is NOT true yet, measured rather than assumed.** `Serve.CAPABILITY_PROVIDERS` (`packages/cli/src/commands/serve.ts`) keys `marketplace` and does not key `package-registry`, so the always-on token is force-appended to every app's `requires` and then resolves to no provider — silently, because the resolver warns only for tokens outside the vocabulary. A stock boot still takes the absent-service branch. That half of the ruling belongs to the capability resolver and is not in this package. -- `updatePackage`'s docblock points at the same note, since the ruling names both primitives. - -Published surface: doc comments only. `dist/index.js`, `dist/index.cjs`, `dist/index.d.ts` and `dist/index.d.cts` all carry the corrected text — tsup keeps JSDoc, which is why this ships at all — and no export, type, signature or runtime string moves. diff --git a/.changeset/17681-native-error-name-one-reader.md b/.changeset/17681-native-error-name-one-reader.md deleted file mode 100644 index 11b05cef10b..00000000000 --- a/.changeset/17681-native-error-name-one-reader.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -'@objectstack/types': minor -'@objectstack/rest': patch -'@objectstack/objectql': patch -'@objectstack/runtime': patch ---- - -refactor(types): one `isNativeErrorName` reader, so three doors cannot disagree about what a crash is (#17681) - -The predicate that decides whether a sandboxed body's `throw` is a business -REFUSAL (4xx, the author's words relayed) or a CRASH (5xx, the words withheld) -had **three byte-identical copies** — measured, one distinct 74-character regex -literal across three packages: - -| copy | package | its stated reason for being a copy | -|:--|:--|:--| -| `isScriptFaultMessage` | `@objectstack/rest` (`error-response.ts`, #7543) | the original | -| `isScriptCrash` | `@objectstack/objectql` (`hook-withheld-readonly-fault.ts`) | this package must not depend on `@objectstack/rest` for a regex | -| `sandboxRefusalMessage` | `@objectstack/runtime` (`sandbox/quickjs-runner.ts`, #17265) | rest declares one export subpath and re-exports nothing from `error-response` | - -⭐ **Every reason is a statement about reaching `@objectstack/rest`, and none of -them survives moving the rule.** `@objectstack/types` now owns -`isNativeErrorName` — the name list, the `^` anchor, and the deliberate absence -of a bare `Error:`. All three packages already depend on it and it depends on -none of them, so this fold **adds zero dependency edges** and cannot cycle. - -⚠️ The hazard was never style. One copy learning a new native error name and the -others not means the same throw is a refusal at one door and a crash at the -next — a crash message **leaked** at one boundary and **withheld** at another. -#16013's argument for extracting exactly this class applies verbatim: the -classification is the part nobody may get wrong, so one *tested* helper is worth -more than N correct copies that must each stay correct forever. - -⛔ **No behaviour changes at any door, per case.** This is a pure refactor and -the three WRAPPERS are deliberately NOT folded, because they are not the same -shape and merging them would move a door's answer: - -- rest asks a trimmed message and answers a boolean; -- objectql asks **two** slots — `err.name` **or** `err.innerMessage.trim()` — - because a code hook and a sandboxed body carry the native name in different - places; -- runtime asks the trimmed inner message and answers the **message**, not a - boolean. - -What the three share is the predicate, so the predicate is what moved. Each call -site keeps its own slot choice and its own trimming, and `isNativeErrorName` -deliberately does **not** trim for its callers — a contract pinned in its test. - -**Shipped rather than `skip-changeset`**, measured on a real build: all four -packages publish `files[]: ["dist", …]`, and the built `dist` of each carries -the new call — `@objectstack/types` 4 files, `@objectstack/objectql` 4, -`@objectstack/rest` 3, `@objectstack/runtime` 2 — with `looksLikeInternalErrorLeak` -scoring 4 in `types/dist` as the lit control and a nonexistent symbol scoring 0. -The retired copies are gone from the artifacts too: the regex literal scores -**0** in `rest/dist`, `objectql/dist` and `runtime/dist`, and **2** in -`types/dist` (the ESM and CJS bundles). - -`@objectstack/types` takes **minor**: a new export is a purely additive widening -of a published surface, which is at least minor whatever the commit type says. -The three consumers take `patch` — their artifacts change, their behaviour does -not. diff --git a/.changeset/17690-idatadriver-masked-doors.md b/.changeset/17690-idatadriver-masked-doors.md deleted file mode 100644 index 2629676b299..00000000000 --- a/.changeset/17690-idatadriver-masked-doors.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/driver-sql": minor -"@objectstack/driver-turso": minor ---- - -fix(driver-sql,driver-turso): eight more `IDataDriver` doors publish their declared return type, not a nested `any` (#17690) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (PR #15280 for `SqlDriver.update()` and the `TursoDriver.update()` override, PR #14434 before it on `@objectstack/driver-memory`, PR #17258 for the five `SqlDriver` doors of #15267, PR #17689 for `aggregate()`). No runtime behaviour changes. - -Eight doors published an annotation whose `any` sat **inside** a wider type, while `packages/spec/src/contracts/data-driver.ts` had already declared each one narrower. A consumer holding one of these classes got `any` back and the compiler stopped checking: - -| class | door | published | now | -|---|---|---|---| -| `SqlDriver` | `find` | `Promise` | `Promise[]>` | -| `SqlDriver` | `upsert` | `Promise>` | `Promise>` | -| `SqlDriver` | `bulkUpdate` | `Promise[]>` | `Promise[]>` | -| `SqlDriver` | `temporalFilterValue` | `any` | `unknown` | -| `TursoDriver` | `find` (override) | `Promise` | `Promise[]>` | -| `TursoDriver` | `upsert` (override) | `Promise>` | `Promise>` | -| `TursoDriver` | `bulkUpdate` (override) | `Promise[]>` | `Promise[]>` | -| `RemoteTransport` | `beginTransaction` | `Promise` | `Promise` | - -The `TursoDriver` rows are separate sites, not consequences: an override re-declares the door in that package's own `.d.ts`, so the `@objectstack/driver-sql` narrowing does not reach a consumer holding a `TursoDriver`. - -**What a consumer does.** A cell read off a row now arrives as `unknown` and is typed before use (`String(row.name)`, `Number(cell)`, or a `typeof` narrowing); `Array.prototype.find` over a result set answers `… | undefined` and the absent arm is separated rather than asserted past. Measured across the whole consumer closure of both packages at this change's tree — 115 `typecheck` tasks — the repo-wide cost is **11 sites**, all inside `@objectstack/driver-sql` (9) and `@objectstack/driver-sqlite-wasm` (2), and **zero** outside the driver packages. - -`TursoDriver.beginTransaction` is deliberately NOT narrowed here and stays `Promise`. It overrides `SqlDriver.beginTransaction(): Promise` — narrower than the contract, the honest direction, and the binding declaration for an override — so the contract's `Promise` does not compile there (TS2416). That `any` masks an LSP violation, not an un-narrowed door, and closing it is a separate decision. - - diff --git a/.changeset/17707-concurrent-limit-exceeded-retired.md b/.changeset/17707-concurrent-limit-exceeded-retired.md deleted file mode 100644 index b3b1579a6cf..00000000000 --- a/.changeset/17707-concurrent-limit-exceeded-retired.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING** — `CONCURRENT_LIMIT_EXCEEDED` is removed from the closed `StandardErrorCode` catalogue (#17707). - -A `major`-class change, recorded as `minor` under the launch-window convention. ADR-0049 enforce-or-remove applied to the ADR-0112 error catalogue: ruling A on #17707, narrowed on 2026-09-24 to this code alone. - -**Why.** A catalogue member is the list callers branch on exhaustively, and a member with no producer teaches a branch that cannot fire. `CONCURRENT_LIMIT_EXCEEDED` had no producer behind it when the ruling was recorded, so it leaves the catalogue. Its neighbour `QUOTA_EXCEEDED` stays, unchanged, as the narrowing ruled. - -### FROM → TO - -| removed | what to write instead | -| --- | --- | -| `CONCURRENT_LIMIT_EXCEEDED` (`StandardErrorCode`, 429) | nothing — delete the branch. For request pacing branch on `RATE_LIMIT_EXCEEDED` (HTTP 429; wait `retryAfterSeconds` before retrying). A service that enforces its own concurrency limit registers a code for it in its own error-code ledger. | - -**The one-line fix: delete every branch on `CONCURRENT_LIMIT_EXCEEDED`.** A comparison against a value typed `StandardErrorCode` or `ErrorCode` no longer compiles (`TS2367`). At runtime the spelling now fails `StandardErrorCode`, `ErrorCode` / `ApiErrorSchema.code` and `makeApiErrorSchema(...)` parse, and the failure message is the removal prescription itself. - -**What stays.** The other `StandardErrorCode` members, `QUOTA_EXCEEDED` included, are unchanged. - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** Inside this repository the code occurred only in the enum declaration, the hand-written error catalogue page, the generated reference pages and the unpinned-status baseline, and the pinned objectui checkout does not name it; `@objectstack/spec` is published, so readers elsewhere were not measured. - -The ADR-0087 D3 semantic entry `standard-error-code-concurrent-limit-exceeded-retired` carries the judgement: an error code is wire vocabulary, not a metadata key, so there is no authored source for a D2 conversion to rewrite. - -Clause-②: no - - diff --git a/.changeset/17732-channel-availability-fanout.md b/.changeset/17732-channel-availability-fanout.md deleted file mode 100644 index 3ad3fb864f0..00000000000 --- a/.changeset/17732-channel-availability-fanout.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/service-messaging": minor -"@objectstack/platform-objects": minor ---- - -Notification fan-out asks a channel whether the tenant can send on it before writing anything, so a channel with no transport no longer produces `sys_notification_delivery` rows that exist only to dead-letter (#17732). - -`MessagingChannel` gains one **optional** member, `isAvailable(ctx, { organizationId })`, answering `{ available: true }` or `{ available: false, reason }` from the closed vocabulary `CHANNEL_UNAVAILABLE_REASONS` (today: `transport_not_configured`). `emit()` consults it once per channel per emit — availability is a property of `(tenant × channel)`, not of a recipient — and a channel that answers unavailable gets no delivery row and no `send()` call on either the outbox (P1) or the inline (P0) path. - -- **Optional means available.** A channel that does not implement the member is treated exactly as before. Every existing implementation, in this repo and in yours, keeps working unchanged with no edit; the same is true of a channel that is registered but unknown to this version. ⛔ There is no way to configure the opposite default. -- **The suppression is recorded, not swallowed.** `sys_notification` gains one key, `suppressed_channels` — `[{ channel, reason }]`, `NULL` when nothing was suppressed — written in the *same* insert that creates the event row, so the feature costs no additional write. `EmitResult` gains the matching `suppressed` array, so a caller is never handed a delivery count that silently omits a channel it asked for. -- **The `email` channel answers from the transport it was handed** — a service-registry lookup, no I/O, nothing cached. Mail configuration in this tree is the `mail` settings namespace at `scope: 'global'`, materialised into a single in-memory transport that the settings change bus hot-swaps, so there is no per-tenant row to read and a memoized answer would survive the settings save that fixed it. The query still takes the tenant context so a future tenant-scoped transport needs no interface change. -- **A probe that throws is treated as available** and logged at `warn`: a broken availability check degrades into today's behaviour, never into a silent notification outage. -- ⚠️ **Unchanged on purpose**: a channel named in `channels` that is not *registered* at all keeps its existing path — the inline fan-out reports it as a failed delivery, the outbox enqueues a row the dispatcher dead-letters. It has no implementation to ask, and widening this ruling to cover it is filed separately. diff --git a/.changeset/17751-chart-config-aria-removed.md b/.changeset/17751-chart-config-aria-removed.md deleted file mode 100644 index 43715cba5c0..00000000000 --- a/.changeset/17751-chart-config-aria-removed.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING** — remove `aria` from the chart config, and answer its two alias spellings with the retirement instead of renaming an author onto a tombstone. - -`ChartConfigSchema` declared a nested ARIA block that **no chart renderer has ever applied**. Measured first-hand at this checkout's own `.objectui-sha` pin `53ded82bf7a4` and re-confirmed at objectui HEAD: `AdvancedChartImpl` declares no `aria` prop; `chartConfigPresentation` names it nowhere — its own docblock calls it *"the one declared key with no reader at all"*; `SchemaRenderer`'s ARIA injection reads flat node props and never a nested `aria` object; and `ui/react-blocks.ts` omits it from ``'s thirteen `dataProps`, the one `ChartConfigSchema` key missing from that list. Every objectui hit on the chart paths is a **negative** pin asserting nothing reads it. So a chart could declare accessibility work that had measurably not happened. - -It is the third and last member of the `aria` family retired for exactly this: `dashboard.aria` went at the audit close-out and `dashboard.widgets[].aria` at the widget drill. This one survived both sweeps by **depth**, not by evidence — it sits inside the widget's `chartConfig`, a container no drill had reached until the per-key pass recorded in `liveness/dashboard.json`. - -**Removed rather than enforced**, which is the less usual ADR-0049 answer and is the whole of the ruling (maintainer decision batch #118 item 2, 2026-09-12 — recommendation C, 「其他同意」 to judging the protocol wrong for this one key). The same chart config already carries a **working** accessible-name channel in `description`, which the chart renderer lowers onto the chart graphic as `role="img"` + `aria-label`, pinned in the DOM. Wiring `aria` as well would put two accessible-name sources on one element and demand a precedence rule nobody has written. One node, one accessibility vocabulary. - -## FROM → TO - -| you wrote (17.4 and earlier) | write instead | -| --- | --- | -| `chartConfig: { aria: { ariaLabel: 'Orders by month' } }` on a dashboard widget | `chartConfig: { description: 'Orders by month' }` — the renderer announces it as the chart graphic's accessible name | -| `chart: { aria: { … } }` on a report, or on a report block | the same: `description` on that chart config | -| `chartConfig: { accessibility: { … } }` (an alias for `aria`) | the same — the alias is now a refusal carrying this retirement, and it never accepted the key anyway | -| `chartConfig: { ariaProps: { … } }` (the other alias) | the same | -| `ariaLabel` / `ariaDescribedBy` / `role` on a surface that renders DOM | unchanged — the shared `AriaProps` block stays live on `page.aria`, `page.components[].aria` and the list view `aria` | - -**The one-line fix:** delete `aria` from the chart config; move an accessible name into the sibling `description`. - -`os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. - -## The retirement kit - -- **A `retiredKey()` tombstone, not a bare deletion** — even though `ChartConfigSchema` **is** a `strictObject`. A bare delete would still be loud, but only as a generic unrecognized-key report that cannot carry the prescription; the tombstone types the key `never` for `tsc` and raises the upgrade text at parse. The key therefore stays in the walked shape, which is why its liveness row stays (regraded with a `REMOVED` note, the `rls.priority` precedent) and why the authorable-surface baseline marks it `[RETIRED]` rather than losing the line. -- **Two registered keys from one tombstone.** `ReportChartSchema` is a `ChartConfigSchema.extend(...)`, and an extension copies the retired property into its own walked shape, so the retirement registers `ui/ChartConfig:aria` **and** `ui/ReportChart:aria`. Nothing radiates from the base. -- **The two alias entries are gone from `aliases` and present in `guidance`.** This narrows nothing: an alias table runs only from the `unrecognized_keys` path, so `accessibility:` and `ariaProps:` were *already refused* — the entries only decorated the rejection, and after the retirement they would have decorated it by pointing at the one key the shape is now guaranteed to reject. Leaving them is not a style choice: `shared/alias-integrity.test.ts` refuses an alias whose target accepts nothing, by name. -- **No form input and no locale bundle move.** Unlike its siblings this key never reached a `*.form.ts`, so there is no false-compliant UI half to remove; the generated `chart` / `report` references regenerate with the prescription in place of the old nested-shape table. - -## What an operator with a STORED dashboard or report sees - -A `sys_metadata` `dashboard` or `report` row written before this release can carry the key at any of its three coordinates — `widgets[].chartConfig.aria`, `chart.aria`, `blocks[].chart.aria`. Nothing breaks at read: the ADR-0087 conversion `chart-config-aria-removed` (protocol 18) replays on rehydration and strips it, so the row is served canonical. `os migrate meta --stored --apply` rewrites the rows; the next save through the metadata door heals one row the way it heals any pre-protocol shape. - -The strip is the **whole** of it — there is no paired semantic entry, and that is a statement, not an omission. The key never had an effect to lose, so deleting it changes no behaviour and closes no hole. It stops an unkept promise from being made. - - diff --git a/.changeset/17759-account-nav-connect-agent-i18n.md b/.changeset/17759-account-nav-connect-agent-i18n.md deleted file mode 100644 index 540fabcea07..00000000000 --- a/.changeset/17759-account-nav-connect-agent-i18n.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/platform-objects': minor ---- - -`apps.account.navigation.nav_connect_agent` is translated in all four locales, so the Connect an Agent page renders the same string behind both doors - -`@objectstack/mcp` contributes the Connect an Agent page into **two** apps — `setup` (admins) and, since #17646, `account` → Developer (every authenticated user). The translation bundles are keyed `apps..navigation.`, one namespace per app, and only the `setup` key existed. So `apps.setup.navigation.nav_connect_agent` never answered for the Account door, and one destination rendered two different strings for the same signed-in user: - -| door | before | -|:--|:--| -| Setup → Integrations | 「连接智能体」 / 「エージェントを接続」 / "Conectar un agente" | -| Account → Developer | `Connect an Agent`, the English literal, in every locale | - -The population that got the untranslated one is precisely the non-admin on a non-English locale: Account is the only one of the two doors they can open. - -Adds the key to `en` / `zh-CN` / `ja-JP` / `es-ES`, mirroring the Setup twin's strings verbatim, plus the `#8765` provenance row in each of the three hand-maintained `.source-hashes.ts` tables (`en` is the source, not a copy of one, so it has no table and gets no row). The recorded digest is `collectSourceHashes(en)['apps.account.navigation.nav_connect_agent.label']` — the repo's own `hashSource`, not a hand-written value. - -⛔ No behaviour outside the bundle moves. No nav item, permission, route or page is added: the contribution and the destination already existed and are untouched, and the Setup key is byte-unchanged. This is an additive key on a published payload, which is why it ships `minor` rather than `patch`. - -Neither gate over this surface could see the gap, and neither is changed here: `pnpm check:app-nav-i18n` scopes itself to `APP_NAME = 'setup'` and skips every contribution targeting another app, and `app-nav-translation-parity.test.ts` walks statically declared nav — the Account entry is contributed at runtime, so no static walk reaches it. Extending the gate is the next step in the standing repair order and lands in `packages/cli/scripts/**` under its own card, deliberately not folded in here. diff --git a/.changeset/17778-field-rule-predicate-fault-semantics.md b/.changeset/17778-field-rule-predicate-fault-semantics.md deleted file mode 100644 index 3516959bbcc..00000000000 --- a/.changeset/17778-field-rule-predicate-fault-semantics.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -**ADR-0137 makes field-rule predicate fault semantics part of the contract** (#17778): what SUBMIT and RENDER do when a predicate cannot run. - -Clause-②: no - -**The predicate fault-semantics contract is recorded, not enforced, by this release.** ADR-0137 states what a field-rule predicate does when it cannot RUN: at SUBMIT a faulting predicate refuses the write and names the field and the rule (D2); at RENDER visibility stays fail-OPEN, so a rule that could not run never hides a control and lets the form write `null` over a column the user never saw (D3); a blank or faulting GATE predicate is diagnosed, never a silent `true` (D4); and the evaluation helper's fallback stays freely specifiable (D5), because fault-to-flag and fault-to-throw both exist only because it is a parameter. Those are consequences CONSUMERS deliver — `packages/spec` carries no business logic — and they land in the ObjectUI half. D1's authoring refusal (an `ast`-only envelope and a blank `source` are refused at authoring) is ruled by decision batch #122 item 2 and ships with the evaluated-slot narrowing that owns it, under that change's own ADR-0087 entry. - -**ADR-0089 gains an addendum, not a reopening.** It unified the `visibleWhen` / `visibleOn` / `visibility` family under one name; ADR-0137 owns what that family does when a predicate cannot run, and ADR-0089 itself is unchanged by this release. - -**Not carried by this entry: the `cel` / `expression` return-type narrowing to `EvaluatedExpression`.** This card touched that signature too, but main shipped the identical narrowing first, under #18638 (card #15811) — see that release's own changeset for the `EvaluatedExpression` story and the TS2322 it fixes. Restating it here would announce, a second time, a fact this release has already shipped under a different entry. diff --git a/.changeset/17779-dashboard-metric-family-single-measure.md b/.changeset/17779-dashboard-metric-family-single-measure.md deleted file mode 100644 index 8ef1aa54336..00000000000 --- a/.changeset/17779-dashboard-metric-family-single-measure.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: a metric-family dashboard widget declares exactly ONE measure — `values` is bounded above on `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` (#17779; objectui#8894 ruling D, decision batch #119 item 4) - -Clause-②: yes (narrowing) — this diff BOTH narrows and widens, which is the shape this arm exists for. The accept set NARROWS (that is the change). What makes the value `yes` is the other axis: the published surface GAINS one exported symbol, `checkDashboardWidgetMetricMeasureArity`, and a new exported symbol is the mechanical floor for in-seat contract review. - - - -**BREAKING** accept-set narrowing at `dashboard.widgets[].values`, shipped as -`minor` under this repo's launch-window convention for breaking changes -(`check-changeset-no-major` refuses `major` outright while the window is open, so -breaking-ness is carried by this banner and by the ADR-0087 disposition above, -never by the bump level). The mechanical prescription is registered under -protocol major 18 as `dashboard-widget-metric-family-multi-measure-refused`. - -**What was wrong.** `DashboardWidgetSchema.values` was -`z.array(z.string()).min(1)` with **no upper bound on any widget type**, so a -`metric` tile could declare three measures. Measured on this tree before the -change: `{ type: 'metric', values: ['a','b','c'] }` returned `success: true`, -and so did `kpi`, `gauge`, `solid-gauge` and `bullet`, with `bogusProp` refused -by name on the same call as the lit control. The dataset query then **selected -and computed all three** and the tile rendered `values[0]` — the other two were -queried and dropped on the floor (objectui#7293 defect 1). objectui PR #8887 -landed a sub-caption that says so, which makes the tile honest about dropping -them; it does not make the document legal. - -The maintainer ruled **D** on objectui#8894 (decision batch #119 item 4, -2026-09-12 「同意」) under the standing rule 「协议不正确的应该先修改协议。」 — -judge the protocol wrong rather than invent display semantics for `values[1..]`. -A metric tile answers one number; `ChartTypeSchema` groups these five under -*"Performance (single value)"* in its own words. Several numbers is a different -visual, not a variant of this one. - -### Write N tiles for N measures - -| wrote | write instead | -|---|---| -| `{ id: 'sales', type: 'metric', values: ['amount_sum', 'count'] }` | `{ id: 'sales', type: 'metric', values: ['amount_sum'] }` **and** `{ id: 'sales_count', type: 'metric', values: ['count'] }` | -| several numbers wanted in ONE widget | a different visual: `type: 'table'` renders a row of measures, and `bar` / `line` / `area` / `combo` render one mark per measure — all keep the unbounded `values` they have always had | - -Splitting is not done for you and no conversion could do it: N tiles need N ids -and N boxes on a 12-column grid, which is a layout decision about a dashboard -the registry has never seen. The refusal lands at `widgets[N].values` with one -`custom` issue naming the widget's `id`, the number of measures it declared and -the authored `type`, and prescribing one measure per tile. - -**Exactly one is a conjunction, not one rule.** The field's own `.min(1)` still -owns the empty array (`too_small`, unchanged, and the new check deliberately -adds no second issue there); the new upper bound is -`checkDashboardWidgetMetricMeasureArity`, exported so objectui's `.shape` mirror -can re-attach it. A widget that declares no `type` is refused too — `type` -defaults to `metric` and zod applies defaults before object-level checks — and -the message says so rather than claiming the author wrote it. - -**Nothing else moves.** All fifteen other `ChartTypeSchema` members — `bar`, -`horizontal-bar`, `column`, `line`, `area`, `pie`, `donut`, `funnel`, `scatter`, -`treemap`, `sankey`, `combo`, `radar`, `table`, `pivot` — keep accepting three -measures, byte for byte; `ReportSchema.values` is a separate declaration and is -untouched; and `dashboard.zod.ts` has no other `.min(1)` **array** key at all -(its one other `.min(1)` is `dashboard.columns`, a number bound, unchanged). -Fleet census over every tracked `.ts` / `.tsx` / `.json` / `.mdx` / `.md` / -`.yaml` at the branch point: **187** brace-local literals carrying a -`values: [...]`, **39** of them on a metric-family `type`, and **0** of those -carrying more than one measure. Both counts are lit controls on the scan. diff --git a/.changeset/17780-plugin-lifecycle-duration-units.md b/.changeset/17780-plugin-lifecycle-duration-units.md deleted file mode 100644 index 2702e1b133b..00000000000 --- a/.changeset/17780-plugin-lifecycle-duration-units.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor ---- - -feat(spec)!: the three `kernel/plugin-lifecycle-advanced.zod.ts` duration keys carry their unit in the key name (#17780, ruling A on #15939) - - - -**BREAKING** — the health-check period, the health-check deadline and the hot-reload debounce -now carry `Ms` in the key name. - -| | before | after | -|:--|:--|:--| -| `PluginHealthCheck` | `interval: 30000` | `intervalMs: 30000` | -| `PluginHealthCheck` | `timeout: 5000` | `timeoutMs: 5000` | -| `HotReloadConfig` | `debounceDelay: 1000` | `debounceDelayMs: 1000` | -| values, defaults, min bounds | ms; 30000 / 5000 / 1000; min 1000 / 100 / 0 | **unchanged** | - -## Migration - -```diff - const health = PluginHealthCheckSchema.parse({ -- interval: 30000, -- timeout: 5000, -+ intervalMs: 30000, -+ timeoutMs: 5000, - }); - - hotReload.registerPlugin('my-plugin', { -- debounceDelay: 1000, -+ debounceDelayMs: 1000, - }); -``` - -Rename the keys. Every value is the same number of milliseconds it always was, and the -30000 / 5000 / 1000 defaults are unchanged; nothing else on either def moves. - -## Why - -Each key named milliseconds in a source JSDoc — "Health check interval in milliseconds", -"Timeout for health check in milliseconds", "Debounce delay before reloading (milliseconds)" — -and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is. -Measured by the `check:duration-unit-keys` census on this tree, all three read -`[name: -] [prose: -]`: no unit in the name and none in the published prose either. -`interval` was the sharpest of the three — its describe carried one unit-shaped token, the -parenthetical "(default: 30s)", naming SECONDS for a value the schema bounds and defaults in -MILLISECONDS. Executes director-seat ruling A on #15939 (2026-09-11, maintainer 「同意」, -decision batch #115), the per-file remediation of the #14478 rule. - -The suffix is the family's own spelling, counted on this tree: 100 key-position `*Ms` -declarations across `packages/spec`, `timeoutMs` 29 of them and `intervalMs` 3. -`debounceDelay` takes the plain suffix rather than a shortened form because it is the only -debounce-shaped key spelling in the repo (no `debounceMs` variant anywhere) while the -Delay-plus-`Ms` pairing is already attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, -`delayMs`) — so unlike the `Ttl`-versus-`TTL` question the sibling round settled, there was no -competing family spelling to choose between. - -## The kit - -- a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value - reaching the parse raises the rename prescription instead of being silently stripped — - neither `PluginHealthCheckSchema` nor `HotReloadConfigSchema` is `.strict()`, and here the - stripped value would land on a `setInterval` period, a race deadline and a `setTimeout` delay -- the ADR-0087 D3 semantic entry `kernel-health-check-and-hot-reload-durations-unit-in-key` and - three `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: neither def is an authorable - surface — both are library parameters a host passes to `PluginHealthMonitor` / - `HotReloadManager` in TypeScript — so the chain has no seam that runs on them, the same - reading `plugin-auto-restart-never-reinitialised` and `hot-reload-watch-placeholder-retired` - recorded for keys on these two defs -- `@objectstack/core` moves with the rename: `PluginHealthMonitor` and `HotReloadManager` read - the suffixed keys, and each class's registration-time refusal table gains a row so a host - still passing an old spelling is answered with an ADR-0112 `VALIDATION_ERROR` / 400 naming - the rename, rather than getting `undefined` where a duration belongs -- pin tests on both schemas and both classes: the refusal carries the rename prescription, the - suffixed keys parse at the magnitude the retired ones carried with the same defaults, and the - describes publish the unit. The two minimum-bound pins were rewritten rather than left: spelled - through the bare keys they would have stayed green off the tombstone's refusal instead of the - bound, so they now assert the `too_small` issue code on the suffixed keys -- `HotReloadConfig.shutdownTimeout` is deliberately NOT renamed with them — its JSDoc reads - "Graceful shutdown timeout" and names no unit anywhere, so it is the unit-nowhere shape the - #14478 gate leaves outside its verdict, not part of this row set diff --git a/.changeset/17781-runtime-config-resource-limits-timeout-ms.md b/.changeset/17781-runtime-config-resource-limits-timeout-ms.md deleted file mode 100644 index 8cc7780ac56..00000000000 --- a/.changeset/17781-runtime-config-resource-limits-timeout-ms.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the fifth `kernel/plugin-security-advanced.zod.ts` duration — `RuntimeConfig.resourceLimits.timeout` — carries its unit in the key name (#17781, ruling A on #15939) - - - -**BREAKING** — the execution timeout on a plugin sandbox's runtime block carries its unit in the -key name. - -| | before | after | -|:--|:--|:--| -| authored key | `resourceLimits.timeout: 60000` | `resourceLimits.timeoutMs: 60000` | -| published describe | `Maximum execution time` | `Maximum execution time in milliseconds` | -| value + bound | milliseconds, `int().min(0)` | **unchanged** | - -## Migration - -```diff - resourceLimits: { - maxMemory: 1073741824, -- timeout: 60000, -+ timeoutMs: 60000, - } -``` - -Rename the key. The value is the same number of milliseconds it always was and the `int().min(0)` -bound rides along with it; nothing else on `RuntimeConfig` moves. - -## Why - -This is the key #15678 deliberately left alone, and this changeset closes it. `#15678` renamed the -four other plugin-security durations on this same file and recorded, accurately, that this one was -out of its scope: `resourceLimits.timeout` named its unit only in the JSDoc above it — "Execution -timeout in milliseconds" — a channel `check:duration-unit-keys` does not read (it reads -`.describe()` and `.meta({ description })`), and its describe said "Maximum execution time" and -named no unit at all. So the gate listed the key among the duration-shaped keys without judging it, -neither an offender nor an exemption, and the reader who most needs the unit — the reader of -`content/docs/references/kernel/plugin-security-advanced.mdx`, who never sees the source JSDoc — -got a bare integer and could not tell 60000 milliseconds from 60000 seconds. That JSDoc-channel gap -was filed as #15939 and is now ruled: director-seat **ruling A** (2026-09-11, maintainer 「同意」, -decision batch #115) remediates the population per file. Under the #14478 rule, moving the unit -into the describe alone is itself a violation — unit in prose, none in the name — so the key is -renamed and the describe is corrected in one stroke. - -Spelled `Ms`, the same token `SandboxConfig.process.timeoutMs` on this very file already carries: -counted on this tree, the suffixed family spells it that way in every member (29 key-position -`timeoutMs` declarations across `packages/spec/src/**/*.zod.ts`, 40 distinct `*Ms` keys), and no -`timeoutMillis`, `timeout_ms` or `timeoutMS` variant exists anywhere in `packages/spec/src`. - -⚠️ Two keys on this one file spelled `timeout` and both now retire to a key spelled `timeoutMs`: -`RuntimeConfig.resourceLimits.timeout` (this one) and `SandboxConfig.process.timeout` (#15678). -They are different keys on different shapes, so each refusal names its own shape — check which -block you are editing. - -## The kit - -- a `retiredKey()` tombstone on the old spelling, so `tsc` types it `never` and a value reaching - the parse raises the rename prescription instead of being silently stripped (the nested - `resourceLimits` object is not `.strict()`) -- the ADR-0087 D3 semantic entry `kernel-runtime-config-timeout-unit-in-key`, which states - explicitly that it completes what #15678 left alone so the two read as a sequence, and the - `RETIRED_KEYS_BY_MAJOR[18]` row `kernel/RuntimeConfig:resourceLimits.timeout`. No D2 conversion: - a `RuntimeConfig` is the engine block of the `SandboxConfig` a host or a plugin security manifest - constructs, `stack.zod.ts` declares no sandbox, security-policy or runtime-config collection, and - it is not a stored `sys_metadata` row — so the chain has no seam that runs on it. That is the - same reading #15678 recorded for the four keys it renamed. -- the pin test that asserted this key stays bare is **replaced, not removed**: it now pins that the - bare spelling is refused with the rename prescription, that `timeoutMs` parses at the same - magnitude beside its siblings, that the describe publishes the unit, and that the two same-named - `timeout` retirements on this file name their own shapes apart -- `content/docs/references/kernel/plugin-security-advanced.mdx` regenerated by `gen:docs`: three - rows move and the tombstone prescription renders in place of the old describe -- no authorable-surface row moves — that ratchet records top-level keys per def, and this key is - nested under `resourceLimits` (measured: `kernel/RuntimeConfig:` carries exactly - `engine`, `engineConfig` and `resourceLimits` across `authorable-surface/` and - `authorable-surface.base.json`, and `check:authorable-surface` is green without regeneration) diff --git a/.changeset/17782-logging-duration-units.md b/.changeset/17782-logging-duration-units.md deleted file mode 100644 index 193e3ba1603..00000000000 --- a/.changeset/17782-logging-duration-units.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the four `system/logging.zod.ts` duration keys carry their unit in the key name (#17782, ruling A on #15939) - - - -**BREAKING** — the HTTP log destination's batch flush, retry backoff start and request deadline, -and the logging buffer's flush, now carry `Ms` in the key name. - -| def | before | after | -|:--|:--|:--| -| `HttpDestinationConfig` | `batch.flushInterval: 5000` | `batch.flushIntervalMs: 5000` | -| `HttpDestinationConfig` | `retry.initialDelay: 1000` | `retry.initialDelayMs: 1000` | -| `HttpDestinationConfig` | `timeout: 30000` | `timeoutMs: 30000` | -| `LoggingConfig` | `buffer.flushInterval: 1000` | `buffer.flushIntervalMs: 1000` | -| values, defaults, bounds | ms; 5000 / 1000 / 30000 / 1000; positive int | **unchanged** | - -## Migration - -```diff - const destination = HttpDestinationConfigSchema.parse({ - url: 'https://logs.example.com/v1/logs', -- batch: { maxSize: 500, flushInterval: 10000 }, -- retry: { maxAttempts: 3, initialDelay: 1000 }, -- timeout: 30000, -+ batch: { maxSize: 500, flushIntervalMs: 10000 }, -+ retry: { maxAttempts: 3, initialDelayMs: 1000 }, -+ timeoutMs: 30000, - }); - - const logging = LoggingConfigSchema.parse({ - name: 'app_logging', - label: 'App logging', - destinations: [], -- buffer: { enabled: true, size: 5000, flushInterval: 2000 }, -+ buffer: { enabled: true, size: 5000, flushIntervalMs: 2000 }, - }); -``` - -Rename the keys. Every value is the same number of milliseconds it always was, and the -5000 / 1000 / 30000 / 1000 defaults are unchanged; nothing else on either def moves. - -## Why - -Each key named milliseconds in a source JSDoc — "Flush interval in milliseconds", "Initial retry -delay in milliseconds", "Timeout in milliseconds" — and the JSDoc above a key is not what -`content/docs/references/**` renders; `.describe()` is, and **none of the four carried one at -all**. Measured by the `check:duration-unit-keys` census on this tree before the change, all four -read `[name: -] [prose: -]`: no unit in the key, and no published prose to supply it either. So -`content/docs/references/system/logging.mdx` printed a bare `5000` / `1000` / `30000` / `1000`, -and nothing on the page decided milliseconds from seconds. Under the #14478 rule, moving the unit -into the describe alone would itself be a violation (unit in prose, none in the name), so each key -is renamed and given the describe it never had in the same stroke. Executes director-seat ruling A -on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the -#14478 rule. - -⚠️ `flushInterval` was declared **twice** on this file, in two different defs and with two -different defaults — 5000 on the HTTP destination's `batch`, 1000 on the logging `buffer`. They are -two keys, not one; each gets its own tombstone, its own registered row, and a prescription that -names its def, so an author who lands on one is not sent to the other. - -The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` -declarations in `packages/spec/src` against 75 `*Seconds:`. The only competing unit spellings are -3 `*MS:` and 9 `*Millis:`, and every one of them mirrors a name fixed outside this repo — MongoDB's -`maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and -`connectionTimeoutMillis` on `PoolConfigSchema` — so unlike the `Ttl`-versus-`TTL` question a -sibling round had to settle, there was no in-repo alternative to choose between. All three target -spellings were already attested as key-position `*.zod.ts` declarations before this change: -`flushIntervalMs` 1 (on `kernel/events/integrations.zod.ts`, at the same 1000 default), -`initialDelayMs` 5, `timeoutMs` 30. - -## The kit - -- a `retiredKey()` tombstone on each of the four old spellings, so `tsc` types it `never` and a - value reaching the parse raises the rename prescription instead of being silently stripped — none - of the four enclosing objects is `.strict()` (`HttpDestinationConfig` itself and its nested - `batch` and `retry`; `LoggingConfig`'s nested `buffer`) -- the ADR-0087 D3 semantic entry `logging-durations-unit-in-key` and four - `RETIRED_KEYS_BY_MAJOR[18]` rows, one per key. No D2 conversion: `stack.zod.ts` declares no - logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is - referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no - rehydration seam that runs on an authored logging document — the same reading - `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key -- pin tests per key: the refusal carries the rename prescription and names the def, the suffixed - key parses at the magnitude the retired one carried with the same default, and the describe - publishes the unit -- exactly one authorable-surface row pair moves, and it is the one that should: that ratchet records - top-level keys per def (`build-schemas.ts` reads `schema.properties` one level deep), and - `HttpDestinationConfig.timeout` is the only top-level key of the four — - `system/HttpDestinationConfig:timeout` becomes `[RETIRED]` beside a new - `system/HttpDestinationConfig:timeoutMs`, and the `authorable-defaults/` row is renamed with it. - The three nested keys move neither file, which is correct and not an omission -- the pinned objectui checkout is untouched by this rename: at `.objectui-sha` pin - `53ded82bf7a494f54e344e19099dbf00854b8694` it spells `flushInterval` 0 times, `initialDelay` 0, - `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 6409 tracked files, against lit - controls `useState` 2304 and `timeout` 702 on the same corpus diff --git a/.changeset/17783-metrics-jsdoc-durations-unit-in-key.md b/.changeset/17783-metrics-jsdoc-durations-unit-in-key.md deleted file mode 100644 index 4e5525fe7c2..00000000000 --- a/.changeset/17783-metrics-jsdoc-durations-unit-in-key.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the five `system/metrics.zod.ts` durations carry their unit in the key name (#17783, ruling A on #15939) - - - -**BREAKING** — the five metrics durations whose unit was stated only in a source JSDoc now carry -it in the key name, and each published `.describe()` states it too. - -| def | before | after | -|:--|:--|:--| -| `MetricDefinition` | `summary.maxAge: 600` | `summary.maxAgeSeconds: 600` | -| `ServiceLevelObjective` | `errorBudget.burnRateWindows[].window: 3600` | `errorBudget.burnRateWindows[].durationSeconds: 3600` | -| `MetricExportConfig` | `interval: 60` | `intervalSeconds: 60` | -| `MetricsConfig` | `collectionInterval: 15` | `collectionIntervalSeconds: 15` | -| `MetricsConfig` | `retention.period: 604800` | `retention.durationSeconds: 604800` | - -Every value is seconds, exactly as before, and every default (600, 3600 as authored, 60, 15, -604800) is unchanged. - -## Migration - -```diff - summary: { -- maxAge: 600, -+ maxAgeSeconds: 600, - } - - errorBudget: { -- burnRateWindows: [{ window: 3600, threshold: 14.4 }], -+ burnRateWindows: [{ durationSeconds: 3600, threshold: 14.4 }], - } - - exports: [{ - type: 'prometheus', -- interval: 60, -+ intervalSeconds: 60, - }], -- collectionInterval: 15, -+ collectionIntervalSeconds: 15, - retention: { -- period: 604800, -+ durationSeconds: 604800, - }, -``` - -Rename the keys. Nothing else on these four defs moves, and the three same-named objects on this -file — `MetricAggregationConfig.window`, `ServiceLevelIndicator.window` and -`ServiceLevelObjective.period` — are untouched. - -## Why - -Each key named its unit in a source JSDoc — "Max age of observations in seconds", "Window size in -seconds", "Export interval in seconds", "Collection interval in seconds", "Retention period in -seconds" — and nowhere else. Four of the five carried no `.describe()` at all and the fifth read -"Window size", so the text `content/docs/references/system/metrics.mdx` publishes named no unit: -600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of -milliseconds, and nothing on the page decided between them. Executes director-seat ruling A on -#15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the -#14478 rule — under that rule, moving the unit into the describe alone is itself a violation (unit -in prose, none in the name), so each key is renamed and its describe corrected together. - -Three of the five new names are deliberately **not** the mechanical suffix, and this file supplied -the reason for each: - -- `burnRateWindows[].window` → **`durationSeconds`**, not `windowSeconds`. It is the fourth window - length on this file, and #15679 already settled that a window length here reads `durationSeconds` - so the measurements read alike. `windowSeconds` would stutter against the enclosing - `burnRateWindows` array — the same objection #15679 recorded against `window.windowSeconds` — and - on this tree `windowSeconds` is not an authorable key at all: its only key-position occurrence is - an alias-map entry in `ServerRateLimitConfigSchema` that maps the spelling *away* to `windowMs`. -- `retention.period` → **`durationSeconds`**, not `periodSeconds`. `period` is calendar vocabulary - elsewhere in this spec (`ServiceLevelObjective.period.type` selects rolling or calendar, - `PluginRegistryEntry.pricing.billingPeriod` is monthly or yearly), so `periodSeconds` would have - kept the ambiguous half of the name — the same objection #15679 raised against `sizeSeconds`. -- `collectionInterval` → **`collectionIntervalSeconds`**, keeping the qualifier, because - `MetricExportConfig.intervalSeconds` is a different cadence one def over that this same change - creates. - -The two mechanical spellings are attested: `maxAgeSeconds` is the token -`AccessControlConfig.maxAgeSeconds` already carries after this same rule renamed it on -`system/object-storage.zod.ts`, and it keeps the `age` stem that the sibling `ageBuckets` counts -buckets of; `intervalSeconds` is the token four seconds-valued cadences already carry. Counted in -key position across `packages/spec/src` at `fc28c1d38`, the base of this change, the seconds -suffixes run `Seconds` 40, `Sec` 1 (`maxExecutionTimeSec`) and `S` 0 — the two bare `*S` keys on -that corpus, `maxCommitTimeMS` and `enableRLS`, are a millisecond spelling and a boolean. This -change takes `Seconds` to 45 at `9b62f54671`. - -## The kit - -- a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value reaching - the parse raises the rename prescription instead of being silently stripped (none of the five - enclosing shapes is `.strict()`) -- the ADR-0087 D3 semantic entry `system-metrics-jsdoc-durations-unit-in-key` and five - `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: `stack.zod.ts` declares no metrics collection - and none of these defs is a stored metadata row — the reading - `system-metrics-window-durations-unit-in-key` already recorded for this file -- pin tests per key: the refusal carries the rename prescription and is not an `unrecognized_keys` - issue, the suffixed key parses at the magnitude the retired one carried with the same default, - and each describe publishes the unit -- two authorable-surface rows move, three do not: that ratchet records **top-level** keys per def, - so `MetricExportConfig:interval` and `MetricsConfig:collectionInterval` become `[RETIRED]` beside - their suffixed rows (and their `authorable-defaults` rows move with them), while - `summary.maxAge`, `burnRateWindows[].window` and `retention.period` are nested and move nothing diff --git a/.changeset/17784-tenant-schema-cache-ttl-seconds.md b/.changeset/17784-tenant-schema-cache-ttl-seconds.md deleted file mode 100644 index faf3b8e0881..00000000000 --- a/.changeset/17784-tenant-schema-cache-ttl-seconds.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the `system/tenant.zod.ts` schema-cache TTL key carries its unit in the key name (#17784, ruling A on #15939) - - - -**BREAKING** — the schema-cache TTL on the `isolated_schema` tenant isolation strategy carries -its unit in the key name. - -| | before | after | -|:--|:--|:--| -| authored key | `performance.schemaCacheTTL: 3600` | `performance.schemaCacheTtlSeconds: 3600` | -| published describe | `Schema cache TTL` | `Schema cache TTL in seconds` | -| value + default | seconds, `3600` | **unchanged** | - -## Migration - -```diff - performance: { -- schemaCacheTTL: 3600, -+ schemaCacheTtlSeconds: 3600, - } -``` - -Rename the key. The value is the same number of seconds it always was, and the `3600` default is -unchanged; nothing else on `SchemaLevelIsolationStrategy` moves. - -## Why - -The key named its unit in a source JSDoc — "Schema cache TTL in seconds" — and nowhere else. The -`.describe()` that `content/docs/references/system/tenant.mdx` renders said "Schema cache TTL" and -named no unit at all, so the one reader who most needs it, the reader of the published reference -page, was the only reader who never saw it: `3600` is a plausible number of seconds and a plausible -number of milliseconds, and nothing on the page decided between them. Executes director-seat ruling -A on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the -#14478 rule — under that rule, moving the unit into the describe alone is itself a violation (unit -in prose, none in the name), so the key is renamed and the describe is corrected together. - -The new spelling is `Ttl`, not `TTL`: counted on this tree, every member of the suffixed family -already spells it that way — `cacheTtlSeconds` (11), `ttlSeconds` (3), `defaultCacheTtlSeconds` (1). - -## The kit - -- a `retiredKey()` tombstone on the old spelling, so `tsc` types it `never` and a value reaching the - parse raises the rename prescription instead of being silently stripped (the nested `performance` - object is not `.strict()`) -- the ADR-0087 D3 semantic entry `tenant-schema-cache-ttl-unit-in-key` and the - `RETIRED_KEYS_BY_MAJOR[18]` row `system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL`. - No D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is - not a stored metadata row, so the chain has no seam that runs on it — the same reading - `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file -- pin tests on `SchemaLevelIsolationStrategySchema`: the refusal carries the rename prescription, the - suffixed key parses at the magnitude the retired one carried with the same `3600` default, and the - describe publishes the unit -- no authorable-surface row moves — that ratchet records top-level keys per def, and this one is - nested under `performance` (measured: 0 hits for the key across `authorable-surface/` and - `authorable-surface.base.json`, against 4 for the `system/MigrationPlan:` control) diff --git a/.changeset/17785-tracing-otel-exporter-duration-units.md b/.changeset/17785-tracing-otel-exporter-duration-units.md deleted file mode 100644 index ab053b6829c..00000000000 --- a/.changeset/17785-tracing-otel-exporter-duration-units.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the four `system/tracing.zod.ts` duration keys carry their unit in the key name (#17785, ruling A on #15939) - - - -**BREAKING** — the OTel exporter deadline, the batch processor's two knobs and the background -span-export period now carry `Ms` in the key name. - -| | before | after | -|:--|:--|:--| -| `OpenTelemetryCompatibility.exporter` | `timeout: 10000` | `timeoutMs: 10000` | -| `OpenTelemetryCompatibility.exporter.batch` | `exportTimeout: 30000` | `exportTimeoutMs: 30000` | -| `OpenTelemetryCompatibility.exporter.batch` | `scheduledDelay: 5000` | `scheduledDelayMs: 5000` | -| `TracingConfig.performance` | `exportInterval: 5000` | `exportIntervalMs: 5000` | -| values, defaults, bounds | ms; 10000 / 30000 / 5000 / 5000; `int().positive()` | **unchanged** | - -## Migration - -```diff - const otel = OpenTelemetryCompatibilitySchema.parse({ - exporter: { - type: 'otlp_grpc', -- timeout: 10000, -+ timeoutMs: 10000, - batch: { -- exportTimeout: 30000, -- scheduledDelay: 5000, -+ exportTimeoutMs: 30000, -+ scheduledDelayMs: 5000, - }, - }, - resource: { serviceName: 'api-server' }, - }); - - const tracing = TracingConfigSchema.parse({ - name: 'default_tracing', - label: 'Default Tracing', -- performance: { exportInterval: 5000 }, -+ performance: { exportIntervalMs: 5000 }, - }); -``` - -Rename the keys. Every value is the same number of milliseconds it always was, the -10000 / 30000 / 5000 / 5000 defaults are unchanged, and nothing else on either def moves. - -## Why - -Each key named milliseconds in a source JSDoc — "Timeout in milliseconds", "Export timeout in -milliseconds", "Scheduled delay in milliseconds", "Background export interval in milliseconds" — -and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is. -Measured on this tree: all four carried **no `.describe()` at all**, so the published reference -row for each was a bare integer with no unit anywhere on the page. That is a strictly worse -channel than the unit-in-prose shape #14478 already refuses — here the reference reader had no -prose to misread. All four magnitudes read plausibly in both units (10000, 30000, 5000, 5000), -and an operator who reads seconds sets an exporter deadline 1000x short. Executes director-seat -ruling A on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file -remediation of the #14478 rule, and closes the last of that ruling's seven cards. - -The suffix is the family's own spelling, counted in key position across `packages/spec/src`: -281 `*Ms` declarations over 42 distinct names, `timeoutMs` 65 of them and `intervalMs` 14, -against **0** key-position `timeoutSeconds`. The Delay-plus-`Ms` pairing is likewise already -attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, `delayMs`, `debounceDelayMs`) with no -competing `scheduledDelay` spelling anywhere. This file is milliseconds throughout and its own -landed precedent is `Span.duration → durationMs` (#15679) — the opposite of the sibling metrics -card, whose rows were seconds. - -`exporter.timeoutMs` and `exporter.batch.exportTimeoutMs` deliberately sit one nesting level -apart. The pair pre-exists the rename: the `batch` sub-object is the OpenTelemetry batch span -processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) -beside the exporter's own request deadline. Renaming either to something more distinctive would -depart from the vocabulary this shape mirrors, and the nesting already disambiguates every read -point — `exporter.timeoutMs` versus `exporter.batch.exportTimeoutMs`. - -## The kit - -- a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value - reaching the parse raises the rename prescription instead of being silently stripped. Neither - `OpenTelemetryCompatibilitySchema` nor `TracingConfigSchema` nor any object nested inside them - is `.strict()`, so `unrecognized_keys` was never the alternative — a bare deletion would have - landed a default on an exporter deadline and a background export period -- the ADR-0087 D3 semantic entry `system-tracing-otel-exporter-durations-unit-in-key` and four - `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: `stack.zod.ts` declares no tracing - collection, no metadata-type binding or manifest embed carries either def, and a tracing - configuration is never a stored `sys_metadata` row — so the chain has no seam that runs on - them, the same reading `system-tracing-span-duration-unit-in-key` recorded for the other key - on this file -- pin tests: a refusal pin per row asserting the issue **code** (never a bare `toThrow()`) and - the FROM → TO prescription, an acceptance pin at each retired key's magnitude with the same - default, a bounds pin, and a describe pin proving the unit now reaches the published channel -- the `authorable-surface` / `authorable-defaults` ratchets move **nothing**, and that is the - correct outcome rather than an omission: those artifacts record top-level keys per def - (`build-schemas.ts` reads `schema.properties` one level deep) and every one of these four is - nested -- `Span.duration → durationMs`'s own entry is untouched — a predecessor's scoped record stays - true, and this round's entry opens by saying how it relates to it diff --git a/.changeset/17786-duration-describe-units.md b/.changeset/17786-duration-describe-units.md deleted file mode 100644 index 9295e04201e..00000000000 --- a/.changeset/17786-duration-describe-units.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Three duration keys now name their unit in the `.describe()` prose that reaches the published output, not only in the key name and the JSDoc above them: `PluginLoadingEvent.durationMs` (`kernel/plugin-loading.zod.ts`), `AppInstallResult.durationMs` (`system/app-install.zod.ts`) and `MigrationPlan.estimatedDurationMs` (`system/deploy-bundle.zod.ts`). - -The first carried no `.describe()` at all, so the generated reference row for `durationMs` rendered an empty description cell; the other two said `Installation duration` and `Estimated execution time`, naming a duration with no unit. All three JSDoc blocks already said milliseconds, and all three key names already carry `Ms`. Only the channel an author — very often a model (ADR-0033) — actually reads was missing it. - -⛔ Not a rename, and no key moves: the unit is already in the key name, which is what the #14478 rule asks for. This is the describe-only remediation of Ruling A on #15939, and it is the one of the seven remediations that needs no ADR-0087 conversion, no tombstone and no published-key rename. - -**The published surface was measured rather than assumed**, because a changeset is owed only if the changed text actually ships. Measured after `pnpm --filter @objectstack/spec build`, over the paths this package's `files[]` actually publishes: - -- **The changed text ships.** `Duration in milliseconds` reads 24 occurrences across 12 `dist/` bundle files and 6 across `json-schema/`; the other two read 8 in `dist/` and 2 in `json-schema/` each. The generated reference pages under `content/docs/references/**` render all three rows and are regenerated in this change. -- **Positive control that ships**: the neighbouring describe `Objects created/updated` — `dist` 4, `json-schema` 2. -- **Negative control that does not ship**: `no exemption by blindness`, a sentence that exists only in `packages/spec/scripts/`, a path outside `files[]` — 0 across every published path, 1 in its own unpublished file. -- **Dark control**: a fabricated needle reads 0 everywhere, so a zero above is a reading rather than a broken instrument. - -One measured refinement worth recording for the next author, since it cuts against the obvious reading of "published output": **`dist/` alone does not discriminate the two prose channels.** JSDoc text and even a `//` line comment ride into the emitted bundles verbatim (`Objects created or updated`, JSDoc-only, reads 4 in `dist/`). What separates the channels is `json-schema/`, which carries describe prose and 0 comment prose. So `dist` presence is necessary and not sufficient evidence that a string reached the governed channel; the `json-schema/` reading is the one that decides it. diff --git a/.changeset/17790-info-detail-package-fold.md b/.changeset/17790-info-detail-package-fold.md deleted file mode 100644 index 5b5307e069e..00000000000 --- a/.changeset/17790-info-detail-package-fold.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os info`'s **detail** reads now resolve a package-owned collection through the seam the package already has for it, so an ADR-0130 D4 / option-B project (every definition inside `packages[]`, none flattened up) stops contradicting itself. - -Measured through the real binary on the card's own repro, before the change: - -``` -os info --json exit 0 stats.objects = 1 · objects[] length = 0 -os info exit 0 Data: 1 Objects 2 Fields (no `Objects:` section, no `Apps:` section) -``` - -`stats` had learned to resolve `packages[]`; the four reads beside it had not, so one `--json` payload asserted `stats.objects: 1` next to `objects: []` — and nothing in the payload distinguished *this project has no objects* from *this reader could not see them*. `--json` is the face a machine reads, so a consumer could not recover from it. - -- **The four reads** — the `--json` `objects` array and the `Objects:` / `Agents:` / `Apps:` text sections in `commands/info.ts` — go through `resolveStackCollection` (`utils/stack-collections.ts`), the one place this package resolves a package-owned collection. -- **Strictly additive.** That seam answers the caller's original expression FIRST and consults `packages[]` only when the top level does not carry the key at all, so **every stack the platform emits today reports exactly what it reported before** — pinned by a control run whose definitions are the same literals, authored at the top level instead. -- **No new failure mode.** `collectMetadataStats` on the line above already resolves the same package list through the same seam, so a malformed `packages` has already answered its ADR-0112 `422` before these reads run. - -⛔ **Not decided here:** whether an option-B project's detail listing should be this flat union or grouped per package. Each entry keeps the shape and the key set it has always had — no package attribution is added — so that published-output-shape question stays exactly as open as it was. diff --git a/.changeset/17818-prototype-fallthrough-lookups.md b/.changeset/17818-prototype-fallthrough-lookups.md deleted file mode 100644 index b7832926a4e..00000000000 --- a/.changeset/17818-prototype-fallthrough-lookups.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): four lookup folds no longer hand out `Object.prototype` members for an off-vocabulary key (#17818) - -`normalizeFilterOperator` (`/ui`), `resolveDiscoveryEnvironment` (`/api`), and -`pluralToSingular` / `singularToPlural` (`/meta-spelling`, re-exported from -`/shared`) each read a module-level lookup table with a runtime key through a -bare index. Every one of those tables is an ordinary object, so a key that is -not in the vocabulary resolved a member of `Object.prototype` instead of -falling through — and the `?? fallback` each function already writes never -fired, because the inherited member is truthy. - -Measured on Node v22.22.2, before and after — each fold evaluated at this -change's implementation and again at its merge base, against the TypeScript -sources that the build and the test run both consume: - -| call | before | after | -|:--|:--|:--| -| `normalizeFilterOperator('constructor')` | the `Object` function | `'constructor'` | -| `normalizeFilterOperator('toString')` | `Object.prototype.toString` | `'toString'` | -| `normalizeFilterOperator('valueOf')` | `Object.prototype.valueOf` | `'valueOf'` | -| `normalizeFilterOperator('__proto__')` | `Object.prototype` | `'__proto__'` | -| `resolveDiscoveryEnvironment('constructor')` | the `Object` function | `'development'` | -| `resolveDiscoveryEnvironment('__proto__')` | `Object.prototype` | `'development'` | -| `pluralToSingular('constructor')` | the `Object` function | `'constructor'` | -| `singularToPlural('__proto__')` | `Object.prototype` | `'__proto__'` | - -Each function's declared refusal value is what it now answers — the same value -each already gave for an ordinary unknown word such as `nope`. ⛔ No new -fallback was invented. `resolveDiscoveryEnvironment` is the sharpest case: its -own docblock promises "a value guaranteed to satisfy -`DiscoveryEnvironmentSchema`", and for `constructor` it returned a `Function`. - -⚠️ **Why `minor` and not `patch`.** The level is carried by this change's -declared contract-review status, ⛔ not by a widening — the guard only NARROWS. -An off-vocabulary key that previously resolved an inherited member now gets each -function's own declared refusal value, and nothing that answered before answers -differently. Nothing in the declared vocabulary moves: every canonical operator, -every `EnvironmentType` bucket, both operator shorthands and every manifest -collection spelling answers byte-identically to before, and the only inputs -whose answer changes are the four prototype-member spellings above, which no -signature ever admitted. - -The guard is the `Object.prototype.hasOwnProperty.call(table, key) && table[key]` -shape already landed in `src/data/type-compat.ts`, and carries that site's two -recorded rejections: ⛔ not a null-prototype table (it does not type-check -against the `Record` annotation, and the spelling that does compile silently -costs the exhaustiveness check), and ⛔ not a list of prototype member names -(which the next prototype member defeats). diff --git a/.changeset/17825-environments-update-jsdoc-accept-set.md b/.changeset/17825-environments-update-jsdoc-accept-set.md deleted file mode 100644 index a8d126f3d3e..00000000000 --- a/.changeset/17825-environments-update-jsdoc-accept-set.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/client": patch ---- - -`environments.update`'s JSDoc no longer advertises writes the control plane refuses, and `environments.updateVisibility` carries a current-state note. - -The `update` comment listed `display_name, plan, status, is_default, metadata` as the updatable set, and the namespace route table listed `plan` and `status` too. `plan` and `status` are read-only columns on the control plane; the generic `PATCH /api/v1/cloud/environments/:id` route answers an unknown or read-only key with a **400** rather than dropping it, so the comment was actively teaching a call that fails. The same prose implied `visibility` was writable while it is server-owned. - -Three prose sites move, all in `packages/client/src/index.ts`: - -- **The namespace route-table docblock.** The PATCH accept-set now reads `display_name, is_default, metadata`, with the redirects stated: plan changes go through the billing routes, status changes through the lifecycle actions (archive / restore / suspend / resume), and `visibility` is server-owned. -- **`update`'s JSDoc.** The same accept-set with per-field detail, and — the sentence that matters most to a caller — that an unknown or read-only key is answered with a 400 and is **not** dropped silently. Silent-drop is the assumption a caller reasonably makes today, and it is the wrong one. -- **`updateVisibility`'s JSDoc.** A note that the call is refused today, so the paragraph describing what `public` does describes a capability that does not exist yet. The 2026-09-12 maintainer ruling keeps `visibility` server-owned and forced to `private` until the public-listing feature ships, at which point it gets its own endpoint rather than this generic update. - -⛔ **No signature, type or runtime byte moves.** `patch` stays `Record` and `updateVisibility`'s signature and body are byte-for-byte unchanged (verified by hash, before and after). Narrowing a published accept-set is as much a breaking change as widening one, and retiring, re-signing or throwing from a published SDK method is a maintainer ruling — neither is a doc fix's to make. What reaches consumers is the hover text in `dist/index.d.ts`. - -⚠️ The 400 is an **inherited reading**, not one measured from this repo: `/api/v1/cloud/*` is served by `objectstack-ai/cloud`, which is not readable from here, so no gate here can check it. The comments say so at the point of the claim rather than leaving a later reader to try. diff --git a/.changeset/17847-ai-slot-501-not-404.md b/.changeset/17847-ai-slot-501-not-404.md deleted file mode 100644 index c887eed540f..00000000000 --- a/.changeset/17847-ai-slot-501-not-404.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): the AI Operations note said the slot 404s when no AI service is mounted — it has answered 501 since the shared `capabilityUnavailable` exit landed (#17847) - -Clause-②: no — prose only. No schema key moves, no accept set widens or narrows, no export changes, and no runtime behaviour is touched; `packages/runtime` is not in this diff. - -`src/api/protocol.zod.ts` ships inside this package (`files[]` carries `src/**/*.zod.ts`, and `npm pack --dry-run` lists `src/api/protocol.zod.ts` among its 2016 entries), so the sentence an author reads is a published byte. It said: - -> this repo's dispatcher only proxies `/api/v1/ai/**` to whatever `buildAIRoutes()` mounted, or 404s "AI service is not configured" - -Both halves were stale. `packages/runtime/src/domains/ai.ts` reaches the shared `capabilityUnavailable(deps, 'ai')` exit, which answers **501 Not Implemented** — `/ai/*` IS mounted, so the request reaches a handler with nothing behind it, and 404 would claim the path does not exist. And the quoted body is no longer a local string: it comes from the shared `serviceUnavailableMessage`, the same sentence `discovery.services.ai` reports for the slot, so the 501 body and the discovery entry cannot drift into naming different remedies. The literal `AI service is not configured` survived nowhere in the tree except in that stale comment. - -The replacement is the same three-arm text the other three live sites carry after #16211 / PR #17844 (`packages/client/src/index.ts`, `packages/runtime/src/route-ledger.ts`, `packages/runtime/src/domains/ai.ts`), because an unqualified "`/ai/*` answers 501" would manufacture a second inaccurate statement: - -- an **anonymous** caller is refused **401** first (`ANONYMOUS_DENY_STATUS`), ahead of the slot being consulted — neither the 501 nor the courtesy below is owed to a caller who has not authenticated; -- **`GET /ai/agents` answers 200** with an empty list (`{ agents: [] }` under the envelope's `data`) — a deliberate console courtesy, so polling does not log an error on every navigation; -- every other `/ai/*` route answers **501** carrying the shared remedy sentence. - -All three arms were measured rather than copied: `packages/runtime/src/domains/ai-anonymous-deny-ordering.test.ts` pins each of them and passes 13/13 on this tree. diff --git a/.changeset/17852-record-proto-key-preparse-guard.md b/.changeset/17852-record-proto-key-preparse-guard.md deleted file mode 100644 index b1ed4c6c3ae..00000000000 --- a/.changeset/17852-record-proto-key-preparse-guard.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING** for authored metadata — `ObjectSchema.fields` refuses a key named `__proto__`, `constructor` or `prototype`, and `AssignmentConfigSchema.assignments` (the `assignment` flow node's variable map) refuses a key named `__proto__` — both refused with a named, located error at parse time, rather than silently accepted and then silently mishandled (objectstack#17852, objectstack#18847). - -## Why - -zod's `z.record()` skips a `__proto__` own key entirely, above its own key schema — the record parser's `if (key === "__proto__") continue;` runs before `def.keyType._zod.run`, so no key grammar (a regex, `.refine()`, `.superRefine()`, even a key schema that rejects every string) can ever see that key. A document whose `fields` (or `assignments`) carried a `__proto__` own key — which `JSON.parse` produces routinely — used to parse as SUCCESS with that key silently missing from the output: the validator accepted a document and handed back a *different* document. `os build` writes the release artifact from that returned document, so the failure shape is success, silent, and irreversible into the shipped artifact. - -Two independent mechanisms close this, one per name class, because they are not reachable the same way: - -- `__proto__` is refused by a **pre-parse guard** that reads the raw input's own keys before the record ever parses, at both `ObjectSchema.fields` and `AssignmentConfigSchema.assignments`. -- `constructor` and `prototype` — which, unlike `__proto__`, DO reach the key schema unskipped — are refused by `ObjectSchema.fields`' own key grammar (they were ordinary lowercase words its regex already admitted). They are **not** refused at `AssignmentConfigSchema.assignments`: that slot's key type carries no grammar at all (`z.string().min(1)`), both names are legal flow-VARIABLE names measured to survive parse intact today, and no ruling narrows that slot's accept set for them — only its `__proto__` half moves. - -Measured: zero authored use of any of the three names as a `fields` key or an `assignments` variable name, across this repo, `examples/` and `objectui`. - -## Known gap, left open on purpose - -The guard runs at parse time only. It does not project into the published JSON Schema (`packages/spec/json-schema/**`) — the general gap that closes is tracked separately (objectstack#18670) and stays open after this change. - -Clause-②: yes (narrowing) - - diff --git a/.changeset/17857-distinct-unresolvable-column-attribution.md b/.changeset/17857-distinct-unresolvable-column-attribution.md deleted file mode 100644 index f498f1b6507..00000000000 --- a/.changeset/17857-distinct-unresolvable-column-attribution.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -`SqlDriver.distinct()` now answers **one unresolvable column the way the other three read doors do** — a `400` that names it — instead of a `DATABASE_ERROR` / `500` server fault. - -The same condition (a column name the table does not have) asked at four doors used to get three answers and one server fault. Measured on `origin/main` at `dbea1756d9`, embedded SQLite, and identical on live PostgreSQL 16.13: - -| door | before | after | -|:--|:--|:--| -| `count(t, { where: { nosuchcol: 1 } })` | `INVALID_FILTER` / 400 | unchanged | -| `find(t, { where: { nosuchcol: 1 } })` | `INVALID_FILTER` / 400 | unchanged | -| `aggregate(t, { groupBy: ['nosuchcol'] })` | `INVALID_FIELD` / 400 | unchanged | -| `distinct(t, 'title', { nosuchcol: 1 })` | **`DATABASE_ERROR` / 500** | **`INVALID_FILTER` / 400** | -| `distinct(t, 'nosuchcol')` | **`DATABASE_ERROR` / 500** | **`INVALID_FIELD` / 400** | - -A caller's own mistake — a field name that does not exist — was served as a server fault naming nothing they could act on, one door away from a `400` that names the column. A picklist-populating `distinct()` sits beside the `find()` and `count()` of the same list view. - -**Attribution comes from the caller's own request, never from the backend's prose.** The dialect names the column but not the clause, so the clause is read off the call this driver just compiled — the shape `aggregateBackendFault` established for `aggregate()`: - -1. the name **equals the `field` argument** ⇒ `INVALID_FIELD` / 400 naming the listed column, with the `field` and `object` riders the ingress door's refusals carry; -2. it does not ⇒ the statement's only remaining column sources are the WHERE compiled from `filters` and the tenant-scope predicate, both filters, so the existing `INVALID_FILTER` refusal applies verbatim — the same sentence `find()` and `count()` give; -3. the dialect wording yields **no name** ⇒ no attribution is supportable and the terminal `DATABASE_ERROR` / 500 envelope stands unchanged. - -Arm 2 is the **complement** of arm 1 rather than a search of the `filters` AST, which keeps a nested filter (`{ $or: [{ nosuchcol: 1 }] }`) on the same `400` as a flat one. - -⛔ **No input that was refused before is accepted now, and no exported symbol moves.** The call fails either way; what changes is the refusal's code, status and words. No error code is minted — `INVALID_FIELD` is a standard-catalog member (ADR-0112) and already this repo's answer for a named column an object does not have. No new dialect recognizer is added: both predicates are the ones `find()`, `count()` and `aggregate()` already share. - -A caller that branched on `DATABASE_ERROR` / `500` for a mistyped `distinct()` field or filter key now sees `INVALID_FIELD` / `INVALID_FILTER` `400`s instead; that is the point of the change, and it matches what the same mistake already returned from every other read door. diff --git a/.changeset/17883-generate-migration-file-family-width.md b/.changeset/17883-generate-migration-file-family-width.md deleted file mode 100644 index f8ffcb8d95c..00000000000 --- a/.changeset/17883-generate-migration-file-family-width.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os generate migration` gives the file family — `file` / `image` / `avatar` / `video` / `audio` — the **same column width in both formats**. The typescript format emitted a bare `table.string(name)`, knex's `varchar(255)`, while `--format sql` emitted `VARCHAR(2048)` for the same field, so one command answered one field with two widths depending on the flag (#17883). - -2048 is not a new number: ADR-0104 ruled the generator's `VARCHAR(2048)` the end-state for this family, `driver-sql` moved to it (`MEDIA_ID_VARCHAR_CHARS`, #15989), and `os migrate files-to-references --apply` retypes the column to `varchar(2048)`. The typescript format was the one producer left at 255 — so a deployment scaffolded from it declared a width the migration it will later run retypes away from. - -```diff -- table.string('cover_image').nullable(); -+ table.string('cover_image', 2048).nullable(); -``` - -- **No regeneration is required of anyone.** `syncSchema` / `initObjects` are additive and never alter an existing column's type, and a `sys_file` id is far shorter than 255, so nothing stored today is at risk either way. What moves is the **declared** width of tables generated from now on. -- **The width is now read from the sql format's own entry** instead of being retyped beside it, so the two formats cannot drift apart again; `generate-file-reference-width.pin.test.ts` measures both against `driver-sql`'s constant, which is what stops the two halves from "meeting in the middle" at some third value. -- ⛔ **Nothing outside the family moved.** The `text` family, the reference types the file family used to share an arm with (`lookup` / `master_detail` / `user` / `tree`), `autonumber`, and every `--format sql` answer are byte-identical. diff --git a/.changeset/17885-artifact-door-default-flip-class.md b/.changeset/17885-artifact-door-default-flip-class.md deleted file mode 100644 index b6fa7a453a3..00000000000 --- a/.changeset/17885-artifact-door-default-flip-class.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-core": patch ---- - -The artifact-ingestion door no longer replays the **default-flip** class of ADR-0087 conversion, so an artifact carrying `defineApp({ hidden: true })` is registered with `hidden: true` — not as an unpublished app (#17885, #4829). - -`app-hidden-to-unpublished` rewrites `app.hidden: true` into `app._unpublished: true`. Both keys are live and they mean opposite kinds of thing: `hidden` is navigation presentation and *"never an access gate"* (`ui/app.zod.ts`), while `_unpublished` is the machine-managed publish gate `filterAppForUser` drops the app on for every user without `studio.access` / `setup.access`. Measured before the change, on an artifact declaring `engines.protocol: ^17.0.0` — the range `create-objectstack` stamps — against a 17.4.0 runtime: the door emitted the `app-hidden-to-unpublished` notice and the object that reached registration carried `hidden: undefined`, `_unpublished: true`. So an author who asked for "keep this out of the App Switcher" got "nobody but a builder can see this" — the incident the `_unpublished` split was introduced to end, arriving through the conversion layer. - -- **The entry is not withdrawn and no key moves.** It still fires where its precondition is a fact — the stored-row rehydration seams (a pre-split `hidden: true` row can only have come from the materialization path) and `os migrate meta`, where the operator asserts the source's age. What changed is that the artifact door, whose evidence is the artifact's **declared `engines.protocol` floor** rather than its age, no longer treats that guess as sufficient for a rewrite that reinterprets a live authorable key. -- **The retired window stays open.** Closing it wholesale would fix this and re-break #12772: an artifact built by 17.1.0 tooling carrying `allowRestore` / `allowPurge` would again be refused at the tombstone with no operator remedy. The door refuses one named class by id, with its reason written beside it, and the pin drives a retired conversion and a non-retired one through the same window to prove it. -- **New seam option, no new export.** `applyConversions` accepts `excludeConversionIds` — the seat-level spelling of "my evidence cannot carry this entry". `retiredFromLoadPath` cannot express it: that flag's jurisdiction is the authoring funnel and nothing else. -- ⛔ **The consumer is unchanged.** `filterAppForUser` withholding on `_unpublished` is correct; the defect was who writes `_unpublished`. - -Deployments whose apps were being served as unpublished purely because of a permissive `engines.protocol` range will see those apps again, for every user, on the next boot. No artifact file changes and no stored row is rewritten. diff --git a/.changeset/17891-app-nav-i18n-app-population.md b/.changeset/17891-app-nav-i18n-app-population.md deleted file mode 100644 index 09db136ae47..00000000000 --- a/.changeset/17891-app-nav-i18n-app-population.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`check:app-nav-i18n` now judges the PLATFORM APPS' navigation — Setup **and Account** — instead of narrowing to `setup` at every site. - -The gate is named "every id labelled in every locale" and was structurally blind to one whole app: it printed a byte-identical `OK (10 contributor(s), 54 merged setup nav id(s), 4 locale(s), …)` line before and after the Account app's contributed `nav_connect_agent` label landed, so nothing it printed could tell you it had skipped an app. - -Six sites narrowed it, only three of which were the obvious filters: - -- the contribution filter, the app-shell filter and the merged-app lookup; -- the **locale-file lookup** (`data.apps..navigation`) — widening the first three without this one yields a gate that collects `account` ids and then hunts for their labels under `apps.setup.navigation`; -- the **build prerequisite**, a package path hard-coded to `@objectstack/setup`; -- the **contributor roster**, which booted no package that registers the Account shell — so `account` had no merged app to judge at all. - -Behaviour now: - -- the population is declared with its criterion (an app is judged iff the ADR-0048 platform-app loop registers its shell by default **and** at least one package contributes navigation into it at runtime), which is why `studio` and `crm_app` are out; -- the per-contributor "landed at least one nav id" invariant is applied **per app**, never over a union across apps — a union would let a contributor serving two apps keep passing on one of them after the other silently stopped; -- every verdict, the refusal advisory and the pass line name the app they are actually about, and the pass line carries a per-app id count; -- `--self-test` gains negative controls for the union softening, for a verdict that names the wrong app subtree, and for a pass line that cannot notice an app leaving the population. - -The `setup` judgement is unchanged: the same 54 merged ids, the same verdict, and the same count in the pass line. diff --git a/.changeset/17909-approvals-resume-failed-provenance.md b/.changeset/17909-approvals-resume-failed-provenance.md deleted file mode 100644 index 3c0f172539a..00000000000 --- a/.changeset/17909-approvals-resume-failed-provenance.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`@objectstack/plugin-approvals` is now registered as a second emitter of the already-registered `RESUME_FAILED` in `ERROR_CODE_LEDGER`, so the only correct implementation of `ResumeFailureReport.code` stops being refused by `check:error-code-provenance`. - -**The contradiction this closes.** `ResumeFailureReport` (`contracts/approval-service.ts`) declares `code: ErrorCode` as **required** — "a success answer has no envelope `code` to fall back on" — and its docblock prescribes `RESUME_FAILED` for a run that could not be advanced. But the ledger listed that code only under `@objectstack/rest`, so the first producer to fill the slot stamped a registered code its own owner key did not list, which the provenance gate refuses. The declaration shipped in a state where satisfying it tripped a sibling gate. - -**Measured, not derived.** With PR #17908's stamp site present and the ledger unchanged, the guard answers exit 1 and names it: `@objectstack/plugin-approvals stamps 'RESUME_FAILED' (objlit) at packages/plugins/plugin-approvals/src/approval-service.ts:3370 — not listed under its own owner key`. With this row, the same tree answers exit 0 with the site counted as listed. - -**A row, not a waiver — the precedent's own predicate decides it.** The `EXTERNAL_IMPORT_ERROR` waiver records "the door stamps this code itself for every throw and never reads the producer's declaration". Both halves fail for `resumeFailure`: it rides a **success** answer, which the REST approvals door serves with `res.json(out)` verbatim, and `packages/rest/src` spells `resumeFailure` nowhere. The producer's literal *is* the wire value, so the door names no vocabulary to waive it under. - -**One code, not the three the docblock names.** `RESUME_TARGET_LOST` is a thrown message prefix mapped by rest's catch and stays under rest's row; `RESUME_IN_PROGRESS` is compared and never constructed in this package, and is emitted by `@objectstack/service-automation`, which carries its own row. A row for a code the package does not stamp would be the dead weight this file's gate refuses. - -⛔ **No wire byte moves and no accept set widens.** `RESUME_FAILED` was already in the registered union, so no response can now carry a code it could not carry before; the per-package rows are provenance, not identity. No exported symbol is added and no published payload gains a key. diff --git a/.changeset/17928-wait-node-config-required.md b/.changeset/17928-wait-node-config-required.md deleted file mode 100644 index 2875308afe1..00000000000 --- a/.changeset/17928-wait-node-config-required.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-automation': minor ---- - -fix(automation): a `wait` node must say what resumes it — the config block is required at the contract, and the executor stops defaulting to a duration-less timer (#17928) - -**BREAKING** — a `type: 'wait'` flow node with no `waitEventConfig` block, and a -`type: 'boundary_event'` node with no `boundaryConfig` block, no longer parse. -Under `eventType: 'timer'`, `timerDuration` is now required and may not be blank -— and that half sits on the `waitEventConfig` BLOCK, not on the node type, so it -bites on ANY node carrying the block: a `start` node spelled -`waitEventConfig: { eventType: 'timer' }` parsed before and is refused now. It is -still a narrowing in every direction (no shape starts parsing that did not), and -the block is inert on a node type no executor reads it from, so the practical -reach is `wait`. - -`eventType` has been required *inside* each block since protocol 17, so -`waitEventConfig: {}` was already a loud parse error. The block itself was -optional — so "omit the key" and "omit the block" were two documents with two -verdicts, and the accepted one was the silent one. It is also the state a -freshly created node is in, which is what made it reachable from a designer's -default screen rather than only by hand-authoring. - -What that document did, measured through a real `engine.execute()` run rather -than read off the source: - -``` -FROM { id: 'pause', type: 'wait', label: 'Wait' } // parses clean - -> { success: true, suspend: true } // run status: paused - scheduled jobs: [] <- with a job service ANSWERING - variables: no `pause.waitUntil` <- cold boot cannot re-arm - log lines: 0 at any level <- warn, error, info, debug - -TO FlowNodeSchema.safeParse(...) - -> { success: false, - issues: [{ code: 'custom', path: ['waitEventConfig'], - message: 'a `wait` node requires a `waitEventConfig` block saying - what resumes it … `waitEventConfig: { eventType: 'timer', - timerDuration: 'PT1H' }` … or `{ eventType: 'signal', - signalName: 'order_paid' }` …' }] } -``` - -The control — the same node with `{ eventType: 'timer', timerDuration: 'PT1H' }` -— armed the one-shot job and persisted the deadline, so the zeros above are a -reading of this path and not of a dead harness. - -**The executor follows the contract.** `wait-node.ts` carried -`(node.waitEventConfig ?? {})` and `String(wec.eventType ?? 'timer')` under a -comment declaring the second one deliberate — "a wait node without one is a -VALID TIMER WAIT". Both fallbacks are retired. A node that still reaches -`execute` without the block (a stored pre-migration document on a path that -skipped the parse) is now a **guard refusal** — `errorClass: 'guard'`, so a -`fault` edge cannot route a metadata defect into a handler that reports success -— and it **logs**, naming the node and the remedy, because the defect being -closed was silence. It never suspends with `success: true` again. Two smaller -corrections ride along in the same return: the timer branch stops answering -`output` as a present key holding `undefined` (it is absent when no deadline was -computed), and the reversed comment is deleted rather than left describing a -behaviour that is gone. - -**`screen.mode` now declares the default the executor applies; `http.method` -still declares none.** Both were read by running the executors with the key -absent, not by reading the Zod: - -| key | absent ⇒ the runtime applies | declared | -| --- | --- | --- | -| `ScreenConfig.mode` | `'create'` (object-form branch; the flat `fields` branch never reads it) | `.default('create')` | -| `HttpConfig.method` | `GET` inline, **`POST`** when `durable: true` | ⛔ none — two values, no single default | - -Declaring `.default('GET')` on `method` would materialise `GET` at parse time, -the durable arm's own `?? 'POST'` would never fire again, and every stored -durable callout that omits the method would silently change verb. That is the -defect this card exists to end, pointed the other way. - -**Migration.** A stored `wait` node with no block has no lossless conversion — -the missing value is an intent no artifact records, and the old runtime's pick -(`'timer'` with no duration) was not a wait at all — so this is an ADR-0087 D3 -semantic entry rather than a D2 conversion: `os migrate meta --from 17` names -each node to edit. Declare the resume condition and re-publish the flow. ⚠️ -Behaviour the fix deliberately changes: a run that used to park forever now -waits the duration you declare or the signal you name. - -**`boundary_event` gets the contract half only.** The runtime registers no -executor for that node type at all — a flow reaching one fails with -`NO_EXECUTOR` before any config is read, identically whether the block is -present or absent — so there is no silent executor branch behind it. The -refusal fixes the authoring surface; `try_catch` (ADR-0031) remains the native -construct for error handling. - - diff --git a/.changeset/17929-resume-failure-report-schema-strip.md b/.changeset/17929-resume-failure-report-schema-strip.md deleted file mode 100644 index d6e8b50ccb2..00000000000 --- a/.changeset/17929-resume-failure-report-schema-strip.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`ResumeFailureReport`'s docblock no longer invites a caller to parse that member with `ResumeFailureDetailsSchema` — the one path that deletes the report's `code`, silently. - -The docblock said two things in one paragraph: that a caller "that parses this member with `ResumeFailureDetailsSchema` reads the same three facts it reads off that door", and that `code` is the one member a success envelope cannot leave to its envelope, because on a success answer nothing else names the failure class. Each sentence is true on its own; together they route a reader into losing exactly the member the second one calls indispensable. `ResumeFailureDetailsSchema` declares `runId` / `status` / `repairable` and not `code`, and it is a plain non-strict `z.object`, so the key is stripped — measured on this tree, `safeParse` of a full report answers `success: true` with `error: undefined` and hands back an object with no `code` at all. No refusal, no `unrecognized_keys` issue, nothing logged. - -- **Prose only — no schema moves, deliberately.** `ResumeFailureDetailsSchema` is the wire schema of the automation resume door's `400 FLOW_FAILED` `error.details`, where the registered code rides on the `error` envelope it is parsed beside. Declaring `code` on it would put a second spelling of the failure class on that door's answer, widen a published accept surface, and break the "declared ONCE" identity the contract pin asserts — the report minus its `code` IS `ResumeFailureDetails`. The defect is in the sentence that misdirects, not in the schema, which is correct where it is actually used. -- **What a consumer does instead:** read `code` off the report. It is typed `ErrorCode`, required, and needs no parse. That schema stays the right reader for the three shared members, and the right reader on the resume door. -- **Both halves are pinned** in `contracts/resume-failure-report.pin.test.ts`: that the strip is silent (parse succeeds, no issue raised, no `code` in the output), and that the docblock carries the warning and no longer carries the invitation. Prose is unassertable except by reading it, so the contract source is read — the pattern that file already uses for the absence rule. - -Clause-②: no diff --git a/.changeset/17931-approval-onemptyapprovers-fallback.md b/.changeset/17931-approval-onemptyapprovers-fallback.md deleted file mode 100644 index 268f3cdf4e5..00000000000 --- a/.changeset/17931-approval-onemptyapprovers-fallback.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/plugin-approvals": minor -"@objectstack/lint": minor ---- - -Approval nodes gain a fourth empty-slate policy — `onEmptyApprovers: 'fallback'` with a sibling `fallbackApprovers` list — so a rung that expands to nobody opens the request on people you named instead of on a slot nobody can act on. - -Until now an approval node whose approvers resolved to nobody had three endings, and none of them named anyone: `admin_rescue` (the default — the request opens on a dead `type:value` slot and waits for a privileged admin), `fail` (the run dies) and `auto_approve` (the record is waved through). All five graph approver types reach that dead end, and `{ type: 'manager' }` reaches it without anybody authoring a wrong value: `manager` omits `value`, so the literal the expansion falls back to is `manager:undefined`. - -```ts -{ - approvers: [{ type: 'manager' }], - onEmptyApprovers: 'fallback', - fallbackApprovers: [{ type: 'org_membership_level', value: 'owner' }], -} -``` - -- **`fallbackApprovers` is the approver shape you already write** — the same entries as `approvers`, resolved by the same expansion, so every approver type, OOO delegation and `per_group` tagging behaves identically on it. It is not a second, reduced approver dialect. -- **The pairing is enforced in both directions.** `'fallback'` without a list is refused; a list under any other policy is refused too, because nothing would ever read it — a node that declares a rescue slate and silently ignores it is the failure this config shape is `.strict()` against. Both messages name both keys. -- **A fallback that itself resolves to nobody degrades to `admin_rescue`.** The run is never killed and the record is never waved through by a policy whose author only asked for different people; the log says both that the fallback fired and that it found nobody. -- **This is on the node, not on the `manager` rung** — the node is already where emptiness is decided, and a fallback is wanted for every approver type, not one of them. -- **`os lint` names the new escape and keeps firing without it.** `approval-approvers-may-resolve-empty` still reports a manager-only slate even when a fallback is declared: the rule reads shape, and a static check can no more prove a `fallbackApprovers` list resolves than it can read `sys_user.manager_id`. A seeded manager chain remains the one silencer. diff --git a/.changeset/17936-residue-rule-runtime-authoring-door.md b/.changeset/17936-residue-rule-runtime-authoring-door.md deleted file mode 100644 index 539e9b0c822..00000000000 --- a/.changeset/17936-residue-rule-runtime-authoring-door.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -`validateRetiredPermissionResidue` now runs at the runtime authoring door on `permission` writes, at advisory tier — so a Studio / REST `/meta` / MCP author who writes `allowRestore: false` or `allowPurge: false` and never runs `os lint` is told the line has no effect (#17936, out of #17425 ruling D). - -Clause-②: no - -The rule was registered `CLI_ONLY` on an open question its own `surfaceReason` recorded: does the gate's `body` reach it BEFORE the per-type `safeParse`, whose residue stage strips the only evidence it reads? Wiring it without that reading would have published a phantom check. **Measured: it does reach it.** `saveMetaItem` keeps the AUTHORED body verbatim on purpose — `parsed.data` would strip the Studio-only auxiliary fields an overlay rides with — and grafts back exactly two normalizations (filter `operator` spellings, the form `groups` → `sections` key move), each a walk over the authored keys that adds and removes nothing else. So `assertRuntimeAuthoringRules` is handed the raw document, the gate passes it through as `item`, and the residue is present in the snapshot the rule reads. - -What changes for a caller: - -- A `permission` publish carrying either retired key **still succeeds** and now returns one `advisories[]` entry per occurrence, in the door's existing six-key diagnostics envelope (`{severity, rule, where, path, message, hint}`) — the shape Studio and MCP already render for a 422's `issues[]`. `rule` is `permission-retired-lifecycle-residue`, `path` is the name-keyed `permissions..objects..`, and `hint` is the tombstone's own prescription, read from the schema rather than retyped. -- ⛔ **Never a refusal.** The rule is advisory tier; the accept set is untouched, and a value that is *not* the retired default (`true`, `0`, `null`) is still refused by the tombstone at the parse, with its prescription attached, exactly as before. -- **Draft saves are unchanged** (#4463 D1), and so is every other metadata type: `permission` is the only declared `runtimeTypes` member, because `stack.permissions` is the only collection the rule reads. -- **The CLI door is unchanged** — `os validate` / `os build` / `os lint` run the rule exactly as they did, with the same positional `permissions[i]…` path. The name-keying is the runtime gate's wire rewrite and does not reach the commands. diff --git a/.changeset/17964-environments-updatevisibility-retired.md b/.changeset/17964-environments-updatevisibility-retired.md deleted file mode 100644 index f1fd71fcd16..00000000000 --- a/.changeset/17964-environments-updatevisibility-retired.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/client': minor ---- - -**BREAKING** — `client.environments.updateVisibility(id, visibility)` is REMOVED from the published SDK surface. - -Clause-②: no - -A `major`-class change, recorded as `minor` under the launch-window convention. Director-seat decision batch #132 item 1, maintainer 「同意」, 2026-09-13; ADR-0049 enforce-or-remove. - -**Why.** The method's only behaviour was a write the control plane refuses. It PATCHed the generic `/api/v1/cloud/environments/:id` route with `{ visibility }`, and `visibility` is one of the server-owned columns that route rejects — the same accept-set (`display_name`, `is_default`, `metadata`) the `update` docblock already records. Its own docblock described a whole capability ("`public` lists the environment and freely exposes all revisions") that does not exist. The 2026-09-12 maintainer ruling keeps `visibility` server-owned and forced to `private` until the public-listing feature ships, at which point that capability arrives on its **own** endpoint rather than on this generic update — so this method was never going to be its carrier, even once it lands. A published method that is known never to be implemented is removed rather than left throwing forever. - -## No FROM → TO mapping, and why this section is not one - -There is no replacement to rewrite a call into, and stating one would be false. **Delete the call.** No behaviour is lost: the write it issued was already refused. The channel that reaches every affected consumer is the compiler, at their own call site — strictly more precise than any prose here. When the public-listing endpoint ships, a NEW method is written against it; ⛔ restoring this signature would re-declare the refused generic-update write. - -`objectstack migrate meta` has nothing to reach: an SDK call site is source code, not stored metadata, so no ADR-0087 conversion entry and no migration-chain step can act on it. - -Also in the same change: `packages/runtime/src/http-dispatcher.ts` loses an orphaned control-plane route-table docblock that documented routes that file does not serve — `/cloud/*` is skipped there, and the table repeated the corrected accept-set. Comment-only; no runtime byte moves. - - diff --git a/.changeset/17973-compare-preset-lowering.md b/.changeset/17973-compare-preset-lowering.md deleted file mode 100644 index e502f30ba2d..00000000000 --- a/.changeset/17973-compare-preset-lowering.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -fix(analytics): a `dateRange` preset plus `compareTo` is lowered and shifted instead of refused as an "invalid date" (#17973) - -`DatasetExecutor.runCompare` read the STRING arm of `dateRange` as -`[range, range]` — the degenerate fallback #17015 removed from every other -analytics face. `parseUTC` was handed the preset NAME, so a declared, honoured -member of the closed vocabulary was refused outright. Measured end to end -through the executor, a valid preset plus `compareTo`: - -``` -DATASET_INVALID 400 [dataset-executor] invalid date in dateRange: "last_30_days" -``` - -The diagnostic is not merely unhelpful, it is FALSE. `last_30_days` is exactly -what the schema, the dashboard date filter and the docs tell an author to -write, so "invalid date" sends them to check a date that is already correct — -a repair that does not exist. This face was not in #17015's kit, so nothing -measured it and nothing noticed. - -Both arms now go through one face lowering, which calls the shared -`resolveAnalyticsDateRangeString` for the string arm — the same call the -ObjectQL strategy, the native-SQL strategy, the draft-preview evaluator and -driver-memory's cube face make — and the lowered window is then projected onto -the comparison math's UTC calendar, with `endExclusive` honoured so that a -calendar preset's exclusive upper bound does not itself add a day to the -projected window. On the UTC calendar, `this_month` plus -`compareTo: { kind: 'previousYear' }` now compares September against the -previous September, rather than refusing. ⚠️ Outside UTC the projection costs a -day of its own — third note below. - -Three consequences worth knowing when you upgrade: - -- **A string outside the vocabulary now answers the shared envelope.** On this - path it used to be `DATASET_INVALID`; it is now - `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400, the ADR-0112 envelope the other - faces already raise, with the message that lists the thirteen declared preset - names. One condition, one envelope. Code keying on `DATASET_INVALID` for an - unrecognised `dateRange` STRING should key on - `ANALYTICS_DATE_RANGE_UNRECOGNIZED` instead. -- **The caller's explicit `[start, end]` window is untouched**, bound for bound, - with the inclusive upper reading it has always had — including the - `DATASET_INVALID "invalid date in dateRange"` refusal for a bound that is not - a date, which is unchanged. -- **⚠️ A calendar preset lowered in a NON-UTC zone gives a comparison window one - day too wide** — in either direction, depending on which side of UTC the zone - sits. The comparison math is UTC-calendar throughout (`parseUTC` reads a bare - day as UTC midnight, `toISODate` emits a UTC day), so a window computed - against another zone's calendar is projected onto UTC day boundaries: east of - UTC the start lands a day early, west of UTC the end lands a day late. - Measured through the executor, `this_month` plus - `compareTo: { kind: 'previousYear' }` frozen at `2026-09-09` — - `UTC` gives `['2025-09-01','2025-09-30']` (30 days, correct), - `Asia/Shanghai` gives `['2025-08-31','2025-09-30']` and `America/New_York` - gives `['2025-09-01','2025-10-01']` (31 days each). ⛔ This is NOT a - regression: the same input used to be refused outright, so no - previously-working input behaves differently — what changed is that the - preset arm produces a window at all, which is what makes the projection - observable. Tracked in #18245. It is deliberately not repaired here, because - a timezone-aware calendar-day extraction in this module would be the second - implementation `analytics-date-range.ts`'s own header exists to refuse. - -`runCompare` is also registered as a face in the shared `dateRange` conformance -kit, so the next face that forgets to lower a preset is caught by a test rather -than by a customer. diff --git a/.changeset/17975-objectql-per-row-previous-docblock.md b/.changeset/17975-objectql-per-row-previous-docblock.md deleted file mode 100644 index 2051b99186b..00000000000 --- a/.changeset/17975-objectql-per-row-previous-docblock.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -docs(objectql): the per-row `before*` docblock states the #16074 rule — a row-invariant-in-effect rewrite is ADMITTED (#17975) - -`dispatchPerRowBeforeHooks`'s docblock (ADR-0058 Addendum II, clause D3) still -said per-row `previous` was supplied *"so a guard can REFUSE the write (throw), -not so a rewrite can be aimed"*, and a test comment in -`bulk-write-per-row-hooks.test.ts` said the same. Ruling #16074, landed in -`@objectstack/spec` by PR #17249, retired that: a per-row `previous`-conditioned -rewrite is admitted when its written KEY SET is the same on every matched row -and is assigned IN PLACE, kept safe by the engine's -`MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099). Key-set divergence, a -per-row VALUE and a row-conditioned REPLACEMENT of `ctx.input.data` all stay -outside the contract. - -This is published text, not an internal comment: JSDoc on a `private` member -survives `.d.ts` emit. Measured in the shipped `@objectstack/objectql@17.4.0` -tarball — the retired sentence is present in six published files, including -`dist/util-Dw5ZTIII.d.ts:3554`, on a member of the `ObjectQL` class that both -the `.` and `./core` entrypoints export. Every consumer's editor surfaces it on -hover, so as soon as spec's changeset is consumed the two packages would state -opposite contracts. - -No behaviour change: the engine already follows the new rule, and the three -shipped provenance stamps (`email-template-provenance.ts`, -`sharing-rule-provenance.ts`, `webhook-provenance.ts`) all assign in place. The -admitted shape's coverage already exists in -`multi-update-hook-key-divergence.test.ts`; the test comment now points at it. - -Graded `patch`: the act moves published PROSE. It adds no exported symbol, no -key and no accepted value — the accept set was widened by PR #17249 in -`@objectstack/spec`, not here — so this PR declares no clause ②. diff --git a/.changeset/17987-element-navigation-declaration.md b/.changeset/17987-element-navigation-declaration.md deleted file mode 100644 index da5fff3152b..00000000000 --- a/.changeset/17987-element-navigation-declaration.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): declare `navigation` on the standalone `object-kanban` / `object-calendar` element faces, and give `object-timeline` the `ComponentPropsMap` row it never had (#17987) - -Clause-②: yes (widening) — one new optional key on two published element faces plus one new row, so the accept set grows. Nothing previously admitted is refused, nothing is renamed or retired, and no producer is required to write anything. - -**What changes for an author.** A record-click navigation block written on a -STANDALONE element node is now declared where it is read. Before this, the same -document ran correctly in objectui's renderer and was refused by name at the -authoring door: - -``` -FROM ComponentPropsMap['object-kanban'].safeParse({ objectName: 'task', - navigation: { mode: 'drawer' } }) - -> success: false, unrecognized_keys: ['navigation'] -TO -> success: true, navigation: { mode: 'drawer', preventNavigation: false, - openNewTab: false, size: 'auto' } -``` - -`object-calendar` moves identically. The value is `NavigationConfigSchema` — -the same def `ListViewSchema.navigation` already declares, taken by reference, -so a standalone element and a list view speak one vocabulary and the retired -`navigation.view` key (17.5.0) stays retired on every face that carries it. - -**`object-timeline` gains a row.** It was registered in objectui and reachable -through the component type union's open string arm with no entry in -`ComponentPropsMap`, so the authoring gate skipped it entirely: a real key and -a typo rode through alike. The row declares the key set measured from the -renderer's own read points at the `.objectui-sha` pin this repo builds against -— `objectName`, `timeline`, `filter`, `sort`, `limit`, `data`, `items`, -`variant`, `dateFormat`, `rowLabel`, `minDate`, `maxDate`, `descriptionField`, -`mapping` and `navigation` — and refuses everything else, the flat `startDateField` / -`titleField` / `scale` handoff spellings with a prescription pointing at the -`timeline` config block that owns them. - -**What does NOT change.** The view-level `navigation` on `ListViewSchema` is -untouched: the element key is an ADDITIONAL carrier for the standalone -placement, not a replacement, and both faces keep judging the same block. The -parse is unchanged for every document that did not author these keys, and the -component type union is not narrowed — an `object-timeline` node reaches -`PageComponentSchema` through the open string arm exactly as it did before. - -Executes the objectui#8652 maintainer ruling (verbatim `B`). diff --git a/.changeset/18012-between-blank-endpoint-refused.md b/.changeset/18012-between-blank-endpoint-refused.md deleted file mode 100644 index eeaf7d3ca3a..00000000000 --- a/.changeset/18012-between-blank-endpoint-refused.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING for authored metadata** — a `$between` range now requires two endpoints that are present and non-empty. A blank bound (`''` or an absent `undefined` bound, at either side) is refused at the authoring door, and the refusal names the blank side (#18012). - -Clause-②: yes - -Maintainer ruling A on decision batch #146 item 5, 2026-09-17 「146 同意」. - -## What changed, and why it is a new rule rather than a repair - -`FieldOperatorsSchema.safeParse({ $between: [1, ''] })` answered `success: true` — measured on the card against spec 17.4.0 and re-measured on `main` before this change. That acceptance was **conformant**: the endpoint contract shared by both bounds says verbatim that "Each endpoint is a number, a Date, or a string", and the empty string is a string. So this narrows a published face by adding a rule to it, rather than pulling code back to a declaration it was already violating. - -What made the acceptance wrong is the other half of the same contract — "Closed interval [min, max]" — which no backend can honour against a blank. `driver-sql` binds the blank into `whereBetween`; the JS matchers compare it as a value. Either way the range stops bounding on that side **while still reading as a complete two-element range**, so the query runs with one meaningless boundary and no signal at any layer. The reference matcher was already taught to survive the `null` form of exactly this (a bounded range answered every valued row, because both of the arm's comparisons are false against a missing bound); the door that admitted it was never addressed. - -The only producer ever measured is a UI builder padding a **half-typed** pair so a length-based completeness check passes it. Nobody writes a blank bound on purpose — which is why it is refused rather than given a published meaning. - -``` -FROM FieldOperatorsSchema.safeParse({ $between: [1, ''] }) - -> { success: true } // a half-filled range, green all the way - // to the driver - -TO FieldOperatorsSchema.safeParse({ $between: [1, ''] }) - -> { success: false, - issues: [{ code: 'custom', path: ['$between', 1], - message: 'A blank value is not a valid $between endpoint at index 1 - (the MAX bound). …' }] } -``` - -## Migration — FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `{ $between: [1, ''] }` | `{ $between: [1, 100] }` — the upper bound you meant, written out | -| `{ $between: ['', '2026-12-31'] }` | `{ $between: ['2026-01-01', '2026-12-31'] }` — the lower bound you meant | -| a range that was only ever bounded on ONE side | `{ "$gte": min }` or `{ "$lte": max }` — a one-sided bound is not a range | - -**The one-line fix: write the bound that is missing, or — if only one side was ever meant — drop `$between` and write that side as a scalar comparison.** ⛔ Not mechanically convertible: the bound the author did not type is not recoverable from the one they did, so this ships as an ADR-0087 D3 structured TODO and **no D2 conversion**. Both of the two readings a conversion could take are wrong — dropping the operator deletes a constraint the author wrote and silently WIDENS the result set, and treating the blank side as unbounded invents a filter nobody authored. - - - -## What does NOT change - -- **Arity.** A one-element or three-element `$between` was already refused, and still is, by the tuple's own contract. This rule is about a two-element range one of whose elements means nothing. -- **`null` bounds.** Already refused since 2026-08-31, and they keep **their own** message, which prescribes the null predicate — an author who wrote `null` was reaching for absence, not for a bound. Two blank spellings, two intents, two remedies. -- **Falsiness.** `{ $between: [0, 100] }` and `{ $between: ['0', '9'] }` parse exactly as before. The rule is blankness, not falsiness. -- **Whitespace-only endpoints** are deliberately **not** judged. The ruling is the empty string; widening the refusal past it would narrow a published face further than the ruling did. -- **The set slots.** `{ $in: ['', 'won'] }`, `{ $nin: [''] }`, `{ $eq: '' }` and `{ $gte: '' }` are untouched — an empty string is a legitimate stored VALUE, and only an interval ENDPOINT is judged here. -- **Stored documents.** The read path does not re-validate stored rows, and the stored-row conversion pass neither validates nor drops anything, so no stored view becomes unreadable. What changes is that **re-saving** one is refused, at the endpoint's own path, with the blank side named. -- **The published export surface.** No export is added, removed or renamed; the refusal rides the existing endpoint factory that both the documentation copy (`RangeOperatorSchema`) and the enforced copy (`FieldOperatorsSchema`) already share, so the two cannot drift. diff --git a/.changeset/18023-capability-name-collision-diagnostic.md b/.changeset/18023-capability-name-collision-diagnostic.md deleted file mode 100644 index 4057a25e79e..00000000000 --- a/.changeset/18023-capability-name-collision-diagnostic.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -A **capability name collision now reaches the author**. When a package declares a capability whose name a *different* package already owns, `bootstrapDeclaredCapabilities` refuses to write into that row — correct under ADR-0086 D4, and unchanged — but the refusal is no longer invisible (#18023). - -Measured on the pre-change tree, with a collision seeded and **no logger passed**: - -``` -skippedForeign = 1 (the declaration was dropped) -author-visible console lines = 0 (log, info, warn, error, debug — all five) -diagnostic records on outcome = undefined -``` - -The branch reported through `logger?.warn?.(…)` — optionally chained **twice** — so a caller that passed no logger produced no output at all, and a package's whole declared capability vanished with one internal counter incremented. This module's own header said such a row was "skipped loudly"; nothing about it was loud. Same case after the change: - -``` -skippedForeign = 1 (unchanged — the skip is not what was wrong) -author-visible console lines = 1 warn: [security] [capability_name_collision] … -diagnostic records on outcome = 1 { name, declaredBy, ownedBy, grantedBy, message, fix } -``` - -**What the author is told is axis-specific, and deliberately not a copy of the permission-set wording.** On that axis the entire declared set is not materialized and none of its permissions are in effect. Here the capability name still *resolves* — the owning package's row answers for it, and the seeder still reports the name as materialized so the back-compat derivation does not clobber that row. What is lost is narrower and is now stated precisely: the declaring package's authored `label`, `description` and `scope` are not applied, and `sys_capability.package_id` attributes the capability to the other package, so the declaring package has no provenance claim over it. The record also names the bootstrap permission set(s) that grant the capability, so the blast radius does not have to be looked up. - -New published surface on `@objectstack/plugin-security`, for the same reason the permission-set diagnostic is published — the author-time door must consume one derivation rather than re-spell it: - -- `CAPABILITY_NAME_COLLISION` — the stable `capability_name_collision` grep token. -- `capabilityNameCollisionDiagnostic()` / `CapabilityNameCollisionDiagnostic` — the record. -- `formatCapabilityNameCollisionDiagnostic()` — the one-line rendering. -- `reportCapabilityNameCollisions()` — the report channel, which prints through `console.warn` when no sink is injected and keeps the receiver when one is, so a class-based host logger does not throw. - -⛔ **The owner-comparison predicate is not duplicated.** Both axes call the existing `permissionSetNameIsForeign`, and the capability seeder's branch now routes through it instead of its own `===`, so a nullish owner reads FOREIGN on both axes by construction. - -`CapabilitySeedOutcome` gains an optional `collisions` key carrying those records, so a caller that reads no log at all can still ask what happened. It is absent, never `[]`, when a pass collided on nothing. diff --git a/.changeset/18024-permission-set-collision-compile-door.md b/.changeset/18024-permission-set-collision-compile-door.md deleted file mode 100644 index 1a0b0c68eaf..00000000000 --- a/.changeset/18024-permission-set-collision-compile-door.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os build` and `os validate` now report a **permission-set name collision** — the compile-time half of the #17516 refusal, raised behind the SAME predicate and the SAME sentence as the runtime door so the two cannot drift (#18024). - -When two packages in one artifact declare a permission set under the same name, `bootstrapDeclaredPermissions` refuses to write into the row the first one owns. That refusal is correct under ADR-0086 D4 and is **unchanged here** — the whole declared set (its object, field, tab and system permissions) is dropped at boot, and nothing else reports it. #17516 gave that drop a runtime door; until now no door said anything at compile time, so the first an author heard of it was a boot warning on a deployed environment. - -Measured on the pre-change tree (`origin/main` 8fe5cb8e5), by grep over `packages/cli/src`, `packages/spec/src` and `packages/metadata/src`: - -``` -permission-set collision diagnostic, compile time = 0 files -control: `collision|duplicate` in packages/cli/src = 20 files (so the zero is a reading, - not a dead grep) -``` - -Both commands now compute it, and the findings ride the `warnings` key both payloads already declare — no new top-level key, and no new published export. - -- **Reports; it never refuses.** `severity: 'warning'` is declared at the producer and the failure direction is CLOSED: the set is not installed, so nothing is over-granted. Exiting non-zero would narrow what `os build` accepts, which is the option #14553's ruling weighed for `navigationContributions` and did not take. -- **One derivation, so the two doors cannot drift.** The owner comparison is `permissionSetNameIsForeign` and the sentence is `permissionSetNameCollisionDiagnostic` + `formatPermissionSetNameCollisionDiagnostic`, both consumed from `@objectstack/plugin-security`'s package entry — where #17516 published them for exactly this consumer. No second predicate, no retyped sentence: two doors phrasing one refusal differently is the defect, not the fix. -- **Only the composed case is judged.** A name owned by a package some *other* artifact installed is invisible without a database and stays unreported — the same bound the navigation-contribution check keeps for a contribution aimed at an app no package here ships. -- **A package re-declaring its own set name is not a collision.** That is an idempotent re-seed at runtime, which is why the check asks the shipped ownership predicate rather than counting duplicate names. Ablated on disk: removing that one call leaves the suite at 1 failed / 9 passed, and restoring it returns 10 / 10. diff --git a/.changeset/18028-import-users-manager-second-pass.md b/.changeset/18028-import-users-manager-second-pass.md deleted file mode 100644 index 54d056a7375..00000000000 --- a/.changeset/18028-import-users-manager-second-pass.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -The bulk identity import admits `manager_id`, resolved in a second pass keyed on the importer's identity key - -`POST /api/v1/auth/admin/import-users` now reads a `manager_id` column. Until -this change it matched `manager_id` **0** times — against a positive control of -`email` at 73 — so a CSV naming everyone's manager built the org chart for -nobody, silently: the column was dropped on create (the identity write path -composes its own better-auth body) and filtered out on upsert (it is not in -`SYS_USER_IMPORT_UPDATE_FIELDS`). With -`POST /api/v1/auth/admin/set-user-manager` shipped, the import surface was the -one remaining route that could populate the column at scale and did not. - -**What the cell holds is an identity key, not a user id.** A CSV author has the -manager's email or phone number, never their `usr_…` id, so the cell is read -with the same key the importer already keys rows by. One spelling, `manager_id` -— the phone column's three historical aliases are debt this key does not -inherit. - -**The pass is SECOND, and that is load-bearing.** A manager named in row 40 may -be created by row 90, so the links are applied after the row engine has -returned and every row in the batch exists. A resolve inside the per-row write -would refuse exactly that input and would appear to work only on a file whose -rows happened to arrive in dependency order. A manager who is *not* in the file -is resolved against the directory instead, so an org chart can be grown one -batch at a time. - -**Every refusal is the write surface's, applied per row.** The importer calls -`applyUserManagerLink` — the same derivation `POST /admin/set-user-manager` -runs — so self-assignment, a link that closes a cycle, a chain past the depth -cap, a manager provably outside every organization the user belongs to, and any -identity whose `sys_user.source` is `idp_provisioned` are refused on import -exactly as they are on the endpoint, with the endpoint's own `reason` -discriminator carried through. There is no second copy of those predicates. - -**A manager problem never costs the row its identity.** The user is created -either way; the failure is reported on that row — `rows[].manager` carries the -machine-readable outcome in the shape `rows[].delivery` already uses -(`unresolved`, or the refusal's own `reason`), and `rows[].error` carries the -sentence. It is ⛔ not a whole-import failure and ⛔ not a silent skip, and an -engine fault while linking is reported the same way rather than turning a 200 -that created N users into a 500 that reports none of them. No `rows[].code` is -stamped for a manager outcome: a row-level code would have to be registered in -the `packages/spec` error-code ledger, which this change is fenced out of, so -the machine-readable half lives on `rows[].manager` instead of on a code the -vocabulary does not carry. - -**New on the response.** `data.summary.manager` is -`{ linked, unresolved, refused }`, beside `data.summary.delivery`, and the -run-level `sys_audit_log` row records the same split. Row objects are typed as -the newly exported `IdentityImportRowResult`, whose `manager` member is an -`ImportManagerOutcome`. - -**Unchanged, deliberately.** `SYS_USER_PROFILE_EDIT_FIELDS` and -`SYS_USER_IMPORT_UPDATE_FIELDS` are untouched — the import reaches the column -by system context, the same way it already reaches `phone_number` and `role`, -and the same way the admin endpoint does. `manager_id` keeps `readonly: true` -on the column. Nothing derives a manager from org-unit membership. A dry run -does not run the pass at all and reports zeroes rather than half-answering -about links it could not evaluate. diff --git a/.changeset/18031-permissions-key-two-readings.md b/.changeset/18031-permissions-key-two-readings.md deleted file mode 100644 index 96ad72556a7..00000000000 --- a/.changeset/18031-permissions-key-two-readings.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/spec": patch ---- - -A package whose `manifest.permissions` carries the ADR-0025 capability grant is now NAMED when the audience-binding reconciler skips it, instead of vanishing; and both halves of the `permissions` key now point at each other in the spec (#18031). - -`permissions` has two incompatible readings and the package registry stores both in the same slot. At the AUTHORING stage `ManifestSchema.permissions` is the capability grant a plugin requests — the legacy flat `string[]`, or the structured `{ services, hooks, network, fs }` block (ADR-0025 §3.2). At the ASSEMBLED stage the collection wins and the same key is the ADR-0090 `PermissionSet[]` collection (`AssembledPackageBodySchema`, ADR-0130 D4). `SchemaRegistry.installPackage` records whichever stage its caller handed it. - -- **`collectDeclaredSuggestions` reports the reading it cannot use.** It wants the assembled one. Handed the authoring one it returned an empty list and logged nothing: the structured arm is an object, so `Array.isArray(manifest.permissions)` was false and the value never entered the loop; every member of the legacy arm is a bare string, so `consider`'s `typeof ps !== 'object'` line dropped all of them. A package declaring the other reading produced no `sys_audience_binding_suggestion` row, no prompt and no log. It now warns once per engine per package and arm, naming which arm it found, what is lost if the author meant permission sets (no admin is ever prompted to bind the set, and the deployment goes on looking healthy), and where the sets belong — the package's own `defineStack({ permissions: [ … ] })`, which is what the assembled body carries. -- **`warn`, not `error`, and deliberately.** Nothing here claims to have persisted anything, so this is a functional degradation — a prompt that is not offered. Same reasoning, one step weaker, as the write-refusal report beside it, and the same sink (`SuggestionDeps['logger']`, which declares no `error`). -- **Reported once per engine per package+arm.** The pass runs at boot, after every package-door `permission` publish and on every list call, while a manifest's shape is fixed for as long as that package is installed; an undeduplicated line would repeat on every console page load and be skimmed past. -- **The spec half is declaration text only — no key, export, arm or accept-set moved.** `ManifestSchema.permissions` now says it describes the AUTHORING stage and names the assembled-stage counterpart; the stack collection `permissions` names the manifest-stage grant; and `InstalledPackageSchema.manifest` says it is the authoring STAGE rather than "whatever was stored", pointing at `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` for the stage a `defineStack()` host installs. -- ⛔ **The union at the key was NOT widened, and must not be.** Widening a manifest key into a union of both stages is road C of #14242, rejected by name by the maintainer on 2026-09-02 in favour of road B — declare the assembled stage rather than widen the authoring one — because a union at the key makes neither stage checkable (Prime Directive #12). That ruling is why the fix here is a report and a cross-reference rather than a schema change. diff --git a/.changeset/18048-tombstones-name-the-carrier-release.md b/.changeset/18048-tombstones-name-the-carrier-release.md deleted file mode 100644 index 0f6663e29cc..00000000000 --- a/.changeset/18048-tombstones-name-the-carrier-release.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/core': patch ---- - -fix(spec,core): every ADR-0049 tombstone names the npm release that actually carries its removal, and a gate keeps it that way (#18048) - -Clause-②: no - -Thirty-six sites across fifteen files dated a removal to the next npm major of -`@objectstack/spec` — a bare **18** attached to the package name. -There is no npm 18, and under ADR-0087's level ruling (Amended 2026-09-13) there -will not be one as the carrier for a retirement: *"A tombstone names the npm -release it ships in, ⛔ never the protocol major […] a retirement shipping -`minor` lands in `17.x.y`"*. An author who met one of these was sent to a -version that does not exist. A sibling repository had already hung a cleanup -schedule on "the PR that pushes the spec package to its next major" — an event -that will never come. - -**The number was determined per site from `packages/spec/CHANGELOG.md`, not -pasted.** The sites split cleanly in two, and the two halves take different -spellings because different things are known about them: - -- **Already shipped ⇒ the release that carries it.** The three - `PluginHealthCheck` restart keys (`b72db01`), `HotReloadConfig.stateStrategy` - / `distributedConfig` (`4635f3e`), `HotReloadConfig.watchPatterns` - (`ee3595c`) and the form-view `options[].default` narrowing (`c459da6`) all - landed in **`17.3.0`**, which is published. These say `17.3.0` — the version - an upgrading reader greps in the CHANGELOG. -- **Not shipped yet ⇒ the bare published major `17`.** The seven cron-typed - positions, the `scheduled` cache-warmup strategy and the three - `PluginStartupResult` members are still unreleased changesets, so the carrier - minor is unknown at authoring time and any digit would be a guess — the same - guess that produced this defect. ADR-0087 guarantees the major: a pre-GA - retirement ships `minor`, so the carrier is some `17.x.y`. Bare `17` asserts - exactly what is known, cannot go stale as minors accumulate, and is the house - form already on 588 other sites. - -**Why `Clause-②: no`.** Every affected string is a docblock, a doc page, or a -`retiredKey()` / `guidance` MESSAGE. The key is refused before and after, so the -accept/reject result does not move for any input. The control that decides it: -the phrase has 0 hits across `packages/*/api-surface` and -`packages/*/export-origins`, so no published declaration baseline carries these -sentences and none moves. - -**No protocol-major reference is altered.** `toMajor: 18`, `step18` and the -`PROTOCOL_VERSION` ladder are correct and untouched — ADR-0087: *"the two move -independently"*. - -**Three sites are deliberately left saying 18**, because they quote the wrong -number in order to forbid it: the ADR-0087 ruling itself, and the two -`docs/v17-docs-sweep.md` rows that carry this class's detection fingerprint. -Four more say `99` on purpose — a synthetic "next major" fixture that must name -a version that does not exist. - -**A gate lands with the prose**, because this is the class's second appearance: -ten sites of it were corrected by hand in July with no gate, and the card closed -`completed`. `pnpm check:future-spec-major` derives the class from -`packages/spec/package.json` at runtime — a major above the published one, never -a hardcoded 18 — joins string-concatenation, JSDoc and plain-wrap line breaks -before matching, and reads the backticked package name, because each of those is -an independent way for a matcher to read zero and print green. diff --git a/.changeset/18053-package-registry-capability.md b/.changeset/18053-package-registry-capability.md deleted file mode 100644 index 9fe516aa53b..00000000000 --- a/.changeset/18053-package-registry-capability.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`package-registry` is a platform capability of its own, and an always-on one: the `sys_packages` container and the boot hydration that replays it no longer hide behind the `marketplace` token, which is left naming only the optional catalogue / browsing half (#18053, director ruling A′ on #17676). - -A package is a first-class persistent entity whether or not a deployment has a store — an admin-created package does not depend on the marketplace existing. Until now the only way to get the persistence was `requires: ['marketplace']`, so a stock boot had no `sys_packages` at all and `protocol.installPackage` / `updatePackage` fell back to their in-memory branches: an admin-created package did not survive a restart, under a token advertising a store that was not there. - -- **`PLATFORM_CAPABILITY_TOKENS` gains `package-registry`** — one new token, none removed, so `marketplace` keeps working exactly as before for anyone who declares it. The vocabulary is a closed set validated by `defineStack`, so this widens what an app may write, and nothing it already writes stops parsing. -- **`PLATFORM_ALWAYS_ON_CAPABILITIES` gains `package-registry` at the tail.** The slate's ordering contract is a role, not a count: the entry binds into nothing on the slate (its one hard requirement is the ObjectQL engine, which is not a capability token), so it joins after every bind target like any other reader. `--preset minimal` still opts out of the whole slate. -- **`PLATFORM_CAPABILITY_PROVIDERS` gains a row naming `@objectstack/service-package`, `open` edition** — the same package `marketplace` names today, because that package ships exactly one plugin and everything it does is the persistence half. The catalogue surface `marketplace` is left naming ships in `@objectstack/cloud-connection` and is mounted off a resolved marketplace URL, never through the token; repointing the `marketplace` row at it moves the runtime's own resolver with it and is the engine-lane half of the same ruling (#17676 items 2/3/5). -- ⚠️ **Declaration first, runtime second — measured, not assumed.** `objectstack serve` mounts a slate entry only when `Serve.CAPABILITY_PROVIDERS` keys the token, and that registry keys `marketplace`. Until the engine-lane half lands, appending `package-registry` mounts nothing under the standalone CLI: a stock boot is exactly as capable as before, no more and no less. This package is the single list both the CLI and cloud's per-tenant runtime read, which is why the declaration is the half that goes first. diff --git a/.changeset/18056-email-template-locale-rungs.md b/.changeset/18056-email-template-locale-rungs.md deleted file mode 100644 index e956d5dd8a2..00000000000 --- a/.changeset/18056-email-template-locale-rungs.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -docs(spec): scope the email-template locale-floor claims to a call that NAMES a locale (#18056) - -Clause-②: yes — no accept set moves (no key is added, removed or revalidated), -but what a PUBLISHED package states about its own resolution contract is -corrected, which is a contract act in substance. - -`packages/spec` stated two different rung counts for one resolution. -`EmailTemplateDefinitionSchema.locale`'s `describe` and the -`EMAIL_TEMPLATE_FLOOR_LOCALE` docblock published **one** retry rung and an -explicit "no fallback floor at all"; `SendTemplateInput.locale` in -`contracts/email-service.ts`, same package, documents a **three-rung** ladder -whose third rung is reachable exactly on the path the first says cannot exist. - -Measured against the runtime rather than reconciled by preference — -`EmailService.resolveAndRenderTemplate` and `createSysEmailTemplateLoader` in -`@objectstack/plugin-email`, and the CI pins in -`template-locale-resolution.test.ts` — the three-rung text is the correct one: - -1. the named locale, matched exactly (no language-subtag folding); -2. the literal `en-US`, which is also where a call naming no locale starts; -3. **only for a call that named no locale**, and only when the bundle carries - no `en-US` row: the bundle's lowest locale tag. - -So a bundle with no `en-US` row dead-letters (`TEMPLATE_NOT_FOUND`, permanent) -for every recipient whose locale was NAMED, and silently renders whichever -language sorts first for every call that named none. The shipped declaration -promised the loud permanent refusal on the path where the runtime performs the -silent fill; an author reading it was told a missing locale always -dead-letters. Both call shapes are now named wherever the floor is claimed, and -the ladder itself is stated in one place only. - -Also corrected: `SendTemplateInput.template` said the service "picks the -best-matching locale row", which the resolver has never done — there is no -best match and no folding, only the ladder above. - -`defineStack`'s `warnEmailTemplateLocaleFloor` gains a declaration of the two -shapes it deliberately does NOT examine (a stack whose `i18n.supportedLocales` -is absent or empty; a bundle whose tags all fall outside `supportedLocales`) — -both can still ship a floorless bundle. Its control flow is unchanged — the same -bundles warn, once each, and the warning stays advisory — but the emitted warning -TEXT did change, and now names BOTH call shapes: it says the bundle has no -fallback floor *for a send that names a locale*, and adds that a send naming NO -locale does not fail but drops to that bundle's lowest tag and renders it -silently. A test asserting on the old wording needs updating. Whether either -undeclared shape should warn is the ADR-0049 enforce-or-remove question and is -not answered here. Both shapes are now pinned against a warning discriminator so -neither can change without a test saying so. diff --git a/.changeset/18058-install-door-contract-rebind.md b/.changeset/18058-install-door-contract-rebind.md deleted file mode 100644 index c670fa2a2bc..00000000000 --- a/.changeset/18058-install-door-contract-rebind.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/runtime": minor -"@objectstack/client": patch ---- - -The package-install request contract now names the door that actually serves it, declares the two body forms that door accepts, and the door honours `enableOnInstall` instead of ignoring it (#18058). - -`PackageInstallRequestSchema` was declared, published and bound to `POST /api/v1/packages/install` — a path the composed runtime mounts nowhere: the dispatcher answers `handled=false` and `@objectstack/rest`'s registrar mounts only `POST /api/v1/packages/publish`. Meanwhile `POST /api/v1/packages`, the door that answers `201`, had no declared request contract at all, so the read contract was strictly more truthful than the write contract producing the rows it describes. - -Clause-②: yes (widening) - -**What moved on the published surface** - -- `PackageApiContracts.installPackage.path` — `'/api/v1/packages/install'` → `'/api/v1/packages'`. A caller that read the constant to build a URL was building one nothing serves; a caller that hard-coded the old string gets a `404` today and should send `POST /api/v1/packages`. The method (`POST`) is unchanged and is what distinguishes this entry from `listPackages`. -- `PackageApiContracts.installPackage.input` — `PackageInstallRequestSchema` → the new `PackageInstallBodySchema`. The wrapped schema is still exported and still parses the wrapped form; the new export is a union that also parses a bare manifest. -- `PackageInstallRequestSchema` gains **`overwrite?: boolean`**. This is a declaration of behaviour that already shipped: the door reads `overwrite` from the body (or `?overwrite=true`) to opt back in to replacing an already-installed id instead of answering `409 Conflict`, the first-party SDK sends it, and no schema declared it — so any parse at that door would have silently stripped it and turned a deliberate re-install into a conflict. -- **`PackageInstallBodySchema`** / `PackageInstallBody` / `PackageInstallBodyParsed` are new. The door reads `body.manifest || body`, and first-party callers really do post a bare manifest as the whole body, so the contract declares both forms as a union — every parse is a full parse of one coherent form, never a tolerant shape. The two branches are disjoint, but only the BARE one is CLOSED: `PackageInstallRequestSchema` is a plain `z.object`, so an unknown key on the wrapped form is DROPPED (`{ manifest, bogus: 1 }` parses and `bogus` is gone) while the same key on a bare manifest is refused by name. That asymmetry matches the door, which reads four keys off the wrapper and ignores the rest — closing the wrapped branch would refuse bodies the door answers `201` to. The bare form carries no install options: `settings`, `enableOnInstall` and `overwrite` are not manifest keys and the manifest surface is closed, so a bare-form caller reaches `overwrite` through the query string alone. - -**What moved at the runtime** - -`POST /api/v1/packages` now honours `enableOnInstall: false` in the wrapped body: the package installs `disabled`, through the same registry flip and durable state write `PATCH /packages/:id/disable` uses, so a restart does not re-enable what the caller switched off. `true` and absent install enabled, which is the declared default. Previously the key was declared in three schemas, sent by the SDK, and read by no handler at all. - -The durable write happens on **both** arms, not just the disable. `POST /packages` is a create that an already-installed id reaches through `overwrite`, and `DELETE /packages/:id` does not clear this record either, so an install could answer `201` with `enabled: true` while the state file still listed the id as disabled — and `SchemaRegistry.installPackage` reads that file at boot, re-installing the package DISABLED one restart later with nothing red in between. The mirror of that risk is why the write follows the ROW this door returned rather than the request's intent: `SchemaRegistry.installPackage` lands an id in the boot-seeded `initialDisabledPackageIds` DISABLED whatever the request says, and `enableOnInstall` defaults to `true`, so persisting the request would clear an operator's earlier disable off disk on the SDK's default call while the row being served says `enabled: false`. A flag-absent install of a seeded id therefore answers `enabled: false` and records it disabled — wire, registry and disk agree, and the next boot reads the same. Every install now persists the state it returned. - -**What the declaration does NOT cover — the measured residual** - -This is a subset description of the live door, deliberately, and it is recorded rather than implied. Measured through `HttpDispatcher.handlePackages`, the door also answers `201` to: a manifest missing `type` and/or `version` (both of the runtime's own door drives post one); unknown keys on either form (refused by name on the bare branch, dropped on the wrapped one, `201` either way); a string-typed `enableOnInstall` / `overwrite`, which is compared against `true`/`false`/`'true'` and therefore treated as absent — `enableOnInstall: 'false'` installs ENABLED; and install options spelled on the bare form, which are ignored. In the opposite direction the door answers `400` to a whitespace-only `id` this declaration admits. `ManifestSchema` is not relaxed to close any of that. - -**Documentation** - -`packages/client`'s README install example could not parse against the manifest contract — no `id`, no `type`, and a `label` key the closed manifest surface refuses by name — and the live door answered it `400 Package id is required`. It is now a manifest that parses, and the example names the `overwrite` opt-in beside it. diff --git a/.changeset/18063-transport-declares-no-transactions.md b/.changeset/18063-transport-declares-no-transactions.md deleted file mode 100644 index 6d11a31ea7e..00000000000 --- a/.changeset/18063-transport-declares-no-transactions.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor -"@objectstack/objectql": minor -"@objectstack/driver-sql": minor -"@objectstack/driver-turso": minor ---- - -feat(spec,core,objectql,driver-sql,driver-turso): a transport can declare it has no transactions, and every transaction gate reads the declaration instead of method presence (#18063) - -Maintainer ruling, decision batch #148 item 3, letter B, 「同意」 2026-09-17, verbatim and untranslated: - -> `packages/spec`: the driver contract gains a way for a transport to **declare 「no transactions」** (the dev picks the smallest spelling the existing capability/contract surface already has — a capability bit is preferred over a new key), and the engine's transaction gating reads the declaration instead of method presence. - -**`DriverCapabilities` gains one live bit, `transactionsUnsupported`.** A transport sets it to say that a handle it issued would be a FALSE SUCCESS rather than a missing feature: the caller gets a handle, the writes execute and are already durable, `rollback()` resolves and undoes nothing. Absence means `false`, exactly like `batchSchemaSync`, so a driver that declares nothing keeps the behaviour it has today. - -**⛔ This is not `DriverCapabilities.transactions` un-retired, and the difference is not cosmetic.** That key was tombstoned in 17.0.0 under ADR-0049 enforce-or-remove and STAYS tombstoned — writing it is still a compile error and still a parse refusal carrying its prescription. It claimed "I support transactions" and nothing read it; this one declares "my transport cannot honour one" and the engine dispatches on it. Reviving the name would have inverted the record's own `absence = false` convention into a tri-state, turned a documented refusal into silent acceptance of a value whose meaning had changed underneath it, and made the tombstone's published text ("no code in any repository ever read it") false. A new key costs one bit; the name costs all of that. - -**Adding a bit to a record enforce-or-remove has pruned SATISFIES that ADR rather than reversing it.** The audit removed thirty-one bits for one stated reason — no code anywhere read them — and kept the three where method presence provably cannot carry the signal. This change is the creation of the missing reader: `driverSupportsTransactions()` (exported from `@objectstack/spec`) is the one definition of the gate, and all FOUR places that used to spell `typeof driver.beginTransaction === 'function'` ask it — `ObjectQL.transaction()`, `ScopedContext.transaction`, the `ScopedContext` begin/commit/rollback trio, and `@objectstack/core`'s `engineCanRollBack`. The bit arrives WITH its reader, in the same change, which is the honest order the ADR asks for. - -**Why method presence could not carry it.** `TursoDriver extends SqlDriver`, whose `beginTransaction()` opens a real knex transaction, so the inherited method reported the libSQL REMOTE transport as transactional. It is not — `RemoteTransport`'s data methods take no `options` argument at all, so a handle cannot reach the statement that would have to join it. A subclass cannot opt out of a door it did not open. This is the mirror of `batchSchemaSync`, which exists because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot. - -**What changes for a caller.** On a datasource whose driver declares the bit, `engine.transaction()` now takes the DECLARED non-transactional path (ADR-0119 D1) instead of opening a transaction it cannot honour: the degrade warns once per datasource — naming the declaration, not a missing method — and `{ require: true }` throws `TransactionUnsupportedError` before the callback writes anything. `ScopedContext.transaction` and the discrete begin/commit/rollback trio read the same predicate; the trio's `begin` returns `null`. Both are the answers a driver with no `beginTransaction` already received. - -**`driver-turso`.** The remote face declares `transactionsUnsupported: true`; local and embedded-replica inherit `false` from the base and are untouched. `TursoDriver.beginTransaction()` publishes the inherited declaration instead of `Promise` — the annotation the earlier `any` was masking an LSP violation to avoid, dissolved rather than widened: the remote arm returns `never` (it refuses), so the only arm that still returns is the base's. `SqlDriver.beginTransaction()` keeps its narrow `Promise`; nothing in the base was widened. - -**`@objectstack/core`.** `engineCanRollBack()` — the ADR-0119 D4 gate that `@objectstack/metadata-protocol` uses for `batchData` / `updateManyData` / `deleteManyData` under `options.atomic`, and that `runMigrationJournal()` uses to decide whether to start at all — reads the same predicate. It has to: it does not open the transaction itself, it vouches that `engine.transaction()` will, and on a driver that declares the bit the engine now takes its non-transactional path. A gate still reading method presence would vouch for a runtime that is about to run the callback with no transaction, so the atomic batch would answer `rollback` over writes that stayed on disk and the journal would write `chunk_done` rows its own contract says mean "committed". What a caller sees on such a datasource instead: `batchData({ atomic: true })` refuses with `501 NOT_IMPLEMENTED` — retry without `atomic`, or probe `capabilities.transactionalBatch` on `/discovery` first — and `runMigrationJournal()` refuses with `MigrationJournalRefusal('NOT_IMPLEMENTED')` before writing a single journal row. Both are the answers a driver with no `beginTransaction` already received. - -**`RemoteTransport` loses `beginTransaction()`, `commit()` and `rollback()`.** They are a published surface, and this is **minor** rather than major on the ruling's own stated ground: that transport never honoured a transaction, so no working behaviour is withdrawn. They had already become unreachable from every caller in the repository when the driver started refusing them; they are now gone, and the declaration keeps them gone by design rather than by audit. diff --git a/.changeset/18066-meta-item-absent-404.md b/.changeset/18066-meta-item-absent-404.md deleted file mode 100644 index e1f55c0c6dd..00000000000 --- a/.changeset/18066-meta-item-absent-404.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -`GET /api/v1/meta/:type/:name` answers `404 RESOURCE_NOT_FOUND` for a name with nothing behind it, instead of `200` carrying the declared envelope minus its `item` member (#18066). - -Measured on a real server (`examples/app-showcase`, API 17.4.0, four absent names, all identical): - -``` -GET /api/v1/meta/app/no_such_app_xyz -200 {"type":"app","name":"no_such_app_xyz","lock":"none","editable":true,"deletable":true,"resettable":false} -``` - -Two declarations in this repository already said otherwise, and this restores what they declare rather than deciding anything new. `GetMetaItemResponseSchema` — the route's own `responseSchema` — makes `item` a required member; parsing the body above against it fails `invalid_type` / `expected: 'nonoptional'` at `item`. And the **cached** arm of this same route has always answered this condition with `404 RESOURCE_NOT_FOUND`, because `getMetaItemCached` throws on a falsy `item`. Which arm a request took was deciding whether absence was an error at all — `app`, `dashboard`, `doc`, `book`, `?state=draft`, `?preview=draft`, `?package=` and every `enableCache: false` deployment are diverted around the cache. - -- **Every type is affected, not only `app`.** The fall-through sat in the shared tail of the uncached arm, below the per-type gates. The report measured `app` because that type bypasses the cache structurally; a `?state=draft` or `?package=` read of any type reached the same 200. -- ⚠️ **The break was at `JSON.stringify`, not in the producer.** `metadata-protocol`'s `getMetaItem` returns `{ type, name, item: undefined, lock, … }` for a miss — `item` is *present* holding `undefined`, which `z.unknown()` admits — so the returned object conforms and only the serialized body does not. A conformance probe written against the object rather than the wire bytes reports agreement. -- **The permission denial is unchanged.** `403 PERMISSION_DENIED` for an app that exists and whose `requiredPermissions` the session lacks answers exactly as before: the new check is ordered ahead of every gate, and those gates are reachable only by a document that exists, so an absent name can never be converted into a denial. Enumerating app names through the 403 stays impossible. -- **It also closes an enumeration hole in the other direction.** ADR-0045 §3 makes an unpublished app *externally unobservable*, and an unpublished app answered this 404 while a nonexistent name answered the 200 — so the pair of responses reported which app names exist-but-are-unpublished. Both absence answers now come from one emitter and are byte-identical. -- **An unreadable metadata store is still `503`, never this 404.** That distinction is a producer-side throw and never reaches the new check. - -⚠️ **For callers**: a probe that read "the call did not throw" as "this name resolves" now sees the 404 it should always have seen. A caller that read the item-less 200 as a create-vs-edit signal must read the status instead. The console side was already corrected independently (objectui#9262 reads both dialects as absence), so no first-party consumer depends on the old shape. diff --git a/.changeset/18075-agreement-shape-is-an-offence.md b/.changeset/18075-agreement-shape-is-an-offence.md deleted file mode 100644 index 8705188ffd8..00000000000 --- a/.changeset/18075-agreement-shape-is-an-offence.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`latencyMs` and `frequencyHours` name their unit in the published describe, and `check:duration-unit-keys` refuses the agreement shape - -`AIUsageRecord.latencyMs` carried no `.describe()` at all, and -`DatabaseLevelIsolationStrategy.backup.frequencyHours` described `'Backup -frequency'`. Both keys already carried their unit in the key NAME and in a JSDoc -block above it — and neither of those is a channel the published JSON Schema or -`content/docs/references/**` prints. So the reference page published -`frequencyHours | integer | Backup frequency` and left the reader to infer the -unit from the key name, which on a duration is a guess with a 3600x error on the -other side of it. Both describes now name the unit, and the `description` in the -shipped JSON Schema moves with them. - -**Ruled 2026-09-18 (decision batch #158 item 5, letter A).** The AGREEMENT shape -— a unit in the key name, the SAME unit in the JSDoc, none in the describe — IS -an offence. `check:duration-unit-keys` carried a carve-out -(`!jsdocUnits.some((u) => keyUnits.includes(u))`) that spared it for one release -while the question sat open, together with two self-test cases pinned as -DEFERRED and a header note recording shape (b) as repealed. The carve-out is -gone, those two cases are POSITIVE controls, and shape (b) is a base refusal -again. Agreement between a key name and a source comment is agreement between -two channels the published page does not print; it says nothing about the one -it does. - -⚠️ **This also makes an already-published sentence true.** The changeset for -#15939 states that the gate refuses a key whose JSDoc names a unit its describe -does not, *"or there is no describe at all"* — which over-claimed by exactly the -two rows above while the carve-out stood. The two rows are remediated and the -carve-out is removed, so the claim now holds of the gate; nothing is edited in -place to make it hold. - -The `EpochMs` instant exemption reads the JSDoc channel too, riding the same -ruling. It refused a describe that contradicted the schema but never a JSDoc -that did, while the duration-type exemption beside it refused all three -channels — the same lie with two answers depending on which exemption class the -key fell into. No row in the tree carried the shape; a fixture pair pins it. - -⛔ No published key, accept set, default or runtime behaviour moves. The two -changes to shipped artefacts are `description` strings. - -Clause-②: no diff --git a/.changeset/18091-seeder-refusal-diagnostics.md b/.changeset/18091-seeder-refusal-diagnostics.md deleted file mode 100644 index b31044db520..00000000000 --- a/.changeset/18091-seeder-refusal-diagnostics.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -**The five remaining seeder refusals now reach the author.** The two declared-metadata seeders refuse to write in five more places, and every one of them reported through `logger?.warn?.(…)` — optionally chained **twice**, so a caller that injected no logger got no output at all (#18091). - -Measured on the pre-change tree, each site driven with **no logger passed** while all five console channels were spied, beside the two already-repaired axes as lit controls in the same harness: - -``` - counter author-visible lines -curated platform capability refused skippedPlatform = 1 0 -capability declaration unowned skippedUnowned = 1 0 -capability rows unreadable unreadable = 1 0 -permission set declaration unowned (no counter at all) 0 -permission set rows unreadable unreadable = 1 0 -LIT CONTROL capability_name_collision skippedForeign = 1 1 -LIT CONTROL permission_set_name_… skippedForeign = 1 1 -``` - -Every one of those zeros is now a 1, with the counters unchanged. - -⛔ **No skip changed.** They are correct under ADR-0086 D4 (a package never writes into a foreign record) and ADR-0086 D3 (a package-managed row with no `package_id` makes uninstall undefined). The defect was only that the refusal never reached the author who caused it. - -**Each site words its own consequence** — the reason a mechanical copy was rejected. A curated-platform-name hijack still *resolves* against the curated row, so nothing is denied and only the authored metadata and the provenance claim are lost; an unowned **capability** has three different outcomes depending on what already stands in `sys_capability`; an unowned **permission set** keeps every grant working (the evaluator resolves declared sets through the metadata registry) and loses only the *record* — the Setup surface, the provenance axis and uninstall; and an unreadable read compared nothing, so nothing is lost and nothing arrived either. One generic "declaration skipped" line would send the first author hunting for a broken grant that is not broken. - -**What is shared is exactly one thing: where the line goes.** This shape had already been repaired one instance at a time twice, each repair restating the same two lines at its own call site. `reportThroughSink()` is now the single derivation, so a sixth refusal site cannot re-earn this card. It also improves on both spellings it replaces: a host sink that lies about its shape used to buy safety with silence (`logger?.warn?.(…)`) or noise with a throw (`logger.warn(…)`) — the `typeof` guard buys neither, and keeps the receiver so a class-based host logger does not throw. - -New published surface on `@objectstack/plugin-security`, on the criterion the two existing collision diagnostics state and no wider — a refusal an **author** can cause has a second door by construction (`@objectstack/lint`, `os build` / `os validate`), and both of these are decidable from the declaration alone with no database: - -- `CAPABILITY_PLATFORM_NAME_REFUSED` / `capabilityPlatformNameRefusedDiagnostic()` / `reportCapabilityPlatformNameRefused()` and the `CapabilityPlatformNameRefusedDiagnostic` record. -- `CAPABILITY_DECLARATION_UNOWNED` / `capabilityDeclarationUnownedDiagnostic()` / `reportCapabilityDeclarationUnowned()` and the `CapabilityDeclarationUnownedDiagnostic` record. -- `PERMISSION_SET_DECLARATION_UNOWNED` / `permissionSetDeclarationUnownedDiagnostic()` / `reportPermissionSetDeclarationUnowned()` and the `PermissionSetDeclarationUnownedDiagnostic` record. - -⛔ The two unreadable-rows summaries are deliberately **not** published: an unreadable database is a runtime condition no compile-time door can raise, so they stay package-private for the reason `position_name_fold_grant` does. - -⚠️ The end-of-pass `logger?.info?.(…)` summary in each seeder keeps its outer `?.` **deliberately**. A pass that did its work and refused nothing must stay silent on every console channel with no sink injected; routing a healthy boot's info line to the console would turn that control into noise and buy no author anything. The refusal channel is the one where silence was the defect. diff --git a/.changeset/18095-retire-reference-carrier-shape-gate.md b/.changeset/18095-retire-reference-carrier-shape-gate.md deleted file mode 100644 index 4c88331622b..00000000000 --- a/.changeset/18095-retire-reference-carrier-shape-gate.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/lint": minor ---- - -A `reference` carrier that no reader can read is now **REFUSED** where it is read, instead of coming back as `undefined`. The source-level gate that guarded the same shape (`check:reference-carrier-shape`) is retired in the same change (#18095, executing a maintainer ruling). - -`FieldSchema.reference` is `z.string().optional()`, so `ObjectSchema.safeParse` already refuses an object- or array-valued carrier at the contract door with a located `invalid_type` issue. Measured on the pre-change tree: - -``` -ObjectSchema.safeParse({ fields: { invoice: { type: 'lookup', - reference: { object: 'shop_invoice' } } } }) - -> success = false, issue invalid_type at path ["fields","invoice","reference"] -control: the same object with reference: 'shop_invoice' - -> success = true (so the refusal is about the carrier's SHAPE) -``` - -What was missing was the other door — the one a value reaches only when it never went through parse at all. #13053's fixture spelled `reference: { object: … }` inside `fields:`, and the rule reading it answered `undefined`: refused where it was written, read as absent where it was consumed, reported nowhere. The fixture passed, and would have kept passing. - -**New export — `referenceCarrierOf(def, reader?)` in `@objectstack/spec/data`.** It answers the carrier as the string the contract declares, and throws a `TypeError` naming the shape and the fix when the key is present in any other shape. `null`, `undefined` and `''` are ABSENCE, not a wrong shape, and still answer `undefined` — a field is allowed to name no target. - -**`referenceTargetOf` reads through it**, so the single arbiter of "what does this field expand into" refuses rather than answering "no target". Every consumer that already asks the arbiter — `$expand`, the record-title deriver, the dangling-reference audit, the analytics dimension labeller — inherits the refusal with no edit. - -**`@objectstack/lint`** routes its own target readers through the same accessor: `refOf` in `validate-security-posture.ts` (the reader in the #13053 incident) and in `data-model-rules.ts`, plus the object-graph slice every other rule downstream reads. - -Upgrading: nothing conformant changes. A non-string `reference` could not be authored, stored or parsed before this release either; what changes is that a hand-built fixture or a raw registry entry carrying one now fails loudly at the read instead of being silently treated as targetless. If a test asserted the old silence, assert the refusal instead — `packages/cli/test/data-model-rules.test.ts` is the worked example. diff --git a/.changeset/18102-flow-edge-member-filter.md b/.changeset/18102-flow-edge-member-filter.md deleted file mode 100644 index 85f91af6725..00000000000 --- a/.changeset/18102-flow-edge-member-filter.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`collectFlowGraphs` no longer hands out a `FlowGraph` whose `edges` can hold a non-record — the sibling list #16752's repair did not reach (#18102). - -`FlowGraph.edges` is declared `readonly FlowEdgeParsed[]`. The walk forwarded it untouched, four lines from the node-side member filter the same walk has carried since #16752, and a nested region's edge list is admitted on `Array.isArray` alone — which proves the LIST and never its MEMBERS. A YAML `edges:` list item left empty deserialises to `null`, and a region its own schema refused is left RAW for `validateControlFlow` to name, so the producer handed out an array holding a member its own declared element type excludes. Measured on `main`: - -``` -collectFlowGraphs({ nodes: [start, loop{ body: { nodes: [], edges: [null] } }], edges: [] }) - graph[1] scope="loop 'lp' body" edges=[null] declared readonly FlowEdgeParsed[] -``` - -- **The junk member is DROPPED, per list**, through the same one predicate the node side uses (`isRegionDict`), so the two lists the walk hands out cannot drift from each other. Copy-on-write per list: a well-formed flow is handed back the very same arrays. -- ⭐ **The real edge beside it is still HANDED OUT**, and so is the node list. "No non-record members" is half a contract — a filter that emptied `edges`, or reached into `nodes`, would satisfy it. Both are pinned. -- **This is a drop in the producer, not a refusal.** No authoring door's accept set moves: `FlowSchema.safeParse` still returns an envelope rather than throwing, the region `safeParse` refusal in `validateControlFlow` still owns and still reports the malformed region, and `FlowGraph.path` still indexes the RAW node list so a Zod issue stays anchored where the author wrote it. ⛔ Not a looser signature either — the declared element type is unchanged and is now true. -- **Latent, not live — measured, and not for the reason the filing gave.** There are THREE `graph.edges` consumers on the tree, not two. The two in `packages/lint` coerce through `recordsOf` (#16910). The third is `packages/services/service-automation`'s registration pass, which reads `.id` / `.source` / `.target` straight off each member with no guard, and is shielded only by call ORDER — `validateControlFlow` refuses the malformed region a few frames earlier in `registerFlow`. So no throw is reachable today, by one belt more than was counted. After this change the declared type carries it, and the next consumer needs neither a coercion nor a call-order argument. -- **`analyzeRegion` is not one of those consumers.** It throws a `TypeError` on a `null` / `undefined` edge member (measured), but nothing routes producer output into it: its in-repo callers hand it post-`safeParse` region data. It reads an edge list, it does not read `FlowGraph.edges`. -- **No behaviour changes on well-formed metadata.** The only input whose handling moves is input whose declared type already said it could not exist. - -Clause-②: no diff --git a/.changeset/18110-subflow-map-refused-rollup.md b/.changeset/18110-subflow-map-refused-rollup.md deleted file mode 100644 index 03fbde67e8f..00000000000 --- a/.changeset/18110-subflow-map-refused-rollup.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -fix(service-automation): on the synchronous path, a child run that REFUSES stops its parent, in `subflow` and in `map` alike (#18110, #18555) - -**Clause-②: yes (widening)** — `NodeExecutionResult` is barrel-exported from this package's single entry point, and it gains two new optional members. Nothing previously accepted is refused and nothing is retired, so this is a widening of the published executor contract, not a narrowing. Contract-review tier. - -A child flow that runs to completion in one go and ends on an `end` node declaring `outcome: 'refused'` used to roll up to its parent as an ordinary success. `subflow-node.ts` branched only on `child.status === 'paused'` and `!child.success`; a refused child is neither (`{ success: true, status: 'refused' }` — *a refusal is a successful evaluation that says no*), so it fell through the success exit. The parent walked the node's out-edges, recorded `completed` and fired its **own** `successMessage` over the child's refusal — the author got the exact opposite of what they wrote, fail-open. `map-node.ts` had the identical branch set and the identical hole: a refusing row let every row after it through. - -- **New on `NodeExecutionResult`: `refuse?: boolean` and `refusalMessage?: string`.** The executor-facing half of the unwinding protocol `suspend?: boolean` already uses. A node that sets `refuse` terminates its run as `refused` — a terminal status this package has published since #15788, so **no new status value** and nothing authorable changes. -- **`subflow` and `map` both set it** when their child run returns `status: 'refused'`. One channel, two call sites. -- **The child's `selected` / `acted` / `unmeasuredEffect` rollup (#4354) survives the refusal**, because the engine throws the refusal signal from the same position it throws the suspend signal: after the node's success step is pushed, after its `childSteps` are folded and after its output is written back. A child that refused really can have written rows before it said no. -- ⛔ **A refusal is still not a failure.** It does not consume retry budget, is not routable by a `fault` edge, and is not counted in `nodes[].failures`. -- **Region-boundary diagnostic, text only**: the message a structured region raises when a refusal tries to cross it now names whichever node carried the refusal, instead of asserting it was an `end` node — which, for a refusing `subflow`/`map` inside a region, sent the author looking for a node that was not in their region. Region **semantics** are unchanged. - -**Scope — the RESUMED leg is not covered.** This fixes the path where the child run finishes inside the parent's own `engine.execute` call and its outcome is read from that return value. A child that durably PAUSES first — a nested `approval` / `screen` / `wait` — and only refuses when it is later resumed still reaches its parent through the resume machinery, which reads the child's outcome at different seams and does not consult `status: 'refused'` at any of them. Both of those seams pre-date this change and neither is a regression of it, but neither is closed by it either, and the resumed leg is the one a screen flow actually takes. A follow-up card covers it: #18714. - -For third-party node executors this is additive: an executor that never sets `refuse` behaves exactly as before. diff --git a/.changeset/18113-icontains-text-comparand-refusal.md b/.changeset/18113-icontains-text-comparand-refusal.md deleted file mode 100644 index c2b3e1f2529..00000000000 --- a/.changeset/18113-icontains-text-comparand-refusal.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`@objectstack/spec/data` publishes the case-insensitive-contains **text-comparand door** — `isRefusedTextComparand(target)` and `textComparandRefusalReason(field, operator, target)` — so every face reads one implementation of a refusal the package already declared as data (#18113, objectui#9048 ruling D). - -`FILTER_TEXT_CASES` has carried two REJECTION rows for that operator since #5701 — an empty comparand and a non-string one, both `code: 'INVALID_FILTER'`, both `mustMention: ['$icontains']` — but only as cases a backend is *checked against*. Every face that honoured them wrote its own copy of the discrimination and its own wording, which is how the same authored filter came to be refused in one dialect and lowered onto the wire in another. The rule now lives with the producer of the rule. - -- **`isRefusedTextComparand(target)`** answers `true` for exactly those two shapes. It answers `true` for `undefined` as well: a vocabulary with an "absent" the `$` dialect does not have (a stored view rule whose operator takes no comparand) must test for absence **before** this door — that carve-out is the caller's, not a third row. -- **`textComparandRefusalReason(field, operator, target)`** returns the CONTRACT half of the message: **no leading capital, no trailing period, no envelope**, so each face seats it in its own sentence — a matcher that has a row to exclude logs it, a producer that has none throws it. ⛔ No new error code: `INVALID_FILTER` is declared and already in the ADR-0112 ledger. -- **`operator` is the spelling that ARRIVED** (`$icontains` from a `$`-dialect filter, `icontains` from the infix/view vocabulary), never a canonical substitute — telling an author about a key their dialect cannot contain is the misdirection this door exists to end. -- ⚠️ **Consequence for the infix dialect**: `mustMention` is spelled `$icontains` because the published rows' filters are, so for an arriving `icontains` the reason names what arrived and does **not** carry the `$`-dialect token. The face serving that vocabulary names the `$` twin in its own tail. Pinned in both directions in `filter-text-comparand.test.ts`. -- **The message bytes are the contract, not prose.** They are the bytes two shipped faces already emit byte for byte; `mustMention` is what makes a reword a different failure to honour the same row, and a transcription pin catches the reword `mustMention` cannot. ⛔ Change them only by changing the rows they answer. - -Additive: no existing export changes, no behaviour moves. `describeComparand` — the guard that keeps a BigInt or a cyclic comparand from making `JSON.stringify` throw *inside* the refusal — travels with the reason as a module-internal helper and is deliberately not published; exporting it is a published-surface decision for the PR that needs it. diff --git a/.changeset/18114-epochms-instants-tranche1.md b/.changeset/18114-epochms-instants-tranche1.md deleted file mode 100644 index 386bb47d068..00000000000 --- a/.changeset/18114-epochms-instants-tranche1.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -Ten wall-clock instants now declare their unit through the shared `EpochMs` schema (`@objectstack/spec/shared`) instead of a bare `z.number()`. No key is renamed and no key is added or removed. - -`EpochMs` is `z.number().int()` with the describe "Unix timestamp in milliseconds (epoch)". Adopting it moves each key's published JSON Schema from `{"type":"number"}` to `{"type":"integer"}` and puts the millisecond unit on the contract itself, where a reader of the reference page, the JSON Schema or the TypeScript surface all see the same answer. Before this, the unit lived in a JSDoc block (invisible in every published artifact), in prose that named only the epoch and not the unit, or nowhere at all — the ×1000 ambiguity a `timestamp: number` key carries by default. - -The keys, by schema: - -- `Data.DocumentVersion.createdAt`, `Data.Document.access.expiresAt` -- `System.SupplierSecurityAssessment.assessedAt`, `.validUntil`, `.remediationItems[].deadline` -- `Identity.Account.expiresAt` -- `Kernel.PluginLoadingEvent.timestamp`, `Kernel.PluginLoadingState.startedAt`, `.completedAt` -- the shared connector OAuth2 auth shape's `tokenExpiry` - -**What an author must change: nothing, unless they were writing a fractional millisecond.** Seven of the ten previously accepted any `number` and now accept integers only; `Date.now()` — the value every one of these keys is documented to carry — is already an integer. The three `Kernel.PluginLoading*` keys already declared `.int().min(0)`; they keep that floor (`EpochMs.min(0)`), so their accepted set is byte-for-byte what it was and only their description is new. - -`timestamp`, `tokenExpiry`, `deadline` and `validUntil` deliberately keep their names. `EpochMs`'s own docblock recommends spelling an instant `*At`, but a rename of a published key is a retirement with its own ADR-0087 entry and is not part of this change. diff --git a/.changeset/18118-retire-observability-cel-arms.md b/.changeset/18118-retire-observability-cel-arms.md deleted file mode 100644 index 4bd886b0232..00000000000 --- a/.changeset/18118-retire-observability-cel-arms.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING** — retire the CEL predicate arms of `ServiceLevelIndicator.successCriteria` -and `TraceSamplingConfig.composite[].condition`, the two observability predicates nothing -ever evaluated. - -Both slots were `z.union([, ])`. The -expression arm parsed, normalized a bare string to `{ dialect: 'cel', source }`, -registered, and was served back — and **nothing anywhere evaluated it**. An identity scan -over the whole tree finds every hit for `successCriteria`, `ServiceLevelIndicatorSchema` -and `TraceSamplingConfigSchema` outside `packages/spec/src` to be a generated artefact or -prose; inside it the only readers are the schemas' own unit tests and the two census tests -that enumerate expression slots. No service, plugin, runtime or CLI path reads either key. -So an author — very often an AI reading the generated reference page (ADR-0033) — who -wrote `successCriteria: 'p95 < 300ms'` got a green parse and no signal, indistinguishable -from a predicate that ran and answered. - -ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18 (director decision batch #160 -item 3, letter A). By the standing criterion that a declared-but-unread capability is kept -only when mainstream platforms in the domain have it: application platforms do not carry -SLI success criteria or trace-sampling conditions as authorable application metadata — -that lives in observability infrastructure (SLO products, OTel sampling policy) and is -structured there, not a free expression. The `cron-declared-unwired` family was retired -outright under the same ADR after the same measurement. - -## FROM → TO - -| you wrote (17.4 and earlier) | write instead | -| --- | --- | -| `successCriteria: 'p95 < 300ms'` | `successCriteria: { threshold: 300, operator: 'lt', percentile: 0.95 }` — the structured rule this slot has always carried | -| `successCriteria: { dialect: 'cel', source: 'p95 < 300ms' }` | the same structured rule; the envelope spelling goes with the bare-string one | -| `condition: 'record.amount > 10'` on a composite sampling branch | `condition: { service: 'api', attributes: { 'http.route': '/v1/orders' } }` — a structured filter object carrying no `dialect` key | -| `condition: { dialect: 'cel', source: 'record.amount > 10' }` | the same structured filter; an object carrying `dialect` is refused as an expression attempt | - -**The one-line fix:** delete the predicate and write the structured shape the slot already -carried. A criterion or a sampling rule the structured shape cannot express has no home in -application metadata at all — it belongs in the SLO product or the OpenTelemetry sampler -configuration that actually evaluates it. ⛔ Do not translate a predicate into a threshold -by guessing the number: nothing was evaluating it, so there is no behaviour to preserve and -a wrong number is worse than an absent one. - -## The retirement kit - -- **Neither KEY is retired — one ARM of each key's union is.** `successCriteria` and - `condition` both survive with their structured arm intact, so `retiredKey()` and an - ADR-0087 D2 strip are both the wrong tool: they retire a key. The prescription hangs on - the surviving schema's own `error` map, dispatched on `issue.input` — the - `HookBodyCapability` / `object.managedBy: 'system'` pattern for a narrowing a key - survives. -- **Where the prescription reaches, measured on zod 4.4.** A schema's `error` map is - consulted for the top-level `invalid_type` a NON-OBJECT raises, and not for the child - issues a wrong-shaped OBJECT raises. So on `successCriteria` the bare-string spelling - carries the prescription and the `{ dialect, source }` envelope is refused by the - structured arm's own missing-key issues (`threshold`, `operator`). On `condition` both - spellings carry it, because the structured arm is a record whose aborting `dialect` - refine sees the object itself. Pinned both ways in the schemas' unit tests, the negative - included: a value refused for a reason that is NOT the retirement must not borrow its - sentence. -- **ADR-0087 disposition: a D3 SEMANTIC entry**, `observability-cel-predicates-retired`, - not a D2 conversion. A predicate is an intent that no threshold/operator pair or - attribute filter records; a mechanical strip would delete what the author meant and leave - no trace of which SLI or which sampling branch lost it — and it would not even be lossless - in the weak sense, because `successCriteria` is REQUIRED (a strip leaves an SLI that no - longer parses) and a composite branch stripped of its `condition` declares no condition at - all. That is the one place this retirement parts company with the two precedents it copies - its MECHANISM from: `crypto.hash` on `HookBodyCapability` and `managedBy: 'system'` both - ALSO registered a D2 conversion, because for each of them a mechanical rewrite existed. - Here none does, which is what makes D3 the right disposition rather than merely an - available one. The prescriptions therefore carry **no** `os migrate meta` sentence — that - sentence is owed only where a conversion covers the surface. -- **The same-major D3 record is absorbed, per the playbook's 「同 major 记账」.** The - `evaluated-expression-slots-source-required` entry landed into this same unpublished step, - and it enumerated these two slots among its 36 declaring positions while instructing the - upgrader to give a sampling `condition` a dialect and a non-blank `source` — the exact - envelope this head now refuses. Both entries first ship together, so the composite of the - two changes is the retirement alone: that entry now reads 34 positions, names the two - absentees and why, and routes them to this retirement instead of to its own repair. -- **The surviving accept sets are pinned beside the refusals.** `successCriteria` still - takes `{ threshold, operator, percentile? }`; a composite `condition` still takes any - filter object carrying no `dialect` key — `{ source: 'x' }` included, because `source` - alone is an ordinary filter key and the retirement narrowed the `dialect` door only. -- **FOUR published JSON Schemas change projection direction**, and it is mechanical rather - than chosen: the retired arm held the last `.transform()` in each of these subtrees, so - each def now projects in output mode instead of falling back to the input shape. All four - lose `x-io: input`, and what each gains differs: - - | published schema | gains | - | --- | --- | - | `system/MetricsConfig` | `default: []` on `slis`, plus 8 `required` members | - | `system/TracingConfig` | `default: {"type":"always_on","rules":[]}` on `sampling`, plus 4 `required` members | - | `system/ServiceLevelIndicator` | one `required` member, `enabled` | - | `system/TraceSamplingConfig` | one `required` member, `rules` | - - Only the first two carry a `default` move, so only those two are declarable in - `DEFAULT_CHANGES_BY_MAJOR` — the nested pair's `required` growth has no ratchet row to - live in and is stated here instead. A `required` that lists defaulted keys is this repo's - existing output-mode convention, not a new one, and the same-category control - `system/CacheConfig` is untouched. The reference pages show the same signature: the nested - type cells of both pages lose the `?` from their default-bearing keys. **No runtime default - moves** — measured twice, by byte-identity of the untouched `.default(…)` and by parsing a - minimal config on the built package. - -## What is deliberately NOT in this change - -- **The structured arms.** `{ threshold, operator, percentile }` and the sampling filter - record are equally unread today. The ruling says so and leaves them to their own card: - they carry no dialect and are outside the expression ledger's remit. -- **`skills/objectstack-formula/SKILL.md`**, which still lists `metrics` / `tracing` under - `structured | cel`. The ruling assigns that correction to the skills lane, at tier, and - this diff does not touch it. -- **`packages/spec/src/shared/expression.zod.ts`.** `EvaluatedExpressionInputSchema` is - untouched and stays the schema of every remaining evaluated slot; what left is two - references to it. - -Shipped as `minor` under the repo's launch-window convention, in which `major` is refused -by `check-changeset-no-major` and breaking-ness is carried by the banner above plus the -ADR-0087 disposition rather than by the level. - -Clause-②: yes (narrowing) - - diff --git a/.changeset/18122-closed-duration-types.md b/.changeset/18122-closed-duration-types.md deleted file mode 100644 index 78dbfe3bde1..00000000000 --- a/.changeset/18122-closed-duration-types.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -spec(shared): closed duration types `DurationMs` / `DurationSeconds` beside `EpochMs` (#18122) - -Two new schemas and their type aliases, reachable on the **`@objectstack/spec/shared`** subpath — the same published surface `EpochMs` reaches consumers on, and the reason this is a `minor`: the entry gains exported symbols. The root `.` entry is deliberately untouched, because `EpochMs` is not on it either and mirroring the precedent means mirroring its width. - -```ts -import { DurationMs, DurationSeconds } from '@objectstack/spec/shared'; - -// the unit rides on the VALUE; the default stays at the site -updateAge: DurationSeconds.default(60 * 60 * 24).describe('Session update frequency'), -``` - -Both are `z.number().int().nonnegative()`. Author state and parsed state coincide — no `.default()` and no `.transform()` on the type itself — so there is deliberately no `DurationMsParsed` / `DurationSecondsParsed`, and the isomorphism is pinned (ADR-0122). - -**Why a type and not a longer name list.** `check:duration-unit-keys` (#14478, ruling B) reads one channel: a unit token in the key NAME, cross-checked against the `.describe()` prose. It deliberately declines to judge a key whose prose names no unit at all, because judging those by name alone was measured to fire 44 times and mostly on counts wearing a duration's vocabulary — `contextWindow`, `backoffMultiplier`, `snapshotInterval` ("every N events"). Ruling A on #18115 adds a second declaration channel instead: a duration declares its unit either on its value (one of these types) or as a token in its key name, and the 25-token name list retires from judge to hint. - -**Why this refinement**, measured against the six genuine duration rows the ruling derives the unit set from — `shutdownTimeout`, `cors.maxAge`, `slideInterval`, `session.updateAge`, `meta.duration` and `FileValue.duration`. Three of the six already declare `.int()`, and both rows that carry a default default to an integer (`30000`, `60 * 60 * 24`). One declares `.min(0)` and one `.positive()`; none declares a negative floor, so `.nonnegative()` is the weakest floor every declared floor implies — and `.positive()` would be too strong, since a zero timeout means "do not wait" and one of the six already accepts it. - -**Nothing else moves, on purpose.** This is step ① of three. No key is converted to the new types (#18124, step ③), and no gate behaviour changes (#18123, step ②): `check:duration-unit-keys` recognises exactly one identifier root today, `EpochMs`, so a key typed `DurationMs` is outside its population rather than exempted by it — the gate learns to read the new channel in step ②. `DurationMinutes` / `DurationHours` / `DurationDays` are deliberately absent: the unit set is derived from the conversion population, never declared ahead of it, so a third unit arrives in the PR that converts the row needing it. - -Nothing an author can write today is removed, renamed or refused: the six rows still declare exactly what they declared before this landed. diff --git a/.changeset/18124-genuine-duration-rows-declare-their-unit.md b/.changeset/18124-genuine-duration-rows-declare-their-unit.md deleted file mode 100644 index 2d9245ae55c..00000000000 --- a/.changeset/18124-genuine-duration-rows-declare-their-unit.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -spec: the genuine duration rows declare their unit — `DurationMs` / `DurationSeconds` and two `externalVocabulary` mirrors (#18124) - -**BREAKING** — three keys that accepted any `number` now accept whole, non-negative numbers only. No key is renamed, added or removed, and no exported symbol moves. - -Step ③ of ruling A on #18115. Step ① added the closed duration vocabulary and step ② taught `check:duration-unit-keys` to read it; this converts the rows the census found carrying a genuine duration with its unit written down in no channel a reader can reach. - -**Six rows declare the unit on the value**, by adopting `DurationMs` / `DurationSeconds` (`@objectstack/spec/shared`) and stating the unit in the describe the reference page renders: - -- `API.BaseResponse.meta.duration` — milliseconds -- `Kernel.HotReloadConfig.shutdownTimeout` — milliseconds -- `System.MetricAggregationConfig.window.slideInterval` — seconds -- `System.MetricsConfig.retention.downsampling[].resolution` — seconds -- `System.MetadataLoadResult.loadTime`, `System.MetadataSaveResult.saveTime` — milliseconds - -**Two rows declare it by mirror**, with `.meta({ externalVocabulary })` plus the unit in the describe, because the key name is fixed outside this repo and renaming it would break the correspondence that makes it readable: - -- `Kernel.KernelSecurityPolicy.cors.maxAge` — seconds, per CORS `Access-Control-Max-Age` (WHATWG Fetch). This is the same declaration its twin `CorsConfig.maxAge` already carried. -- `System.AuthConfig.session.updateAge` — seconds, per better-auth `session.updateAge`. Its sibling `session.expiresIn` already carried the marker; this closes the pair. - -**What an author must change: nothing, unless they were writing a fraction or a negative span.** Only `meta.duration`, `loadTime` and `saveTime` change what they accept — each was a bare `z.number()` and is now `z.number().int().nonnegative()`. `shutdownTimeout` declared `.int().min(0)` and `slideInterval` / `resolution` declared `.int().positive()`; all three keep their floor, so their accepted set is byte-for-byte what it was and only their description is new. The two mirror rows keep their types untouched. - -Every unit is a measurement of the row's producer, printed in the PR body per row, never a reading of the key name. - -Clause-②: no (narrowing) - diff --git a/.changeset/18133-liveness-governance-denominator.md b/.changeset/18133-liveness-governance-denominator.md deleted file mode 100644 index 86801d73049..00000000000 --- a/.changeset/18133-liveness-governance-denominator.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The liveness ledger's published README no longer claims the metadata-type registry is "exactly the set of authorable metadata types" — the governance denominator is now that set, and every run prints it - -`check-liveness.mts` built its coverage denominator from -`listMetadataTypeSchemaTypes()` under a comment stating that function returns -"exactly the set of *authorable* metadata types", and the ledger README carried -the same sentence. It is false in a specific, load-bearing way: that function -deliberately does not enumerate `UNREGISTERED_KIND_SCHEMAS` — enrolling those -entries there "would claim a status this change is careful not to grant" — while -the kinds bound in that map are authored on every boot through their stack -collections (`connectors:`, `sharingRules:`, `analyticsCubes:`, `webhooks:`) and -on every write through `PUT /api/v1/meta/:type/:name`, whose `resolveOverlaySchema` -resolves them through `getMetadataTypeSchema()`. - -So `connector`, `sharing_rule` and `analytics_cube` sat in **neither** `GOVERNED` -**nor** `PENDING_GOVERNANCE`, and a type in no bucket produces no row in any of -this gate's lists. The blindness was therefore invisible in the gate's own -output: `ungoverned: []` read exactly the same whether the gate had looked and -found nothing or had never looked at all. - -The denominator is now `authorableTypes()` — the registered kinds UNION -`listUnregisteredKindSchemaTypes()`, the enumeration helper that exists so a check -can read that map and which grants nothing by listing a name. The registry itself -is untouched: no kind is registered, no enum grows, no create seed is demanded and -no accept set moves, and the same split already landed one gate over as -`reachabilityRootTypes()` in `build-schemas.ts`. The three newly visible types are -recorded as declared debts with a reason and an issue number apiece, which is what -the ratchet asks for and what the README now says; the direction of travel is out -of that map and into `GOVERNED`. - -Every run also prints the denominator and its composition unconditionally. That -line used to appear only when `PENDING_GOVERNANCE` was non-empty, so the one state -worth reporting — "N authorable types looked at, none unaccounted for" — rendered -as nothing at all, which is the same silence an unseen type produces. diff --git a/.changeset/18139-client-anonymous-get-session-statements.md b/.changeset/18139-client-anonymous-get-session-statements.md deleted file mode 100644 index 5e53c63e64c..00000000000 --- a/.changeset/18139-client-anonymous-get-session-statements.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -'@objectstack/client': patch ---- - -`auth.me()` and the `/auth/*` wire table say what `/get-session` answers an anonymous caller TODAY: `401 UNAUTHENTICATED`, not `200 null` - -objectstack#17881 (`374d9d3afa`) landed `plugin-auth`'s -`refuseAnonymousSession`, which converts better-auth's `200` + the literal JSON -`null` on `GET /api/v1/auth/get-session` into the declared ADR-0112 refusal -envelope — HTTP `401`, `code: UNAUTHENTICATED` — before it leaves the process. -`@objectstack/client` reaches the server over the wire, so that is exactly what -it sees. Three present-tense statements in the SDK still described the retired -shape, none of them carrying a rev or a date, so none of them read as history. - -**FROM → TO for a caller.** An anonymous `auth.me()` no longer RESOLVES with -the literal `null`; it REJECTS. The SDK's shared `fetch` wrapper throws on the -non-2xx, so: - -| you wrote | write instead | -|:--|:--| -| `const s = await client.auth.me(); if (s === null) …` | `try { await client.auth.me() } catch (e) { if (e.code === 'UNAUTHENTICATED') … }` | - -That is the behaviour objectstack#17881 shipped; what moves here is only the -SDK's description of it. A reader coding against the old table wrote a `null` -branch that can never be taken and omitted the rejection branch that now fires. - -**What changed** - -- `normalizeSessionResponse`'s `/auth/*` transcript no longer lists the - anonymous `200 null` row among the bodies that helper is handed — it is not - handed that body at all, because the rejection happens one frame out. The - current answer is stated separately, anchored to the producer. -- The closing `!body`-guard paragraph no longer claims that guard carries the - anonymous answer, and no longer says closing the gap needs the published - return annotation to widen. objectstack#17238 ruled the opposite: the - producer moved and `SessionResponseSchema` is untouched. -- `auth.me()`'s docblock says the anonymous call rejects rather than resolving - outside its declared type. - -⛔ No behaviour changes. `SessionResponseSchema`, every published return -annotation and the `!body` guard's own code are byte-identical; only what the -SDK says about them moves. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/client`'s published `files[]` is -`["dist","README.md","CHANGELOG.md"]`, and `auth.me()` is a member of the -exported `ObjectStackClient`, so its TSDoc is emitted into the shipped -declarations — measured on the built artifact: the corrected sentence is -present in `dist/index.d.ts`, `dist/index.d.mts`, `dist/index.js` and -`dist/index.mjs`, the retired sentence is absent from `dist` afterwards, and -`getActiveMember` was carried as the lit control, found in the same four files. - -Clause-②: no — no schema key moves, no accept set widens or narrows, no export -changes, and `ERROR_CODE_LEDGER` / `StandardErrorCode` are untouched -(`UNAUTHENTICATED` is an existing standard member that objectstack#17881 -already derives via `standardErrorCodeForHttpStatus`). The direction is a -pull-back: the runtime already answers 401 and the SDK's self-description was -lagging. diff --git a/.changeset/18153-record-lock-message-user-facing.md b/.changeset/18153-record-lock-message-user-facing.md deleted file mode 100644 index d88ed0b84a6..00000000000 --- a/.changeset/18153-record-lock-message-user-facing.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -fix(approvals): the record-lock refusal names the record, not its primary key (#18153) - -Clause-②: no - -A record held by a live approval refused the write with -`record '' of '' is locked while an approval is in progress`. The -console copies that sentence into a toast verbatim, so an end user read an -opaque primary key and a machine identifier — neither of which tells them an -approval has the record — and a deny-path toast is exactly the string that ends -up in screenshots, screen recordings and support tickets. - -It now reads `Opportunity 'Acme renewal' is locked while an approval is in -progress, and cannot be edited until that approval is complete`, degrading to -`This Opportunity is locked …` when the object declares no resolvable title and -to `This record is locked …` when the registry is unreachable — ⛔ never back to -the id. The record id and the object's API name are not deleted: they move to -the CONSOLE (`logger.info`, alongside the pending request's id), which is where -a support path reads them and where a screen recording does not. - -**No read was added.** Both halves were already in hand at the refusal: the -object's `label` and its ADR-0079 title pointer come from the engine's in-memory -registry (`getSchema`), and the record itself is `ctx.previous`, the pre-image -the engine has already read — measured on all four update shapes (by-id, -`updateManyData`, predicate `multi`, unscoped `multi`), every one of which -dispatches the hook per row with `previous` bound. Deliberately NOT used: a -system-context read of the record on the deny path (it would title a row the -caller may not be allowed to READ — the very state this lock exists to gate) and -the `payload_json` snapshot (served redacted per reader). - -**Nothing else moved.** `RECORD_LOCKED` and its `409` are unchanged and pinned -in both directions, the `CODE: message` envelope is unchanged, and the three -OPERATOR-facing refusals in the same file — the two `PENDING_LOCK_LIMIT` cap -messages and the unanswerable-intersection message — still name the object's API -name, which is the useful thing to say to whoever has to rescope that write. -They are pinned byte for byte so a later "harmonise the lock's messages" sweep -cannot fold them into the end-user shape. - -A client asserting on the old sentence's text will need updating; a client -branching on `error.code` or the 409 needs no change. diff --git a/.changeset/18159-record-block-field-security-pair.md b/.changeset/18159-record-block-field-security-pair.md deleted file mode 100644 index d502a9a04c6..00000000000 --- a/.changeset/18159-record-block-field-security-pair.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`record:details`, `record:highlights` and `record:related_list` accept `enforceFieldSecurity` and `redactFields` — the two field-security keys objectui's detail renderers have been honouring on documents this contract refused by name (#18159). - -Clause-②: yes (widening) - -All three blocks are `strictObject`s that declared neither key, while `@object-ui/plugin-detail` reads both off each of the three. An author who wrote either was refused at publish, and the same document was honoured on the raw-node path — a contract that could not be satisfied by writing it down. Both keys are declared here, optional, with no schema default, so an absent key stays absent rather than becoming "the author asked for off". - -- **`enforceFieldSecurity`** (boolean) folds the block's field list — the detail body's fields and sections, the highlight chips, the related list's `columns` — through the caller's field-read permissions before rendering, so a field the permission set denies leaves no empty row behind. -- **`redactFields`** (string array) drops the names it lists outright. On `record:related_list` it also reaches the columns the list derives for itself when none are authored. -- **The claim is held to what the render path does.** Both are presentation filters, applied in the browser after the record is fetched: the values are in the page either way, so neither is a data-access control and neither is the object's `publicSharing.redactFields`, which removes them server-side. Each `describe()` says that in the text an author reads, rather than leaving the key names to imply it (Prime Directive #10). The gates that do keep a value from a caller are the field's own `requiredPermissions` / `maskingRule` (ADR-0066 D3) and the permission set. -- **⚠️ On `record:details`, `redactFields` neighbours the already-declared `hideFields`** and on a well-formed field list the two remove the same rows: `hideFields` is the dedupe channel the renderer also writes to (live `record:highlights` registrations, the page-title field), `redactFields` is the author's deliberate omission and the arm that participates in the renderer's fail-closed fold. Converging them is a contract question this change did not open. -- **The third key the same three renderers read — `requiredPermissions` — is declared in the same release, by its own entry.** It is the block-level ADR-0066 capability gate, not a member of this pair. - -⚠️ **Not measured here**: the runtime behaviour of either declared key in a browser, and whether any authored document anywhere writes them. "The schema refused it" is not "nobody writes it"; only the first is measured. diff --git a/.changeset/18159-record-block-required-permissions.md b/.changeset/18159-record-block-required-permissions.md deleted file mode 100644 index 80546d85116..00000000000 --- a/.changeset/18159-record-block-required-permissions.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`record:details`, `record:highlights` and `record:related_list` accept `requiredPermissions`, the block-level capability gate objectui's detail renderers read, with the same shape and the same describe as `record:quick_actions` — whose published describe changes in this release (#18159). - -Clause-②: yes (widening) - -- **Additive.** All three blocks are `strictObject`s that refused the key by name. It is now declared optional, `z.array(z.string())`, with no schema default, so an absent key stays absent and nothing that parsed before stops parsing. -- **One key, one meaning, one text, on all four record blocks.** The names are ADR-0066 capabilities (what permission sets grant through `systemPermissions`), not object actions. The user must hold all of them; otherwise the block renders an insufficient-permissions notice in place of its content. It is presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities renders the block as if they were held (fails open). To keep data from a user, gate the object, the field or the action. -- **⚠️ Published text changes: the describe of `record:quick_actions.requiredPermissions`.** It read "Hide the whole bar unless the current user holds every named permission on this object." Against the renderer the pinned console ships, "on this object" is false — the gate reads the user's capability set and is not object-scoped — and the sentence named no fail-open case. The shape is unchanged; only the text moves. If a page writes object actions there (`read`, `update`), the console reads them as capability names. diff --git a/.changeset/18163-export-hook-api-types.md b/.changeset/18163-export-hook-api-types.md deleted file mode 100644 index 8fd064cd754..00000000000 --- a/.changeset/18163-export-hook-api-types.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`@objectstack/spec/data` now exports the typed hook `ctx.api` face — `HookApi`, `HookObjectApi`, `HookQuery`, `HookCountQuery`, `HookUpdateDoc`, `HookUpdateOptions`, `HookDeleteOptions`, `HookDoc` and `HookDriverPassthroughOptions` — so a metadata app's `*.hook.ts` imports the platform's type instead of hand-declaring one (#18163). The same entry additionally re-exports `EngineTransactionInfo` and `EngineTransactionOptions`, which its public declarations reference structurally: without them a consumer that imports only `@objectstack/spec/data` and emits declarations answers `TS2883: The inferred type ... cannot be named without a reference to ...`. Type-only re-exports of the declarations `@objectstack/spec/contracts` already publishes, not second declarations. - -```ts -import type { HookApi } from '@objectstack/spec/data'; - -const api = ctx.api as HookApi | undefined; -if (!api) return; -const owner = await api.object('user').findOne({ where: { id: ctx.input.owner } }); -``` - -The platform already implemented this surface; it just never published a type an app could import, so every app re-derived the engine's option vocabulary in a copy that drifts the moment the engine moves. The reference third-party app carried ~2,358 authored tokens of one in a single file, imported by 17 hook files. - -- **The query shape is `where`-only — there is no `filter` key, deliberately.** `RPC_QUERY_ALIAS_SLOTS` declares `filter` as the alias of `where` (and `top` as the alias of `limit`); every engine entry point folds the `where` slot, collapsing redundant identical spellings and REFUSING the slot when the two spellings carry different values. So `{ where, filter }` is silent when they happen to agree and a runtime throw when they do not. Omitting the alias keys makes it neither: `TS2353: 'filter' does not exist in type 'HookQuery'`, at the authoring site. -- **Not a second dialect of `IScopedContext`.** `contracts/scoped-context.ts` stays the CHECKED IMPLEMENTATION contract ObjectQL's `ScopedContext` and `ObjectRepository` carry `implements` clauses against, with its deliberately loose `Record` bags. This is the authoring half of the same seam: `HookApi` is assignable to `IScopedContext`, so `ctx.api as HookApi` stays a direct cast, and nothing about the older contract changes. -- **Every option shape is DERIVED, not transcribed.** Each is an `Omit`/`Pick` over the `Engine*Options` schemas that the engine's own per-method legal-key sets are pinned against, so a key added to a schema reaches the published type in the same run it reaches the engine's accepted set. `count` is the one shape without the driver pass-through keys, because the engine forwards no bag on that method and rejects them there — engine behaviour no document states, and exactly what a hand-written copy gets wrong. -- **What is deliberately absent, each for a stated reason**: `context` (the repository injects it and discards a caller's), the `cursor` / `distinct` / `upsert` tombstones, `sudo()` (the #5945 exclusion stands — `Hook.runAs: 'system'` is the declared way to run elevated), and `aggregate` / `execute` / `create` / `deleteById`. - -Additive only: eleven new exported names from `./data` (nine new declarations plus two type-only re-exports), no removal and no signature change, so nothing an existing consumer imports moves. - -Clause-②: yes (widening) diff --git a/.changeset/18170-package-docs-uncollected-directory.md b/.changeset/18170-package-docs-uncollected-directory.md deleted file mode 100644 index 634e65cd6bc..00000000000 --- a/.changeset/18170-package-docs-uncollected-directory.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): `os build` says what the ADR-0046 package-docs collector did not read (#18170) - -Package docs are collected from exactly one directory — `/src/docs` -(ADR-0046 §3.2). Under an ADR-0130 multi-package layout, where every top-level -directory under `src/` is a package, a docs directory belongs to its package: -`src//docs/`. Move one there and the two conventions disagree in the worst -possible way — the collector reads nothing, the build prints its usual -`Collecting package docs (ADR-0046)...` step line, exits **0**, and writes an -artifact with no `docs[]` at all. Measured on `objectstack-ai/hotcrm` at -`590b095` (pin 17.4.0), `git mv src/docs src/sales/docs` as the only change: -four package docs gone, nothing in the output naming the loss. - -`os build`, `os validate` and `os lint` now report one **warning** per -`src//docs/` directory that holds Markdown, through the doc-issue channel -they already share (text face and `--json` `warnings` alike): - -``` -⚠ src/sales/docs: src/sales/docs/ holds 4 Markdown file(s) that were NOT - collected: package docs are read from src/docs/ only (ADR-0046 §3.2), so these - are absent from the artifact's `docs[]` and from every book that includes them. - Move them into src/docs/ (doc names carry the package namespace prefix, so - packages do not collide there), declare them inline as `defineStack({ docs })`, - or delete them if they are not package docs. Found: … - rule: docs/uncollected-directory -``` - -**Nothing that built before builds differently.** The rule is `warning`, not -`error`, on purpose: an error fails the build, and a `src//docs/` directory -is not declared anywhere the build can read — the collector can only *guess* it -was meant as ADR-0046 docs, and refusing a tree that is green today on a guess -is worse than the silence it replaces. What changes is that the loss is now -audible. Existing behaviour on the flat layout is byte-identical: `src/docs/` is -never itself flagged, and a subdirectory under it is still the -`docs/flat-directory` error it always was. - -**What this deliberately does NOT do**: it does not collect those files. -Reading package docs from each package directory widens the accepted set and -needs a decision this change does not make — an ADR-0130 D4 artifact registers -per package, so per-package docs have to say which package body they belong to, -and where they attach in an option-B artifact is open. The card -(objectstack-ai/objectstack#18170) offers both repairs and names the loud -failure as its minimum; that is the half delivered here. diff --git a/.changeset/18171-config-module-named-export-rule.md b/.changeset/18171-config-module-named-export-rule.md deleted file mode 100644 index c7ed09bed9e..00000000000 --- a/.changeset/18171-config-module-named-export-rule.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -The strict-parse refusal now says WHY a key the author never wrote inside `defineStack()` is being judged as a stack key. - -`objectstack.config.ts` is loaded as a MODULE: `loadConfig()` takes the default export as the base and -then merges every NAMED export onto it as a top-level stack key, under the export's own name. That is -deliberate — `onEnable` and `functions` are declared stack keys an app authors as named exports, and -unwrapping `mod.default` alone dropped them. The consequence nothing stated is that a named export is -legal only when its name is a key `ObjectStackDefinitionSchema` declares, so a helper exported beside -the stack (`export const collectPackageDirs = …`) arrives at the strict parse as a top-level stack key -of that name and is refused there as unrecognised. - -The refusal was already loud and named the key. It is unchanged: same key, same `unrecognized_keys`, -same failing parse, same exit code, same `--json` payload. What `os build` and `os validate` now add, -on the text face only, is the rule it enforces and the fix — move the helper into a sibling module and -import it from the config. `LoadedConfig` gained a `namedExports` reading so that explanation has a -provenance to read instead of guessing; nothing about which configs load has changed. - -The same rule is now on the config-authoring docs page, in the CLI configuration reference, and in the -comment every `os init` template ships at the top of the config it scaffolds. diff --git a/.changeset/18177-bulk-action-param-strict.md b/.changeset/18177-bulk-action-param-strict.md deleted file mode 100644 index be93c659d9d..00000000000 --- a/.changeset/18177-bulk-action-param-strict.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING for authored metadata** — `BulkActionParamSchema` is strict, matching its single-record twin `ActionParamSchema`, and declares `dependsOn` (#18177, decision batch #146 item 4, letter A). - -Clause-②: yes (narrowing) - - - -A list view's `bulkActionDefs[].params[]` entry was `.passthrough()`, so **the shape examined nothing** — and that is the whole finding, not the framing. Measured against installed spec 17.4.0, three parses per schema in one process: - -| | positive control (minimal valid) | negative control (nonsense key) | subject (`dependsOn`) | -| --- | --- | --- | --- | -| `BulkActionParamSchema` | parses | **ACCEPTED** | accepted | -| `ActionParamSchema` | parses | refused `unrecognized_keys` | refused `unrecognized_keys` | - -It accepted `zzz_nonsense_key_that_no_producer_emits_8755` in the **same run** that it accepted `dependsOn`. ⇒ "the bulk schema accepts it" was never evidence that a key was licensed, in either direction: a shape that examines nothing can neither authorise `dependsOn` nor refuse a typo. Both control legs are now pinned in `src/ui/bulk-action.test.ts` in their post-close form, together, so a future re-opening of the shape cannot pass as a green `dependsOn` assertion. - -The maintainer's ruling: 「Breaking for authored metadata」, one-shot — no grace window, no dual spelling. - -### `dependsOn` is DECLARED, not refused — and needs no edit - -It was already live on this surface and the renderer honours it, so this half is a contract catching up with behaviour. `bulkParamToField` does not destructure it out, so it rides the adapter's spread onto the field metadata, where **both** widget families read it: the option family (`SelectField` / `MultiSelectField` / `RadioField` / `CheckboxesField`) gates and refreshes the offered set through `useCascadingOptions`, and the reference-bearing pickers (`LookupField`, and `UserField` through it) lower it into a hard candidate filter. Retiring it was measured off the table — an ablation removing it from that spread reddens 7 of 12 cases in the consuming repo. - -Shape and description mirror **`FieldSchema.dependsOn`**, which is the single-record twin *for this key*: `ActionParamSchema` declares no `dependsOn` at all, because the single-record dialog reaches it through the field-backed route this surface does not have. One vocabulary, two doors. - -```ts -params: [ - { name: 'account', type: 'lookup', object: 'showcase_account' }, - { name: 'contact', type: 'lookup', object: 'showcase_contact', dependsOn: ['account'] }, - { name: 'owner', type: 'lookup', object: 'sys_user', - dependsOn: [{ field: 'account', param: 'account_id' }] }, // remote key differs -] -``` - -On a bulk param the "record" a binding resolves against is the dialog's own in-progress param values — a bulk run holds a selection, not a row — so a binding names a **sibling param of the same def**. - -### Migration — FROM → TO - -Every rejection names the surface, echoes the key and carries its own fix. Nothing below is mechanical, which is why this registers as an ADR-0087 **D3 structured TODO** rather than a D2 conversion: an arbitrary unknown key has no mapping target, and deleting it automatically is the silent data loss ADR-0078 bans. - -| You wrote on a bulk param | Write instead | -| --- | --- | -| `helpText: '…'` | `help: '…'` | -| `defaultValue: x` | `default: x` | -| `reference: 'sys_user'` | `object: 'sys_user'` | -| `displayField: 'name'` | `labelField: 'name'` | -| `field: 'owner'` (field-backed param) | declare it inline — `name` + `type`, plus `object` for a picker. The bulk surface has no field-backed route: `resolveActionParams` consults the object's field definitions for the single-record dialog, `toBulkParam` never does | -| `visible: '…'` on the param | move the predicate to the DEF (`bulkActionDefs[].visible`), which gates the button and narrows the run per record | -| `visibleWhen: '…'` on the param | it is a per-**option** key — write it inside `options[]` | -| `carryOver` / `defaultFromRow` / `requiresFeature` / `objectOverride` | ACTION-param contracts with no bulk equivalent: a bulk dialog runs over a selection and holds no row. Use `default` for a fixed prefill, or the def's `patch` for a value the user must not see; gate the button with the def's `visible` / `requiredPermissions` | -| `min` / `max` / `step` / `precision` / `scale` / `rows` / `accept` / `maxSize`, or the picker knobs `lookupFilters` / `lookupColumns` / `lookupPageSize` / `descriptionField` / `picker` / `subtitle` / `avatarField` / `idField` / `allowCreate` | remove the key — see the warning below | - -### ⚠️ The widget-config family really was honoured, and really is refused now - -This is the half of the narrowing that costs something, so it is stated rather than buried. Those keys rode the same `...extra` spread `dependsOn` rides, and whichever widget read one honoured it (`min`/`max`/`step` at NumberField / SliderField / CurrencyField / PercentField, `accept`/`maxSize` at FileField / ImageField, `rows` at TextAreaField / RichTextField, the picker knobs at LookupField). They are refused now, with one prescription naming `FieldSchema` as the shape they are real on. - -⛔ **Do not read that prescription as "declare it on the object's field instead"** — the bulk surface has no field-backed param route, so the value does not reach this dialog either. If a bulk param genuinely needs one of these keys, it has to be declared on `BulkActionParamSchema`; open an issue rather than working around it. They were not declared here because the census below found no author writing one, and a declared key is published contract whose removal costs a full retirement. - -**Census, with its boundary.** Taken at authoring time over the two repositories reachable from that session: `objectstack@176b03582e` (7 authored bulk-param literals) and `objectui@3e4f6324f7` (3) — **zero** carrying a key this shape does not declare. ⚠️ **hotcrm was NOT REACHABLE and is UNMEASURED, not clean.** If you keep your own metadata corpus, run `objectstack validate` before upgrading rather than inheriting this result. - -### What is deliberately NOT closed - -`params[].options[]` stays `.passthrough()`, on its own measurement rather than by symmetry with its parent: `bulkParamToField` spreads every option entry into the field metadata, and the option widgets read `color` / `icon` / `disabled` / `visibleWhen` beyond the declared `{ label, value }`. Closing it would delete widget config the renderer honours — the exact defect this change closes one level up. The declared pair is still type-checked. - -⛔ No renderer is edited and no key is removed from any other shape. `BulkActionDefSchema` was already strict and is untouched. diff --git a/.changeset/18179-timer-duration-unusable-refusal.md b/.changeset/18179-timer-duration-unusable-refusal.md deleted file mode 100644 index 4e18a0b316e..00000000000 --- a/.changeset/18179-timer-duration-unusable-refusal.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -fix(automation): a `wait` node whose `timerDuration` yields no wait is refused loudly instead of parking the run forever (#18179) - -#17928 closed the **absent** `waitEventConfig` block: the contract now requires -the block, and requires a non-blank `timerDuration` under `eventType: 'timer'`. -Neither half can evaluate the string. `timerDuration` is `z.string()`, so -`'not-a-duration'`, `'1 hour'`, `'P'`, `'PT0S'`, `'0'` and `'-5'` are all -documents that SAVE — and `parseIsoDuration` answers `undefined` for every one -of them, exactly as it did for the absent key. - -Measured through a real `engine.execute()` run with a job service **answering**, -not read off the source: - -``` -FROM waitEventConfig: { eventType: 'timer', timerDuration: 'not-a-duration' } - -> FlowNodeSchema.safeParse(...) // succeeds — the document saves - -> { success: true, suspend: true } // run status: paused, forever - scheduled jobs: [] <- with a job service ANSWERING - variables: no `pause.waitUntil` <- cold boot cannot re-arm it - log lines: 0 at any level <- warn, error, info, debug - -TO -> { success: false, errorClass: 'guard', error: "wait 'pause': timerDuration - \"not-a-duration\" is not a usable wait — …" } // run status: failed - one `warn` naming the node, the offending value and the remedy -``` - -The state the old path left behind was **un-refused, un-armed, un-persisted and -un-logged, while reporting success**: neither the arming branch (guarded on the -deadline) nor the "no job service" fallback (guarded on the service) could run, -so control fell straight through to the suspending return. The comment there -pointed at recovery via a later boot's re-arm pass "when the deadline was -persisted" — and no deadline had been persisted. - -**The remedy the refusal prints.** Write an ISO-8601 duration -(`timerDuration: 'PT1H'`, `'P3D'`, `'PT90M'`) or a QUOTED positive millisecond -count (`'60000'`), then re-publish the flow. For a pause with no deadline, -declare an `eventType` that names its resumer instead (`'signal'` / `'webhook'` -/ `'manual'` / `'condition'`). - -Zero and negative are the same verdict and deliberately not a separate one: -`'PT0S'` is not a short wait, it is a deadline already past, and it parks just -as permanently as an unparseable string. - -⚠️ Behaviour this deliberately changes: a stored flow carrying one of these -values used to reach `paused` and report success. It now fails the run at that -node. Nothing that parsed stops parsing — no authorable key is removed, renamed -or narrowed — and the refusal is `guard`-class, so a `fault` edge cannot route -the metadata defect into a handler that reports success. diff --git a/.changeset/18190-translation-messages-example-single-segment.md b/.changeset/18190-translation-messages-example-single-segment.md deleted file mode 100644 index a8152152c1f..00000000000 --- a/.changeset/18190-translation-messages-example-single-segment.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -docs(translation): the two `TranslationData` / `TranslationItem` `@example` blocks stop teaching a `messages` id that cannot resolve (#18190) - -`messages` is declared a flat `Record` (`translation.zod.ts` — `messages: z.record(z.string(), z.string())`), while `t()` resolves a key by walking its dot path segment by segment. Both implementations do this, identically: - -- `packages/core/src/fallbacks/memory-i18n.ts` — `resolveKey()`, `key.split('.')`, walked by `t()`; -- `packages/services/service-i18n/src/file-i18n-adapter.ts` — a second `resolveKey()` with the same body, walked by `t()` through `resolveFromLocale()`. - -So an id that merely *contains* a dot is one flat key named `common.save`, and `t('messages.common.save', …)` looks for a nested `common` object, finds a string or nothing at the first hop, and returns the key itself. Both docblock `@example` blocks on this schema demonstrated exactly that id — the doorway an author (or an authoring agent) copies from. - -- The JSON example on `TranslationDataSchema` and the TypeScript example on `TranslationItemSchema` now author `commonSave`, the single-segment spelling `content/docs/protocol/kernel/i18n-standard.mdx` already prescribes and `packages/plugins/plugin-audit/src/translations/messages.ts` already applies to its own bundle. -- Both docblocks now state the rule, so the counter-example is named as one rather than demonstrated. - -⚠️ **The schema still accepts a dotted id** — nothing is narrowed here and no key is retired. Whether the door should refuse a dotted `messages` key narrows a published accept set and rides its own card; this change is the doorway half only. - -For authors: a `messages` id containing a dot never resolved, so re-spelling one single-segment (`'common.save'` → `commonSave`, looked up as `messages.commonSave`) turns a key that was returning itself into one that translates. No key that resolved before stops resolving. diff --git a/.changeset/18199-multi-valued-invariant-cli.md b/.changeset/18199-multi-valued-invariant-cli.md deleted file mode 100644 index a09c1d6c2de..00000000000 --- a/.changeset/18199-multi-valued-invariant-cli.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os generate migration` and `os generate types` ask the ONE definition of "is this field multi-valued" — `isMultiValueField` in `@objectstack/spec` — instead of reading `field.multiple` raw, so the DDL they scaffold is the DDL `driver-sql` creates for the same object again (#18199). - -Clause-②: no — no schema key moves, no accept set widens or narrows, no export changes. The generators' inputs and outputs keep their shapes; what changes is which predicate decides one branch inside them. - -The maintainer ruling of 2026-09-13 (decision batch #128 item 5, option 1′) gave "multi-valued" one definition and made storage follow it. #17469 landed the `driver-sql` half — `createColumn` short-circuits on the spec predicate above its own type switch, `isJsonField` and `fieldHasColumn` derive from it — and left `packages/cli` reading the flag. For one release the two answered differently, which is #14829 ("the platform and the GENERATED DDL as two lists") in reverse: - -| declaration | `os generate migration` before | `driver-sql` | now | -|---|---|---|---| -| `{ type: 'text', multiple: true }` | `JSONB` / `table.jsonb` | `TEXT` | `TEXT` / `table.text` | -| `{ type: 'lookup', multiple: true }` | `JSONB` / `table.jsonb` | JSON column | unchanged | - -Two further shapes moved with it, both the same raw read: - -- **`os generate types` stops emitting a nested array for a redundantly-flagged option type.** `multiple: true` is accepted (redundantly) on `multiselect` / `checkboxes` / `tags`, and the generated property type was `string[][]`; it is `string[]` now, which is what the value contract says and what the platform stores. -- **A column DEFAULT is no longer withheld from a single-value field that carries the flag.** `{ type: 'text', multiple: true, defaultValue: 'x' }` emitted a column with no DEFAULT while the driver emits `DEFAULT 'x'`. - -⚠️ These declarations are refused at the authoring entrance by the same ruling's `FieldSchema` change, so they reach the generators only through the doors that never run it (`registerExternalObject` / `initObjects`, and a hand-written config the generators read unvalidated). Reachable, not authorable — which is why this is a `patch` and not a break. diff --git a/.changeset/18203-translation-target-contributed-nav.md b/.changeset/18203-translation-target-contributed-nav.md deleted file mode 100644 index a9ffca4939a..00000000000 --- a/.changeset/18203-translation-target-contributed-nav.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`translation-target-unknown` no longer calls a locale key for a CONTRIBUTED navigation item an orphan — the remedy it printed deleted a translation the runtime honours (#18203) - -`validateTranslationReferences` built the `apps..navigation.*` universe from the app's authored `navigation` array alone. An item injected by another package through `manifest.navigationContributions` (ADR-0029 D7, ADR-0130) is never in that array, so every locale key for it was reported as naming an item *"which app X does not declare"*, at `error` since 17.4.0, with the remedy *"Match the key to the navigation item's `id`, or drop it."* - -⚠️ **That remedy is wrong in the worst direction a false positive can point: following it deletes a working translation.** Measured on `objectstack-ai/hotcrm` `be11c07` (pin 17.4.0), where a service module contributes five items into `crm_enterprise`: - -| | measured | -| :-- | :-- | -| `os build` | **15** findings — 5 contributed items × 3 non-default locales | -| `GET /api/v1/meta/app?id=crm_enterprise` | returns all 5 items, `zh-CN` labels **resolved** from the app's own pack | - -The universe now folds in every contribution aimed at the app, walked by the same `walkNav` a declared subtree gets, so what the rule judges is the population the runtime serves rather than the array the author typed. - -**Both carriers are read**, because a stack in hand has two shapes and `os build` runs the rule table over both: - -- `packages[].manifest.navigationContributions` — the ADR-0130 D4 artifact entry. This is the shape the per-package leg needs (`compile.ts` step 3b-ii): the app's owning package declares no contribution of its own, and the union run above it de-duplicates, so a fix reading only the union would have left that leg reporting the finding alone. -- `manifest.navigationContributions` — the stack's own `StackSchema.manifest`, where a single-`defineStack` project's contributions live. `os validate` judges only the union stack, so reading the artifact form alone would have left the fast inner-loop command still reporting what the build no longer does. - -**The runtime's fold is deliberately not imported, and the union is faithful anyway.** `@objectstack/lint` depends on `@objectstack/spec` and never on a runtime; `applyNavContributions` is a `SchemaRegistry` method in `@objectstack/objectql`. A second implementation would normally be exactly the drift this class of defect is made of — except that the fold pushes the contributed items in *every* branch: into a `group` that resolves, at the app top level when the `group` id names nothing (a `nav_contribution_group_missing` diagnostic, never a refusal), and at the top level when `group` is omitted. It chooses **where** an item lands and never **whether**, so the set of addressable ids is invariant under it. All three placements are pinned side by side so that invariant cannot quietly stop holding. - -**The control, which is the point of the change.** Widening a universe trades a false positive for a blind spot unless the genuine orphan still reports. A key that nothing contributes is still an `error` carrying `translation-target-unknown`, its path and its message; a contribution aimed at app B does not make its ids addressable under app A; and the contributed ids join the population the hint enumerates, so the remedy an author is handed lists what they may actually key to. - -**What this still cannot see, stated rather than implied.** Contributions registered imperatively by plugin code (`engine.registerAppNavContribution` from a plugin's `init`) are not metadata, and no static rule can read them — that is the population `pnpm check:app-nav-i18n` has to boot a composition to judge. A locale key for one of those is still reported here. diff --git a/.changeset/18211-org-scoping-engine-named-receiver.md b/.changeset/18211-org-scoping-engine-named-receiver.md deleted file mode 100644 index 83ef95ade22..00000000000 --- a/.changeset/18211-org-scoping-engine-named-receiver.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/organizations": minor ---- - -`claimOrphanOrgRows` and `claimOrgSeedOwnership` name the ObjectQL doors they write through — a package-private `OrgScopingEngine` interface replaces `ql: any` on both, and `OrgScopingQuerySlot` states the doors the plugin forwards rather than only the three it calls itself (#18211). - -The package's public entry is unchanged: `src/index.ts` exports exactly the nine names it exported before, byte for byte. What moved on the published surface is the two exported functions' signatures, and nothing else. - -Runtime behaviour is unchanged: the same guards run, the same rows are updated, and an engine without a `registry` still returns `[]` with a warning instead of throwing — `registry` is optional on the new type precisely so that tested path stays describable. - -- **Why a type and not a comment.** The tenant-audit census decides whether a write call site is an engine write by reading the **receiver's declared type**. An `any` receiver has no type to read, so both of these sites were reported as sites nothing could place — an error in that census, never a default, because a write it cannot see is a write the tenant-audit population does not certify. Naming the doors places both by type. The certified population moves 223 to 225 and both read as elevated (they write under `context: SYSTEM_CTX`). -- **Narrow on purpose**, following `OrphanCleanupEngine` in `@objectstack/plugin-sharing`: `OrgScopingEngine` declares `find`, `update` and an optional `registry`, and nothing else. Widen it by adding a door that is actually used, never by re-exporting the engine's full contract — and keep it package-private: the census reads the type declared at the receiver, never the package entry, so exporting it would widen a published surface and buy the fix nothing. -- **The slot change is a finding, not a refactor.** `OrgScopingQuerySlot` declared `registerMiddleware`, `find` and `getSchema` — but the plugin also hands that value to `claimOrphanOrgRows`, which writes through it. While the back-fill's parameter was `any` that coupling was invisible to the type system; naming the parameter turned it into a type error, and the slot now states it. -- **Type-level tightening for consumers.** A caller passing a value that does not structurally offer `find` and `update` no longer compiles. Such a caller already got `[]` and a warning at run time from the existing guards, so nothing that worked stops working — but the failure moves from run time to build time, which is why this is not a patch. The parameter type is inlined into the emitted declarations, so a consumer never needs to name it. -- ⛔ **No `UNTYPED_RECEIVERS` ledger row was added.** That ledger is documented shrink-only and keyed by (file, receiver); growing it by two rows to silence two sites runs against its own discipline, and a typed receiver needs no row at all. diff --git a/.changeset/18232-analytics-one-refusal-wording.md b/.changeset/18232-analytics-one-refusal-wording.md deleted file mode 100644 index d1b2520a789..00000000000 --- a/.changeset/18232-analytics-one-refusal-wording.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/service-analytics': patch -'@objectstack/core': patch ---- - -analytics `dateRange`: one condition, one refusal wording - -An array `dateRange` that is not a two-bound window is refused by the -`service-analytics` faces with the platform's ONE shared sentence -(`analyticsDateRangeRefusalMessage`, origin `runtime`) instead of a -package-private second wording. The envelope is unchanged — -`ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 — so nothing that classifies on -`code`/`status` is affected; only the `message` text changes, and it now agrees -byte-for-byte with the sentence the schema door answers with for the same value. - -The second wording existed because the shared sentence used to judge a bare -string against the preset vocabulary and to end with "Refused at the schema", -neither of which is true of an array refused past the schema door. Both grounds -were removed when `analyticsDateRangeRefusalMessage` gained its required -`origin` parameter and began describing a non-string by what is wrong with it. - -⚠️ **The message no longer echoes the value you sent.** For an ARRAY -`dateRange` the shared sentence DESCRIBES the shape instead: what used to read -`dateRange ["a","b","c"] is a 3-element array` now reads `received a 3-element -array, not the two bounds [start, end]`. That applies to EVERY array shape this -face refuses, not to unusual ones only — `[null, null]` now reads `received an -array with a non-string bound`, and `['', '']` is where the description carries -least, `received a two-element array`. A bare STRING `dateRange` is still quoted -back to you. So a log line that used to carry the offending array no longer -does: if you need the value at that site, read it from the request you already -have, ⛔ not from the message. - -⛔ If you match on the old text (`[service-analytics] dateRange …`), match on -`error.code === 'ANALYTICS_DATE_RANGE_UNRECOGNIZED'` instead — the message was -never the contract, the envelope is. diff --git a/.changeset/18235-flow-runtime-state-reason.md b/.changeset/18235-flow-runtime-state-reason.md deleted file mode 100644 index 914690d901d..00000000000 --- a/.changeset/18235-flow-runtime-state-reason.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-automation": minor -"@objectstack/types": patch ---- - -`FlowRuntimeState` now declares `reason` — the optional sentence saying WHY a flow is not armed — and the automation engine populates it, so `GET /automation/_status` can tell a policy-disabled flow apart from a broken binding (#18235). - -Ruling G item 6 on #17396 names three surfaces that must each carry a DISTINCT reason for a flow left unarmed because package-authored scheduled work is switched off, and must never read as "binding failed". Two of them shipped: `getTriggerBindingAudit()` and the CLI startup summary. The third — a console — could not be built: Studio's only status door answers `FlowRuntimeState` rows, and that shape had no field a reason could travel in, so on the wire a policy-disabled flow was `enabled: true, bound: false, triggerType: 'schedule'`, byte-identical to one whose trigger is missing. - -**Clause-②: yes (widening)** — one new key on an already-published payload, so the shape a consumer reads against grows. Nothing previously emitted is removed or renamed, and no producer is required to write it. - -- **Optional, and additive by measurement.** Every producer of these rows — the engine, and the test doubles in `packages/runtime`, `packages/cli` and `packages/qa/dogfood` — writes `{ name, enabled, bound }` at minimum; a required key would have broken all of them and would demand a reason from rows that have none. The key is absent (not `undefined`-valued) on any row that is bound, disabled, or declares no trigger. -- **One vocabulary, not a new one.** The sentence is the one `getTriggerBindingAudit()` already answers for the same flow: both doors now read a single private `describeUnboundReason()` on the engine, so Studio and the boot summary cannot drift. A free-form string, matching the two surfaces that already carry this reason; ⛔ consumers render it, they do not parse it. -- **Read from the RECORD, never re-derived.** The policy sentence comes from the engine's recorded refusal (`policyDisabledFlows`, cleared the moment a flow gets past the gate), never from a live `resolveScheduledWorkPolicy()` read at call time. `_status` is served on demand, arbitrarily long after the bind — re-deriving would report a binding failure for a trigger that was never called, the defect the implementing round of #17396 already caught once. -- **Wire, not rendering.** `SCHEDULED_WORK_DISABLED_REASON`'s docblock is corrected: Studio's door now carries the reason, while displaying it distinctly remains objectui#9217's card. Declared is not delivered, and reaching the wire is not being shown. The published prose carrying the same claim moves with it — `content/docs/automation/flows.mdx`'s callout said the status door "has no field to say why", which this change makes false; both carriers are corrected in one landing, and neither now claims a console *renders* it. diff --git a/.changeset/18239-merge-objects-refusal.md b/.changeset/18239-merge-objects-refusal.md deleted file mode 100644 index c3807ebcc62..00000000000 --- a/.changeset/18239-merge-objects-refusal.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `composeStacks` refuses a stack whose `objects` is not an array, with the ADR-0112 envelope - -**BREAKING** — `composeStacks`, a public root export, now refuses a class of input it used to crash on, skip in silence, or compose by accident. - -Step 2 of `composeStacks` (`mergeObjects`) iterated each input's `objects` with no shape guard. The strict `defineStack` parse already rejects a non-array `objects`, so the reachable population is an input that bypassed it — a hand-built stack object, or `defineStack(config, { strict: false })`. Measured before this change, composing a well-formed stack with such an input: - -| the second stack's `objects` | before | after | -| :--- | :--- | :--- | -| a map (`{ b_item: {…} }`) or a number | bare `TypeError: … is not iterable`, `code` and `status` both `undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422` | -| `null`, `''`, `0`, `false` | composed, the stack's objects silently absent | refused, `STACK_SCHEMA_INVALID`, `status: 422` | -| a `Set` of objects | composed as if it were an array | refused, `STACK_SCHEMA_INVALID`, `status: 422` | - -A composed artifact is complete or it is refused: skipping a stack's objects composes an artifact that silently lacks them, so no non-array `objects` is skipped. An absent `objects` (`undefined`) is not malformed and composes as before. The refusal carries the code the strict parse raises for the same authored mistake — one code for one defect, whichever door catches it — with the zod issue on `issues` (`path: ['objects']`, `expected: 'array'`) and a message naming the stack by manifest id and position. The map form is an authoring spelling `defineStack` normalizes before any check runs; a stack that reaches composition without passing through `defineStack` never had it normalized, and is refused like any other non-array. - -A non-object entry inside an array `objects` (`null`, a number) is skipped and reported once through the composer's malformed-collection warning, the shape step 3 gives a non-array collection; before, it raised a bare `TypeError` reading `name` off it. The artifact cross-reference pass skips such an entry too. - -No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`, and the error class stays module-local. - - - -Clause-②: no (narrowing) diff --git a/.changeset/18245-compareto-timezone-day-projection.md b/.changeset/18245-compareto-timezone-day-projection.md deleted file mode 100644 index 4f7deb63560..00000000000 --- a/.changeset/18245-compareto-timezone-day-projection.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -fix(service-analytics): resolve `compareTo`'s comparison window on the reference calendar, not UTC - -`DatasetExecutor`'s `compareTo` day math carried its own local `parseUTC`/`toISODate` pair -and read every bound on the UTC calendar. The lowered preset window is a pair of INSTANTS -that open and close at the *reference zone's* midnight, so projecting them onto UTC days -moved a boundary in every non-UTC zone — and in opposite directions either side of the -meridian. `this_month` + `compareTo: { kind: 'previousYear' }` frozen at 2026-09-09 compared -30-day September against a 31-day window: `Asia/Shanghai` opened at `2025-08-31`, -`America/New_York` closed at `2025-10-01`. No error, no warning — a slightly-too-wide -comparison leg rendered exactly like a correct one. - -The local pair is deleted. The bare-calendar-day arithmetic (year shift, previous-period -length, bucket ordinals) now runs through `@objectstack/core`'s `zonedDateStartToUtcMs` on -its zone-free UTC proxy, and the one seam that turns instants into days — the lowered -window's projection — goes through the same package's `bucketDateKey`, threaded with the -timezone `buildQuery` already resolves the primary pass in. UTC callers are unaffected. diff --git a/.changeset/18247-read-audit-failure-reported-per-cause.md b/.changeset/18247-read-audit-failure-reported-per-cause.md deleted file mode 100644 index e3f3cfd5b4a..00000000000 --- a/.changeset/18247-read-audit-failure-reported-per-cause.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -**The read-audit failure report now speaks once per CAUSE instead of once per PROCESS, and prints the telemetry-datasource remedy only for the cause it is the remedy for.** - -`installReadAuditWriter`'s `reportReadAuditWriteFailure` (`read-audit.ts`) carried its own process-level `failureReported` boolean and its own fixed message literal — the third independent copy of the pair #15166 fixed in `audit-writers.ts` and #17452 fixed in `auth-event-audit.ts`. Both defects were live on a seam the repo has already declared durability-critical (`persistReadAuditRows` is registered in `DURABILITY_CRITICAL_CALLEES`): - -- **The first failure of any cause silenced every later failure of every other cause for the life of the process.** A server could keep losing record-view batches for hours to a second, unrelated fault with one `error` line at the top of the log describing the first — and record-view rows are written from a buffer off the request path, so no in-flight request is left to notice. The dedupe key is now the failure's identity, `auditFailureCauseKey`, imported from `audit-writers.ts` rather than re-spelled. A repeat of an already-reported cause still degrades to `debug`; a NEW cause gets its own `error` line, once. -- **The ADR-0057 §3.6 / `OS_TELEMETRY_DB` datasource guidance printed unconditionally**, so a fault with nothing to do with datasource routing (an `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` refusal, say) sent the operator to check something that was working. The guidance is not deleted and not weakened — it is asked for through the shared `isMissingTableError` predicate and printed for exactly the missing-table cause it was written for; every other cause now gets the driver's own verdict quoted at the head of the line plus the fix that matches it. - -**Behaviour that deliberately does not change:** the once-per-degradation anti-noise rule itself (a repeat of the same cause is still one line), the `error`-then-`warn` sink fallback (#9657), and the rule that an audit failure never reaches the read. - -No API, option or type moves; nothing an author writes changes. - -Clause-②: no diff --git a/.changeset/18253-explain-loud-unknown-object.md b/.changeset/18253-explain-loud-unknown-object.md deleted file mode 100644 index 16ad0a7d2c2..00000000000 --- a/.changeset/18253-explain-loud-unknown-object.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/rest": minor ---- - -`security explain` refuses an object name that does not exist instead of reporting `denies` — a typo is no longer indistinguishable from a permission decision (#18253). - -`GET/POST /api/v1/security/explain?object=leave_requst` used to walk all nine layers for a name nobody declared and answer `200` with `allowed: false` and `object_crud: 'denies'` — the byte-identical pair a **real** denial answers. Only the layer prose differed (#10401/#10424), and no client branches on prose, so the tool an administrator opens to ask "why can this person see this record" answered confidently about a record that does not exist. - -It now answers `404` with `error.code: 'OBJECT_NOT_FOUND'`. - -- **The engine decides, the door maps.** `explainAccess` throws `ExplainObjectNotFoundError` (plugin-security `errors.ts`), so every caller of `ISecurityService.explain` gets the refusal, not only the HTTP one; the REST route turns it into the status. A judgement made at the door would have been loud in one caller and silent in the other. -- **Nothing was newly minted.** `OBJECT_NOT_FOUND` at 404 is what this platform already answers for an unregistered object name (`mapDataError`, `packages/rest/src/error-response.ts`) and is a `StandardErrorCode` member, so no ledger row and no `packages/spec` change carries it. The body is emitted through the `/security/explain` family's one refusal emitter (#8073), so it is the ADR-0112 D5 envelope by construction. -- ⚠️ **Only one of the three unresolved causes moved.** An `unpublished_draft` declaration EXISTS (its remedy is "publish it") and a `metadata_unavailable` read did not answer, so both keep today's `denies` explanation — asserting absence there would state as fact the half the condition made unknowable. -- **Callers that branch on the verdict.** A client that treated `allowed: false` as "denied" for a misspelled object now meets a `404` refusal instead of a `200` decision. That is the point of the change, and it is the only wire movement: a resolvable object's report is byte-identical. diff --git a/.changeset/18265-active-environment-in-cloud-config.md b/.changeset/18265-active-environment-in-cloud-config.md deleted file mode 100644 index 2845542e649..00000000000 --- a/.changeset/18265-active-environment-in-cloud-config.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os package publish --install` installs into the environment `os environments switch` just selected, instead of refusing with ``--install` requires `--env ``. Nobody remembers a UUID. - -The two credential stores are two **identities on two servers**, and the active environment used to live in only one of them. `os environments switch` — and `os environments create --activate` — wrote `activeEnvironmentId` into `~/.objectstack/credentials.json` only (the runtime identity, written by `os login`); `os package publish` reads `~/.objectstack/cloud.json` (the cloud identity, written by `os cloud login`) and never opened the other file — so the environment the CLI had just called active, and that `os environments list` marks with a ★, was invisible to the one command that could install into it. - -- **The id now lives in `cloud.json`, beside `activeOrgId`** — the `CloudConfig` field that was already there for exactly this kind of control-plane scope selector, one level up. -- **`os environments switch` records it there as well** when the control plane it just talked to *is* `cloud.json`'s `url`, and keeps writing `credentials.json` unchanged — that copy is what `createApiClient` reads for the `data` / `meta` / `environments` families. -- **`os environments create --activate` records it too**, through the same helper — it is the *other* writer of an active environment id, and the first half of the flow this fixes: `os environments create --org $ORG --name Dev` then `os package publish --install`, with no `switch` in between. Creation succeeding while the record fails stays a warning, never an exit `1`. -- **`--install` with no `--env` and no `$OS_ENVIRONMENT_ID`** falls back to that value, and only when `cloud.json`'s `url` is the control plane being published to. -- **A value written by an older CLI is migrated once**, and only when both files' `url`s agree. -- ⛔ **Publish never reads `credentials.json` for this.** That is not a purity argument: the files carry *different servers* — `credentials.json`'s url falls back to `http://localhost:3000`, `cloud.json`'s default is `https://cloud.objectos.ai`, and the publish POSTs to the latter. An id taken from the runtime store can therefore name an environment on a **different control plane**, which the server resolves by bare id with no name or short-id rescue. The url gate, not the file name, is the invariant, and it lives in one place (`utils/active-environment.ts`). diff --git a/.changeset/18278-date-range-empty-bound-description.md b/.changeset/18278-date-range-empty-bound-description.md deleted file mode 100644 index 2be0ea31a52..00000000000 --- a/.changeset/18278-date-range-empty-bound-description.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The one `timeDimensions[].dateRange` refusal sentence names an EMPTY bound for what it is, instead of handing its author back the shape they just wrote (#18278). - -`AnalyticsDateRangeSchema`'s array arm is `z.tuple([z.string(), z.string()])` — it judges arity and bound TYPE, never a bound's VALUE — so `['', '']` is **accepted** at every schema door and refused past it, by each face's own empty-bound check (`service-analytics`' `date-range-array-arm.ts`, `driver-memory`'s `memory-analytics.ts`). That is the residue `analyticsDateRangeUnrecognizedError`'s header in `@objectstack/core` already named. Measured at `ObjectQLStrategy.dateRangeBounds` before this change, its author read: - -``` -… ; received a two-element array. Refused past the schema door, by the analytics reader -that received it (ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400). -``` - -— the arity they had written, with the value never echoed on this path and nothing said about what was wrong with it. After: - -``` -… ; received a two-element array whose bounds are both empty strings. … -``` - -- **Named at the bound that is empty** — `['', b]` and `[a, '']` say `whose start bound is an empty string` / `whose end bound is an empty string`, because the sentence never echoes the value, so *which* bound is a clause only this builder can supply. -- **A bound that is not a string keeps its TYPE description.** `['', 3]` reads `an array with a non-string bound`: the fault the arm itself refuses is named first, and the arities (`[]`, `['a']`, `[a, b, c]`) are untouched. -- **`a two-element array` survives as the LIT control** — the description for a two-bound window with nothing this clause can name, refused for something it cannot see (an unparseable bound VALUE carries its own `DATASET_INVALID` envelope). The empty-bound clause is not claimed when it is not true. -- ⛔ **Not an accept-set change.** The tuple arm still accepts `['', '']`; only the sentence the faces raise past it changed. The comment that asserted *"the only way such an array reaches a refusal is a bound that is not a string"* — false the whole time this residue was reaching it — is corrected in the same edit, since a false explanation is what kept the case unexamined. diff --git a/.changeset/18305-component-props-map-map-gantt-tree.md b/.changeset/18305-component-props-map-map-gantt-tree.md deleted file mode 100644 index db0facd1356..00000000000 --- a/.changeset/18305-component-props-map-map-gantt-tree.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`ComponentPropsMap` declares `object-map`, `object-gantt` and `object-tree` — the three object-bound SDUI blocks #7751 enumerated past — with each row's key set derived from the objectui renderer's own read points (#18305). - -**Clause-②: yes (widening)** — three new declared rows on a published surface, so the accept set a consumer writes against grows. Nothing previously admitted is refused, and nothing is retired. Contract-review tier. - -Until now the `object-*` family carried six rows, `object-chart` carried a written note saying its key set is not derivable with this section's confidence, and these three carried neither: they were not ruled out, they were never measured. The cost was the one #7751 exists to remove — the `@objectstack/lint` props gate had no schema to dispatch on, so every authored key inside `properties` on one of these nodes parsed clean, stored, shipped and was ignored by the renderer with a success receipt. It also left objectui's own `@object-ui/types` mirror standing in as the authority for `object-map.data` and `object-gantt.data`, and left `object-tree`'s record-source read undeclared on every published face (objectui#8348, PR objectui#9234). Executing the ruling 「8348 以协议为准」 (decision batch #83, 2026-09-08) and batch #136 item 3 (Q1-C). - -Key sets measured from `plugin-map/src/ObjectMap.tsx`, `plugin-gantt/src/ObjectGantt.tsx` and `plugin-tree/src/ObjectTree.tsx` at the `.objectui-sha` pin `53ded82b`, with per-key read-point citations in each schema's header: - -- **`object-map`** — `objectName`, `data`, `staticData`, `filter`, `sort`, `map`, `mapStyle`, `navigation`, `enableClustering`. -- **`object-gantt`** — the same record-source and query keys, plus `gantt`, `navigation`, `label`, `skipWeekends`, `holidays`, `persistLayout`, `viewName`, `markers`, `criticalPath`, `showBaselines`, `readOnly`, `mobileReadOnly`. -- **`object-tree`** — `objectName`, `data`, `staticData`, `filter`, `tree`, `navigation`. No `sort`: this renderer's fetch carries `$filter`, `$top` and `$expand` and no `$orderby`, so a `sort` door here would publish a key with no read site. - -Three things the derivation decided rather than assumed, each pinned: - -- **`data` is the `ViewData` object arm on all three**, because rung 1 of the shared record-source ladder returns the authored value verbatim as a `ViewData`. For map and gantt that agrees with objectui's mirror — verified from the read points first and read back as a check, never as the source. For **`object-tree` it does not**: the mirror declares no `data`, no `staticData`, no `filter` and no `navigation` at all, while the renderer reads all four (`data` on two sites). The row follows the read points, which is what 「以协议为准」 resolving for this block means. -- **The flat top-level config spellings stay unauthorable.** `ObjectView` / `ListView` build these nodes by spreading `options.map` / `options.gantt` / `options.tree`'s CONTENTS at the top level; that is an internal transport form, not a second authoring surface (maintainer ruling objectui#5018, 2026-08-17, inherited by objectui#6469). Writing one now gets a wrong-layer prescription naming the config block instead of a bare unknown-key refusal — the channel `object-calendar` already uses for its own flat field spellings. -- **`filter` and `sort` are the family's one orthography from birth** — `ViewFilterRule[]` and `SortItem[]`, not the `z.unknown()` the original six carried before #15449 and objectui#8221 pulled them back. - -Nothing about the parse of a page changes: `PageComponentSchema.type` already accepted all three through its open string arm, and it still does. What changes is that an authored props bag on one of them is now judged instead of skipped. diff --git a/.changeset/18306-role-word-field-groups.md b/.changeset/18306-role-word-field-groups.md deleted file mode 100644 index 2a3352f8076..00000000000 --- a/.changeset/18306-role-word-field-groups.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -fix(lint)!: the ADR-0090 D3 vocabulary freeze visits `objects[].fieldGroups[]` (#18306) - - - -**BREAKING** in the accept-set sense — a declaration that passes today can fail tomorrow. -Landing in the launch window as `minor` (the lockstep convention: `major` is refused by -`check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087 -disposition above). - -**Clause-②: no (narrowing)** — the rule refuses more than it did; no key is added to any -published payload and no public surface grows, so `lanes/spec.md`'s widening test is not -met. Narrowing is still a semantic-surface change, which is why it is declared here rather -than shipped silently. - -`security-role-word` (ADR-0090 D3) judged an object's name, field names and labels, action -names and labels, permission sets, positions, apps and books — and not the field-group -heading that renders directly above the fields it was already judging. So on one record page -a field labelled `Role Of Record` was refused while the group header above it, -`Account & Role`, was admitted: the author renames the field and the heading keeps the word. -That is the exact "refused on one surface, admitted on another" shape (#7220) that this -rule's own split was made to avoid, one grain finer. - -Both halves of the group declaration are judged, as on every other surface: `key` is an -identifier (`Field.group` assigns membership by it, and a layout section's `group` inherits -the group by it, ADR-0085 §5), `label` is the header an admin reads. ADR-0090 D3 bans the -word in "identifiers, UI copy, and documentation", and a field group declares both. - -Pages, views and components stay out, unchanged: `role` there is the HTML/ARIA attribute — a -machine word with a fixed foreign meaning, not a word the author picked. `listViews`, -`recordTypes` and the other label-bearing surfaces are deliberately not swept in with this; -each needs its own reading first. - -**What an author does.** Nothing is renamed for you and nothing is auto-rewritten: the -platform vocabulary is `permission_set` (capability), `position` (distribution), -`business_unit` (hierarchy), and the refusal itself names it at the exact path -(`objects[i].fieldGroups[j].key` / `.label`). A group heading reading `Account & Role` -becomes `Account & Assignment`; a group keyed `role_info` becomes `assignment`, and the -member fields' `group` pointers move with it. - -Unaffected: a system object (`sys_*` / `isSystem: true`) keeps the better-auth exemption on -its field groups exactly as it keeps it on its fields, and a group carrying no reserved word -is silent. diff --git a/.changeset/18318-evalcontext-no-query-api.md b/.changeset/18318-evalcontext-no-query-api.md deleted file mode 100644 index ff6962523e2..00000000000 --- a/.changeset/18318-evalcontext-no-query-api.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/formula': minor ---- - -`EvalContext` no longer declares `api?: { exists, count, lookup }` — the kernel query API behind `os.exists` / `os.count` / `os.lookup`, which `buildScope()` never bound (#18318). - -**BREAKING** for a TypeScript consumer: an `EvalContext` literal that carries `api` stops compiling. The level stays `minor` because the launch window refuses `major` outright — while it is open, breaking-ness is carried by this banner and by the ADR-0087 disposition at the foot of this changeset, not by the bump. - -The member's docblock said it was "implemented opportunistically by call sites that have a query engine", and no call site ever could: `ctx.api` was read **zero** times in this package — control in the same sweep, `ctx.user`, three reads in `stdlib.ts` — so the three functions reached no evaluation scope however completely a caller populated the member. An author who wrote a predicate to the declaration got `runtime: found no matching overload for 'dyn.lookup(string, dyn)'` instead, and because an unevaluable predicate refuses the write it guards, a validation rule authored that way locked **every** write on its object. The harm came from the declaration existing, not from the implementation missing, so it is removed rather than implemented — with the reason written at the deletion site, and with no shim, alias or reserved spelling left behind. - -**Your fix — delete the `api: { … }` property.** There is no replacement key and nothing to re-point: every implementation ever passed there was discarded before evaluation, so removing the property changes no result your predicates produce. TypeScript is where you will hear about it: an `EvalContext` literal carrying `api` now fails to compile, which is the whole of the break. Reading a related record's field from inside a predicate remains unexpressible in any spelling — that capability is tracked as its own card, relationship traversal (`record.crm_account.type`), and deliberately not as `os.lookup` queries; no schedule is implied by this removal. - - - -Clause-②: yes diff --git a/.changeset/18331-objectql-hook-wrappers-per-row-docblocks.md b/.changeset/18331-objectql-hook-wrappers-per-row-docblocks.md deleted file mode 100644 index a64f616518f..00000000000 --- a/.changeset/18331-objectql-hook-wrappers-per-row-docblocks.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -docs(objectql): the hook-wrapper docblocks state the per-row `before*` contract (#18331) - -Two docblocks in `hook-wrappers.ts` stated the RETIRED batch model in the -present tense: `pickRecordPayload`'s said a predicate (`multi: true`) bulk -update's `before*` dispatch "still fires once for the batch with no prior row", -and `pickPreviousPayload`'s "when `previous` is ABSENT" list named that same -dispatch as an absence case because "it fires ONCE for N matched rows". - -Ruling #16074 / ADR-0058 Addendum II (clauses D1/D2) retired that model, and the -engine already implements the replacement: `dispatchPerRowBeforeHooks` dispatches -`before*` once per matched row on the single-record shape and binds that row's -pre-image (`previous: coerceBooleanFields(schema, row)`). So both phases of a -predicate write now merge, materialise and bind `previous` exactly as a -single-record write does; what remains unbound is any update-shaped context -whose prior row is not in hand, which is what the second docblock now says. - -This is published text, not an internal comment. Measured against the shipped -`@objectstack/objectql@17.4.0` tarball: the first docblock is emitted verbatim -onto the exported `hookRecordState` declaration (`dist/util-Dw5ZTIII.d.ts:8039`, -and the matching `.d.mts`), reachable from both the `.` and `./core` -entrypoints, so every consumer's editor surfaces the retired sentence on hover. -The second docblock does NOT ship — `pickPreviousPayload` is module-private and -appears in `dist/` only as an `{@link}` reference — but it is the source a -maintainer reads, and two docblocks one screen apart stating opposite contracts -is the drift this repairs. - -No behaviour change and no assertion change: prose only. - -Graded `patch`: the act moves published PROSE. It adds no exported symbol, no -key and no accepted value — the accept set was widened by PR #17249 in -`@objectstack/spec`, not here — so this PR declares no clause ② (`Clause-②: no`). diff --git a/.changeset/18336-retire-walled-legacy-platform-admin-anchor.md b/.changeset/18336-retire-walled-legacy-platform-admin-anchor.md deleted file mode 100644 index f758c9e62d1..00000000000 --- a/.changeset/18336-retire-walled-legacy-platform-admin-anchor.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/plugin-security": minor -"@objectstack/plugin-auth": minor -"@objectstack/runtime": minor -"@objectstack/plugin-hono-server": minor -"@objectstack/organizations": minor ---- - -**Breaking (shipped as `minor` under the launch-window convention).** Under a **walled** tenancy posture (`group` / `isolated`), a legacy unscoped `admin_full_access` grant row no longer confers `PLATFORM_ADMIN`; platform standing there is derived from `OS_PLATFORM_OWNER_EMAIL` and from nothing else. The migration pointer that announced this since 17.3.0 is retired with it: `reportLegacyPlatformAdminGrant` and `resetLegacyPlatformAdminGrantReport` are **removed from `@objectstack/core`'s published entry** (#18336, #11663 leg L5). - -⚠️ **The `single` posture is untouched, deliberately.** Its zero-config first-user promotion still mints that row and that row still confers `PLATFORM_ADMIN` — a development environment started for a moment cannot be asked to declare an administrator first. Choice 4A (#11974) rules that promotion correct, and the maintainer's 2026-09-08 ruling on #16682 is verbatim: 「retiring the walled write must not retire the `single` one」. The `single` half's disposition is #11979's. ADR-0131 D5, as amended 2026-09-17 (#18413), is the governing record. - -**What a walled deployment must do.** Declare each administrator's **verified** address in `OS_PLATFORM_OWNER_EMAIL` (comma-separated for several) before upgrading. A walled rig that upgrades with the variable undeclared and an unscoped grant row still in place has **zero** platform administrators; the bootstrap now says so **at error**, naming the variable, the row and its holder — L4 used to skip that line for exactly this rig, on the ground that the deprecation pointer carried the remedy instead, and both halves of that arrangement have now expired. - -- **17.3.0 opened the window, this closes it.** L4 (17.3.0) stopped the walled bootstrap from ever *writing* the row and started the once-per-process pointer; L5 stops the walled derivation from *reading* it. The window was time-boxed and loud by design (#11663 P5). -- **The retirement takes the ANCHOR, not the ROW.** Nothing here writes, deletes or re-owns any grant row — a walled holder keeps the `admin_full_access` permission set they hold, and loses only platform-admin *standing*: the rung and the built-in `platform_admin` position. That row's ownership is ADR-0131 C3's, on the v18 line. -- **No new query.** The posture gate reads the environment, never the engine, so the recorded query multiset is identical under both of its answers — measured, not asserted. Under a wall the guard's grade-1 scan is skipped outright, so that path issues one read fewer. -- **`@objectstack/plugin-auth` moves with it, at TWO readers.** `ensureDefaultOrganization`'s step-2 legacy fallback is keyed on the same expression: under a wall it no longer answers「which user is the platform admin?」from the oldest unscoped grant, so the account it would have bound as the Default Organization's `owner` — and handed the org's seeded rows to — is no longer selected. ⛔ That reader does not merely count the population, it **confers** on it, which is why it is keyed here rather than sequenced. Its bootstrap-trigger predicate retires the matching `sys_user_permission_set`-insert arm under a wall with it (cost only; the `sys_user` arms are untouched, and on a walled rig the declared owner's verifying update is the only write that ever grows the population). And: -- **`@objectstack/plugin-auth`'s break-glass guard moves with it.** `last-admin-guard.ts` enumerates the administrator population from the SAME anchor, and its contract is to answer the same question the derivation answers. Its grade-1 (grant-anchored) enumeration is now keyed on the identical expression, so under a wall the guard no longer counts a holder the derivation does not recognise. Consequence on a walled rig: a write that would end the last **config**-anchored administrator's standing is now REFUSED where it was permitted, and a write that removes the now-inert grant row is no longer refused as though it removed the last administrator. Under `single` the guard is unchanged. Its two zero-population refusals also gained a walled clause, because「restore the `admin_full_access` row」stopped being a remedy that ends the emptiness there. -- **`@objectstack/organizations`' walled bootstrap moves with it.** That package wraps `ensureDefaultOrganization` and is the runtime that actually performs the default-organization bootstrap on a walled deployment (plugin-auth's own wiring skips it there). With the helper's legacy fallback keyed off, a walled rig carrying a legacy grant row **no longer** has a Default Organization created for that holder, and that holder is no longer bound as its `owner`; the bootstrap waits for a declared administrator to verify instead. ⚠️ Named because the behaviour an operator gets **from this package** moves — its own source does not change, and the pin re-authored inside it is not the reason. -- **Why `@objectstack/runtime` and `@objectstack/plugin-hono-server` are named.** Neither package's own source changes. Both carry `export * from '@objectstack/core'` (`runtime/src/index.ts`, `plugin-hono-server/src/adapter.ts`) and their built `.d.ts` carry that statement, so the two removed names leave their published surfaces too. All publishable packages sit in one Changesets `fixed` group, so naming them moves no version — it is named so the tombstone reaches the CHANGELOG an upgrading consumer of THOSE packages greps. Precedent is mixed (a core-only declaration exists); this follows the `ApiRegistry` precedent, which named every package the removal reached. - - diff --git a/.changeset/18401-meta-generic-branch-itemless-success.md b/.changeset/18401-meta-generic-branch-itemless-success.md deleted file mode 100644 index e9125b75364..00000000000 --- a/.changeset/18401-meta-generic-branch-itemless-success.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -The dispatcher's `/meta` domain answers `GET /meta/:type/:name` for a name with nothing behind it with `404 RESOURCE_NOT_FOUND` on its generic `:type/:name` branch, instead of announcing the miss as a `200` (#18401). - -**Clause-②: no** — no schema key moves, no accept set widens or narrows, no export changes, and no error code is minted: the refusal reuses the branch's own existing `deps.error('Not found', 404)`, whose code `standardErrorCodeForHttpStatus` already derives. - -`protocol.getMetaItem` answers a miss with the protection envelope wrapped around an absent item — `{ type, name, item: undefined, lock, editable, deletable, resettable }`, because `resolveLockState(undefined, false)` is unconditional — never with `undefined`. The generic branch returned that straight through, and `JSON.stringify` at the transport then dropped the `item` member, so a caller was handed a `200` whose body is the declared `GetMetaItemResponseSchema` envelope **minus its required member**. - -- **The branch disagreed with its own sibling.** The `object` branch of the same function already refused that exact shape and answered `404`, so one function answered "does absence mean success?" both ways, decided by which type you asked for. The generic branch now runs the same hit test. -- **A miss still falls through, it is not a hard refusal.** An item-less protocol answer hands the read on to the `MetadataService` resolver exactly as the object branch hands its own on to the ObjectQL registry; only a read that no resolver can satisfy reaches the `404`. -- **No new refusal dialect.** The fall-through ends at the branch's own pre-existing `404`, the ADR-0112 nested `{ success:false, error:{ code, message, httpStatus } }` this file already speaks — so the separate question of how this route spells its refusals is untouched. -- **What a caller observes**: a name with no item behind it. A request that was previously answered `200` with an item-less body is now answered `404`; a request that resolves to a real item is byte-identical to before, protection envelope included. diff --git a/.changeset/18402-meta-item-one-absence-envelope.md b/.changeset/18402-meta-item-one-absence-envelope.md deleted file mode 100644 index c0ec68911e9..00000000000 --- a/.changeset/18402-meta-item-one-absence-envelope.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -fix(rest)!: `GET /meta/:type/:name` answers absence in ONE envelope, whichever arm produced it (#18402) - - - -Clause-②: no - -The contract surface (`packages/spec`) is not in this diff; no authorable key, no closed-set member, no published export and no registry entry moves. - -## What was wrong - -#18066 gave this route ONE absence emitter and reached it from the two conditions that RETURN nothing. The conditions that THROW one were left on the classification door, which renders the flat envelope — a string `error` beside a top-level `code`. So `body.error.code` — the accessor #8013 settled on and objectui#4252 reads — was `undefined` on exactly those, and **which envelope a caller had to parse for an absence was decided by two things it cannot see**: - -- `metadata.enableCache`, which **defaults to `true`**. The cached arm's `getMetaItemCached` throws `metadataItemNotFoundError` on a falsy `item`; the uncached arm resolves item-less and returns. -- which protocol implementation is mounted. The in-repo `metadata-protocol` resolves item-less from `getMetaItem`; a protocol that throws the miss reached the same flat door. - -Re-measured on `origin/main` at `551139bb7` rather than copied from the report — the same absent `view`, driven through both arms: - -| arm | status | body | -|:--|--:|:--| -| uncached, item-less return | 404 | `{"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}}` | -| cached, producer throws | 404 | `{"error":"Metadata item view/no_such_view not found","code":"RESOURCE_NOT_FOUND"}` | - -Same route, same status, same code, two envelopes — and the flat one echoed the type and the name where the emitter says one fixed sentence. - -## What it does now - -Both arms reach `sendMetaItemAbsent`. The route's absence answer is one body: - -``` -404 {"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}} -``` - -⭐ This **strengthens** the ADR-0045 §3 property rather than merely preserving it. The unpublished app and the service-gated one already answered through the emitter, so an absence that kept the thrown dialect was a response pair that told them apart — by envelope shape, and by the producer's prose. Byte-identity across all of them is now pinned on the SERIALIZED body, not on object equality. - -## **BREAKING** — the default wire answer moves for non-`app` types - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` (the lockstep convention: `major` is refused by `check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087 disposition above). - -What breaks: on `GET /meta/:type/:name`, the **absence** refusal moves from the flat top-level `code` to the nested `error.code`. ⚠️ For every type that does **not** bypass the cache — `object`, `view`, `flow`, `page` and the rest — this is the **default** answer, not a minority path: `metadata.enableCache` defaults to `true`, so those types took the cached arm and the cached arm threw. Measured in this repo against a real booted app: the showcase declares no `enableCache`, and its dogfood pin on `GET /meta/object/:name` was reading the flat `body.code` — a real consumer, in-tree, depending on the flat shape for exactly this refusal. - -Only `app` (and `dashboard`, `doc`, `book`, `?state=draft`, `?preview=draft`, `?package=`) bypassed the cache and already answered the nested shape. - -**The remedy is one accessor.** Read `body.error.code` instead of `body.code` on this route's 404. Nothing else about the refusal moves: the status is still `404`, the code is still `RESOURCE_NOT_FOUND`, and the message is the emitter's fixed sentence rather than the producer's. `ObjectStackClient` normalizes both envelopes already, so SDK callers are unaffected. - -## ⛔ What it deliberately does NOT do - -- **It is not "every 404 is absence."** `NO_DRAFT` is a 404 on this same route — the Studio designer's `?state=draft` probe — and it says the item **is** there and its draft is not. Folding it in would tell a designer the object does not exist: #5532's flattening, reintroduced by the repair for a sibling of it. A producer-declared code the ADR-0112 ledger does not know keeps its `declaredCode` for the same reason, and a producer that declared NO code gets none invented for it. -- **It does not converge the flat dialect itself.** That envelope POSITION is the live ratchet **#9559** owns repo-wide (`check:route-envelope`); converting two of `sendDeclaredFault`'s four emissions here would mint a new divergence — the same audience refusal answering two shapes depending on which ROUTE served it. diff --git a/.changeset/18406-listmap-config-style.md b/.changeset/18406-listmap-config-style.md deleted file mode 100644 index c13f4aa104f..00000000000 --- a/.changeset/18406-listmap-config-style.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`ListMapConfigSchema` now declares `style` — optional `z.string()`, the map style URL the renderer already reads and the schema refused by name (#18406). In the same stroke `object-map`'s `map` prop points at `ListMapConfigSchema` again, retracting the `z.unknown()` that the missing key had forced. - -`ListMapConfigSchema` is a `strictObject`, and `style` was the one member of the renderer's own documented config surface it omitted. Measured at the `.objectui-sha` pin `53ded82b`: objectui's `ObjectMapConfigSchema` (`packages/types/src/zod/objectql.zod.ts:562`) declares all eight keys, `getMapConfig` reads `schema.mapStyle || schema.map?.style` (`packages/plugin-map/src/ObjectMap.tsx:365`), and objectui's own `content/docs/plugins/plugin-map.mdx:131` documents `style` inside the block. `ListMapConfigSchema.safeParse({ style: 'https://tiles.example/style.json' })` answered `success: false`, so a map style could not be declared through the spec's list-view face at all. Declared here under the director seat's decision batch #153 item 4 letter 1, confirmed by the maintainer verbatim 「其他同意」. - -**Clause-②: yes (widening)** — one new declared key on a published, strict accept set, so the set a consumer writes against grows. Nothing previously admitted is refused, and nothing is retired. Contract-review tier. - -- **`style`, not `mapStyle`, and not both.** Mapbox and MapLibre both call a style URL `style`, and that is the name the renderer reads inside the config block. The competing spelling — objectui#5017's dev warning teaching `map: { mapStyle }` — is corrected on the objectui side rather than learned here, and no alias is declared: an alias would be a permanent obligation for a key nobody has written yet. -- **Not the node-level `style`.** A component node's `style` is `BaseSchema.style`, an inline CSS record; the renderer stopped reading a top-level `style` as a map style at objectui#5017. The component-level `mapStyle` prop is unchanged and still wins when both are present. -- **`object-map.map` stops being `z.unknown()`.** That posture existed only because pointing the door at a schema missing `style` would have refused a value the renderer honours. With the gap closed, the door takes the spec's own block — so a misspelling inside an authored `map` block is now refused at `map`, by name, instead of passing through an open value. The two pins that recorded the divergence are inverted in the same change. -- **The generated projections move with it** — `authorable-surface/ui.json` gains `ui/ListMapConfig:style`, and `content/docs/references/ui/view.mdx` plus `content/docs/references/ui/component.mdx` gain the key; the `object-map.map` row in the component reference changes from `any` to the block's real shape and gains a nested-shape table. diff --git a/.changeset/18408-multi-value-invariant-objectql-turso.md b/.changeset/18408-multi-value-invariant-objectql-turso.md deleted file mode 100644 index c8a5c0aea4c..00000000000 --- a/.changeset/18408-multi-value-invariant-objectql-turso.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@objectstack/objectql': patch -'@objectstack/driver-turso': patch ---- - -fix(objectql,driver-turso): "is this field multi-valued" is `isMultiValueField` here too — the `domain:engine` half of the one-definition ruling (#18408) - -Maintainer ruling, 2026-09-13 (decision batch #128 item 5, option 1′): there is -ONE definition of 「is this field multi-valued」, `@objectstack/spec`'s -`isMultiValueField`, and storage follows it. `driver-sql` was aligned by #17469 -and `os generate migration` by #18199. These four sites were the remainder: they -read `field.multiple` raw, which answers `true` on types the predicate calls -single-valued (`text`, `master_detail`, `tree`, `number`, …) and `false` on the -inherently-multi option types (`multiselect` / `checkboxes` / `tags`) that carry -no flag at all. - -**`@objectstack/driver-turso`** — `RemoteTransport.mapFieldTypeToSQL` short- -circuited its whole type switch on the raw flag, so a `{ type: 'number', -multiple: true }` field was declared `TEXT` in remote mode while the SAME -driver's local transport (`SqlDriver`, aligned since #17469) declared `float`: -one declaration, two storage classes, chosen by which URL the deployment -happens to hold. New columns for such a field are now declared by the field's -own type. Genuinely multi-valued fields (`lookup` / `select` / `file` / `image` -/ `user` flagged `multiple`, and the inherently-multi option types with or -without it) are unchanged — still the JSON-array `TEXT` column. - -**`@objectstack/objectql`** — three sites, all deciding the SHAPE of a stored -value: - -- the option-derived insert default (`resolveOptionDefault`) assembles an array - for a multi-valued field. A `multiselect` / `checkboxes` / `tags` field with an - option marked `default: true` and no `multiple` flag was defaulted to a bare - scalar, which this engine's own validator then refused as - `invalid_type_array` on the insert the default was resolved for; -- the referential-integrity dependents probe (`referenceProbeFilter`) composes - `$contains` for a multi-valued reference and bare equality for a scalar one. A - `master_detail` flagged `multiple` is outside `MULTI_CAPABLE_TYPES`, so every - aligned storage side builds it a scalar column — the probe now asks that column - the question it can answer, instead of a substring match repaired afterwards by - a second narrowing pass; -- the cascade-delete `multiValued` verdict, which that probe, the `set_null` - write shape and the required-FK escalation all read. - -**What a deployment feels.** Only declarations that are already off-spec move: -`FieldSchema` has refused `multiple` on a non-capable type since #17469 (ADR-0087 -semantic entry 18), so these shapes now reach the engine and the driver only -through doors that never run it — `registerExternalObject` / `initObjects` and a -driver's own unvalidated input. Existing columns are untouched: the remote -transport only ever declares types for columns it is creating. A deployment -holding one of these shapes should re-declare the field — drop the flag if the -value really is single, or move the field to a multi-capable type if it is not — -which is the same prescription entry 18 already carries. - -No export is added, removed or renamed in either package, and no authorable key -changes its name, type or optionality. diff --git a/.changeset/18412-platform-admin-standing-audit.md b/.changeset/18412-platform-admin-standing-audit.md deleted file mode 100644 index e49e6ed14bd..00000000000 --- a/.changeset/18412-platform-admin-standing-audit.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/plugin-audit": minor ---- - -The walled boot records platform-admin standing on the existing audit ledger, so «who held administrator standing, and from when» survives the move off the stored grant row (#18412). - -Platform-admin standing moved from a **stored grant row** to **config-derived, request-time resolution** (#11663 re-anchor, ADR-0131). The row carried its own history; config carries none. After the migration the only trace of a grant or a revocation was a change to `OS_PLATFORM_OWNER_EMAIL` plus a restart — the product keeps no environment-variable history and an auditor cannot read one. `sys_audit_log` recorded the ACTIONS all along; what had no writer at all was the **basis** of the authority behind them. - -The answer was already being computed and thrown away: `resolvePlatformAdminStanding` builds the per-entry summary at every walled boot and the bootstrap logs it at `info`. - -- **`@objectstack/plugin-audit`** — `sys_audit_log.action` declares one new value, `platform_admin_standing_change`, WRITER-FIRST (the only way a value is allowed onto that enum). Its rows appear on the shipped, unfiltered `recent` and `all_events` views; ⛔ no new list view, ⛔ no new object, ⛔ no new configuration key. -- **`@objectstack/plugin-security`** — the walled bootstrap compares the resolved standing against the last snapshot already on the ledger and writes **one entry per CHANGE of standing**, plus the **first-boot baseline**. A restarted rig writes nothing. Each row carries, per declared entry, the declared spelling, whether an account exists, whether it is verified, and which user id holds standing; `old_value` and `new_value` state both sides of the delta, and `old_value` is null on the baseline row and only there. -- **The `single` posture is untouched.** It still promotes the first registrant and still writes a durable grant row, so the durability this restores is walled-posture-specific. -- ⭐ **`organization_id` is NULL on this row, deliberately and by maintainer ruling** (2026-09-18, director batch #153 item 2). The record is deployment-level by construction: ADR-0131 §1.5 rejects inventing a platform organization in its own words («it is the natural repair and the wrong one … exists only to give NULL a new name»), a tenant id would file a whole-deployment fact behind one tenant's wall, and the first-boot baseline is written before any `sys_organization` row exists at all. This follows the tree's four existing deployment-level audit writers, and is the shape ADR-0131 D7 will later make structural by dropping the column. The exception is recorded beside the write, on the card, and in a pin — ⛔ it is not a gap waiting to be repaired. -- **Nothing here widens who holds standing or what standing permits.** The derivation site is untouched; this adds a RECORD of authority, never a grant of it. -- **Best-effort, and never fatal to boot.** A deployment that never mounted the optional `@objectstack/plugin-audit` skips silently — an unmounted ledger is a composition choice, not a fault. A ledger read that is REFUSED writes nothing and says so: «cannot tell» is not «first boot», and reading it that way would file a fresh baseline on every restart. A mounted ledger whose insert fails reports a durability degradation on the `error` channel. diff --git a/.changeset/18419-config-shadowed-named-export-reported.md b/.changeset/18419-config-shadowed-named-export-reported.md deleted file mode 100644 index dc87d03c0a0..00000000000 --- a/.changeset/18419-config-shadowed-named-export-reported.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli)!: a named export the config's default export already declares is reported instead of silently dropped (#18419) - - - -`objectstack.config.ts` is loaded as a module: `loadConfig()` takes the default export as the base and merges every named export onto it as a top-level stack key. A named export whose name the default export **already carries** loses — the default's value wins — and until now it lost in complete silence. `os build` exited 0, the artifact carried the default's value, and nothing was written at any level: - -```ts -export default defineStack({ manifest, objects: [Task] }); -export const objects = [Task, Invoice]; // Invoice never reached the artifact -``` - -The loader now says so on stderr, names every shadowed key, and states the rule and the remedy. It is an **advisory, not a refusal** — the stack that comes out is valid, it is merely missing what the shadowed export carried — which is the disposition this package already gives the same failure class (`#3786`'s undeclared authoring keys are "advisory, never fatal"; `#4095`'s orphaned runtime members are "reported rather than dropped"). It goes to stderr rather than stdout because `loadConfig()` is handed no `--json` flag and twelve commands call it, so a `--json` run's stdout stays a single parseable document. `LoadedConfig.shadowedNamedExports` carries the same names structurally. - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` (the lockstep convention: `major` is refused by `check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087 disposition above): the collision test now reads **own keys only**. `key in merged` walked the prototype chain, so every `Object.prototype` member — `toString`, `valueOf`, `constructor`, `hasOwnProperty`, `propertyIsEnumerable`, `toLocaleString`, `isPrototypeOf` — was treated as a key the default export "already carries" when the default carries no such key at all. Such an export was skipped by the merge and therefore never reached the strict parse that refuses an undeclared stack key by name, so `export const toString = …` beside a valid stack built green while `export const collectPackageDirs = …` was refused. That hole is closed: those names now merge like any other and are refused by name, the same sentence every other undeclared helper export has always got. - -Nobody's metadata or stored data changes. A config affected by the narrowing was already shipping that export's value nowhere; what changes is that the build now says so instead of exiting 0. Move the helper into a sibling module and import it, which is what the config-authoring docs have always prescribed for a helper exported beside the stack. diff --git a/.changeset/18424-channel-send-transport-absent.md b/.changeset/18424-channel-send-transport-absent.md deleted file mode 100644 index 2acf63882f3..00000000000 --- a/.changeset/18424-channel-send-transport-absent.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/service-messaging": patch ---- - -`email-channel` and `sms-channel` — `send()` now REFUSES when its transport is not installed, instead of returning `{ ok: true }` for a delivery nothing was sent for (#18424). - -Two members of one object answered one condition differently, and the one a caller acts on said success: `isAvailable()` correctly returned `{ available: false, reason: 'transport_not_configured' }` while `send()` returned `{ ok: true }` — "capability not installed — no-op". The `sys_notification_delivery` row reached `status: 'success'`, nothing went red, no row dead-lettered, and a deployment with an unconfigured email or SMS transport reported every notification as delivered. - -- **`send()` now answers with the reason `isAvailable()` already returns.** `{ ok: false, error: "transport_not_configured: no 'email' service is registered; nothing was sent to ''" }`. The token is the declared `CHANNEL_UNAVAILABLE_REASONS` member, held inside that closed set by its type annotation — ⛔ no new error code, so nothing new to aggregate on. -- **`classifyError()` grades it `permanent`**, so the row dead-letters on attempt one rather than burning the retry ladder against a transport no attempt can install. Driven, ⛔ not assumed: in the composition `MessagingServicePlugin` ships, the mount gate (`lazyChannelMount`, #18050) already answers this same condition by unmounting the channel, and the dispatcher acks such a row `dead` with `attempts: 1`. Both compositions now end one condition the same way. -- **⛔ Not a suppression.** A suppression is fan-out's pre-write answer on `sys_notification.suppressed_channels`; by the time `send()` runs the delivery row exists and `SendResult` has no suppression arm. `channel-availability.test.ts`'s boundary — an unmounted channel is REFUSED, ⛔ not suppressed (#18041) — is unmoved, and this change lands on its refusal side. - -**What changes for a consumer:** a delivery attempted with no transport now reports failure. If you compose these channels yourself through the public `createEmailChannel` / `createSmsChannel` exports with a resolver that can answer `undefined`, deliveries that silently "succeeded" will now appear as `dead` rows carrying `transport_not_configured` — register the transport, or drop the channel from the notify's channel list. Deployments using `MessagingServicePlugin` are unaffected: there the channel is not mounted at all while its transport is absent, and fan-out already refused it. - -Clause-②: no diff --git a/.changeset/18431-per-package-docs-collector.md b/.changeset/18431-per-package-docs-collector.md deleted file mode 100644 index 0c4833477b5..00000000000 --- a/.changeset/18431-per-package-docs-collector.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -Clause-②: yes - -`os build` reads package docs from **each package directory** of an ADR-0130 layout — `src//docs/*.md` — and attaches them to the **owning package's body** (`packages[i].manifest.docs`), linted against **that package's own `namespace`** (#18431). - -A module can now ship its own docs. Before this, ADR-0046 collection was anchored at exactly one path, `/src/docs`, so an ADR-0130 project that moved its docs into their packages lost all of them — loudly since #18428, but lost. The maintainer's ruling (batch #147 item 4) decided the two contract questions that blocked the widening, and both are implemented literally: - -- **Where they attach**: to `packages[i]`, ⛔ never the artifact top level. A body's docs are served because the load path **registers every body**: `AppPlugin` hands the whole artifact to `getService('manifest').register(…)`, which runs `resolveArtifactPackageOrder` (every package body, when `packages` is present) and calls `registerApp(body)` for each; `registerApp` feeds `registerMetadataCollections`, whose `METADATA_ARRAY_KEYS` carries `docs`. A doc written onto a body therefore reaches the registry under its owning package, so a flattened copy would buy nothing and would destroy the ownership D1 is about. -- **Whose namespace the lint uses**: the owning package's. A doc outside any package keeps `stack.manifest.namespace`. A multi-package artifact therefore has **one prefix rule per package** and ⛔ no single global prefix — and ⛔ no fallback between the two: a package doc that fails its own package's prefix is refused, never re-tried against the artifact's. - -**What it costs — the refused classes measured here.** Three classes of input that `os build` accepted before are refused now. Each follows from the ruled prefix rule — the owning package's `namespace`, ⛔ with no fallback to the artifact's — reaching docs the artifact's own prefix used to judge, or docs the docs lint did not reach at all; each needs the artifact to carry a `packages[]`, which `composeStacks(…, { manifest: 'preserve' })` produces from N authored stacks and which a hand-written entry also parses into (`ArtifactPackageSchema`); and each is pinned in the unit tier rather than only stated here. - -**(1) A package doc carrying the ARTIFACT's prefix instead of its own.** A package that declares a namespace DIFFERENT from the artifact manifest's used to have its docs judged by the artifact's prefix; they are judged by its own now. - -``` -FROM packages[i] with namespace "sales" inside an artifact whose manifest.namespace is "crm" - shipping a doc named crm_orders_guide -> accepted before, REFUSED now -TO rename it to sales_orders_guide (and the file to sales_orders_guide.md) -``` - -The refusal is `docs/namespace-prefix`, an error, and it names that exact spelling. - -**(2) A package that ships docs and declares NO namespace at all.** `manifest.namespace` is optional, so such a body is legal and its docs used to be judged under the artifact's prefix — the one global rule. With one prefix rule per package and no fallback, that package's own namespace is the only one that can answer for its docs, and ADR-0046 §3.2 requires it. - -``` -FROM packages[i] with NO namespace, inside an artifact whose manifest.namespace is "crm", - shipping docs (inline, or now from src//docs/) -> accepted before, REFUSED now -TO declare namespace: "sales" on that package — its docs then take the "sales_" prefix - or move those docs up to the stack level, where stack.manifest.namespace still judges them -``` - -The refusal is `docs/namespace-required`, an error, located at `packages[i].manifest.namespace` — the key to add. - -**(3) A hand-written `packages[i].manifest.docs` entry with no copy of that doc at the artifact top level.** `packages` is an authorable key of the stack definition, and its docblock says a hand-written entry still parses — it is an assembled body carrying no collections. That body admits every collection the artifact envelope does not keep for itself, `docs` among them, and `DocSchema.name` says a namespace prefix is "recommended, not required". Before this change nothing linted such a doc at all: the CLI's docs pass read the stack's own `docs` and `src/docs/` and never looked at `packages[]`, and no `@objectstack/lint` rule reads `.docs`, so `os build` exited 0 whatever the doc was called. Clause 2 makes it that package's doc, so every docs rule now reaches it under that package's namespace. - -``` -FROM packages[i] with namespace "sales" carrying docs: [{ name: "playbook", ... }] - and NO copy of that doc at the artifact top level -> exited 0 before, REFUSED now -TO rename it to sales_playbook — or fix whichever rule the message names, because - the whole docs lint reaches it now, not the prefix rule alone -``` - -The refusal for that example is `docs/namespace-prefix`, an error, at `packages[i].docs/playbook`. A doc name a sibling package also declares is a cross-owner `docs/duplicate-name`; an image is `docs/no-images`; and so on through ADR-0046's v1 bans. - -⚠️ **Those are the refused classes this change MEASURED — ⛔ not a claim that they are all of them.** Two of the three were added after a contract review of this card falsified an earlier draft of this note that had called the list complete; the closed claim is therefore dropped rather than re-made one class further out. The boundary that is honest: every refusal above is ONE rule — a package's docs are judged by that package's own `namespace`, with no fallback to the artifact's (the ruling's clause 2) — reaching a set of docs it did not reach before, and a shape nobody has measured yet can meet that rule the same way. If a build that was green fails on a doc, read the rule id the message carries: `docs/namespace-prefix` wants the owning package's prefix, `docs/namespace-required` wants that package to declare a `namespace`, `docs/duplicate-name` names both owners, and the content rules (`docs/no-images`, `docs/no-mdx`, `docs/filename`, `docs/flat-directory`) are ADR-0046's v1 bans, themselves unchanged. - -In the other direction the same change is a widening, and the larger half: before it a package's `src//docs/` was not read at all, and a package could not ship a doc under its OWN prefix. An artifact whose every doc is package-owned also no longer needs a `stack.manifest.namespace` of its own. - -⚠️ Same-prefix LINKS and metadata-embed references are deliberately NOT partitioned with the naming rule — both resolve across the whole artifact. A doc's prefix says who judges its NAME; a link asks whether the target EXISTS, and ADR-0130 D1 exists so that N packages may share a namespace and cross-link inside it. Partitioning links too would have turned an ordinary cross-package link into `docs/broken-link` and stopped an artifact that built green from building; that was caught by this card's contract review and is pinned in the unit tier. - -Also in this change: - -- **The #18428 warning stays**, and now says *why* a directory was not read. Unchanged, word for word, for a stack that declares no `packages[]` — where "read from `src/docs/` only" is still the whole truth. For a directory that names **no** package it lists the declared packages and the two spellings a directory is matched against (`id`, and the last dot-segment of that `id`); for one that names **more than one** it names the candidates and refuses to guess. ⛔ `namespace` is not a matching spelling: ADR-0130 D1 exists so that N packages can share one, so matching on it would be ambiguous exactly where it matters. -- **A cross-owner duplicate doc name stays an error.** It PRESERVES a refusal rather than adding one: before the split every doc reached the lint in one flattened array, so two owners declaring one name already raised `docs/duplicate-name`. Splitting the set per package would have dropped that silently, and ADR-0130 D1 lets packages of one artifact share a namespace, so the prefix does not keep them apart. The rule is authoring hygiene — ⛔ not a claim that one registration overwrites the other, which ADR-0048 §3.3/§3.4 retired. -- **`os dev` mirrors `os build`.** The config-load path collects the same per-package directories onto the same bodies, so dev serves what a built artifact serves. -- **The step line counts the whole collection**, package sets included, and says how many came from package directories — a build that read four package docs no longer announces `0 collected`. - -Single-package projects are untouched: with no `packages[]` there is nothing to attribute, the flat `src/docs/` keeps attaching exactly where it always did, and the emitted artifact is byte-identical. diff --git a/.changeset/18432-docs-step-line-reports-count.md b/.changeset/18432-docs-step-line-reports-count.md deleted file mode 100644 index 8523141466a..00000000000 --- a/.changeset/18432-docs-step-line-reports-count.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os build` / `os compile` — the package-docs step line is printed **after** the collection it announces and carries the count, so a build that collected nothing no longer reads identically to one that collected four documents (#18432). - -``` - → Collecting package docs (ADR-0046)... ← before: every run - → Collecting package docs (ADR-0046)... 0 collected ← after: this run found none - → Collecting package docs (ADR-0046)... 4 collected -``` - -The sentence was unconditional and was emitted **before** `collectAndLintDocs` ran, so the reassurance it offers — the docs step ran, and it found your docs — was true of every run including the ones that found nothing at all. This is the reassurance half of #18170: an exit-0 build carrying the usual progress line is the shape every reader trusts. #18428 landed the audible half, where an uncollected docs directory speaks for itself. - -- **The docs step now reports what it collected, not what it attempted.** A project whose `src/docs/` is empty, or whose docs directory moved into a package under an ADR-0130 layout, prints `0 collected` here instead of the same sentence a successful collection prints. -- **The printed number is the artifact's `docs` set**, the same `docsResult.docs` the build writes into `dist/objectstack.json` — pinned from both ends (absent directory, empty directory, two docs) in `packages/cli/test/build-docs-step-count.e2e.test.ts`, because a test that only asserted the sentence was printed passes on the defective tree. -- **`--json` is unchanged**: the line has always lived behind `if (!flags.json)` and the machine face still emits one JSON document with no step text. diff --git a/.changeset/18441-translation-target-unknown-contribution-surfaces.md b/.changeset/18441-translation-target-unknown-contribution-surfaces.md deleted file mode 100644 index cd6c461069d..00000000000 --- a/.changeset/18441-translation-target-unknown-contribution-surfaces.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`translation-target-unknown` no longer reports the locale keys a package ships for what it CONTRIBUTES into metadata another package owns — `objectExtensions[]`-injected fields and validation rules, and the navigation items it contributes into an app it does not declare (#18441, #18442). - -Both were `error`, so each one FAILED the run it appeared in, and both carried the orphan remedy — *"Point the key at a declared field, or drop it"*, *"Match the key to an app's `name`, or drop it"* — which deletes a translation the runtime resolves. Measured on the two probe stacks: - -- `objects: [crm_lead { name }]` + `objectExtensions: [{ extend: 'crm_lead', fields: { sla_tier } }]` + a `zh-CN` key for `sla_tier` produced one `error` at `translations[0]["zh-CN"].objects.crm_lead.fields.sla_tier`. A genuinely undeclared field on the same stack produced a finding identical but for the name, so **a correct author and a real typo were indistinguishable in the output** — an author who extended an object correctly was told their correct key was wrong, in a run that failed. -- in `os build`'s per-package leg, a contributor package carrying `navigationContributions` and no apps of its own was told app `crm_enterprise` is one *"which this stack does not define"* — whether or not the app's owner was an entry of the same artifact. Declaring that app is the owning package's job; the contributor cannot do it. - -Both folds widen what a key may RESOLVE against and nothing else, so every genuine orphan still reports at `error` with the rule id intact: a typo on an extended object, a `_validations` name no layer declares, an object neither defined nor extended, an app neither defined nor contributed into, and a contributed navigation id nothing contributes are each pinned as a control beside the case they neighbour. - -Two bounds worth reading before widening either fold further: - -- **The extension fold is exactly two rungs wide because `ObjectExtensionSchema` is.** The declared entry keys are `extend`, `priority`, `fields`, `validations`, `indexes`, `label`, `pluralLabel` and `description`; `views`, `listViews`, `actions`, `fieldGroups`, `sections`, `tabs` and `hooks` are refused BY NAME at the extension level with authoring guidance. So `fields.*` and `_validations.*` are the only rungs of this rule an extension can reach, and a `_views` / `_sections` / `_tabs` / `_actions` key on an extended object is an orphan exactly as before. A new pin asserts that surface against the schema, so the sizing cannot silently stop being true. -- **An extension target this stack does not DEFINE is rung 2b of the cross-package ladder**: the object key resolves — the extension is proof the stack means that name — and the subtree is skipped WHOLLY, for the reason rung 2 skips a registered platform object's. The owner's field set is not visible from a package that only extends it, and judging the subtree against the injected names alone would report the owner's own field keys as orphans, which is the same defect one level up. - -No schema moved, no export moved, and no accept set moved: this is a lint rule's false-positive set narrowing. `Clause-②: no` diff --git a/.changeset/18459-navigation-mode-list-seven.md b/.changeset/18459-navigation-mode-list-seven.md deleted file mode 100644 index c3ad87cb5d8..00000000000 --- a/.changeset/18459-navigation-mode-list-seven.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the four `z.unknown()` navigation doors in `ComponentPropsMap` list all seven `NavigationModeSchema` modes (#18459) - -Clause-②: no - -`object-grid`, `object-map`, `object-gantt` and `object-tree` declare `navigation` -as `z.unknown()`, so the `.describe()` on each is the WHOLE published account of -what a mode may be — nothing else in the protocol narrows those four doors, and -the generated reference renders their type as `any` beside that sentence. Three -of them listed six of the seven `NavigationModeSchema` values (no `new_window`) -and `object-grid`, the precedent the other three copied, listed five (no -`popover` either). An author reading the shipped reference was told a value the -platform honours does not exist. - -**Re-measured at the current `.objectui-sha` pin `87af769e9a3e`, not at the pin -the finding was taken at.** All four blocks hand `schema.navigation` straight -into the shared `useNavigationOverlay` hook; that hook types its own mode union -AS this package's `NavigationModeSchema` (its own docblock: *"the seven modes -this hook switches on are exactly the seven the exported union publishes"*, -held by a parity test on the objectui side); its click router carries a -`new_window` branch that delegates to `onNavigate` and otherwise falls through -to a `window.open`, and `object-gantt` additionally implements that action -itself. `popover` is an overlay mode in the same router and every one of the -four passes it an anchor. So all seven modes reach all four doors. - -**Why `object-grid` is in the same change.** It is the row the other three were -copied from and it understates by two rather than one; correcting three while -leaving the source of the pattern intact would leave the family in the state -that produced the defect. All four now name the schema as well as the values, -so the next member added to `NavigationModeSchema` has a named edge into these -rows instead of four independently drifting lists. - -**Why `Clause-②: no`.** The doors stay `z.unknown()` — a `navigation` value is -accepted before and after this change, whatever its `mode` reads. Nothing is -added to, removed from or narrowed on any authorable surface: the diff is four -description strings and the reference page regenerated from them, and -`check:authorable-surface`, `check:api-surface` and `check:export-origins` all -pass with no artifact to regenerate. What moves is what an author is TOLD, which -is why this ships as a `patch` rather than as no changeset at all: the sentence -is published bytes — `src/ui/component.zod.ts` ships verbatim under this -package's `files[]` entry `src/**/*.zod.ts`, and the compiled string ships in -`dist/ui/index.js` and `dist/ui/index.mjs`. diff --git a/.changeset/18459-objectui-pin-citations-remeasured.md b/.changeset/18459-objectui-pin-citations-remeasured.md deleted file mode 100644 index 5bc3c6f767e..00000000000 --- a/.changeset/18459-objectui-pin-citations-remeasured.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the `ComponentPropsMap` objectui read-point records are re-measured at the live pin and now assert it (#18459) - -Clause-②: no - -`packages/spec/src` carries READ-POINT RECORDS: docblocks that say "this key is -LIVE, and here is the objectui `file:line` that reads it". A record anchors -itself to the objectui tree its numbers were counted in, and -`check:objectui-pin-citations` recognises two spellings for that anchor — an -ASSERTING one (`.objectui-sha` = ``, checked against the pin file on every -run, so a pin bump reds on it) and a HISTORICAL one (`.objectui-sha` pin -``, a dated record that a later bump does not falsify, and that nothing -re-checks). - -Eleven records — ten in `src/ui/component.zod.ts`, one in its sibling test — -were in the historical spelling naming the RETIRED pin `53ded82b`, although -every one of them is a live read-point record whose whole purpose is to stay -re-checkable. Each was re-READ -against objectui at `87af769e9a3e` and converted to the asserting spelling, so -the next pin bump fails on them instead of carrying them. - -**The drift was real, not hypothetical, and three anchors could not have been -repaired by refreshing numbers:** - -- `ObjectMap.tsx`'s array-shorthand head inside `getDataConfig` is DELETED - (objectui#8348); an authored `data` array now reaches that renderer through - the React props channel alone, never through the record-source ladder. -- `ObjectTree.tsx`'s `?? schema.titleField` rung is DELETED (objectui#8841). - The flat-spelling prescription that names `titleField` stays TRUE on its - other half — `ListView`'s flatten still resolves `treeCfg.titleField` into - `labelField` before emitting — and that is now what the record cites. -- `object-kanban`'s navigation read no longer carries the `(schema as any)` - cast the record quoted, while its `object-calendar` twin still does. - -A fourth is a count rather than an anchor: the `plugin-tree` registry shell's -`ElementDataSourceGate` control reading. It was re-taken at BOTH ends of the hop -by ONE method — occurrences of that identifier in each control's own -`src/index.tsx` — and nothing moved: 3 each for `plugin-map`, `plugin-gantt`, -`plugin-grid` and `plugin-calendar`, 0 for `plugin-tree`, at -`87af769e9` and at `53ded82bf` alike, so the ZERO that discriminates is the -whole reading. The `7 each` the record used to carry is reproducible at neither -pin by that method, nor by a whole-package count (7 / 5 / 11 / 5). ⛔ A count is -a reading only with its METHOD beside it — without one, re-stating the carried -number is exactly what survives a re-measure. - -`plugin-timeline/src/renderer.tsx:1215` is at the same number with the same -content at both pins — and it is not alone there: -`plugin-timeline/src/index.tsx:333` (`limit: 'limit',`), an anchor of that same -record, is too, read by the same method at both pins. Which is exactly why a -number that did not move is no more a reading on its own than one that did. - -Three further records in the same blocks cited an objectui sha WITHOUT naming -`.objectui-sha`, so they sat outside the gate's population entirely — neither -asserting nor historical, simply unseen. They are re-measured and spelled so -the gate can see them. - -**Why `Clause-②: no`.** Every changed line is a comment. No schema, describe -string, export, key or refusal text moves, so no input's accept/reject verdict -can change; `check:generated` reports all fifteen spec artifacts up to date -with nothing to regenerate. It ships as a `patch` rather than as no changeset -because the bytes are published: `src/ui/component.zod.ts` ships verbatim under -this package's `files[]` entry `src/**/*.zod.ts`, measured against the packed -tarball with a positive and a negative control. diff --git a/.changeset/18490-nav-contribution-groups-imports-package-id-owner.md b/.changeset/18490-nav-contribution-groups-imports-package-id-owner.md deleted file mode 100644 index 8c0d17c521b..00000000000 --- a/.changeset/18490-nav-contribution-groups-imports-package-id-owner.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os build` / `os validate` name an artifact package the same way the runtime fold does when its `manifest.id` and `manifest.name` are both empty — `nav-contribution-groups.ts` no longer carries its own copy of the artifact package-id rule and imports the declared owner instead (#18490). - -Clause-②: no - -`packages/cli/src/utils/artifact-packages.ts` declares itself the sole owner of "which package is this", and says why in its own header: *"⛔ A second copy is the one that must not happen. … Two readers computing 'which package is this' slightly differently is how one entry comes to judge a different set of packages than the other while both look right."* `nav-contribution-groups.ts` exported a second implementation, `artifactPackagesOf`, which differed from the owner in one guard — a non-empty check on `manifest.name` — and the two had already drifted on a real input. - -- **The divergent input is reachable, measured rather than assumed.** `ManifestSchema` requires `id` and `name` as strings and constrains neither to be non-empty, so `{ manifest: { id: '', name: '', … } }` parses green through the same `normalizeStackInput` + `ObjectStackDefinitionSchema` chain both commands run. For that package the owner answered `''` and the deleted copy answered `` `packages[]` ``. -- **Importing the owner chose `''`, and `''` is the answer this path needs.** `ObjectQL.registerApp` derives the id it registers a navigation contribution under as `manifest.id || manifest.name`, with no positional fallback, so the read-time fold names that package `''` and prints `Package "" contributes …`. The build used to print `Package "packages[0]" …` for the same artifact — two doors naming one package differently, which is the divergence the shared `checkNavContributionGroups` predicate exists to prevent, one field over. -- **What an author sees change**: for an artifact package with an empty `id` *and* an empty `name`, the `packageId` on a `nav_contribution_group_missing` warning — and the package name inside its message — is now `''` instead of `packages[]`, in both `os build` and `os validate`, matching what the runtime already reports at boot. Every package with a non-empty `id` or `name` is unaffected: both rules answered identically there, measured on the control legs. -- **The id is carried and printed, never keyed on.** Two packages that both resolve to `''` still produce two findings rather than collapsing into one — pinned, because that failure mode would present as a report going quiet rather than as an error. - -`artifactPackagesOf` is removed. It was never reachable through this package's `exports` map (`.`, `./console`, `./hook-body`), so no consumer import can break; the removal is internal to `dist`. diff --git a/.changeset/18499-email-locale-docblocks.md b/.changeset/18499-email-locale-docblocks.md deleted file mode 100644 index 19ac20e9323..00000000000 --- a/.changeset/18499-email-locale-docblocks.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/platform-objects': patch -'@objectstack/plugin-email': patch -'@objectstack/service-messaging': patch ---- - -docs(email): the shipped carriers said "best-matching locale"; the resolver matches `(name, locale)` exactly (#18499) - -Clause-②: no — no accept set moves and no published payload key changes; the -corrected prose ships as JSDoc in each package's `dist/*.d.ts` (and, for -`@objectstack/service-messaging`, inside the bundled `dist/index.js`), which is -why this is a changeset rather than `skip-changeset`. - -`packages/plugins/plugin-email/src/template-loader.ts` already enumerates -"the EmailService picks the best-matching locale" as a FALSE declaration, and -three shipped carriers still stated it. Measured against the code at this -branch's base rather than against the card's transcription: - -- `createSysEmailTemplateLoader.load` — `locale` given ⇒ exact `{ name, locale }` - match ordered by `id`, or `null`; `locale` absent ⇒ `{ name, locale: 'en-US' }` - first, and only if that misses `{ name }` ordered by `locale` ascending; -- `EmailService.resolveAndRenderTemplate` — `wanted = input.locale?.trim() || - 'en-US'`, then exactly one retry at the literal `'en-US'` when the call NAMED a - locale, then `TEMPLATE_NOT_FOUND`; the unpinned rung is reachable only for a - call that named no locale. - -No language-subtag folding anywhere on that path, and nothing that could be -called a "best match". Corrected: - -- `sys_email_template`'s object doc (`@objectstack/platform-objects`) now states - the exact match, the single `en-US` rung and the no-locale last resort; -- `sys_notification_template.locale`'s sibling-declaration comment - (`@objectstack/service-messaging`) said "both resolve a template by - best-matching locale", which was false in a second way: the two resolvers do - not agree. `NotificationTemplateStore.load` walks `(topic, channel, locale)` - through a candidate list — the named tag, its primary subtag, then - `DEFAULT_LOCALE` (`'en'`) — so it DOES fold a subtag, where - `sys_email_template` does not. Only the shared 16-char BCP-47 bound is shared; - the resolution is not, and the comment now says so; -- `template-loader.ts`'s own "What was wrong" block quoted two sentences it can - no longer quote — one was already stale at this base (the - `EmailTemplateDefinitionSchema.locale` text it reproduces has zero occurrences - in `packages/spec` today) and the other is corrected above. Both bullets are - now cited rather than quoted, so a later rewording cannot strand them again. - -No resolution behaviour changes: every edit in this changeset is prose. diff --git a/.changeset/18509-identity-image-logo-nullish.md b/.changeset/18509-identity-image-logo-nullish.md deleted file mode 100644 index 3ac87d0b3b5..00000000000 --- a/.changeset/18509-identity-image-logo-nullish.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`UserSchema.image` and `OrganizationSchema.logo` are declared `z.string().url().nullish()` — a URL string, `null`, or the key absent are all accepted — so the user and organization bodies this platform serves parse against the schemas it publishes (#18509). - -Both were `z.string().url().optional()`: a URL string or the key's absence, and `null` refused. Both columns are better-auth-owned and nullable — `sys_user.image` and `sys_organization.logo` are each `Field.url({ required: false })`, reaching SQLite as `varchar(255)` with `notnull=0` — and better-auth SELECTs them and serialises them present-and-null for a user who never set an avatar and an organization created without a logo. - -Measured through a real `AuthManager` (better-auth 1.7.3) over a real `ObjectQL` on a real `SqliteWasmDriver`, with the platform's own `sys_user` / `sys_organization` object definitions: - -``` -/auth/sign-up/email -> user.image = null -/auth/get-session -> user.image = null -/auth/organization/create -> logo = null -/auth/organization/list -> [0].logo = null -/auth/organization/get-full-organization - -> logo = null - -> members[].user.image = null - -UserSchema.safeParse() - -> [{ path: ["image"], code: "invalid_type", - message: "Invalid input: expected string, received null" }] -OrganizationSchema.safeParse() - -> [{ path: ["logo"], code: "invalid_type", - message: "Invalid input: expected string, received null" }, … ] -``` - -Those two paths now parse. - -- **Measured, not inferred.** #18509 exists because PR #18501's contract review named these two siblings as *not measured* rather than folding them into the `SessionUserSchema.image` ruling it had. The verdict here comes from the probe above, run the way that ruling's own evidence was taken; the analogy was only ever a reason to look. -- **The declaration was the thing that was wrong.** Prime Directive #12's default — fix the producer, never widen the consumer — rests on the premise it states out loud, that we own both ends. We do not: the nullable columns belong to a third-party model, so PD #12's own exit clause is the operative sentence. -- **A pure widening.** `.nullish()`, not `.nullable()`: the key's ABSENCE is a legal shape today, so `.nullable()` would retire a live shape as the price of admitting `null`. Every body legal before this change is still legal. -- **`.url()` is kept, and it does not fight `null`.** These two declarations carry `.url()`, which `SessionUserSchema.image` did not, so the question had to be answered rather than copied. `.nullish()` wraps the whole `z.string().url()`: `null` and `undefined` are separate branches the URL check never sees, while a present string is still required to be a well-formed URL. Of six inputs — absent, `null`, `''`, a URL, a non-URL, a number — exactly one row moves, and it is the ruled one. `''` and `'not-a-url'` are still refused. -- **No key is added or removed** — both keys were already authored and already published, so no authorable surface moves and nothing is retired. -- **`OrganizationSchema` is not made whole by this.** The same probe found `metadata` served present-and-null and `/auth/organization/create` omitting the required `updatedAt`. Those are separate defects with their own reasoning, filed separately rather than folded in; #18509 asked about `logo`. diff --git a/.changeset/18510-normalize-session-response-jsdoc-past-tense.md b/.changeset/18510-normalize-session-response-jsdoc-past-tense.md deleted file mode 100644 index 8b7463ffec6..00000000000 --- a/.changeset/18510-normalize-session-response-jsdoc-past-tense.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/client": patch ---- - -fix(client): `normalizeSessionResponse`'s JSDoc records the `data.user.image` gap as closed, not as tracked (#18510) - -Clause-②: no - -No declaration, accept set, export or runtime behaviour moves. What moves is one -sentence of developer commentary and the pin that now holds it honest. - -The block above `normalizeSessionResponse` says what the lift compensates for, -so it names the cards that opened and closed each compensation. One clause was -still in the present tense: - -> … with one gap that is NOT this: `data.user.image` served `null` against a -> declared `string | undefined` (#17235, tracked separately). - -Both halves went false when `SessionUserSchema.image` widened to -`z.string().nullish()` and #17235 closed — the sentence described a live gap -that no longer existed and pointed the next reader at a closed card as somewhere -to go look. It now reads in the past tense, naming the widening that closed it -and the residue list in `auth-login-register-envelope.test.ts` that is pinned -empty. Nothing else in the block moves. - -**Why this is a `patch` and not `skip-changeset`, measured rather than -assumed.** "Only comments changed" is not "nothing published moves", and on this -package the two answers differ. `@objectstack/client` ships `dist`, `README.md` -and `CHANGELOG.md`; `dist` is six files (`index.js`, `index.mjs`, `index.d.ts`, -`index.d.mts` and a `.map` beside each of the two bundles — this package emits no -`*.cjs` and no `*.d.cts`). Built from the same tree before and after the change: - -- the comment text reaches **none** of the six (`no longer residue`, - `data.user.image` and `tracked separately` each 0 hits), while the positive - controls land — `{@link normalizeSessionResponse}` appears 3× in each bundle - and 3× in each `.d.ts`, carried there by the JSDoc of the **exported** - `auth.login` / `auth.register` / `auth.me` that link to it, and `set-auth-token` - 4× / 3×. So comment text from this file does reach the published types; this - block's own text does not, because the function it documents is not exported; -- `index.js`, `index.mjs`, `index.d.ts` and `index.d.mts` are **byte-identical** - across the change (sha256, same build, reproducibility control re-run); -- both `.map` files **differ**. Neither carries `sourcesContent`, so no comment - text ships inside them either — the position table shifts because the rewritten - comment is two lines longer than the one it replaced. - -So the published tarball's bytes do move, and a released package whose shipped -bytes move takes a changeset. diff --git a/.changeset/18516-span-auto-wide-types.md b/.changeset/18516-span-auto-wide-types.md deleted file mode 100644 index 274dfd01d38..00000000000 --- a/.changeset/18516-span-auto-wide-types.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`FormField.span`'s `'auto'` description now names the field types the form renderer actually widens, instead of three that it does not (#18516). - -`Clause-②: no` - -The clause told authors that wide widgets *"like textarea/richtext/json/file/subform take the whole row"*. Three of those five names were wrong and two real ones were missing. Re-measured against objectui at the `.objectui-sha` pin `53ded82bf7` by executing that tree's own `mapFieldTypeToFormType`, `isWideFieldType` and `resolveColSpan` over the whole `FieldType` population (**49** members, agreed by two instruments — the executed `.options` and the enum's source tokens with comments stripped): - -- `WIDE_FIELD_TYPES` is **ten** entries — `textarea` / `markdown` / `html` / `grid` / `richtext`, each bare and `field:`-prefixed — in `plugin-form/src/autoLayout.ts` and again in its `plugin-detail` twin. -- `json` and `file` **are** spec field types, and both resolve to **one cell**, not the row (`json` maps to `field:code`, `file` to `field:file`). `subform` is not a spec field type at all. -- `markdown` and `html` **are** widened, and the sentence named neither. -- The set that resolves to the full column count is **five**: `textarea`, `markdown`, `html`, `richtext` and **`repeater`**. The measurement has to follow the path the form actually walks — every site that assigns `FormField.type` maps the spec name through `mapFieldTypeToFormType` first — and on that path `repeater` becomes `field:grid`, which is a member of `WIDE_FIELD_TYPES`. Measured only on the bare spec name the set is the four long-form types, which is what objectui's own pin asserts at that sha ("its spec-facing surface is EXACTLY the long-form family"); that reading is true of the bare path and is not the one an author's field takes. The literal `grid` stays unnamed because it is an objectui-local metadata key rather than a `FieldType`, so `type: 'grid'` is refused — but `repeater` is the spec spelling that reaches the same widget, and it is accepted. - -An author reads that sentence to decide a form layout, so the cost of a wrong name is a layout decided on a type that behaves the opposite way — in either direction. - -What the clause says now: those five resolve to the full **column count** — the number `resolveColSpan` really returns — leaving the sentence beside it to state how far down the container-query tiers that span is emitted. At this pin only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid, whose container class is `grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3`), so a wide field is one cell of two at the `@md` tier. Promising a whole row at every tier would be the same defect with its sign flipped. - -Both readings are of one pin, so the citation moves from the historical spelling to the **asserting** one (`` `.objectui-sha` = `` ``): `check:objectui-pin-citations` compares an asserting citation against the pin file, so the next pin bump reds on this sentence and it cannot rot silently. That matters here — objectui `bd09957380` is already ahead of the pin and emits one clamped class per multi-column tier, which makes this sentence's tier half wrong the moment a bump absorbs it. - -No key, default, enum member or export moves: the same authored metadata is accepted and refused as before. diff --git a/.changeset/18535-anchor-declared-capabilities-consumers.md b/.changeset/18535-anchor-declared-capabilities-consumers.md deleted file mode 100644 index b89091e26a4..00000000000 --- a/.changeset/18535-anchor-declared-capabilities-consumers.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -The `everyone`-anchor doors now pass the stack's declared capabilities, so an app capability token a stack DECLARES no longer makes its `isDefault` set unbindable (#18535). - -ADR-0090 D5 rules the `everyone`-anchor offending list as 「平台系统权限;带 package provenance 的应用声明 capability 令牌不计」, and PR #17811 landed the predicate that implements it: `describeHighPrivilegeBits(def, context?)` excuses a `systemPermissions` name when the caller says this stack declared it. No consumer in this package passed a context, so all three doors kept judging an app's own gate exactly like `manage_users` — declared ≠ enforced on a contract both the ADR and the spec had already ruled, and an app that declared a capability its navigation gates on could not ship the "every employee holds this" set those gates need. - -All three now read one source — the stack's `capabilities:` declarations, through `readDeclaredCapabilityContext` (registry first, metadata service as the fallback, exactly as the `sys_capability` seeder reads them): - -- **the boot binding** (`bindBaselineToEveryone`) — the ADR-0090 D5 bind of the configured baseline set(s) to this organization's `everyone` anchor; -- **the engine write gate** on a `sys_position_permission_set` insert/update, read at most once per pass and only once an anchor row is in play; -- **`confirmAudienceBindingSuggestion`**'s early refusal, which is the friendly rendition of that same gate — one source is what keeps it from answering "confirmed" and then having its own insert refused under it. - -**Why the declarations and not the `sys_capability` rows.** The predicate's docblock names the rows at boot, but the boot binding runs BEFORE `bootstrapDeclaredCapabilities` seeds them (the bind must follow `bootstrapBuiltinRoles`, which seeds the anchor, and precede the suggestion reconciliation), so the rows are empty there on a first boot. Reading them would refuse every declared token one layer in. - -**Two things do not move.** The platform floor is absolute — declaring a capability named `manage_users` launders nothing, because the predicate applies `PLATFORM_CAPABILITY_NAMES` itself — and an UNDECLARED name still refuses at every door, as does every unreadable or empty declaration list (「omission refuses」). The `guest` tier is untouched: the predicate drops the context for it by contract. - -**What changes for a consumer:** a permission set whose `systemPermissions` names only capabilities the stack declares, marked `isDefault: true`, now binds to `everyone` at boot instead of logging `refusing to bind fallback set to everyone`. If you were relying on that refusal to keep such a set unbound, remove the token from the set or stop declaring the capability. - -Clause-②: yes (widening) diff --git a/.changeset/18535-lint-anchor-declared-capabilities.md b/.changeset/18535-lint-anchor-declared-capabilities.md deleted file mode 100644 index 5f5ee93e966..00000000000 --- a/.changeset/18535-lint-anchor-declared-capabilities.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -`security-anchor-high-privilege` now reads the stack's own `capabilities:` declarations, so a declared app capability token on an `isDefault` set lints clean (#18535). - -The rule holds an `isDefault: true` set to the `everyone`-anchor tier at authoring time, and ADR-0090 D5 puts 「带 package provenance 的应用声明 capability 令牌」 outside that tier's offending list. The rule called `describeAnchorForbiddenBits(ps, 'everyone')` with no `AnchorBindingContext`, so it reported an error for a set the runtime — once it reads the same declarations — binds without complaint. A lint that refuses what the runtime accepts is the drift ADR-0049 says not to ship, in the direction that is hardest to notice: the author never gets to the runtime. - -`validateSecurityPosture` now builds the context from `stack.capabilities` and passes it at that one call site. Nothing else about the rule moves: - -- an **undeclared** `systemPermissions` token still errors — membership in the declaration list is what excuses a token, not the presence of a `capabilities:` collection; -- a **platform** capability still errors even when the stack declares a capability of that name: the platform floor lives inside the predicate, shared with the runtime gate; -- a stack that declares nothing gets the pre-#17811 verdict verbatim. - -**What changes for a consumer:** `os validate` (and any other caller of this rule) stops reporting `security-anchor-high-privilege` on an `isDefault` set whose `systemPermissions` names only capabilities the same stack declares. A stack that was editing its set to silence this rule can declare the capability instead — which is what the ADR asks for, since the declaration is what the runtime reads at boot. - -Clause-②: yes (widening) diff --git a/.changeset/18540-actions-non-sandboxed-crash-sentence-withheld.md b/.changeset/18540-actions-non-sandboxed-crash-sentence-withheld.md deleted file mode 100644 index 0f73de84e32..00000000000 --- a/.changeset/18540-actions-non-sandboxed-crash-sentence-withheld.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(runtime): a NON-sandboxed crash at `/api/v1/actions` no longer ships its native error message verbatim (#18540) - -Clause-②: no - -A plain `TypeError` thrown by an in-process registered action handler answered -`500 INTERNAL_ERROR` carrying the native sentence on the wire: - -``` -{"success":false,"error":{"code":"INTERNAL_ERROR", - "message":"Cannot read properties of undefined (reading 'id')","httpStatus":500}} -``` - -The identical crash through the `/data` door answered `"Internal server error"` -(#7543 / #15071). One repository, two doors, one already meeting the contract. - -**The status was already right; what leaked was the sentence.** No status code, -no `error.code` and no envelope key moves — reaching this branch already proves -the throw declared no `status`/`statusCode` (the branch above serves those) and -is not a `ValidationError`, so the resolver's status was the 500 fallback and its -code was the status-derived `INTERNAL_ERROR`. Only `error.message` changes. - -**Why neither existing guard caught it.** #17273's crash terminal is keyed on the -SANDBOX — `isNativeErrorName` read over the `innerMessage` the QuickJS runner -fills — and this face never crosses a VM boundary, so nothing sets `innerMessage` -and that terminal never fires. *A predicate that classifies by HOW a crash -arrived is structurally blind to crashes that did not arrive that way, while -looking exhaustive.* The other guard, the dispatcher's 5xx withhold, is gated on -`looksLikeInternalErrorLeak`, which recognises DRIVER DUMPS and reads FALSE for -stack-shaped prose. - -**The structural difference, which is the fix.** The `/data` door is default-DENY: -`classifyDataError` ends in an unconditional `UNCLASSIFIED_FAULT()`, and its -`looksLikeInternalErrorLeak` limb only picks `DATABASE_ERROR` over -`INTERNAL_ERROR` — that limb is not what sanitises. The actions door's -`unexpectedFault` exit relayed `err.message` and was therefore default-ALLOW: -prose shipped unless a heuristic recognised it. That exit is this door's -unclassified-fault terminal, so it now answers the terminal's envelope — -`INTERNAL_ERROR_MESSAGE`, through the same `deps.error` seam #17273's terminal -uses. - -⛔ `looksLikeInternalErrorLeak` is NOT re-pointed at stack-shaped prose. It guards -a different question at every other boundary, and widening it would change what -each of them withholds. - -**Measured population.** Driven through the real `HttpDispatcher.handleActions` -door against `mapDataError` on the same throws: seven shapes leaked at `/actions` -and were already sanitised at `/data` — `TypeError`, `ReferenceError`, -`RangeError`, `SyntaxError`, a driver class whose prose the heuristic does not -recognise (this one shipped a server **filesystem path**), a sandbox timeout and -a sandbox capability denial. All seven now answer the same sentence at both -doors. Two controls are unchanged in both directions: a deliberate rejection -keeps its `400` and its own words, and a crash that DECLARED its own status keeps -that status and that sentence. - -**Who is affected.** Any caller reading `error.message` off a `500` from -`/api/v1/actions` to tell one crash from another. That text was never a contract -— it is the thrown error's own prose — and the full text still reaches the -operator: the `console.error` on the line above keeps it, the same -"the client does not read it, the log keeps it" split `rest` already draws. diff --git a/.changeset/18545-formula-can-permission-predicate.md b/.changeset/18545-formula-can-permission-predicate.md deleted file mode 100644 index 9ee0d0fadfd..00000000000 --- a/.changeset/18545-formula-can-permission-predicate.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/formula': minor ---- - -Add `current_user.can(object, verb)` — the permission predicate — to the CEL engine, together with the data it is answered from. - -`Clause-②: yes` — a new callable name widens the authorable surface. Purely additive: nothing is removed, renamed or narrowed, and every expression that evaluated before evaluates the same way. - -**What you can write now** - -```cel -current_user.can('crm_lead', 'edit') -``` - -`can` is registered **receiver-only**, so it is called ON the acting subject (`current_user`, or its `user` / `ctx.user` / `os.user` aliases — the same object). A bare `can(object, verb)` is deliberately not registered and keeps faulting: a permission question with no subject has no meaning. - -The verb vocabulary is the closed table `OBJECT_PERMISSION_VERBS` in `@objectstack/spec/security` — `read`, `create`, `edit`/`update`/`write`, `delete`/`remove`, `export`, `transfer`, `import`. A verb outside it is refused loudly rather than answered `false`. The answer folds the super-user bits exactly as the enforcement door does, so a predicate and the server's 403 cannot disagree. - -**What a call site must pass** - -`EvalContext` gains `permissions` — a pure data map, object name → `EffectiveObjectPermission`, which is the `objects` map of the published `/auth/me/permissions` response, unchanged. Build it through the new `toEvalPermissions(response.objects)`, which refuses a payload that is not that shape. - -```ts -import { toEvalPermissions } from '@objectstack/formula'; - -const permissions = toEvalPermissions(mePermissions.objects); -ExpressionEngine.evaluate(predicate, { user, record, permissions }); -``` - -**With no permission data in the context, `can` THROWS** (`ok: false`, `kind: 'runtime'`) and names the missing input. It never answers `true` (which would reveal what the subject may not see) and never answers a silent `false` (which would hide a gated element from everyone, indistinguishable from a real denial). An *empty* map is a real answer and evaluates to `false`, as does an object the map does not mention. - -**Also new, all additive**: `EvalPermissions` and `PermissionBinding` types, `registerPermissionPredicate()`, and an optional fourth argument on `registerStdLib()` carrying the binding. Existing three-argument calls are unaffected. diff --git a/.changeset/18545-spec-object-permission-verbs.md b/.changeset/18545-spec-object-permission-verbs.md deleted file mode 100644 index f418973ce41..00000000000 --- a/.changeset/18545-spec-object-permission-verbs.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Publish the object-permission VERB vocabulary and the effective-entry reader from `@objectstack/spec/security`. - -`Clause-②: yes` — new exported names on a published surface. Purely additive: no export is removed, renamed or narrowed, and no schema changes shape. - -**New exports** - -- `OBJECT_PERMISSION_VERBS` — the closed verb → `allow*` bit table. Derived from the bare verbs of the object-permission key aliases (`read`, `create`, `edit`/`update`/`write`, `delete`/`remove`, `export`, `transfer`) plus one row that is not derivable and is recorded as a deliberate choice: `import` → `allowCreate`, because importing rows is creating rows. `restore` / `purge` are absent, as they are on the alias table since their bits were retired. -- `OBJECT_PERMISSION_VERB_NAMES` — the same vocabulary, sorted, for a refusal message to name in full. -- `resolveObjectPermissionVerb(verb)` — the only supported read of the table. Use it rather than indexing the record: a direct index answers `toString` with a function, which a truthiness check reads as a grant. -- `objectPermissionGrants(permission, target)` — whether one `EffectiveObjectPermission` entry grants a bit, folded the way the enforcement path folds it: `viewAllRecords` or `modifyAllRecords` grants read; `modifyAllRecords` grants edit, delete and transfer but never create; `export` is `grant ∧ read`. An absent entry and an all-`false` entry both answer `false`. -- `ObjectPermissionVerbTarget` — the `allow*` bit type a verb can resolve to. - -**Why they are published**: `@objectstack/formula`'s new `current_user.can(object, verb)` predicate reads a `/auth/me/permissions` map, and a client rendering the same capability reads the same map. One table and one fold, published once, so the predicate an author writes and the 403 the server returns cannot answer differently. diff --git a/.changeset/18550-reference-carrier-residue.md b/.changeset/18550-reference-carrier-residue.md deleted file mode 100644 index e283270a120..00000000000 --- a/.changeset/18550-reference-carrier-residue.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/rest": minor -"@objectstack/metadata-protocol": minor -"@objectstack/lint": minor -"@objectstack/verify": minor ---- - -The remaining raw `FieldSchema.reference` readers now **REFUSE** a carrier they cannot read, instead of answering "no target" (#18550). The previous release routed the arbiter (`referenceCarrierOf`) and the lint target readers; these were the measured residue of the same ruling — every reader, not just the arbiter. - -`FieldSchema.reference` is `z.string().optional()`, so `ObjectSchema.safeParse` refuses an object- or array-valued carrier at the contract door. These reads are the other door: the one a value reaches only when it never went through parse — a hand-built fixture, a raw `registerObject`, a stored row rehydrated past its schema. - -**`@objectstack/objectql`** — both of the delete cascade's carrier reads (`planCascadeAtomicity` and `cascadeDeleteRelations`). This is the one with a measurable runtime consequence, and it is why the level is not `patch`: - -``` -before acct=1 task=1 -delete RESOLVED true <- success reported to the caller -after acct=0 task=1 <- an ORPHANED master_detail row -``` - -An unreadable carrier made the relation invisible to the cascade, so the parent was deleted, the detail row stayed, and the caller was told the delete succeeded — no `restrict` refusal, no `set_null`, nothing logged. It now refuses before any row is touched. - -**`@objectstack/rest`** — the public-form lookup picker's field-def fallback. The field def is also hoisted out of the metadata fetch's `catch {}`, so an unreadable carrier is no longer reported as `LOOKUP_TARGET_MISSING`: "no target is declared" and "the declared target cannot be read" want different fixes from whoever owns the metadata. - -**`@objectstack/metadata-protocol`** — the seed dependency graph, which also retires an `as string` cast that asserted exactly what its truthiness guard had not checked. - -**`@objectstack/lint`** — the four remaining target readers: `masterDetailCount` (`validate-expressions`), the `displayField` consumer edge (`validate-field-consumers`), the field and action-param targets (`validate-object-references`), and `masterOf` (`validate-sharing-rule-enforceability`). - -**`@objectstack/verify`** — `relationTarget`, which no longer degrades an unreadable carrier to the generic "has no `reference` target" an object with no relationship metadata at all receives. - -`null`, `undefined` and `''` are ABSENCE, not a wrong shape, and still answer `undefined` at every one of these sites — a field is allowed to name no target, and `StrictField` declares `reference` nullable. Each site's absence answer is pinned alongside its refusal. - -Upgrading: nothing conformant changes. A non-string `reference` could not be authored, stored or parsed before this release either; what changes is that one now fails loudly at the read instead of being read as an absent target. If a test asserted the old silence, assert the refusal instead. diff --git a/.changeset/18552-build-progress-docblock-provenance.md b/.changeset/18552-build-progress-docblock-provenance.md deleted file mode 100644 index 3a26195311c..00000000000 --- a/.changeset/18552-build-progress-docblock-provenance.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): label every producer claim in the `build-progress` docblock — measured, ruled, or inferred (#18552) - -Clause-②: no - -The module docblock on `ai/build-progress.zod.ts` stated three producer claims -as MEASUREMENTS. It ships in this tarball (the published `files[]` carries the -`.zod.ts` sources) and is rendered verbatim into the generated reference page, -and for a CLOSED vocabulary it is the audit trail the "re-measure before you -move the array" discipline reads. One of the three was false, and a reader -deciding whether a fifth phase is warranted would have read all three as -readings. - -Each producer claim now carries exactly one of three labels, defined at the top -of the module: **measured on a named reachable source**, **declared by ruling**, -or **inferred**. - -- Membership is no longer described as uniformly measured. `structure`, `data` - and `done` stay **measured** — the objectui reader's own union and coercion - default, cited with the tree they were read against. `verify` is **declared by - ruling** (cloud#2172, objectui#7388): at the read tree the chat panel has zero - occurrences of `'verify'` against a control of four files for `'structure'`, - and this repository emits no frame at all. That is a good reason for the - member; it is not an observation, and the docblock no longer says it is. -- The cloud#1838 window — "111 seconds and 9 tool calls", "one of them - `verify_build`" — is **inferred**: that record is not reachable from this - repository, so the figure is carried, not measured, and which tools those - calls were is recorded nowhere reachable. What is measured is narrower and - stated as such: `verify_build` is a registered platform tool. -- "A turn that seeds no sample data never reports `data`" and "`apply_edit` - turns need not report `structure`" are **inferred**. The consumer guidance - around them is unchanged and does not rest on them: treat every phase as - optional and compare by value. - -A new `## Liveness watch` section records that `verify`, `hop` and `tool` are -declared ahead of any code that uses them, that cloud#2172 and objectui#7388 -block 2 are the named carriers meant to close that, and that no gate watches it -— `BuildProgressFrame` is not a registered metadata type, so the ADR-0049 -liveness ledger never sees it. - -No schema, export or parse behaviour moves: `BUILD_PROGRESS_PHASES`, -`BuildProgressPhaseSchema` and `BuildProgressFrameSchema` accept and refuse -exactly what they did before. diff --git a/.changeset/18554-expr-schema-hint-surface-roots.md b/.changeset/18554-expr-schema-hint-surface-roots.md deleted file mode 100644 index f1302ca576b..00000000000 --- a/.changeset/18554-expr-schema-hint-surface-roots.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/formula": minor ---- - -`ExprSchemaHint` gains `roots` — an authoring surface naming the binding roots it mounts beyond the platform baseline, so `validateExpression` can accept them without standing down on everything else (#18554). - -A page component's `visibleWhen` binds three roots at runtime, and `ExprSchemaHint` could express neither of the two shapes it needs: `scope: 'record'` refused `page.selectedProjectId != ''` — the worked example `packages/spec/src/ui/page.zod.ts`'s own `visibleWhen` describe ends with, under a sentence naming the contract-bound roots as `record`, `current_user` and page state as `page.` — and prescribed `record.page`, which names nothing on any layer; `scope: 'flattened'` accepted that example and accepted a bare `status == 'done'` with it, which is the shorthand the narrowing exists to catch. Downstream the refusal is not cosmetic: an editor that lints a page block on the `record` face disables Save for the author who wrote the platform's own documented spelling. - -```ts -validateExpression('predicate', "page.selectedProjectId != ''", { - scope: 'record', - roots: ['page'], // what this surface mounts beyond the baseline -}); // -> ok; `status == 'done'` at the same site is still an error -``` - -- **It only ever adds.** A root listed in `roots` is declared alongside `SCOPE_ROOTS`, never instead of it, so passing the key can turn a refusal into an acceptance and never the reverse — a caller adopting it cannot silently lose a check it has today, and a call site that does not pass it gets the verdict and the prescription it got before, byte for byte. -- **Declaring a root is not becoming permissive.** The bare-field shorthand, an undeclared root, and a typo of a declared root are all still hard errors at a surface that declares `page`. Trading a false refusal for a silent acceptance is the worse of the two directions, so the surface says *which* roots it binds rather than asking the validator to stop checking. -- **A mistyped root is sent to the root, not to `record.`.** When a surface has declared its roots, a namespace reference within edit distance of one of them (`pge.selectedProjectId`) is named as an unbound root and pointed at `page`. Every other shape — a bare value reference, a known field used as a JSON namespace, any site with no declared roots — keeps the existing `record.` prescription, which is the right fix for the case it was written for. -- **`introspectScope` advertises what the validator accepts.** Declared roots join the roots it hands an author, from the same declaration, so a root that is accepted is never one an author has no way to discover. -- **Not a closed-set mechanism.** A surface that must *refuse* a baseline root it never mounts still says so with `collectCelRootIdentifiers`, which reads the AST and is independent of this key. The two directions stay two mechanisms. - -Clause-②: yes (widening) diff --git a/.changeset/18559-batch-door-wired-failing-engine.md b/.changeset/18559-batch-door-wired-failing-engine.md deleted file mode 100644 index 915eec2cecb..00000000000 --- a/.changeset/18559-batch-door-wired-failing-engine.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): `POST /api/v1/batch` answers the same thing for a wired-and-failing engine on every wiring — 503, the answer this slot's two other consumers already give (#18559) - -`objectQLProvider` has three consumers in `packages/rest/src/rest-server.ts`. Two reach the -seam through `wiredEngineOrLoud`, which keeps "no engine is wired" and "the engine WAS wired -and could not be resolved" as two facts. The cross-object batch door read the field directly, -so a rejection escaped the read, missed the adjacent `501 NOT_IMPLEMENTED` arm (it tests -`!ql || typeof ql.transaction !== 'function'`, which a rejection never reaches) and landed in -the handler's generic outer `catch`. - -⛔ **Not a re-collapse and not a regression.** The two facts always differed on the wire, so -the decidable test #14251 tightened was already satisfied at this consumer. What was wrong is -that they differed *through a catch-all that knows nothing about this seam*. - -**What moves, measured on a real `RestServer` over a real `ObjectKernel`, driven at the door:** - -| wiring, engine wired and FAILING | before | after | -|:--|:--|:--| -| single-kernel (the composition the open core boots) | 503 `SERVICE_UNAVAILABLE` | 503 — unchanged | -| multi-kernel (a `kernelManager` is wired) | **500 `INTERNAL_ERROR`** | **503 `SERVICE_UNAVAILABLE`** | - -⭐ The single-kernel row is why this is a de-divergence rather than a new wire ruling: there -`computeExecCtx` resolves the engine through its own `wiredEngineOrLoud` branch and raises -before the batch handler's engine line runs, so this door already answered 503. The 500 was -reachable only where that gate's kernel branch absorbs by design and hands the engine question -down. An operator got one of two answers for one fact depending on which composition was -running — and 500 and 503 are not synonyms to a client: one says "I am broken", the other says -"I am temporarily unavailable, retry". - -**Unchanged, and pinned:** both ABSENCE shapes still answer `501 NOT_IMPLEMENTED` on both -wirings — no provider wired at all, and a provider that RESOLVES `undefined`, which is the -seam contract declaring absence rather than failing. The fault MESSAGE is still withheld -(`Internal server error`); only the status and the machine code move. `SERVICE_UNAVAILABLE` is -an existing `StandardErrorCode` already emitted by the sibling `/meta/object/:name/state/:field` -door for this same fact — no new code, no new payload key, no new export. diff --git a/.changeset/18565-kanban-titlefield-position.md b/.changeset/18565-kanban-titlefield-position.md deleted file mode 100644 index 00f909dd3e7..00000000000 --- a/.changeset/18565-kanban-titlefield-position.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -fix(lint): `list-view-field-unknown` walks `kanban.titleField` — the one item-titled face the position table never listed (#18565) - -Clause-②: no - -`POSITIONS` in `validate-list-view-field-refs.ts` declares, per view face, which field-reference keys are walked and at what level, and `kanban` was the only item-titled face with no `titleField` row. From #16894 the key is authorable on `KanbanConfigSchema`, so from that release a misspelt field name cleared the schema door, was walked by nothing, and the board fell back to the ADR-0079 record display name — a title the author did not ask for, on a board that renders correctly, with no gate reporting the miss. The byte-identical typo one block away on `calendar` or `timeline` was reported. - -Measured on this branch, one list view carrying every walked position, one mutation at a time: - -| probe | before | after | -|:--|:--|:--| -| `kanban.titleField` naming a field that does not exist | silent | `warning` `list-view-field-unknown` at `views[0].list.kanban.titleField` | -| `kanban.titleField` naming a real field | silent | silent | -| the other 51 walked positions | 51 reported, 1 silent (this one) | the same 51, each at its same severity | - -Over the repo's own example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`) the findings count is **0 before and 0 after**: five kanban blocks are authored there and none carries `titleField`, so nothing existing starts reporting. Injecting `titleField: 'zz_no_such_field'` into those same boards flips 0 → 1 warning in `app-crm` and `app-showcase`. - -**`warning`, the level `calendar` takes — not the level of the two siblings that spell the key required.** `KanbanConfigSchema` declares `titleField` OPTIONAL (#16894 copied `CalendarConfigSchema` for this exact key and names `TimelineConfigSchema` / `GanttConfigSchema`, the two that spell it required, as the siblings it deliberately does not copy), and the board resolves an unresolvable name through the ADR-0079 display-name chain: measured in objectui `dda8f3815`, `resolveKanbanTitleField` returns the written name, the card reads `rec[titleField]`, finds nothing and falls to `getRecordDisplayName`. Every card still renders — the warning tier's own case in this rule's module note ("the renderer drops one decoration and renders the rest: an optional colour / title / tooltip / cover binding"), where `kanban.groupByField` is the error tier's, collapsing every card into one uncolumned lane. - -No rule id, no severity and no message shape changes for any other position; `list-view-field-unknown` gains one more place it can be reported from. diff --git a/.changeset/18567-sms-channel-is-available.md b/.changeset/18567-sms-channel-is-available.md deleted file mode 100644 index 5ab5c6d4e87..00000000000 --- a/.changeset/18567-sms-channel-is-available.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/service-messaging": patch ---- - -`sms-channel` now declares `isAvailable()`, so fan-out can suppress it on an absent transport exactly as it already suppresses `email` (#18567 — #17732's unfinished half). - -`email-channel` was the only implementation of the optional `MessagingChannel.isAvailable` member in the repository. Fan-out's `resolveChannelAvailability` treats a channel without that member as AVAILABLE — the deliberate default that keeps every third-party channel working — so one condition, "there is no transport", was answered two ways depending on which channel was asked: `email` was suppressed before any `sys_notification_delivery` row was written, while `sms` got a row per recipient that the pipeline could only dead-letter. - -- **The answer is the token `send()` already refuses with**, read off the `TRANSPORT_NOT_CONFIGURED` constant rather than retyped: `{ available: false, reason: 'transport_not_configured' }`. ⛔ No new error code and no new reason token — the vocabulary stays the closed `CHANNEL_UNAVAILABLE_REASONS` set, so the refusal on the delivery row, the suppression record on `sys_notification.suppressed_channels` and the availability answer all name one condition. -- **The probe does no I/O.** It is a service-registry closure call, so fan-out consults it inline and holds no cache — the `sms` settings namespace is `scope: 'global'` and its transport is hot-swapped by the settings change bus, so a memo would save nothing and would keep answering "unavailable" straight through the settings save that fixed it. -- **⛔ It does not weaken #18424 / PR #18562.** `send()`'s refusal is unchanged; it now answers the residue a pre-write suppression cannot cover — a transport present at emit and gone by dispatch, where the delivery row already exists. - -**What changes for a consumer:** if you compose the `sms` channel yourself through the public `createSmsChannel` export with a resolver that can answer `undefined`, an `emit()` targeting `sms` with no transport installed now writes **no** `sys_notification_delivery` rows for that channel and instead records `{ channel: 'sms', reason: 'transport_not_configured' }` on the `sys_notification` event's `suppressed_channels`, returned to the caller as `EmitResult.suppressed`. Those are the same rows that previously existed only to dead-letter, so `result.enqueued` drops and `result.suppressed` gains an entry. Deployments using `MessagingServicePlugin` are unaffected: there the mount gate refuses first and the channel is never registered, which is a composition fact and ⛔ not a suppression. - -Clause-②: no diff --git a/.changeset/18570-seed-name-lookup-degradation-audible.md b/.changeset/18570-seed-name-lookup-degradation-audible.md deleted file mode 100644 index f661e3d4593..00000000000 --- a/.changeset/18570-seed-name-lookup-degradation-audible.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -`seed-name-lookup.ts` — the batched seed existence read's OWN failure now reaches the author when no logger was injected, through the one delivery derivation the package already owns (#18570). - -The oracle every declared-metadata seeder consults hoists one `$in` read out of its loop and degrades to the per-item read when that read cannot answer — an outage, or a page proven to be a prefix of the answer. It reported that degradation through a doubly-optional `logger?.warn?.(…)`, which evaluates to NOTHING when the caller injected no sink: the read failed, the pass silently switched to the slow path, and no human was told. - -Measured differentially rather than read off the code, in this package's own `bootstrap-declared-capabilities` control harness: an unreadable-database pass with **no logger** printed exactly **one** author-visible line — the seeder's own end-of-pass summary — while the batched read that failed *first* said nothing. With this change the same pass prints **two**, and that assertion is now the pin (`toHaveLength(1)` → `toHaveLength(2)`, both lines selected by content). - -- **Delivery only.** The wording, the structured meta (`object`, `names`, `rowBudget`, `organization`) and the two named causes — `unreadable` and `truncated` — are axis-specific and stay at the call site, which is the split `seed-refusal-sink.ts` documents. ⛔ No sixth hand-written copy of the rule, and ⛔ no generic refusal sentence. -- **A read that ANSWERED stays silent on every channel**, with or without a sink — the discriminating control that keeps a healthy boot quiet. -- **It also stops a throw.** `logger?.warn?.(…)` guards `null`/`undefined`, never a non-callable `warn`: a host that declared one and shipped something else raised `TypeError: logger?.warn is not a function` *inside* the degradation path, turning a slower read into a failed boot. The site now asks `typeof` — the same question `reportThroughSink` asks — so such a host takes the console arm instead. -- **No exported surface moves.** `seed-name-lookup.ts` is package-private (`src/index.ts` re-exports nothing from it) and `SeedLookupLogger` is unchanged, both members still optional. diff --git a/.changeset/18571-permission-set-skipped-unowned.md b/.changeset/18571-permission-set-skipped-unowned.md deleted file mode 100644 index 47539712327..00000000000 --- a/.changeset/18571-permission-set-skipped-unowned.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -**The permission-set seeder's unowned refusal now moves a counter.** `PermissionSeedOutcome` gains a required `skippedUnowned`, incremented on the `!packageId` branch of `upsertPackagePermissionSet` — the branch that refuses to materialize a declared set with no resolvable owner (#18571). - -⛔ **The refusal itself is unchanged.** A `managed_by:'package'` row with no `package_id` makes uninstall undefined, which is exactly the ADR-0086 D3 ambiguity the branch exists to prevent. This card adds a channel, not a verdict. - -#18564 gave that refusal its author-visible line. What it left is the programmatic half: the outcome came back with all six counters at zero, so a caller that reads no log at all — a boot report, a Setup surface, a test — could not tell a pass that refused a declaration from a pass with nothing to do. Measured against the sibling axis, which has counted the same refusal at the same boundary since #4967: - -``` -axis unowned refusal counter moved author-visible line -capability skippedUnowned 1 1 -permission set (before) — none declared — 0 1 -permission set (after) skippedUnowned 1 1 -``` - -**Required, not optional** — like `skippedForeign` and unlike `deleted`. An absent key on a *refusal* count reads exactly like a pass with nothing to refuse, which is the defect restated. All four construction sites initialize it (`bootstrapDeclaredPermissions`, `upsertPackagePermissionSet`, `upsertEnvPermissionSet`, `retirePermissionSetRecord`), so every door that returns this outcome answers the question. - -**Both doors onto the branch increment it.** The boot catalog loop aggregates it alongside the five counters it already forwarded; the ADR-0086 P2 publish materializer passes no collector and returns its own outcome, so a fix wired only into the aggregation would have left that caller as silent as before. - -**The accounting closes.** `seeded + updated + unchanged + skippedEnvAuthored + skippedForeign + skippedUnowned + unreadable` is now the number of named declarations a pass read — pinned by a conservation test modelled on the capability axis'. Before this counter that sum was short by every unowned declaration. (Pre-existing caveat, unchanged and outside this change: a write the engine rejects increments no counter; it is reported through `SeedWriteRefusals`.) - -New published surface on `@objectstack/plugin-security`: the `skippedUnowned` member of the barrel-exported `PermissionSeedOutcome`. Reading an outcome is unaffected — the member only adds a number to read. - -The sweep for construction sites covered this repository, the pinned `objectui` checkout and the downstream app repositories available to it, and found none outside the package. That is what was measured, and it cannot speak for a consumer outside those trees. So: if you construct a `PermissionSeedOutcome` yourself — a test double standing in for the seeder is the shape that does — add `skippedUnowned: 0`. Nothing else changes. diff --git a/.changeset/18572-suggester-opposite-pole.md b/.changeset/18572-suggester-opposite-pole.md deleted file mode 100644 index 2392f02962f..00000000000 --- a/.changeset/18572-suggester-opposite-pole.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The unknown-key suggester no longer answers an axis-silent key with one arbitrary end of a range — `dateField` on a calendar, timeline or gantt config is told about `startDateField` **and** `endDateField` instead of being sent to the end of the event (#18572). - -Clause-②: no - -`findClosestMatches` ranks by edit distance and nothing else. On a shape that declares both ends of a range, a key naming neither end is therefore answered with whichever end is spelled more cheaply — re-derived here rather than taken from the card: - -```text -authored `dateField` (9 chars, budget max(2, 9/3) = 3) - -> `endDateField` distance 3 INSIDE the budget <- answered - -> `startDateField` distance 5 outside the budget <- unreachable -``` - -`end` is a three-letter token and `start` a five-letter one; that spelling accident was the whole reason the protocol told an author to bind the **end** of the event. And the suggested key is a declared key the runtime honours, so an author who copied the remedy got a document that **parses**, with the axis silently on the wrong date. ⛔ Nobody had declared that mapping — a generic fuzzy matcher picked one sibling out of two. - -- **The fallback's answer is screened; a declared `aliases` entry never is.** When the guessed candidate carries an axis token the authored key does not, and the shape also declares its opposite-pole sibling, the rename is replaced by a prescription naming both ends: *"`dateField` does not say which end of the range it binds, and this surface declares both `startDateField` and `endDateField` — opposite ends of one axis. Write the one you mean: both parse, so guessing binds the wrong end silently."* A human-written alias is a statement about one spelling and outranks this; only a coin flip is replaced. `this field` keeps answering `length` with `maxLength` exactly as it declares. -- ⛔ **No alias was added and the accepted key set does not move.** `dateField` was refused before this change and is refused after it; what changed is the sentence the refusal carries. Naming both ends rather than picking one is the answer `field.zod.ts` already writes by hand for `visible` — 「the two answers have opposite polarity … Naming both is the only answer that cannot be acted on wrongly」 — generalised to the keys nobody thought to enumerate, which is the set a fuzzy suggester answers. -- **The guard separates an omission from a typo, and that condition was measured.** It fires only when the authored key is at least as close to the candidate MINUS its axis token as to the candidate itself. Without it `axLength` — one dropped character in `maxLength`, with `minLength` declared beside it — would lose a perfectly good suggestion. With it, `axLength` reads as the typo it is (distance 1 vs 2) and `dateField` as the axis-silent key it is (distance 3 vs 0). -- **Census, not just the filed case.** Over **389 unique authoring surfaces** — the population `alias-integrity.test.ts`'s forcing walk registers, deduplicated by its own key (surface + alias table + sorted shape keys); the same walk also yields 421 raw `strictObject` registrations and 388 distinct surface strings, which are different facts — the trap occurs four times, all four fixed here: `dateField` on the calendar, timeline and gantt configs, and `baselineField` on the gantt config (`baselineStartField` / `baselineEndField`). The axis vocabulary is held to the shapes: `alias-integrity.test.ts` now fails on an axis row no surface declares both ends of, the same dead-entry judgement it already applies to `aliases` and `guidance`. diff --git a/.changeset/18576-api-assembled-entry-split.md b/.changeset/18576-api-assembled-entry-split.md deleted file mode 100644 index 78c5d6ed935..00000000000 --- a/.changeset/18576-api-assembled-entry-split.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: split the assembled-stage package API declarations off `@objectstack/spec/api` into the new `@objectstack/spec/api-assembled` entry (#18576) - -**BREAKING** — five Package API declarations, with their types, are no longer exported from `@objectstack/spec/api`. They are exported, unchanged, from the new entry `@objectstack/spec/api-assembled`. - -A `major`-class change — an existing import path stops resolving for these names — recorded as `minor` under the launch-window convention. Maintainer ruling on #18576, batch #145 item 1, letter B, 「同意,其他也同意」. - -**Why.** These five declarations embed the ASSEMBLED package body, which reaches the whole metadata vocabulary and, behind it, the datasource declaration and the driver-config validators. While they were declared inside `@objectstack/spec/api`, that tree was part of every bundle of the entry, and the entry ships as one self-contained bundle that a consumer's tree-shaking can recover little of. A browser module that imports two string constants from `@objectstack/spec/api` paid for all of it. Measured on the splitting PR (esbuild 0.28.2, `platform: browser`, conditions `browser` + `import`, minified, gzip -9), for objectui's `@object-ui/core` `column-sortability.ts`, which imports only those two constants: **311,124 → 166,529 bytes gzipped (−46.5%)**. The `./api` entry bundle itself goes from 612,813 to 469,795 bytes gzipped, and its module graph no longer reaches `stack.zod`, the datasource declaration or any driver-config module. `./api` also no longer needs a `browser` export condition, since nothing in its graph links the server-only pg URL grammar any more; the condition moves to `./api-assembled`. - -### FROM → TO - -| removed from `@objectstack/spec/api` | import instead from | -| --- | --- | -| `AssembledInstalledPackageSchema`, `AssembledInstalledPackage`, `AssembledInstalledPackageParsed` | `@objectstack/spec/api-assembled` | -| `InstalledPackageAtEitherStageSchema`, `InstalledPackageAtEitherStage`, `InstalledPackageAtEitherStageParsed` | `@objectstack/spec/api-assembled` | -| `ListInstalledPackagesResponseSchema`, `ListInstalledPackagesResponse`, `ListInstalledPackagesResponseParsed` | `@objectstack/spec/api-assembled` | -| `GetInstalledPackageResponseSchema`, `GetInstalledPackageResponse`, `GetInstalledPackageResponseParsed` | `@objectstack/spec/api-assembled` | -| `PackageApiContracts` | `@objectstack/spec/api-assembled` | - -**The one-line fix: change the import path.** - -```ts -// before -import { ListInstalledPackagesResponseSchema } from '@objectstack/spec/api'; -// after -import { ListInstalledPackagesResponseSchema } from '@objectstack/spec/api-assembled'; -``` - -The compiler finds every site: `TS2305` ("Module '"@objectstack/spec/api"' has no exported member …"), or `TS2724` with a did-you-mean when a similarly named export exists — measured on the splitting PR, `ListInstalledPackagesResponseSchema` from `/api` answers `TS2724 … Did you mean 'InstallPackageResponseSchema'?`, which is NOT the name you want. Nothing else changes: every schema parses and refuses exactly what it did, `PackageApiContracts` keeps its four entries, and the JSON Schema ids are the same (`json-schema/api/AssembledInstalledPackage.json` and its three siblings are still published under `api/`, and still documented in the API reference). Every other Package API declaration — the two read doors' request schemas, the install / uninstall / upgrade / rollback shapes and `PackageApiErrorCode` — stays on `@objectstack/spec/api`. If you only use those, or any other `/api` contract, you need to do nothing. - -⚠️ **Out-of-repo consumers are NOT MEASURED beyond objectui.** Inside this repository the moved names had four importers (the client's type import, one runtime conformance test, the client's return-type pins and the spec's own unit test), all moved in the same PR. objectui at the pinned `.objectui-sha` imports none of the moved names from anywhere; its six browser-shipped files that import `@objectstack/spec/api` keep resolving every name they use, from `/api` itself. `@object-ui/types` re-exports `@objectstack/spec/api` as a type-only `API` namespace, which loses the moved names with this release; objectui itself references none of them through it. The `cloud` repository was not measured. - -The ADR-0087 D3 semantic entry `api-assembled-entry-split` carries the judgement: an import path is TypeScript source, not metadata, so there is no source a D2 conversion could rewrite. - -Clause-②: yes (narrowing) - - diff --git a/.changeset/18576-client-api-assembled-type-import.md b/.changeset/18576-client-api-assembled-type-import.md deleted file mode 100644 index 77c5417dadd..00000000000 --- a/.changeset/18576-client-api-assembled-type-import.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/client': patch ---- - -fix(client): take `InstalledPackageAtEitherStage` from `@objectstack/spec/api-assembled` (#18576) - -`@objectstack/spec` moved the declarations that embed the assembled package body — `InstalledPackageAtEitherStage` among them — off `@objectstack/spec/api` into the new `@objectstack/spec/api-assembled` entry. The client's `packages.list` / `packages.get` return types (and their scoped twins) name that type, so the published declarations now import it from the new entry. The return types are the same type as before; nothing a caller writes changes. It is a type-only import, erased from the client's bundle. diff --git a/.changeset/18582-connector-analytics-cube-liveness-ledgers.md b/.changeset/18582-connector-analytics-cube-liveness-ledgers.md deleted file mode 100644 index ccc3adaac58..00000000000 --- a/.changeset/18582-connector-analytics-cube-liveness-ledgers.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`liveness/connector.json` and `liveness/analytics_cube.json` — the last two governance debts the liveness ratchet declared are paid, so `PENDING_GOVERNANCE` is empty and every authorable metadata type now has a ledger (#18582). - -The ledgers ship inside this package, so these are the files an upgrading reader greps to learn whether a key they are about to author does anything. Both types are authored through real doors — `defineStack({ connectors })` / `defineStack({ analyticsCubes })` and `PUT /api/v1/meta/{connector,analytics_cube}/:name` — and neither had ever been walked: they were in neither `GOVERNED` nor `PENDING_GOVERNANCE` until #18133 widened the denominator, so their silence read as "nothing to report". - -- **`connector` — 74 properties: 20 `live`, 1 `planned`, 53 `dead`.** One schema, two doors: the ledger's entry exists for the AUTHORING doors, while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a plugin or an ADR-0097 provider factory builds in code. The keys an authored entry can actually reach are the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields, `loadPackageFile` is host-injected rather than authored, and `provider` never reaches the context yet decides on the authoring door whether the entry is materialized at all and which factory does it; `type` and `icon` reach that context and are dropped by all three shipped provider factories. The 53 dead are four declared subsystems with no engine — `syncConfig`, `fieldMappings`, `retryConfig`, `health` — plus `triggers` (the schema's own docblock already said so, #3197), the connector's nested `webhooks`, `status`, both timeouts, and four `retiredKey` tombstones. `authentication` is `planned` because the key is accepted and inert rather than refused: the schema declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, so it parses and the accepted value reaches no consumer, while the #7990 cross-field rule loudly rejects every non-`none` value and names `auth: { type, credentialRef }` as the mechanism to use instead. ADR-0097 §3 ("Credentials are references") backs that refusal of inline secrets — it does not refuse the key. -- **`analytics_cube` — 29 properties: 17 `live`, 12 `dead`.** The query path is genuinely consumed (`sql` is both the FROM table and the object whose RLS read scope is injected; `measures.type` picks the aggregate; `joins[].name` the joined table). What is not: the caching block (`refreshKey`), the `public` access flag that gates nothing, `joins[].relationship` and the REQUIRED `joins[].sql` — the ON clause is synthesised as a foreign-key equality and an authored one is never consulted — and the inner `name` on each of `measures`/`dimensions`, where the record key is the identity. #10238 (is cube authoring live end to end?) is a separate measurement and is not prejudged here. -- **Two prior in-repo claims were falsified and are corrected in the ledgers.** A comment in `src/conversions/registry.ts` says `retryConfig` "and the timeouts beside it are untouched — they are live"; the word does not occur outside `packages/spec` at all. And `bootstrapDeclaredWebhooks` documents itself as materializing each "stack/connector-authored webhook", while its source is `readDeclared(…, 'webhook')` — metadata items the decomposition registers from the top-level `webhooks:` collection, which a connector's nested array never becomes. - -No schema changed and no verdict moved on an existing ledger: `check:liveness` walks two more types and reports the same 583 repo-local evidence paths resolving, with 39 governed types indexed by the README table. - -Clause-②: no diff --git a/.changeset/18582-sharing-rule-liveness-ledger.md b/.changeset/18582-sharing-rule-liveness-ledger.md deleted file mode 100644 index 215bba4d0bf..00000000000 --- a/.changeset/18582-sharing-rule-liveness-ledger.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`liveness/sharing_rule.json` — the sharing-rule authoring surface is now a governed liveness type: every authorable key of `SharingRuleSchema` carries a status, the evidence that settles it and the producer that populates it (part of #18582). - -The ledgers ship inside this package (`files[]` includes `liveness`), so this is a new file in the tarball and two changed ones — `liveness/README.md`'s index row and the generated `liveness/state-counts.md`. Nothing else moves: no schema accepts or refuses anything it did not before, no export changes, and no CLI author warning is added (no entry is marked `authorWarn`). - -- **Why it was ungoverned.** `sharing_rule` is bound in `UNREGISTERED_KIND_SCHEMAS`, which `listMetadataTypeSchemaTypes()` deliberately does not enumerate, so it sat in **neither** `GOVERNED` **nor** `PENDING_GOVERNANCE` and produced no row in any of the gate's lists while the report read complete. Widening the governance denominator to the authorable set made it visible as a declared debt; this pays that debt. `connector` and `analytics_cube` are still owed. -- **Every row cites a producer, because the authoring shape is not the enforced shape.** ADR-0057 D6 makes the `sys_sharing_rule` row canonical — `object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level` — and `bootstrapDeclaredSharingRules` translates each authored key into it at boot. Nothing re-parses `SharingRuleSchema` at enforcement time, so a consumer pointer alone would prove only that a column is read, never that the authored value reaches it. -- **Nine keys are `live`; one is `planned`.** `type` is the `SharingRuleType` discriminator: one member, `criteria`, whose only reader in this repo is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. It is deliberately **not** `dead` and therefore not an enforce-or-remove candidate — the key is required, so removing it would break every authored rule to delete nothing, and the schema keeps it as the discriminant for a future enforced rule type. -- **`sharedWith` is drilled**, so the two recipient keys carry their own verdicts and the change adds no row to the undrilled-container baseline. - -For an author, the practical read: `name`, `object`, `active`, `accessLevel`, `condition` and both `sharedWith` keys change what the runtime grants; `label` and `description` are display-shaped and are shown in Setup; `type` has exactly one legal value and, today, no dispatch behind it. diff --git a/.changeset/18603-anchor-binding-declared-capabilities.md b/.changeset/18603-anchor-binding-declared-capabilities.md deleted file mode 100644 index 5c8424209e4..00000000000 --- a/.changeset/18603-anchor-binding-declared-capabilities.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`AnchorBindingContext`'s boot half names the stack's capability DECLARATIONS, not the `sys_capability` rows the seeder has not written yet - -The docblock named two sources for `declaredCapabilities`: at boot 「the -`sys_capability` rows carrying `managed_by: 'package'` provenance」, at authoring -time the stack's own `capabilities` array. The boot half carried an ordering -precondition the sentence never stated, and a caller following it literally -lands on the defect the input exists to remove. - -`runBootstrap` (`@objectstack/plugin-security`) awaits `bindBaselineToEveryone` -— the ADR-0090 D5 anchor binding, the boot call site that consults -`describeHighPrivilegeBits` — BEFORE it calls `bootstrapDeclaredCapabilities`, -the seeder that WRITES those `managed_by: 'package'` rows. The order is fixed by -two other constraints stated at that call site: the binding must follow the -seeding of the `everyone` anchor it binds to, and precede the audience-binding -suggestion reconciliation. So on a first boot the table is EMPTY at exactly the -moment the docblock said to read it, and this docblock's own 「omission refuses」 -property turns that emptiness into a silent refusal of every declared token — -the app's own `isDefault` set unbindable at the `everyone` anchor, which is the -defect #17811 introduced the input to remove. - -The boot half now names the DECLARATIONS, read through the seeder's own two-step -— the ObjectQL registry first, the metadata service as the fallback — which is -what `readDeclaredCapabilityContext` (`@objectstack/plugin-security`, #18535) -already implements, so the contract text and its one runtime consumer now -corroborate each other instead of contradicting. The `sys_capability` rows stay -a valid source, qualified: only once the seeder has written them, which is where -an admin-surface or post-boot caller reads them. - -⛔ No behaviour changes. The diff is comment text: `git diff` against the branch -point over `src/security/high-privilege.ts` changes **0** non-comment lines (the -same predicate reads 33 on that file's own #17811 commit, which is the control -proving it fires). No predicate, no type, no export, no accept set moves. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `src/security/high-privilege.ts` is NOT shipped as source — -`@objectstack/spec`'s published `files[]` takes `src/**/*.zod.ts`, and this file -is not one (`npm pack --dry-run` lists 2021 files and excludes it, with the -sibling `src/security/permission.zod.ts` present as the lit control). Its -published reach is the emitted declarations, and they move: the new clause is -present in `dist/security/index.d.ts` and `dist/security/index.d.mts`, both in -that same shipped list, with the superseded spelling absent from every built -declaration file and the docblock's unchanged neighbouring sentence present in -the same two as the lit control. diff --git a/.changeset/18605-enable-on-install-one-authority.md b/.changeset/18605-enable-on-install-one-authority.md deleted file mode 100644 index ed321fda054..00000000000 --- a/.changeset/18605-enable-on-install-one-authority.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`enableOnInstall` is declared in three published schemas; each one now says which of the three governs it, and the two that are not the authority say what they are (#18605). - -The install door already honours the key — `POST /api/v1/packages` moves the registry row through the same verbs `PATCH /packages/:id/enable` and `PATCH /packages/:id/disable` use: `true` enables, `false` disables, and an ABSENT key makes no lifecycle call at all, so the row the registry returned stands (#18058). What was left was three declarations that looked identical (`z.boolean().default(true)`, same description) with nothing saying which one an author should read. - -Clause-②: yes - -**The authority** - -`PackageInstallRequestSchema` (`api/package-api.zod.ts`) is the one authority, because it is the request contract of the door that honours the key. Its published description now says so, naming the door that honours the key and the three states it honours. Its doc block carries the map to the other two, so a reader never has to guess which of three identical-looking declarations governs. - -**`kernel/InstallPackageRequest.enableOnInstall` — a COPY of the request key** - -Same type, same optionality, same meaning, restated on the in-process protocol primitive `ObjectStackProtocol.installPackage`. Its published description now records what this layer does with it: the implementation honours the key on the registry row (`true` enables, `false` disables, an ABSENT key makes no lifecycle call at all, tested `=== true` / `=== false` so absence is never collapsed into either), and the HTTP door does not forward the key down that seam — it calls `installPackage({ manifest, settings })` and performs the enable/disable flip itself, because the durable half must follow the row that door returned rather than the request's intent. - -The copy is held to the authority by a **parity pin** rather than by a structural reference. The structural spelling is not available in this direction: the authority is built from `ManifestSchema` and `InstalledPackageSchema`, both declared in `kernel/package-registry.zod.ts`, so `PackageInstallRequestSchema.shape.enableOnInstall` spelled there is an import cycle, and under `OS_EAGER_SCHEMAS=1` — the mode `gen:schema` and `check:authorable-surface` run in — it dies with `ReferenceError: Cannot access 'InstalledPackageSchema' before initialization`. `api/package-install-one-authority.test.ts` parses both declarations over one matrix (absent, `false`, `true`, a string, `null`) and reds on any cell where they disagree. - -**`marketplace/MarketplaceInstallRequest.enableOnInstall` — not this key at all** - -It stays, and its published description says what it is: the marketplace channel's own install option. That request's subject is a listing (`listingId`, `version`, `licenseKey`, `tenantId`), not a manifest; its door is the control plane's `POST /api/v1/marketplace/install`, of which a runtime mounts only a read-only proxy; and the channel resolves the artefact and validates the licence before mapping what it holds into a platform install. It is one translation upstream of the door key, owned by a different party on a different release cadence, so folding it would let a narrowing at the platform door silently narrow a control-plane contract. - -**What does not move** - -No key is added, removed, renamed or retyped, and no default changes: the accept set of all three schemas is byte-for-byte what it was, and `api-surface`, `authorable-surface` and `authorable-defaults` are all unchanged. What moves is the published description text of three keys and the reference pages generated from it. The `Clause-②` declaration is `yes` as the conservative arm, because three published declarations' stated meaning moves. diff --git a/.changeset/18607-client-readme-install-example-manifest.md b/.changeset/18607-client-readme-install-example-manifest.md deleted file mode 100644 index a204a9c7c68..00000000000 --- a/.changeset/18607-client-readme-install-example-manifest.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/client": patch ---- - -docs(client): the published README's `packages.install` example is a manifest `ManifestSchema` actually accepts (#18607) - -The example shipped in the `@objectstack/client` npm tarball was refused on three -counts when parsed against the contract its own call site declares -(`PackageInstallRequestSchema`, whose `manifest` key is `ManifestSchema`): -`invalid_type` at `[manifest, id]`, `invalid_value` at `[manifest, type]` — both -required and absent — and `unrecognized_keys` at `[manifest]` for a `label` key -that `ManifestSchema`'s `strictObject` close refuses by name. - -```diff - await client.packages.install({ -- name: 'vendor_plugin', -- label: 'Vendor Plugin', -+ id: 'com.vendor.plugin', -+ type: 'plugin', -+ name: 'Vendor Plugin', - version: '1.0.0', - }); -``` - -`label` is not a root manifest key and never was: the root shape declares `name` -for the human-readable string (measured — `ManifestSchema` declares 25 root keys -and `label` is not among them), so the example's `label` value moves to `name` -and the machine identifier becomes the reverse-domain `id` the key documents. -`type: 'plugin'` is the enum member the example's own subject names — a -general-purpose functionality extension, not the consumer-installable `app` -bundle. Required root keys, read off the schema rather than the prose: `id`, -`name`, `type`, `version`. - -Nothing parses that contract at the install door today, so the example "worked" -by being posted unvalidated — which is what made it a timed charge rather than a -live outage: closing the door turns a silently-wrong published example into a -loudly-broken one for every reader who copied it. - -Pinned in `packages/client/src/readme-package-install-example.test.ts`, which -parses every `packages.install` manifest literal in this README against that -schema and fails if the corpus is ever empty. - -Clause-②: no - -No schema, export, type or runtime behaviour changes. It ships because the README -is listed in this package's `files[]` and is the first thing a new integrator -copies. diff --git a/.changeset/18612-cubejoin-retire-sql-relationship.md b/.changeset/18612-cubejoin-retire-sql-relationship.md deleted file mode 100644 index d22aaa86076..00000000000 --- a/.changeset/18612-cubejoin-retire-sql-relationship.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING** — retire `CubeJoin.sql` and `CubeJoin.relationship`. A cube join declares -WHICH object it reaches; the ON clause is derived from the declared relationship between -the two cubes' objects and is never authored. - -`CubeJoin.sql` was **required** and described itself as the `ON` clause, and nothing ever -read it. Both analytics strategies synthesise the join: `NativeSQLStrategy` emits -`LEFT JOIN ON ""."" = ""."id"` from the dotted member -path alone, and `ObjectQLStrategy` resolves the join through `cube.joins?.[alias]?.name` and -lowers it to a relationship traversal with no `ON` clause at all. So an authored join -condition was not ignored — it was **replaced**, under a `200`, by an equality the author had -not asked for, with a plausible number attached. `relationship` is the same shape one key -over: it carried a `.default('many_to_one')`, nothing dispatched on the cardinality, and -`one_to_many` parsed, changed no SQL and kept the many-to-one arithmetic. - -ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18 (director batch #154 item 4, -letter 2). The ruling declined the other remedy — executing the author's SQL — as a new -capability whose first design question is an injection boundary, for zero authors today. A -custom join condition, if a customer needs one, is a capability card with that boundary -decided first. - -## FROM → TO - -| you wrote (17.4 and earlier) | write instead | -| --- | --- | -| `joins: { account: { name: 'crm_account', relationship: 'many_to_one', sql: '${orders}.account = ${crm_account}.id' } }` | `joins: { account: { name: 'crm_account' } }` — delete both keys | -| `joins: { a: { name: 'b', relationship: 'one_to_many' } }` | `joins: { a: { name: 'b' } }` — the cardinality was never read; declare it on the object's own relationship field | -| `joins: { a: { name: 'b', on: '…' } }` | `joins: { a: { name: 'b' } }` — `on` was the curated alias for `sql` and is retired with it | - -**The one-line fix:** delete `sql` and `relationship` from every `joins` entry; keep `name`. - -Nothing regresses by deleting them: neither key ever reached a query. What decides the join -is `name` (the joined object, which is also what the per-object RLS/tenant read scope is -computed for) and the declared relationship the runtime derives the equality from. - -## The retirement kit - -- **Strict deletion plus a `guidance` prescription, not a `retiredKey()` tombstone.** Every - cube shape is a `strictObject`, so the key leaves the walked shape entirely and the - refusal carries the upgrade: writing `sql`, `relationship` or `on` on a join is an - `unrecognized_keys` rejection whose message names the key and states that the `ON` clause - is DERIVED from the declared relationship between the two cubes' objects. Same route - `MetricSchema.filters` took one shape over in this same file. -- **`on` is no longer an alias.** It pointed at `sql`; an alias naming a key the shape - cannot accept answers an author with a second rejection, so it became a `guidance` entry - of its own and the rename suggestion is gone. Pinned in both directions. -- **ADR-0087: a D2 conversion AND a D3 semantic entry**, plus the two exact-key - registrations `data/CubeJoin:sql` and `data/CubeJoin:relationship` in - `RETIRED_KEYS_BY_MAJOR[18]`. The conversion is - `cube-join-sql-and-relationship-removed` (`toMajor: 18`, - `retiredFromLoadPath: true`), chained into step 18: it strips both keys from every - `analyticsCubes[].joins.*` wherever the chain is replayed, one notice per stripped site, - each naming the cube that lost the key. It is owed because the removal is measured - against **metadata at rest**, not only against sources: `sql` was required and - `relationship` was defaulted, so every cube artifact ever written from the old schema's - own parse output carries both keys, and the boot door - (`ObjectStackDefinitionSchema` → `analyticsCubes: z.array(CubeSchema)`) would otherwise - refuse it with no remedy short of hand-editing JSON. The strip is lossless in the only - sense that applies: a key that never had an effect has none to lose. The D3 entry - `cube-join-sql-and-relationship-retired` stays as the human-facing record — the strip - removes the key, the entry says why an author who wrote a non-FK `sql` should re-read the - numbers that join produced. -- **The `os migrate meta --from 17` sentence** closes all three prescriptions, which is what - a covered surface owes. -- **The `joins` record KEY is documented.** `name`'s describe now states that the key a join - is declared under is the FOREIGN-KEY FIELD on the cube's own base object — the column the - derived `ON` reads — not a second spelling of the object the join reaches. -- **The liveness ledger rows went WITH the keys** (`liveness/analytics_cube.json`), which is - the strict-deletion route's disposition — the opposite of the tombstone route, which keeps - the row because `retiredKey()` keeps the key in the walked shape. `analytics_cube` drops - from 12 `dead` to 10. -- **The one in-repo producer is fixed in the same diff.** `examples/app-showcase`'s - `DeliveryCube` authored both keys, including an `ON` clause the runtime was replacing; - `dataset-compiler.ts` minted them as two constants no reader consulted. Its join was also - keyed `showcase_project` — the object it reaches — while `showcase_task`'s foreign key is - `project`, so the derived `ON` named a column the base object does not have and the join - never resolved. It is re-keyed `project` here and pinned against the object's own field - map. - -Clause-②: yes (narrowing) - - diff --git a/.changeset/18614-conversion-registry-retryconfig-liveness-claim.md b/.changeset/18614-conversion-registry-retryconfig-liveness-claim.md deleted file mode 100644 index d4a1e9400e3..00000000000 --- a/.changeset/18614-conversion-registry-retryconfig-liveness-claim.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`src/conversions/registry.ts` — the `connector-rate-limit-config-removed` entry no longer asserts that `retryConfig` and the connector timeouts "are live" (#18614). The assertion was measured false; the ledger seeded by #18582 had already recorded the correction on the other side. - -The comment conflated two different statements. That the rate-limit retirement left those keys *in place* is true and is kept — it is what the fixture's single notice demonstrates. That they are *live* was never measured by that entry and is false: the read-probe for `retryConfig`, `connectionTimeoutMs` and `requestTimeoutMs` finds no consumer anywhere outside `packages/spec` (the sibling key `providerConfig`, on the same schema, fires on the identical probe), no retry loop reads a strategy or a backoff, every timeout occurrence outside the spec is a write of the literal `30000` so a def satisfies the post-parse `Connector` type, and `ConnectorProviderContext` carries none of the three — so a provider factory cannot read them either. `liveness/connector.json` classifies all ten rows `dead` and is now cited as the authority. - -Nothing is retired here and no schema moved: ADR-0049 owes these keys a decision, which the corrected comment states rather than pre-empts. The text ships — `tsup` preserves comments, so these bytes reach `dist/index.js`, `dist/index.mjs` and the `shared`/`browser` bundles inside the published tarball, which is why this is a `patch` and not `skip-changeset`. - -Clause-②: no diff --git a/.changeset/18616-turso-remote-transaction-refusal.md b/.changeset/18616-turso-remote-transaction-refusal.md deleted file mode 100644 index 3cbe6273894..00000000000 --- a/.changeset/18616-turso-remote-transaction-refusal.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/driver-turso": minor ---- - -`TursoDriver` in **remote** mode now **refuses** transactions with `NOT_IMPLEMENTED` / `501` instead of accepting them and silently doing nothing with them. Local and embedded-replica modes are unchanged — they inherit `SqlDriver`'s knex transactions and still honour `options.transaction`. - -**What was wrong.** `@objectstack/spec`'s `driver.zod.ts` states the delivery mechanism verbatim: *"A transaction handle to be passed to subsequent operations via `options.transaction`."* On the remote transport nothing could receive it. `RemoteTransport` names a transaction in exactly three members (`beginTransaction()`, `commit(t)`, `rollback(t)`) and **zero** of its data methods take an `options` argument at all — against 13 data methods present in the file, which is what makes that zero a reading. So a write issued between `beginTransaction()` and `rollback()` executed on the plain connection, was **already durable**, and the rollback resolved having undone nothing. Every step reported success. - -**What refuses now**, on the remote arm only: - -- `beginTransaction()`, `commit()` and `rollback()` — the capability is never handed out, so the sequence above cannot start. -- Any driver method that arrives carrying `options.transaction` — `find`, `findOne`, `count`, `aggregate`, `create`, `update`, `upsert`, `delete`, the three bulk methods, `updateMany`, `deleteMany`, `execute`, `syncSchema`, `syncSchemasBatch`, `dropTable`. This second door is not redundant: the engine's `buildDriverOptions` reads `execCtx.transaction` **first**, so a handle threaded through `ExecutionContext` reaches a data method without ever passing through `beginTransaction()`. - -The refusal fires on the **handle**, not on remote mode: a remote call with no transaction in it is untouched, which is every call the platform makes today. It is raised before any statement is built, so a refused call costs no round trip and leaves no partial write. - -**If this refusal now fires for you, it is telling you that you never had the transaction.** The remedies, in order: use the **local or embedded-replica** transport for work that needs atomicity; or take the non-transactional path deliberately — `engine.transaction()` without `require: true` on a driver with no transactions runs the callback with no rollback and says so (ADR-0119 D1). `NOT_IMPLEMENTED` / `501` rather than a `400` because the request is spelled correctly and the spec declares the members: the gap is the backend's, the same two-class taxonomy this driver already applies to remote `auto_number`, aggregate functions and date buckets. - -Implementing real transactions on the remote transport is a separate, larger piece of work and is deliberately **not** part of this change. diff --git a/.changeset/18624-retired-keys-lifecycle-docblock.md b/.changeset/18624-retired-keys-lifecycle-docblock.md deleted file mode 100644 index bd099007e76..00000000000 --- a/.changeset/18624-retired-keys-lifecycle-docblock.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -docs(spec): the `RETIRED_KEYS_BY_MAJOR` Lifecycle docblock names both rejected states, and stops contradicting check (b3)'s printed remedy - -`RETIRED_KEYS_BY_MAJOR`'s docblock is shipped text — it reaches consumers in `dist/index.d.ts` — and since check (b3) landed, two of its sentences were false: - -- **「The one state the gate rejects」**. Check (b3) rejects a *second* state: a NESTED row whose def this build emits but whose dotted path it does not. That state has no aging clock behind it (a nested key never reaches `authorable-surface/` at all), so it is not the aged-out steady state the paragraph described. -- **「Entries are permanent」**, against check (b3)'s own refusal text, which ends `… or delete the entry from packages/spec/src/migrations/registry.ts`. An author following the docblock would not delete; an author following the gate would — two shipped instructions in this repo pushing two people who each did as they were told in opposite directions. - -The Lifecycle paragraph now: - -- scopes the aging-out steady state to a **top-level** tombstone, and says why a nested row can never be in it; -- lists **both** rejected states with the check that owns each and the remedy that check prints — still-LIVE (b2), nested-and-unresolvable (b3) — and states the routing rule that decides which one a row is judged by (a row is read as a path only when its `name` half carries a dot AND this build emits no top-level property of that exact name, so a live dotted top-level key such as `@odata.context` stays on (b2)'s map); -- reconciles permanence with deletion instead of leaving them to contradict: a row that was ever TRUE of some build is history and is never deleted, while a row (b2) or (b3) refuses was never true of any build, so deleting it removes a false claim rather than a record; -- repeats (b3)'s own ⛔ — it cannot yet tell a wrong row apart from every truthful one, and for the shapes it names the remedy is to teach the check, never to delete a row that is telling the truth. - -The `## What reads it` bullet for check (b) and the `@see` roster gain (b3) for the same reason: it reads this table, and neither named it. - -**No behaviour moves.** No gate, schema, export or registry entry is touched — the set of metadata that validates is byte-for-byte what it was. What changes is the text an author reads when a gate refuses their row. diff --git a/.changeset/18639-related-list-columns-listcolumn-union.md b/.changeset/18639-related-list-columns-listcolumn-union.md deleted file mode 100644 index da5b62ce4d8..00000000000 --- a/.changeset/18639-related-list-columns-listcolumn-union.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`record:related_list.columns` now declares the SAME union the saved-view key declares — `z.union([z.array(z.string()), z.array(ListColumnSchema)])` — so a saved view's per-column decoration reaches the related list instead of being refused at the block door (#18639, the upstream half of objectui#9593). - -**Clause-②: yes (widening)** — one published accept set grows: the key admitted `string[]` and now also admits `ListColumn[]`. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write the new arm. Contract-review tier. - -Two published declarations disagreed about one key. `RecordRelatedListProps.columns` (`ui/component.zod.ts`) was `z.array(z.string())`, while `listViews[].columns` (`ui/view.zod.ts`) was already the union — and objectui composes a saved view's `columns` onto this block **verbatim** (`dataSource.view` → `composeElementDataSource` → `savedViewColumns`). A view whose columns carried `label` / `width` / `hidden` / `summary` therefore arrived at a block that declared it could not carry them. - -- **The same union, by reference — not a lookalike.** `ListColumnSchema` is imported from the view face rather than re-spelled, so the object arm is one def with two carriers. The pin asserts reference identity on both sides and then asserts block and saved view return the same verdict for every fixture: two spellings of one key is the defect this closes, so a second spelling would not have fixed it. -- **The arms are exclusive, and the description says so because the schema enforces it.** `['name', { field: 'amount' }]` matches neither arm and is refused. The decoration also survives the parse — a description promising keys a parse strips would be the same defect one layer up, so the pin asserts the parsed value, not merely `success`. -- **Unchanged, by ruling and by measurement.** `field.relatedListColumns` stays child field-name STRINGS only and still refuses a column object with its derivation prescription, and the `field-column-lists-canonicalized` conversion still folds an object entry on that key to its identity string. Both are pinned next to the widening so the fences cannot erode quietly. - -No migration: authors writing `string[]` are unaffected, and the new arm is opt-in. diff --git a/.changeset/18651-getactivemember-anonymous-statements.md b/.changeset/18651-getactivemember-anonymous-statements.md deleted file mode 100644 index 8f75477d29e..00000000000 --- a/.changeset/18651-getactivemember-anonymous-statements.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -'@objectstack/client': patch ---- - -`organizations.getActiveMember`'s own prose says what an anonymous caller gets TODAY: `401 UNAUTHENTICATED` on request ONE — not `200 null` and then a `401 UNAUTHORIZED` from `list-members` - -objectstack#17881 (`374d9d3afa`) landed `plugin-auth`'s -`refuseAnonymousSession`, which converts better-auth's `200` + the literal JSON -`null` on `GET /api/v1/auth/get-session` into the declared ADR-0112 refusal -envelope — HTTP `401`, `code: UNAUTHENTICATED` — before it leaves the process. -`@objectstack/client` reaches the server over the wire, so that is what it -sees. Three present-tense statements in and around `getActiveMember` still -described the retired shape, and they were wrong on two axes at once: the CODE -(`UNAUTHORIZED` vs `UNAUTHENTICATED`) and the REQUEST the refusal arrives on -(the second one, `list-members`, vs the first, `/get-session` itself). - -**FROM → TO for a caller.** `getActiveMember` makes two requests for a -signed-in caller. For an anonymous one it now makes ONE, and rejects: - -| you wrote | write instead | -|:--|:--| -| `try { await c.organizations.getActiveMember(id) } catch (e) { if (e.code === 'UNAUTHORIZED') … }` | `… catch (e) { if (e.code === 'UNAUTHENTICATED') … }` | - -The behaviour is objectstack#17881's and shipped then; what moves here is only -the SDK's description of it. A reader coding against the old prose caught the -wrong code, and expected the refusal on a request that is never put on the -wire. - -**What changed** - -- Step 1 of the two-request list no longer says `/get-session` serves "the - literal `null` for an anonymous one". The signed-in arm keeps its - `(measured)` tag, which is still the 2026-09-09 drive's; the anonymous - answer is stated separately and anchored to the producer, including that - step 2 never reaches the wire. -- The anonymous bullet of that drive's delta list no longer says an anonymous - caller "still gets `401 UNAUTHORIZED`, thrown from the `list-members` - request". It is RE-ANCHORED rather than restamped — the drive's own row is - kept in the past tense and today's answer is stated from the producer, the - same disposition objectstack#18642 used on this family's sibling statements. -- The inline comment on the `userId` read no longer says `Anonymous → null`. - It says an anonymous caller never reaches that line, and says why the - `| null` annotation and the `?? ''` fallback stay as the defensive branch - they always were. - -⛔ No behaviour changes. `packages/client/src/index.ts` changes COMMENTS ONLY — -verified mechanically: of every line the diff touches in that file, zero are -outside a comment. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/client`'s published `files[]` is -`["dist","README.md","CHANGELOG.md"]` and `getActiveMember` is a member of the -exported `ObjectStackClient`, so its TSDoc is emitted into the shipped -artifacts. Measured on the built `dist` at `13e09a5e3c`: the corrected sentence -is present exactly once in `dist/index.d.ts`, `dist/index.d.mts`, -`dist/index.js` and `dist/index.mjs`; the retired sentence is absent from all -four; and `getActiveMember` was carried as the lit control, found in every one -of them. ⚠️ This package emits no `.d.cts` and no `.cjs` — its CJS pair is -`index.js` + `index.d.ts` and its ESM pair is `index.mjs` + `index.d.mts`, so -a `*.d.cts` check here would have measured an absent file. - -Clause-②: no — no schema key moves, no closed set gains or loses a member, no -published export changes and no registry row is touched. `UNAUTHENTICATED` is -an existing `StandardErrorCode` that objectstack#17881 already derives through -`standardErrorCodeForHttpStatus(401)`; nothing is minted here. The direction is -a pull-back: the runtime has answered `401` since objectstack#17881 and the -SDK's self-description was lagging. diff --git a/.changeset/18669-duration-key-rename.md b/.changeset/18669-duration-key-rename.md deleted file mode 100644 index 45910d06ee9..00000000000 --- a/.changeset/18669-duration-key-rename.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `FileValue.duration` and `CompatibilityMatrixEntry.estimatedMigrationTime` carry their unit in the key name (#18669, ruling A) - - - -**BREAKING** — two duration-shaped `z.number()` keys are renamed. No value type moves, no key is -removed from the contract, and nothing already stored is narrowed. - -| def | before | after | -|:--|:--|:--| -| `data/FileValue` | `duration: 12` | `durationSeconds: 12` | -| `kernel/CompatibilityMatrixEntry` | `estimatedMigrationTime: 8` | `estimatedMigrationTimeHours: 8` | - -Maintainer ruling A on #18669 (2026-09-17, decision batch #151 item 4): rename each key, with an -ADR-0087 conversion-layer entry each — ⛔ no new closed type, ⛔ no narrowing of stored data. - -## Why each bare name was worth a rename - -`FileValue.duration` declared its unit in **no channel at all** — no `.describe()`, no JSDoc, no -unit token in the key — so the published reference page printed a bare number and the authoring -site printed nothing. The company it kept is what makes it a trap rather than an omission: the -only other number on `FileValue` is `size`, a **byte** count, so the one member that measured -time was indistinguishable from a count at the site an author (very often a model, ADR-0033) -writes it. - -`CompatibilityMatrixEntry.estimatedMigrationTime` said *"Estimated migration time in hours"* in a -source **JSDoc** and carried no `.describe()` — the #15939 shape, one def over. The JSDoc stops at -the source file; `.describe()` is what `content/docs/references/**` renders, so the published page -printed a bare number directly beside `migrationComplexity`, whose scale *is* named -(`trivial`/`simple`/`moderate`/`complex`/`major`). A reader comparing `major` with `40` could not -tell minutes from hours from days. - -## What an author must change - -Rename the key. **Nothing else** — both values keep the type they had. - -```diff - // an expanded file/image/avatar/video/audio value - { - url: 'https://cdn.example.com/files/clip.mp4', -- duration: 12.34, -+ durationSeconds: 12.34, - } - - // a plugin compatibility-matrix entry - { - from: '1.9.0', to: '2.0.0', compatibility: 'breaking-changes', -- estimatedMigrationTime: 8, -+ estimatedMigrationTimeHours: 8, - } -``` - -## Deliberately NOT narrowed - -Both keys stay `z.number().optional()`. `durationSeconds: 12.34` still parses — a fractional -second is the ordinary shape of a media length — so the closed `DurationSeconds` type -(`z.number().int().nonnegative()`, #18122) was **refused** by the ruling, and so was a bare -`.int()`. `FileValue` is one of the six rows #18122 derived its unit set from; it is the one that -takes a **name** instead of a type. `estimatedMigrationTimeHours` keeps `hours` rather than -converting to seconds, for the same reason: the value does not move. - -The hours key also gains `.describe('Estimated migration time in hours')`, and that half is not -cosmetic — renaming alone would leave the key name and a source comment agreeing about a unit the -published page does not print, which `check:duration-unit-keys` refuses as -`unit-in-jsdoc-not-in-describe` (ruled an offence 2026-09-18, decision batch #158 item 5, letter -A). `FileValue.durationSeconds` gains `.describe('Media duration in seconds')` for the same -reader. - -## The kit - -- a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value reaching - the parse raises the **rename prescription** instead of vanishing. Neither enclosing shape is - `.strict()`: `CompatibilityMatrixEntrySchema` is a plain `z.object` and would have stripped the - old spelling in silence, and `FileValueSchema` is the one deliberate `z.looseObject` in - `field-value.zod.ts` and would have waved it through as an unrecognised extra -- two ADR-0087 D3 semantic entries and two `RETIRED_KEYS_BY_MAJOR[18]` rows. **No D2 conversion** - for either: `FileValueSchema` is the ADR-0104 D3 wave-2 *expanded read* form, derived at read - time from a `sys_file` id (the stored form is `FileReferenceIdValueSchema`, an opaque string), - and a plugin compatibility matrix is a published version manifest that `stack.zod.ts` declares - no collection of — neither is ever a stored `sys_metadata` row, so the chain has no seam that - would see one -- both authorable-surface rows move: each becomes ` [RETIRED]` beside its renamed row, since - both are **top-level** properties of their def - -Clause-②: yes diff --git a/.changeset/18670-project-banned-keys.md b/.changeset/18670-project-banned-keys.md deleted file mode 100644 index aa0f6239aa5..00000000000 --- a/.changeset/18670-project-banned-keys.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -**BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the banned-key rule the tracing sampling filter enforces, so a validator reading the published files stops answering PASS on `{ "dialect": "cel" }` at `TraceSamplingConfig.composite[].condition` — the card's own worked instance of a published file saying yes to metadata the runtime refuses (#18670 item 2, the fourth of the ruling's named arms). - -Clause-②: yes (narrowing) - -One named pattern joins the closed list, and only one: - -- **`banned-keys` — "no document may carry any of these keys"**, emitted as `propertyNames` with a `not` over the banned names. `TraceSamplingConfig.composite[].condition` is a structured filter of match criteria that refuses an object carrying `dialect`, because such an object is an expression attempt and this slot's expression arm was retired in 17.5.0. The published file now says so. - -**The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json` goes from 202 entries / 553 sites to **200 entries / 551 sites**: - -| row | before | after | -|:---|:---|:---| -| `system/TraceSamplingConfig` | `sites: ["composite.element.condition"]` | **deleted** — the schema drops nothing now | -| `system/TracingConfig` | `sites: ["sampling.composite.element.condition"]` | **deleted** — the same node, reached through the parent | - -2 sites closed, **0 sites added anywhere**, and the ledger diff is deletions only. Generator census after: 551 dropped across 200 published schemas, **357 projected** — 224 `non-blank-string`, 129 `required-one-of`, 2 `dependent-required`, **2 `banned-keys`** — 9 undecidable. - -**⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** The arm is EXACT rather than approximate: a JSON object's properties are exactly its own enumerable string-keyed ones and `propertyNames` judges exactly those names, so "none of the banned names is an own property" and "no property name is one of the banned names" are one sentence read from two ends. It is presence and never value — a banned key present with a `null` value is present on both sides. The accept set at the slot is **unchanged in both directions**: every document the runtime takes (`{}`, `{ "service": "api" }`, any filter carrying no `dialect` key) the file still takes, and every document the runtime refuses the file now refuses too — a `dialect`-bearing object of any shape, the CEL envelope included, since that arm is retired and nothing here revives it. Across the published tree, **1528 of the 1530 per-schema files are byte-identical**; the two that move gain the ban and lose the matching `x-dropped-refinements` row, and nothing else in either file changes. - -**The list stays CLOSED.** `packages/spec/src/shared/refinement-projection.ts` declares the vocabulary and builds each predicate from its own declaration — the key list is read once and used by both the published keyword and the enforced rule — so the two cannot name different keys. The predicate judges OWN properties and never `key in value`: `in` walks the prototype chain, so a ban on a name `Object.prototype` carries would refuse `{}` itself while `propertyNames` accepts it, and that is a disagreement about a JSON document rather than an edge outside the domain. A ban over an OPEN set of names — every key starting with `$`, which is what `data/filter.zod.ts`'s normalized field condition refuses — is deliberately not this arm: its keys are a finite list, and a list that merely sampled an open set would be wider than the rule, so those sites stay unprojected — and because the detector reads them `undecidable` rather than `dropped`, they carry NO annotation and hold NO ledger row: published yet unratcheted. - - diff --git a/.changeset/18670-project-dependent-required.md b/.changeset/18670-project-dependent-required.md deleted file mode 100644 index 3097a25e477..00000000000 --- a/.changeset/18670-project-dependent-required.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -**BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the cert/key pairing rule on SSL driver configuration, so a validator reading the published files stops answering PASS on a half-configured client certificate the platform then refuses (#18670 item 2, the third of the ruling's four named arms). - -Clause-②: yes (narrowing) - -One named pattern joins the closed list, and only one: - -- **`dependentRequired` — "whenever this key is present, those keys must be present too"**, emitted as JSON Schema's own `dependentRequired`. `SSLConfig`'s rule that a client certificate and its private key are provided together is precisely `dependentRequired { cert: ['key'], key: ['cert'] }`, so the file now states it. - -**The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json` goes from 201 entries / 553 sites to **200 entries / 551 sites**: - -| row | before | after | -|:---|:---|:---| -| `data/SSLConfig` | `sites: [""]` | **deleted** — the schema drops nothing now | -| `data/SQLDriverConfig` | `sites: ["", "sslConfig"]` | `sites: [""]` — the `sslConfig` site closed | - -2 sites closed, **0 sites added anywhere**, and the ledger diff is deletions only. `data/SQLDriverConfig`'s remaining `""` site is its own separate rule — "`sslConfig` is required when `ssl` is **true**" — which judges a VALUE rather than key presence, is `if`/`then` rather than this arm, and stays dropped and annotated as `x-dropped-refinements`. - -**⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** The arm is EXACT rather than approximate: a key absent from a JSON object is the only way for its value to read `undefined`, and `dependentRequired` triggers on presence, so a key present with any JSON value — `null` included — arms its dependency exactly as the predicate's `!== undefined` does. Measured over a 10,368-document corpus across both affected schemas: the runtime verdict vector is byte-identical before and after (lit control — weakening the dependency map to one direction moves 96 documents), and of the 36 documents the published files stop accepting, **zero** are documents the runtime accepts. Across the whole published tree, 1530 of 1532 files are byte-identical; the two that move gain `dependentRequired` and lose the matching `x-dropped-refinements` row. - -**The list stays CLOSED.** `packages/spec/src/shared/refinement-projection.ts` declares the vocabulary and builds each predicate from its own declaration — the dependency map is read once and used by both the published keyword and the enforced rule — so the two cannot name different keys. A refinement outside the list stays unprojected and keeps its annotation. `propertyNames` / `not` for banned keys remains untaken: the tree carries no candidate whose rule is mechanically derivable, so no arm was constructed for it. - -**Two mechanism repairs ship with it**, both invisible in the published output and both load-bearing from this arm onward. The detector's verdict was reached per NODE while refinements are per CHECK, so a node carrying a declared arm beside an undeclared rule read `projected` outright and the undeclared rule reached neither the ledger nor the annotation; `projected` now requires every check on the node to be declared, and the generator reports partially-stated sites on their own line. And the generator and the detector each passed the projection `override` for themselves — dropping it on the generator side alone left every site reading `projected` behind a green ledger while the published file silently went wide — so both now reach `z.toJSONSchema` through one shared call with no argument left to forget. - - diff --git a/.changeset/18670-project-expressible-refinements.md b/.changeset/18670-project-expressible-refinements.md deleted file mode 100644 index 975e2a05387..00000000000 --- a/.changeset/18670-project-expressible-refinements.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -**BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states two of the rules it used to leave entirely to the runtime, so a validator reading the published files stops answering PASS on metadata the platform then refuses (#18670 item 2). - -Clause-②: yes (narrowing) - -`z.toJSONSchema()` has no arm for a `custom` check: on zod 4.4.3 a plain record, the same record with a `.refine()`, and the same record with an **aborting** `.refine()` all project byte-identically. Every rule written as a refinement was therefore enforced by the runtime and absent from the published file — the direction in which an author's, or an AI's, validator says yes right up to the moment the platform says no. - -Two named patterns now project, and only those two: - -- **at least one of these keys is present** — emitted as `anyOf` of one `required` per key. `shared/Expression.json` states the source-or-ast rule, so `{ "dialect": "cel" }` is refused by the published file exactly as the runtime already refused it. -- **a string with at least one non-whitespace character** — emitted as `minLength: 1` plus the pattern `\S`. Every evaluated and typed expression slot states it, so a whitespace-only `source` is refused at the door. - -**⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** Both patterns are EXACT rather than approximate: a key absent from a JSON object is the only way for its value to read `undefined`, and `String.prototype.trim` removes exactly the ECMA-262 whitespace set that `\S` is the complement of. Both equalities are pinned over their whole input space in `packages/spec/scripts/refinement-projection.test.ts`, including every ECMA-262 WhiteSpace and LineTerminator code point. No refinement was weakened, removed or added; the runtime accepts and refuses exactly what it did before. - -**The list is CLOSED.** `packages/spec/src/shared/refinement-projection.ts` declares the vocabulary and builds each predicate from its own declaration, so the rule the runtime enforces and the keywords the file publishes cannot name different things. A refinement outside that list stays unprojected and keeps its `x-dropped-refinements` annotation. Adding an arm is a public-contract decision with its own measurement, never a refactor — and ⛔ never an open-ended zod-to-JSON-Schema translator over the whole population. - -**Proof of work, in the shrink-only ledger.** `packages/spec/dropped-refinements.baseline.json` reads 201 published schemas / 553 dropped sites, from 246 / 750: 45 rows deleted, 75 rows shrunk, 197 sites closed, zero sites added anywhere. The generator now prints the closed population per pattern on every run (137 `required-one-of`, 60 `non-blank-string`), and reports a site that projects with no declared pattern on its own line. - - diff --git a/.changeset/18670-project-operator-key-pattern.md b/.changeset/18670-project-operator-key-pattern.md deleted file mode 100644 index 9be85cb0994..00000000000 --- a/.changeset/18670-project-operator-key-pattern.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -**BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the `$`-prefix key ban a normalized field condition enforces, so a validator reading the published files stops answering PASS on `{"$and":[{"$bogus":{"$eq":1}}]}` at `data/NormalizedFilter` — a document the runtime refuses by name (#18670 item 2, the fifth arm). - -Clause-②: yes (narrowing) - -One named pattern joins the closed list, and only one: - -- **`banned-key-pattern` — "no document may carry a key matching this pattern"**, emitted as `propertyNames` with a `not` over a `pattern`. `NormalizedFilter`'s `$and` / `$or` members and its `$not` operand each admit a field condition whose keys are field names (`amount`, `account.name`) and never `$`-prefixed operators. The published file now says so at all three nodes. - -**Scoped, and the scope is mechanical.** The ban is over an OPEN set of names, which is why the existing `banned-keys` arm cannot express it — a finite list that merely sampled the set would be wider than the rule. The pattern arm that can express it is bounded by a second closed list: `BannedKeyPattern` is a union of the pattern strings this package publishes, exactly one today (`^\$`), so a call site cannot invent a regex because there is no `string` to pass, and widening it is the same reviewed decision that adding an arm is. That is what answers the standing objection to a regex-shaped declaration — its over-reach cannot be read off the declaration the way a key list's can, so the bound is on how few declarations exist rather than on trusting the next caller. - -**The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json`, entry `data/NormalizedFilter`: - -| row | before | after | -|:---|:---|:---| -| `lazy.$and.element.options[0]` | dropped | **deleted** — reads `projected`, arm `banned-key-pattern` | -| `lazy.$or.element.options[0]` | dropped | **deleted** — reads `projected`, arm `banned-key-pattern` | -| `lazy.$not.options[0]` | dropped | **deleted** — reads `projected`, arm `banned-key-pattern` | - -**⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** The arm is EXACT rather than approximate. A JSON object's properties are exactly its own enumerable string-keyed ones and `propertyNames` judges exactly those names; JSON Schema specifies `pattern` as an ECMA-262 regular expression evaluated as a SEARCH, which is `RegExp.prototype.test` and nothing else — so the same source text decides the same set of names on both sides. It is presence and never value: a matching key present with a `null` value is present to both. Measured with ajv 8 (draft 2020-12) on the generated file, the verdict vector moves in one direction only: the three `$`-prefixed specimens go `true` → `false`, and every document the runtime accepts — `{}`, the empty combinators `{"$and":[]}` / `{"$or":[{}]}` / `{"$not":{}}`, a nested group, an ordinary field condition — is accepted before and after. Across the published tree, **1530 of 1535 files are byte-identical**; one file changes what it accepts, two change annotation only, and the remaining two are the bundle and the build-input hash. - -**The predicate and the keyword are ONE string.** `bannedKeyPattern` compiles its `RegExp` from the declared pattern, so the keyword the file publishes and the rule the runtime enforces cannot come to mean different things — the construction `requiredOneOf`, `dependentRequired` and `bannedKeys` already use, and the reason this arm needs no drift pin either. The `RegExp` carries no flags, which is part of the equality rather than a style choice: a JSON Schema `pattern` has none to carry, and `g` would make `test` stateful through `lastIndex` so a key's verdict would depend on which keys were judged before it. - -**A ratchet repair ships with it, and it is what made the rows exist to delete.** The detector decided `dropped` vs `projected` on a two-rung projection ladder while the generator publishes on a three-rung one — a node whose every io direction refuses over an unrepresentable member still reaches its file when that member sits in a union position, because the emit loop drops the branch and publishes the rest. Nine PUBLISHED sites therefore read `undecidable`, the one verdict the ledger does not count: they held no row, carried no `x-dropped-refinements`, and no repair of them could ever have deleted a row. The three nodes this arm closes were three of the nine. The detector now carries the generator's third rung and reports which rung answered, so a differential can never compare a pruned projection with an unpruned one; and a published site that still cannot be adjudicated fails the build by name, so the blind spot cannot reopen in silence. - -⚠️ **The ledger therefore GREW before it shrank, and the growth is the point.** Six sites became visible that were previously uncounted — `data/FieldOperators` and `data/RangeOperator` gained `$between.items[0]` / `[1]`, `data/NormalizedFilter` gained the same pair under `$not`, and `data/RangeOperator` entered the ledger as a published schema that had been holding no entry at all — then this arm deleted three. Net across the change: **204 entries / 560 sites → 205 / 566**, with the census at **566 dropped / 205 published schemas / 360 projected** (224 `non-blank-string`, 129 `required-one-of`, 3 `banned-key-pattern`, 2 `dependent-required`, 2 `banned-keys`) and **0 undecidable**, down from 9. Those two files gain annotation only: `x-` keywords are ignored by every validator, so the set of documents they accept is unchanged. - -⭐ **Superseding a sibling entry in this same release.** `18670-project-banned-keys.md` records that the `$`-prefix sites 「stay unprojected … they carry NO annotation and hold NO ledger row: published yet unratcheted」. That was a correct reading of its own tree and is no longer true of this one: the sites are projected, the blind spot is closed, and the population it described is empty. The earlier entry is left as the record of what it landed. - - diff --git a/.changeset/18677-validate-per-package-authoring-pass.md b/.changeset/18677-validate-per-package-authoring-pass.md deleted file mode 100644 index 1a5dbe22bfa..00000000000 --- a/.changeset/18677-validate-per-package-authoring-pass.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -`os validate` runs the per-package author-time rule pass `os build` already ran — the false-clean residue #17069 left one layer down. - -`os build` runs the artifact's authoring rules **twice**: once over the union-folded stack, then a second `runAuthoringRules('build', …)` pass over each `artifactPackages(…)` entry with `packageBodyAsStack(…)` as resolution context, de-duplicated against the union run. `os validate` ran the union pass and stopped — it imported neither seam. By `compile.ts`' own description the survivors of that second pass are the per-package findings no union finding already carried under the same rule, `where`, message and non-top-level position — deliberately narrower than everything the union run missed, because two entries rendering the same `where` still collapse. That whole set was findings `os build` reported and `os validate` **structurally could not**. The direction is false-clean, and on the worse door: the fast pre-flight is what an author runs *before* shipping, so its clean bill of health is the strongest false assurance the three commands can give. - -Measured on `origin/main` 09e16a574 over `examples/app-multi-package`, both commands exiting 0: - -``` -os build --json warnings: 4 <- 3 union + 1 per-package survivor -os validate --json warnings: 3 <- the survivor is the defect -``` - -After: both report 4, the same set, in the same order. - -**The loop is now one seam, not two copies.** `runPerPackageAuthoringRules` lives beside `artifactPackages` / `packageBodyAsStack` in `utils/artifact-packages.ts`, whose header already forbids a second copy of that shape by name. What would have drifted between two hand-written loops is not the package reading but the **verdict** — the de-duplication key, the severity split, the `where` prefix. `os build`'s observable output is unchanged (text face byte-identical modulo timings; `--json` payload identical). - -**Severity mapping is `os build`'s, unchanged.** A per-package `error` refuses (exit 1); an advisory joins `warnings`. So `os validate` is narrowed only to the bar the command that *ships* already holds: every input it can now refuse is one `os build` already refuses. - -**BREAKING** — `os validate --strict` can now fail a project it passed before. Measured on a two-package fixture whose union fold is clean and whose per-package run is not (`core` owns `pp_account`; a sibling package owns the view that displays `pp_account.industry`), driving the CLI from source: - -| `os validate` on that fixture | before | after | -|---|---|---| -| `--json` | warnings 0, exit 0 | warnings 1, exit 0 | -| `--json --strict` | exit 0 | **exit 1** | - -The one warning is `field-no-consumers` at `package 'com.example.ppflip.core' — object "pp_account" · field "industry"`, which `os build` already reports on the same fixture: nothing is refused here that `os build` does not already refuse, and the default (non-strict) face is unchanged in that measurement. A run that must keep its old verdict drops `--strict`; a project that wants to keep the flag fixes what the per-package pass reports, which is what `os build` has been reporting all along. - -Why the union fold does not see it: `packageBodyAsStack` hands each package the artifact's whole `packages[]` as resolution context, so a cross-package *reference* still resolves and the reference-integrity rules stay quiet — but a reachability rule asks what the **stack** reads, and per package the stack is that one package's own body. A field whose only consumer lives in a sibling package is therefore live to the union run and inert to the per-package run, and that is the shape that reaches `--strict`. - -Graded `minor` rather than `patch` for the new observable step line, the new advisories and the newly reachable non-zero exit; the launch window refuses `major`, so the breaking-ness is carried by the banner above and the ADR-0087 disposition below. - -Unchanged and out of scope: the ADR-0130 D4 union fold (#17069, fixed — `authoringRuleUnionStack` is in both commands), `--json` rendering (#11727), and disagreements *within* the per-package pass's verdicts (#18204). `os lint` still runs the union pass alone; its `artifactPackages` / `packageBodyAsStack` imports serve its own intra-package duplicate-name advisory, not the shared table. - - diff --git a/.changeset/18682-predicate-relationship-traversal.md b/.changeset/18682-predicate-relationship-traversal.md deleted file mode 100644 index a019ae301a0..00000000000 --- a/.changeset/18682-predicate-relationship-traversal.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -'@objectstack/formula': minor -'@objectstack/lint': minor -'@objectstack/objectql': minor -'@objectstack/plugin-security': minor ---- - -A validation rule can read one hop through a lookup — `record.account.type` on an opportunity resolves the owning account's field instead of faulting (#18682) - -Clause-②: yes (widening) - -A validation predicate could only read the record it guards. A `lookup` / -`master_detail` field carries an **id**, so the natural cross-object rule — -"a partner account may not carry an opportunity over 10000" — faulted with -`runtime: No such key: type`, and because a broken validation is fail-closed it -rejected every write on the object. The capability mainstream platforms provide -as a matter of course could not be authored at all. - -### What you can write now - -```ts -validations: [{ - name: 'partner_cap', - type: 'script', - message: 'Partner accounts are capped at 10000.', - condition: "record.account.type == 'partner' && record.amount > 10000", -}] -``` - -One hop, through any reference-typed field (`lookup`, `master_detail`, `user`, -`tree`). The engine reads the related row before evaluating and binds it in -place of the id, so `record..` resolves. - -### It is data pinned BEFORE evaluation, not a query from inside CEL - -There is no `os.lookup(...)` / `os.exists` / `os.count` — those stay removed. -The engine statically analyses the predicate, learns exactly which reference -fields it reads through and which related fields it names, and loads those -**before** evaluation. Every registered function stays pure once `now` is -pinned, so `objectstack build` artifacts stay byte-stable. - -The cost is bounded by construction: one hop, only the fields a rule actually -names, one batched read per reference field per write, and nothing at all when -no rule traverses. - -### Read authority — system, bounded by the projection - -The related row is read under **system authority**. A validation rule's output -is a pass/fail the *system* enforces, not data handed to the caller — which is -why RLS predicates are excluded from this capability altogether. Reading as the -acting user instead made the rule unauthorable for exactly the persona it exists -to constrain: a member with CRUD on the child and no read on the parent faulted -on every write. - -What bounds the elevation is the **projection**: only the -columns the predicate names, intersected with the related object's declared -fields. A column the related object does not declare never enters the query, and -is refused as the authoring fault it is — distinct from a column that exists and -is empty, which evaluates as `null`. - -A related object no organization wall scopes — no tenant column (`sys_user` -behind a `user` field), `tenancy.enabled: false`, or `external` — is bounded by -row as well: for any caller that is not system (a user, a public-form -submitter, a caller with no principal), only a row the caller's own read of that -object returns. A reference to any other row refuses the write as not readable, -whatever that row holds. Under a walled posture (`group` or `isolated`), such a -caller with no active organization gets no related read at all: a rule reading -through a stored reference refuses the write as not found. - -⚠️ **The accepted cost, stated plainly.** A caller can *infer* a related value -they cannot see by observing which writes are refused. The value itself never -appears — the refusal names the field and the rule, never the value — and the -channel is deliberately no wider than "this rule refused this write". - -### Two shapes are refused, with a prescription - -Both fault at evaluation today, so neither removes anything that works: - -| Shape | Why | Write instead | -| --- | --- | --- | -| `record.account.type == 'x' && record.account == 'acc_1'` | reading through the relationship resolves `record.account` to the related RECORD, so the id comparison would stop matching — silently | `record.account.id == 'acc_1'` for the value comparison | -| `record.account.owner.email` | a second hop is not loaded | denormalise onto `account`'s object, or read it in a hook | - -A field that is **not** reference-typed is untouched: `record.address.city` on -an object-valued field traverses today and keeps traversing. - -### `@objectstack/plugin-security` gains `canWriteObject` - -The WRITE admission — the sibling of the existing `canReadObject`, running the -middleware's own arms in the middleware's own order: system bypass; then, before -anything resolves, the ADR-0103 engine-owned write guard and the ADR-0090 D12 -delegated-administration gate, each called as the middleware's own primitive; -then no resolved permission sets, unresolvable posture, the ADR-0066 D3 -`requiredPermissions` capability AND-gate for both principals, the CRUD grant, -the ADR-0090 D10 delegator check, and — when the caller's payload is supplied — -the field-level security WRITE gate over it (`getFieldPermissions`, folded -through the D3 field-capability contract, intersected with the delegator's mask -under D10, then the forbidden-write detection); and last, the ADR-0123 D2 -no-active-organization wall, the same verdict the middleware's step 3.7 throws -on. It exists for doors that must ask -"could this caller perform this write" without running the engine middleware — -the write preview is the first. - -⭐ What it answers, POSITIVELY — by naming what it RUNS, never a category of the -write decision: the ADR-0103 engine-owned affordance gate, the ADR-0090 D12 -delegated-admin gate, the fail-closed postures (#3545's unresolvable posture and -the D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both -principals, the `allowCreate`/`allowEdit` CRUD grant, the D10 delegator's -independent grant, the step 2.5 FLS write gate over the keys the payload -names, and the ADR-0123 D2 organization wall. It says nothing about any refusal -not in that list. `@objectstack/plugin-security`'s -`can-write-object-admission.test.ts` pins the method's answer equal to the -registered middleware's on its equivalence block's cases, and pins one D12 -UPDATE case as a direction: the method `false`, the middleware `true`. - -⛔ `true` never means the write will succeed, and ⛔ what follows is not an -enumeration of the distance to success: the middleware refuses both before and -after `next()` for reasons this method is never asked. Nearest to hand are the -remaining pre-resolution gates that run beside the two named above — the -package-managed and system-row write gates, which judge a row's PROVENANCE; the -curated-capability-name and audience-anchor binding refusals, which judge a -payload VALUE; and the ADR-0056 public-form grant, which no caller can present -to this method and which has no extracted primitive to call; the row-level and -post-image refusals — the `using` pre-image, the ADR-0055 controlled-by-parent -master edit, the RLS `check` post-image and the Layer 0 tenant post-image, none -of which this method can judge because it is asked about no ROW; the -payload-VALUE refusals the same caller passes by simply not sending the value — -the masked echo and the `owner_id` forge, which therefore widen the caller class -by nothing; the anti-filter-oracle guard on the caller's own predicate, which -this method is handed none of; the post-`next()` assertion that the insert -`check` seam really ran, which judges an executed write; and, outside the -middleware entirely, `readonlyWhen`, the static `readonly` strip and the -validation rules themselves. - -### Scope - -Object validation rules (`script` / `cross_field`) — and the system-authority -read is confined to that one seam. The field-level -`requiredWhen` / `readonlyWhen` / option `visibleWhen` predicates fail **open** -and are deliberately not covered here; RLS predicates are out too. Depth is one -hop. The cleanup UPDATE a `set_null` delete issues on a referencing record -resolves no relationship, so a rule there is evaluated as before this release — -against the bare id, where reading through it faults and refuses the cleanup, -and with it the delete. diff --git a/.changeset/18685-cloud-adr-citation-spelling.md b/.changeset/18685-cloud-adr-citation-spelling.md deleted file mode 100644 index f8d1689bb58..00000000000 --- a/.changeset/18685-cloud-adr-citation-spelling.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`ToolExecutionContext.userMessageText` now cites the cloud decision as `cloud ADR-0025`, not as a bare number that resolves to this repo's plugin-packaging ADR - -The docblock read `(cloud, post-ADR-0025)`. The parenthetical says the layer is -cloud, but the id was spelled bare — and a bare id resolves against *this* -registry, where `ADR-0025` is -[Plugin Package Distribution](../docs/adr/0025-plugin-package-distribution.md): -a real record about `.osplugin` artifacts, code-plugin trust tiers and -marketplace install. Nothing in it decides who owns the agent route. - -That is worse than citing a number nobody has. A dangling id stops a reader; an -id that resolves lets them believe they read the right page and walk away with -the wrong decision. AGENTS.md Prime Directive 13 is explicit — an ADR "lives in -the repository whose code it governs", and a cloud decision is cited as -`cloud ADR-NNNN`, "never as a bare number". - -The line now reads `(cloud, post-cloud ADR-0025)`, which is verbatim what the -sibling member `confirmedBlueprintIdentity` two declarations below already says. -The two were deliberately inconsistent while this was open; they are consistent -again. - -Docblock prose only — no type, no export and no runtime behaviour changes. The -published `.d.ts` carries the comment, which is why this ships as a patch rather -than silently. diff --git a/.changeset/18697-version-grammar-canon-phase1.md b/.changeset/18697-version-grammar-canon-phase1.md deleted file mode 100644 index e12da906daf..00000000000 --- a/.changeset/18697-version-grammar-canon-phase1.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/core': patch -'@objectstack/runtime': patch ---- - -feat(spec): one declaration per version grammar — eight regex carriers of "the version of a package or plugin" now reference three exported constants - -Clause-②: yes (widening) - -**No accept set moves, and that is the whole point of this change.** Eight sites -spelled a version regex out as a literal of their own. Five of those spellings -were byte-identical to each other, two more were byte-identical to each other, -and the eighth stood alone — three accept sets written eight times, growing on -their own: three of the eight were published schema declarations with no parse -caller at all, added by authors who copied a neighbour's literal. Each site now -references the constant carrying the pattern it already enforced, byte for byte. -A ninth in-repo carrier of the same concept spelled no regex at all: -`PackageManifestSchema.version` is a bare `z.string()`, and it stays one here. - -`@objectstack/spec/kernel` gains three exported patterns: - -- `MAJOR_MINOR_PATCH_VERSION_PATTERN` — three numeric segments and nothing - else. Referenced by `ManifestSchema.version`, - `MetadataPluginManifestSchema.version`, `PluginRegistryEntrySchema.version`, - `PluginMetadataSchema.version`, and the `PATCH /api/v1/packages/:id` door in - `@objectstack/runtime`. -- `SEMVER_SHAPED_VERSION_PATTERN` — `major.minor.patch` with an optional - `-prerelease` and an optional `+build` suffix, identifiers in either ASCII - case. Referenced by `PluginSchema.version` and by - `PluginLoader.isSemverShapedVersion` in `@objectstack/core`. Those two - converged on one spelling under the widen-never-narrow ruling and were held - equal by hand until now; they reference one declaration and can no longer - drift apart. -- `SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN` — the same with the suffix - identifiers restricted to lowercase ASCII. Referenced by - `PackageVersionSchema.version`. - -⛔ **The three are not interchangeable** — they are three different accept sets, -and referencing the wrong one moves a published accept set. None of the three is -a SemVer 2.0.0 conformance check and none is named as one: two accept forms -SemVer forbids (leading zeroes in the numeric core, empty and leading-zero -identifiers), one refuses forms it requires. For ordering or precedence, -`dependency-resolver.ts` in `@objectstack/core` is still the module to extend. - -**Nothing an author can write changes.** Every regex is byte-identical to the -literal it replaces — verified per carrier by sha256 over the extracted literal -— and every existing suite passes unedited. Those two together are the -neutrality proof, and they are the whole of it. `PackageManifestSchema.version` -keeps its bare `z.string()`; it is deliberately untouched here. No `.describe()` -text, refusal message or JSON Schema `pattern` moves. Regenerating the spec's -artifacts moved `api-surface/kernel.json` and `export-origins/kernel.json` and -nothing else, each gaining the three constant names — ⛔ read that as a check -that nothing unexpected regenerated, never as evidence about the accept set: the -artifacts that stayed byte-unchanged do not record a `.regex()` pattern in the -first place. A new pin, -`src/kernel/version-grammar.test.ts`, records each grammar's verdict on twelve -witness strings so the next deliberate move to any of them is one visible edit -to one matrix. diff --git a/.changeset/18697-version-grammar-canon-semver.md b/.changeset/18697-version-grammar-canon-semver.md deleted file mode 100644 index af6d5a03d65..00000000000 --- a/.changeset/18697-version-grammar-canon-semver.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/core': minor -'@objectstack/runtime': minor ---- - -feat(spec)!: the canon for "the version of a package or plugin" is SemVer 2.0.0 — nine carriers, one grammar - -Clause-②: yes (narrowing) - - - -**BREAKING** — four published accept sets converge on one, and the fringe each -of them carried outside SemVer 2.0.0 is refused. The widening half needs no -action from anyone; the narrowing half is listed per carrier below, with its -FROM → TO. - -One concept was judged by four different grammars across ten carriers in two -repositories, and the strictest refused `2.0.0-beta.1` — the exact string a -sibling declaration documented as an example of itself. The disagreement was -observable between doors on the same resource, not merely between schema files: -`os plugin build` refused a prerelease the publish door accepted, the Studio -form refused it twice over, the `PATCH` door answered `400`, and the install -door parsed nothing at all. An earlier change collapsed the eight regex literals -onto three exported constants, which removed the drift but not the disagreement. - -`@objectstack/spec/kernel` now exports ONE grammar — -`SEMVER_2_0_0_VERSION_PATTERN`, semver.org's own published expression — and -every carrier references it. - -## What every author gains, with no edit - -Prerelease and build suffixes are accepted on the five carriers that demanded a -bare three-segment core, so `2.0.0-beta.1`, `17.0.0-rc.5`, `1.0.0+20230101` and -`1.0.0-rc.1+exp.sha.5114f85` now pass a key that refused all of them. Identifiers -are case-preserving everywhere, as the standard requires. This repository cuts -prereleases of its own packages while the key describing a package could not -express one; that ends here. - -``` -FROM ManifestSchema.parse({ id: 'com.acme.crm', version: '2.0.0-beta.1', … }) - -> throws // and `os plugin build` exits 1 - -TO ManifestSchema.parse({ id: 'com.acme.crm', version: '2.0.0-beta.1', … }) - -> parses -``` - -## What stops being accepted, per carrier - -Eight strings, all of them forms SemVer 2.0.0 forbids and none of them a valid -prerelease. What they have in common is that no precedence order exists for any -of them — `dependency-resolver.ts` can place none in an order — so a package -versioned this way could be published and never compared against its own -successor. - -``` -FROM version: '01.1.1' TO version: '1.1.1' // §2 no leading zero in -FROM version: '1.01.1' TO version: '1.1.1' // a numeric identifier -FROM version: '1.1.01' TO version: '1.1.1' -FROM version: '1.0.0-0123' TO version: '1.0.0-123' // §9 no leading zero in a - // numeric prerelease id -FROM version: '1.0.0-alpha..1' TO version: '1.0.0-alpha.1' // §9 no empty -FROM version: '1.0.0-alpha..' TO version: '1.0.0-alpha' // identifier -FROM version: '1.0.0-.' TO version: '1.0.0' -FROM version: '1.0.0+.' TO version: '1.0.0' // §10 no empty build id -``` - -⛔ Each repair above is one defensible reading and not the only one, which is -why they ship as ADR-0087 D3 semantic TODOs rather than as mechanical D2 -conversions: a version is how a release is addressed, so rewriting one -re-points whatever already resolved the old string. Run -`objectstack migrate meta --from ` for the per-site list. - -Per carrier: - -- `ManifestSchema.version` and its three sibling declarations - (`MetadataPluginManifestSchema`, `PluginRegistryEntrySchema`, - `PluginMetadataSchema`), plus the `PATCH /api/v1/packages/:id` door: gain the - whole prerelease and build space; lose a leading zero in the numeric core. -- `PluginSchema.version` and the plugin boot path in `@objectstack/core`: lose - those eight and **nothing else**. ⭐ Every valid prerelease and build form the - loader accepts today it still accepts, which is what keeps the widen-never- - narrow ruling on that path honoured rather than reversed; both halves of that - bound are pinned in `plugin.test.ts` and `plugin-loader.test.ts`. -- `PackageVersionSchema.version`: gains case-preserving identifiers - (`1.0.0-Beta.1`, `1.0.0+Build.5`), which the boot path has always accepted and - this key alone refused; loses the same eight. -- `PackageManifestSchema.version`: was a bare `z.string()` constraining nothing, - so it is the one carrier where the grammar is entirely new. `latest`, - `v1.0.0`, `1.0`, the empty string and `2.0.0-beta.1extra!` were accepted and - frozen into a published manifest snapshot; each is refused now. A dist-tag - becomes the version it pointed at, a `v`-prefix drops, a two-segment string - gains its patch. - -## The prose moved with the grammar - -Every `.describe()` names SemVer 2.0.0 and the nine generated reference-doc rows -follow; the `PATCH` door's refusal says so; `manifest.test.ts`'s -「should enforce semantic versioning」 case stops listing `1.0.0-beta` among the -invalid versions. `PluginLoader.isSemverShapedVersion` becomes `isSemverVersion` -— a predicate named for a standard it does not implement gets misused by the -next caller whatever its docblock says, and the name is true now. - -Three exported constants are retired, each replaced by the one canon: - -``` -FROM import { MAJOR_MINOR_PATCH_VERSION_PATTERN } from '@objectstack/spec/kernel' -FROM import { SEMVER_SHAPED_VERSION_PATTERN } from '@objectstack/spec/kernel' -FROM import { SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN } from '@objectstack/spec/kernel' -TO import { SEMVER_2_0_0_VERSION_PATTERN } from '@objectstack/spec/kernel' -``` - -⛔ They are not interchangeable with what they replaced — each named an accept -set that no longer exists, which is why they are retired rather than aliased. A -consumer that referenced one to REPRODUCE a verdict gets the canon's verdict -now; one that referenced it to match a foreign grammar owns that grammar itself. - -The accept set is pinned witness by witness in `version-grammar.test.ts`: move a -cell there and you have moved a published accept set on nine carriers at once, -in one visible edit. diff --git a/.changeset/18714-resumed-leg-refusal-rollup.md b/.changeset/18714-resumed-leg-refusal-rollup.md deleted file mode 100644 index a55f1a803d5..00000000000 --- a/.changeset/18714-resumed-leg-refusal-rollup.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -fix(service-automation): a child that PAUSES and then refuses now rolls its refusal up on both resumed legs — the delegated resume and the up-bubble (#18714) - -**Clause-②: no** — nothing published moves. The two arms are added inside `AutomationEngine`'s private `resumeInternal` / `bubbleToParent`, and the one new type (`ChildRunRefusal`) is module-private, not barrel-exported. No schema key, no closed-set member, no export and no registry entry changes; `refused` has been a published terminal status since #15788 and no new status, code or `ERROR_CODE_LEDGER` entry is minted here. - -#18110 / #18555 gave the `subflow` and `map` executors an arm for `child.status === 'refused'`, and that arm reads the value `engine.execute` **returned** to them — so it covers exactly one shape: a child that runs straight through without pausing. A child that durably PAUSES first (a nested `approval` / `screen` / `wait`) never returns through that call at all. Its outcome reaches its parent on one of two **resumed** legs instead, and neither had an arm. Both pre-date #18110/#18555 and neither is a regression of it; that delivery named the two executors and matched its ruling exactly, and its own changeset filed this card for the remaining half. - -The two legs failed **differently**, so each gets its own arm and its own pin: - -- **Delegated resume** — `engine.resume(parentRunId)`, the path a screen-flow runner takes when it holds one stable run id and posts every wizard step to it. The delegation block tested only `paused` and `!success`; a refused child is neither, so it fell through the ordinary success exit. Measured: the parent answered `{ success: true, successMessage: … }`, its run row recorded **`completed`**, and the node downstream of the `subflow` **ran**. The refusal was lost **fail-open** — the identical shape #18110 closed on the synchronous leg. -- **Up-bubble** — `engine.resume(childRunId)`. `bubbleToParent` was called on the completion path only, so a child resumed to a refusal resolved exactly one of the two runs it is responsible for. Measured: the child row recorded `refused` correctly and the parent stayed **`paused`**, in `listSuspendedRuns()`, indefinitely. Nothing looks wrong; a run is **leaked**. - -What changed: - -- **One terminal shape, both legs.** Each leg records the child's refusal and hands it to a single throw site inside the resume's traversal `try`, which raises the engine's existing internal refusal signal — so the refusal leaves through the same `finishRefusedRun` chokepoint every other producer already uses. ⛔ Deliberately not a second terminal exit per leg: this file's history is a list of outcomes that became a function of which route a run took. -- **The throw site sits past the consumption and before the traversal.** The parent's own suspension is consumed exactly as it is on every other way a resume can end, so the terminal row and the pause can never disagree; and nothing downstream of the awaiting node runs. -- **The parent's terminal row reads `refused`**, carrying the child's already-rendered `refusalMessage` verbatim, and the parent's own `successMessage` stays silent. ⛔ Not `failed`: a refusal is not a failure — it must not consume retry budget, must not be routable by a `fault` edge and must not be counted in `nodes[].failures`. -- **The child's #4354 rollup (`selected` / `acted` / `unmeasuredEffect`) survives on both legs**, for the same reason it survives on the synchronous one: the refusal is raised after the awaiting step has been credited. A child that refused really can have written rows before it said no. -- **Chains of any depth resolve**, because the up-bubble arm resumes the parent for real — the parent consumes its pause, records its own terminal row and bubbles to *its* parent in turn, by the same induction completions already rely on. ⛔ Not a direct ancestor walk like the failure cascade's: that verb records ancestors `failed`, which is the wrong word here. -- **The child's own resumer is told exactly what it was told before** — the bubble is still best-effort at the engine layer and never rewrites the child's envelope. - -Unchanged: the synchronous leg (#18110/#18555), the region-containment refusal (#18881 — a different error type on a different path, which neither resume leg raises or consumes), the retryable delegated resume-bag codes (#14379), the terminal child-failure cascade, and the `RESUME_IN_PROGRESS` / `STORE_UNAVAILABLE` / stranded gradings on the bubble. - -⚠️ **Behavioural direction**: a run that previously finished green over a refusing paused child now terminates `refused`, and a parent that previously sat in `listSuspendedRuns()` forever is now resolved. Both are the authored outcome arriving where it never did; a composition that depended on the fail-open was depending on the defect. diff --git a/.changeset/18728-identity-wires-relay-the-spec.md b/.changeset/18728-identity-wires-relay-the-spec.md deleted file mode 100644 index 510301267fc..00000000000 --- a/.changeset/18728-identity-wires-relay-the-spec.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/client": minor -"@objectstack/plugin-auth": minor ---- - -The identity read routes now serve what `@objectstack/spec/identity` declares: `metadata` arrives DECODED on every organization route that reads the row back, and `updatedAt` is declared optional on `Organization` / `Member` / `Invitation` — the shape better-auth's own serializer documents (#18728). - -Clause-②: yes (widening) — `updatedAt` moves from required to optional on three published schemas, so the set a consumer may hand to `OrganizationSchema` / `MemberSchema` / `InvitationSchema` grows by exactly one shape: the key being absent. Nothing previously admitted is refused, nothing is renamed, and no producer is required to write it. Contract-review tier. - -Three published schemas could not parse a served response. `OrganizationSchema` declared `updatedAt` required and `metadata` an object; the four organization read routes (`setActive`, `get`, `delete`, `list`) carried no `updatedAt` at all and served `metadata` as the stored JSON text. `@objectstack/client` had recorded that as three 「not relayed」 notes rather than as a defect, and with zero in-repo consumers nothing went red — the audience was entirely external. Maintainer ruling C (batch #158 item 4) fixed the producer and made the one remaining key conditional on a measurement, which is what decided each half: - -- **`metadata` is decoded at the producer, unconditionally** — it is our column. plugin-auth's data adapter decodes `sys_organization.metadata` out of its stored JSON text on its READ verbs, so all four routes serve the object the spec declares, and an unset column is OMITTED rather than sent as `null`. ⛔ The write verbs are deliberately untouched: better-auth's own organization adapter decodes the `create` / `update` echoes itself and discriminates on the value still being a string, so decoding there would fold the create echo's `metadata` to `undefined`. Both directions are pinned. -- **`updatedAt` aligns to the documented wire** — ruling C's own fallback A, and its two conditions were measured against the installed better-auth 1.7.3 rather than assumed. The routes are better-auth's endpoints mounted through a single catch-all, each answering `ctx.json(...)` with no ObjectStack post-processing; and the vendor's `organization`, `member` and `invitation` models declare no `updatedAt` field, while its adapter factory's output transform iterates the declared fields only, so an undeclared column is dropped before any route sees it. Control, in the same file: the vendor's `team` and `organizationRole` models DO declare `updatedAt`, so the absence is a reading. For `member` and `invitation` there is additionally no column to serve — `sys_member` and `sys_invitation` are `managedBy: 'better-auth'`, the one disposition under which the platform injects no audit family, and neither declares `updated_at` itself. -- **`@objectstack/client` relays the schemas.** `OrganizationWire` is the spec's `Organization`, `OrganizationMemberWire` is `Member`, and `OrganizationInvitationWire` is `Invitation` with `status` narrowed per route plus the three members the platform adds on top (`teamId` and the two ADR-0105 D8 placement fields, which the non-strict schema strips). The three 「not relayed」 notes are gone. -- **The negative controls are the point.** "The client relays the spec schemas" and "the client stopped validating" look identical from a green positive test, so every accepted body is paired with a refused one — a required field genuinely missing, `metadata` still arriving as the stored JSON TEXT, and a `createdAt` or `updatedAt` present but not a datetime. `.optional()` widened the accept set by absence ONLY; a value that is there is still held to `z.string().datetime()`. - -**Not declared breaking, and the reason is the repo's own criterion** rather than the level being convenient. AGENTS.md binds the breaking class to removing or renaming something an author can write, and to the `(narrowing)` arm of the clause-② pair. Neither holds here: nothing is removed, renamed or retired; the one `packages/spec` edit only widens an accept set; and the `metadata` half is a producer brought into line with a contract this package has published all along — `OrganizationSchema.metadata` has declared an object since it was written, and the client's own comment called the served text 「not relayed」 rather than a shape anyone was promised. No ADR-0087 disposition is claimed because no breaking change is declared: no authored metadata moves, so `objectstack migrate meta` has nothing to visit, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. These three schemas are not metadata types — not in `DEFAULT_METADATA_TYPE_REGISTRY`, no authorable surface. ⚠️ Stated here rather than assumed silently, because it is the one judgement in this diff that the contract review the `Clause-②: yes` declaration commissions should confirm. - -**What a consumer notices**, and where it is delivered: `organization.metadata` was the stored JSON text and is now the decoded object, so a caller that decoded it itself drops that step. - -```ts -// before — the caller decoded what the route sent -const meta = JSON.parse(org.metadata ?? '{}'); -// after — the producer decoded it; the key is ABSENT when unset -const meta = org.metadata ?? {}; -``` - -The channel that reaches that caller is the compiler, on the line that used to work: `JSON.parse` no longer accepts the value. `updatedAt` needs nothing in either direction — it was never on this family's wire, so no caller can have been reading a value, and the declaration now says so out loud instead of promising one. diff --git a/.changeset/18739-batch-cap-embedder-only.md b/.changeset/18739-batch-cap-embedder-only.md deleted file mode 100644 index 8b0c8fe6c54..00000000000 --- a/.changeset/18739-batch-cap-embedder-only.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`BatchUpdateRequestSchema`'s cap comment no longer calls the batch-size cap "DEPLOYMENT policy". It is embedder-only, and this correction narrows the claim onto what is actually reachable. - -`packages/spec/src/api/batch.zod.ts` ships in this package's tarball (`files[]` carries `src/**/*.zod.ts`), so the sentence a reader finds beside `records` is published text. It told them the cap — `RestServerConfig.batch.maxBatchSize`, 1..1000, default 200 — was deployment policy, i.e. something an operator deploying this platform could move. No shipped boot path makes that true. - -**What the comment says now.** The cap keeps its span and its default as schema facts; the reachability sentence says who can write it. A `RestServerConfig` is the ARGUMENT a host passes when it constructs the server, and there is exactly one door: `createRestApiPlugin({ api })`. Neither shipped boot path opens it with a `batch` config — `os serve` forwards exactly two keys out of the stack config's `api:` block (`api.enableProjectScoping`, `api.projectResolution`) and the dev plugin calls `createRestApiPlugin()` with no config at all. A CLI-started deployment therefore always gets the default of 200, and no flag, config file or CLI option moves it; only the embedding host reaches anywhere in the 1..1000 span. - -**Nothing executable moves.** No schema key is added, removed or renamed, no accept set widens or narrows, no export changes, and no runtime behaviour is touched. `records` still carries shape only, the cap is still enforced at the route, and `.min(1)` is still absent. The diff is comment text inside one `lazySchema` factory. - -**Why this shipped as its own correction.** The same false claim had four other carriers, all already corrected under the same 2026-09-07 ruling: this package's `RestServerConfigSchema` docblocks and WHO CAN WRITE THIS CONFIG header, `enforceBatchSize` in `@objectstack/rest`, and the `data-api` and `http-protocol` reference pages. This was the fifth, and it carried the exact phrase struck from `enforceBatchSize` one package over. The wording is copied from those landings rather than invented, so the five now read the same way — as does the per-key REACHABILITY row in `liveness/batch_endpoints.json`, which also ships here. - -Clause-②: no — comment text only. No authorable key moves, no export is added or removed, and no accept set changes in either direction. diff --git a/.changeset/18762-cloud-adr-citation-sweep.md b/.changeset/18762-cloud-adr-citation-sweep.md deleted file mode 100644 index be32ac0c984..00000000000 --- a/.changeset/18762-cloud-adr-citation-sweep.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/cloud-connection': patch ---- - -docs(cloud-connection): cite the cloud control-plane decisions as `cloud ADR-NNNN` instead of bare numbers that resolve to this repo's own records (#18762) - -AGENTS.md Prime Directive 13 is explicit — an ADR "lives in the repository whose -code it governs", and a cloud decision is cited as `cloud ADR-NNNN`, "never as a -bare number, which `scripts/check-adr-anchors.mjs` resolves against *this* -registry (the two number independently)". The rule landed; the stock this -package already carried was never swept. - -Read against this repository's registry, the bare numbers pointed at real but -unrelated records: - -- `ADR-0008` → `docs/adr/0008-metadata-repository-and-change-log.md`, *Metadata - Repository, Change Log & Subscription (M0 → M4)* — zero occurrences of - "control plane", "cloud-connection" or "Phase 1"/"Phase 2". -- `ADR-0007` → `docs/adr/0007-settings-manifest-and-kv-store.md`, *Settings — - Manifest + K/V Store + Resolver*. The cloud ADR-0007 these lines mean is the - one this repo's own ADR-0003 status line already names: the decision that - redefined `sys_package_installation` as management-plane desired state and put - runtime truth in the `LocalManifestSource` ledger. -- `ADR-0009` → `docs/adr/0009-execution-pinned-metadata.md`, *Execution-Pinned - Metadata* — not the marketplace Setup-navigation ownership decision the lines - describe. - -That is worse than citing a number nobody has. A dangling id stops a reader; an -id that resolves lets them believe they read the right page and walk away with -the wrong decision. - -18 citations now carry the `cloud` qualifier, in the spelling this package -already used elsewhere for the very same numbers — `cloud ADR-0008` in -`connection-credential-store.ts`, `cloud ADR-0007 step ⑤` in -`local-manifest-source.ts`, `cloud ADR-0009 P2a` in `marketplace-ui.ts`'s own -header. All three numbers already carried both spellings inside this one -package, and `marketplace-ui.ts` carried both inside a single file — qualified in -its header on line 4, bare on lines 16 and 43. - -What actually reaches a consumer of this package: - -- The npm `description` field, which is the sentence shown on the package page. -- `README.md`, including the closing pointer that already said "in the cloud - repository" while writing the number bare. -- The published `.d.ts`, which carries the module and plugin docblocks. - -No behaviour moves. No type, export, route, schema or runtime path is touched — -this is citation spelling and prose only, which is why it ships as a patch rather -than silently. No ADR record is written or edited. `packages/cloud-connection/CHANGELOG.md` -is deliberately untouched: it is published history, and a released entry is -amended in a dedicated docs-only PR, never as a rider on code changes. diff --git a/.changeset/18778-lint-per-package-authoring-pass.md b/.changeset/18778-lint-per-package-authoring-pass.md deleted file mode 100644 index 4fd50f4fb68..00000000000 --- a/.changeset/18778-lint-per-package-authoring-pass.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -`os lint` runs the per-package author-time rule pass the other two doors already ran - -`os build` has run the author-time rule table a second time, once per -`packages[]` entry with that package's body as the stack and the artifact's own -`packages[]` as resolution context, since #16611; `os validate` joined it in -#18677. `os lint` ran the union fold and stopped, so every finding that pass -produces — in the build command's own words, the per-package findings no union -finding already carried under the same rule, `where`, message and non-top-level -position — was reported by the command that ships and invisible on the fastest -of the three doors. That bound is deliberately narrower than everything the -union run missed: two entries rendering the same `where` still collapse. All -three now call the one shared pass. - -Measured on a two-package project whose union run is clean and whose per-package -run is not (one package owns an object, a sibling package owns the view that -displays its field): - -| | before | after | -|---|---|---| -| `os build --json` | warnings 1 | warnings 1 | -| `os lint --json` | total 0, exit 0 | total 1, exit 0 | -| `os lint --json --strict` | exit 0 | exit 1 | - -**BREAKING** — `os lint --strict` can now fail a project it passed before. A -per-package finding is a finding this door could not see, `--strict` is -documented as "treat warnings as errors", and the verdict moves with it. The -default face is unchanged in the measurement above, and the severity mapping is -`os lint`'s own: an `error` fails the run, a `warning` fails it only under -`--strict`, an `info` stays a suggestion. Nothing is refused here that `os build` -does not already refuse, so the pre-flight is narrowed to the bar the command -that ships already holds and never past it. A run that must keep its old verdict -drops `--strict`; a project that wants to keep it fixes what the pass reports, -which is the same thing `os build` has been reporting all along. - -Clause-②: yes (narrowing) - - diff --git a/.changeset/18779-per-package-dedup-positional-key.md b/.changeset/18779-per-package-dedup-positional-key.md deleted file mode 100644 index e79004b387a..00000000000 --- a/.changeset/18779-per-package-dedup-positional-key.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -The per-package author-time de-duplication key ignores the top-level collection index, so a package-local finding no longer survives as an echo of the union finding it duplicates - -`runPerPackageAuthoringRules` runs the author-time rule table once per -`packages[]` entry and drops anything the union run already reported. Its key -was `rule` + `where` + `path` + `message`, and `path` is **positional**: a -package body re-bases every collection from 0, while the flattened union numbers -that same entry wherever `authoringRuleUnionStack` placed it. -`objects[0].fields.industry` and `objects[1].fields.industry` are ONE finding -under two spellings, so the `Set` never matched them and the echo survived the -filter that exists to remove it. - -Measured on `origin/main` a43b9d0654 over the repo's own two-package fixture -`examples/app-multi-package`, at every door, before and after: - -| | before | after | -|---|---|---| -| `os build --json` | warnings 4, exit 0 | warnings 3, exit 0 | -| `os validate --json` | warnings 4, exit 0 | warnings 3, exit 0 | -| `os lint --json` | total 4, failing 0, exit 0 | total 3, failing 0, exit 0 | -| `os lint --json --strict` | total 4, failing 4, exit 1 | total 3, failing 3, exit 1 | - -The one warning that stops being reported is `field-no-consumers` on -`crm_account.industry` re-reported at the package-local index — the union run's -own finding, printed a second time. Its twin is still reported, which is why no -verdict moves. - -**No input's verdict changes, and that is structural rather than a property of -this fixture.** Every finding the de-duplication drops has, by construction, a -finding carrying the same key already in the reported set: the seed is the union -run's findings, which every door reports, and it grows only with per-package -findings that themselves survived. So a door's refusal cannot flip — `os build` -already exits 1 on a union error before this pass runs, and `os lint --strict` -fails on `errors + warnings`, a count that could only reach zero if the twin -went unreported too. - -Only the **top-level** index is neutralised. Nested positions (`.indexes[1]`, -`.columns[0]`) address the author's own document and read identically in both -views, so they stay in the key and keep discriminating. A finding's own `path` -is never modified — every door still prints the location it always printed. - -What this does **not** buy: the key becomes position-insensitive, not -collision-proof. Two entries that render the same `where` still share a key, -exactly as they already did whenever their indices happened to match. Measured -over every example stack in this repo that parses today (`app-multi-package`'s -built artifact, `app-crm`, `app-showcase`, `app-todo`), 45 registry rules -produced 103 findings and 103 distinct neutralised keys — zero collisions. - -Also corrected: the sentence "what survives the filter is exactly the set the -union could not see", which was false for as long as the key was positional and -had been copied from `compile.ts` into the `os validate` and `os lint` doors as -each was wired. It is now stated at the bound the pass can actually hold, in -every file that carried it. - -Clause-②: no diff --git a/.changeset/18780-build-text-face-advisory-count.md b/.changeset/18780-build-text-face-advisory-count.md deleted file mode 100644 index 28c01f2ca7b..00000000000 --- a/.changeset/18780-build-text-face-advisory-count.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os build`'s text face prints every author-time advisory its own summary line counts — the closing `N author-time warning(s) — see above` no longer stands over a shorter list (#18780). - -Clause-②: no - -`compile.ts` rendered the advisory block at step 3b, inline, straight off the union rule run. Step 3b-ii — the ADR-0130 D4 pass that runs the same rule table once per `packages[]` entry — then appended its survivors to the **same** `ruleAdvisories` binding, and the summary line at the foot of the command counts that binding. So on a multi-package project the count was the complete set and the printed list was the union's alone, and the sentence pointing at it sent the reader back up to find a warning that had never been printed. - -Measured at 17.4.0 on `examples/app-multi-package`, exit 0 on every face: - -``` -os build 3 advisory entries · ⚠ 4 author-time warning(s) — see above -os build --json warnings: 4 <- the count was already right -os validate 4 advisory entries <- since #18769 -``` - -- **The list moves, not the count.** #11529 settled this axis one list over: the summary counts the whole set and the printer NAMES what it withheld, because a count quietly shrunk to match a short list is the false-clean direction — it deletes a finding from the text face of the command that ships while `--json` and `os validate` keep reporting it. The fourth advisory now prints. -- **What an author sees change**: on a stack that declares `packages[]`, the advisory block is rendered after the `Running author-time rules per package (N)...` step line instead of before it, and it now carries the per-package findings — the ones whose `where` reads `package '' — …`. A stack with no `packages[]` is unchanged — measured on a single-package fixture, the before/after captures are 2038 bytes each and differ only in the run's two clocks, `Load time: Nms` and `Build complete (Nms)`: its list was already complete, and the block still precedes every later step line. -- **Still ONE printer call.** The block is deferred to the point where the list is complete rather than printed twice, so the 50-entry cap and its `… and N more … not shown` notice keep judging one list. A second `printAuthoringAdvisories` for the survivors alone would have given the cap a second budget and the notice a second, partial total. -- **The author-time rule FAILURE faces keep their advisories.** A union-level failure exits before the per-package pass runs, so its block is byte-for-byte what it was; the per-package failure face now prints the per-package advisories too, which its own `--json` twin has published since #11772. - -No payload key, no exit code and no `--json` byte moves: `warnings` already carried all four, which is how the mismatch was measurable in the first place. diff --git a/.changeset/18783-server-can-option-visibility.md b/.changeset/18783-server-can-option-visibility.md deleted file mode 100644 index b2357e1e42d..00000000000 --- a/.changeset/18783-server-can-option-visibility.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/plugin-security': minor -'@objectstack/core': minor -'@objectstack/plugin-hono-server': patch ---- - -feat: the server answers `current_user.can(object, verb)` in an option's `visibleWhen` (#18783) - -A `select` / `multiselect` / `radio` / `checkboxes` option can gate itself on the acting subject's grants: - -```ts -stage: Field.select({ - label: 'Stage', - options: [ - { value: 'open', label: 'Open' }, - { value: 'escalated', label: 'Escalated', visibleWhen: "current_user.can('crm_account', 'edit')" }, - ], -}), -``` - -`@objectstack/formula` answers `can` from `EvalContext.permissions` and refuses loudly when none is passed — and until now nothing on the write path passed one. Every authenticated write that picked such an option took the evaluator's fail-open branch: the value was admitted, one `warn` said the predicate "failed to evaluate", and the gate was never enforced for anyone. - -**What changes.** The write path now evaluates the predicate with the subject's effective object permissions — on `insert` (single and batch), by-id `update`, bulk `update`, and the `validate()` preview. A subject whose map withholds the verb is refused with `VALIDATION_FAILED` and a field error `invalid_option` on that field; a subject who holds it is admitted. Options whose `visibleWhen` never calls `can` are unaffected. - -**Where the map comes from — one producer.** - -- `@objectstack/plugin-security` implements `ISecurityService.getEffectiveObjectPermissions` (declared optional in `@objectstack/spec`) and registers the same method on the engine. -- `@objectstack/objectql` gains `registerEffectiveObjectPermissionsResolver(fn)`. The engine asks it at most ONCE per write (an N-row bulk update is one resolution), only when a picked option's predicate calls `can`, never for a write with no acting user, and never keeps the answer past the write. The answer goes through formula's `toEvalPermissions`, so a map that is not the published shape is refused rather than answered from. -- `@objectstack/core` exports `buildEffectiveObjectPermissions`: the most-permissive merge plus the super-user seed, wildcard fold, managed-write clamp and `apiOperations` annotation. `/auth/me/permissions` builds its `objects` slot with it and the new security method returns it, so the console and the server's own `can()` read the same map. The four folds (`foldWildcardSuperUser`, `clampManagedObjectWrites`, `seedSuperUserRestrictedObjects`, `annotateEffectiveApiOperations`) and the `ManagedSchemaLike` / `ApiExposureSchemaLike` types moved from `@objectstack/plugin-hono-server` to `@objectstack/core`; `@objectstack/plugin-hono-server` re-exports them under the same names, so no import changes. The `/auth/me/permissions` response is byte-identical for the same resolved sets (measured on five fixtures against the previous build). - -**Failure stance.** - -- If the security service cannot resolve the map, a write that needs it is refused with the resolution's own error — fail closed. It is never read as "no grants". -- With no security plugin, or an engine older than the seam, there is no permission data. The gate stays unevaluable and the value is admitted with the same `warn` as before, which names the missing input. The security plugin logs one `warn` at start when the engine lacks the seam. - -**Plain-wildcard coverage, closed in this release.** `can()` reads only the per-object entries of the map. Before #20083, `/auth/me/permissions` listed an object for a `'*'` wildcard grant only when that grant carried a super-user bit, so a subject whose access to an object came only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — got `false` from `current_user.can()` for that object, although the data plane admits the write, and was refused on a `can`-gated option. That gap is closed in this same release by #20083 (`.changeset/20083-effective-map-plain-wildcard.md`): `buildEffectiveObjectPermissions` now puts each set's plain `'*'` on the registered public objects that set does not name, so that population's map — and any client that answers `can()` from the same `/auth/me/permissions` map — carries an entry for each object the wildcard covers, with the wildcard's grants, narrowed on a guarded managed object by the same managed-write clamp as every other entry. The map also differed from `PermissionEvaluator.checkObjectPermission` for subjects holding a super-user wildcard: an entry the super-user set itself names narrower read as granted, which is closed in this same release (`.changeset/20136-super-user-fold-per-set.md`). Its missing `transfer` is closed in this same release (`.changeset/20134-super-user-entries-every-bit.md`). - -**No spec key, route or config key is added or removed.** diff --git a/.changeset/18785-one-permission-fold.md b/.changeset/18785-one-permission-fold.md deleted file mode 100644 index 9ffda32b5c0..00000000000 --- a/.changeset/18785-one-permission-fold.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/lint": patch -"@objectstack/plugin-security": patch ---- - -`PermissionEvaluator.checkObjectPermission` and `buildAccessMatrix` now ASK `@objectstack/spec`'s `objectPermissionGrants` instead of restating the super-user fold — one rule, one definition (#18785). - -"Does this effective object permission grant this verb?" had three independent implementations: the spec helper published in 17.4, the enforcement door in `@objectstack/plugin-security`, and the access-matrix snapshot in `@objectstack/lint`. A differential over the full input space — every declared object-permission bit (`allowCreate` / `allowRead` / `allowEdit` / `allowDelete` / `allowTransfer` / `allowExport` / `viewAllRecords` / `modifyAllRecords`) in all three authorable states, 6561 entries by 6 verbs — found **zero** disagreements, so this is a structural convergence and **no behaviour changes**. - -- **No API change, no bit changes meaning.** The read bypass is still `viewAllRecords || modifyAllRecords`, the write bypass is still `modifyAllRecords` alone, `allowCreate` still has no super-user bypass, and `export` is still `grant ∧ read`. -- **The export door keeps its cross-set shape.** `checkObjectPermission('export', …)` still asks `(∃ set granting export) ∧ (∃ set granting read)` across the resolved set list — the same answer the `/me/permissions` most-permissive merge hands the client. Folding it per set would have narrowed the door. -- **Both consumers are pinned to the fold independently of the helper**, so a change to one cell of `objectPermissionGrants` reddens them rather than propagating silently. diff --git a/.changeset/18791-row-color-vocabulary-honesty.md b/.changeset/18791-row-color-vocabulary-honesty.md deleted file mode 100644 index 93ddca86d92..00000000000 --- a/.changeset/18791-row-color-vocabulary-honesty.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `rowColor`'s own prescription stops handing authors the one spelling the renderer drops (#18791) - -Clause-②: yes - -`RowColorConfigSchema.colors` advertised `Map of field value to color (hex/token)`. -The only renderer — objectui `plugin-grid`'s `useRowColor` — hands a `bg-`-prefixed -literal through untouched, otherwise lower-cases and trims the value and resolves it -through its own closed vocabulary of colour NAMES, and returns `undefined` for -everything else. A hex is not a key, and Tailwind v4 has no runtime, so no class can -be fabricated from one. - -The `view/row-color-without-colors` diagnostic checks PRESENCE only, so every link in -the chain was shipping code except the author's step: the gate fires, **the gate -itself hands the author a hex**, the hex parses, publishes, turns the gate green, and -colours nothing. A control whose own prescription switches it off. Measured, not -argued: #18787's reverse-verification leg B swapped four colour names for the four -hexes the `priority` field already declares — the app-local resolvability arm went red -naming all four while the presence arm stayed green. - -Three things change, none of which moves an accept set: - -- **The describe** now names the two spellings that actually reach a class, and names - a hex only as the thing that does not. An author who comes to ask "can I paste the - option colours in?" now finds the answer instead of an invitation. -- **The `fix` string** the presence diagnostic emits prescribes a resolvable colour - name. `token` went with the hex: read as the renderer's colour names it was still - standing beside hex as an equal alternative, and putting a bad option first is as - harmful as offering only the bad option. The string is pinned by feeding the value - it suggests back through `checkViewCompleteness`, so the prescription can only ever - name something the new rule below accepts. -- **A new author-time warning, `view/row-color-unresolvable-value`**, reports values - the resolver drops. This is the half presence-only structurally cannot see: a hex - map CLEARS the `!config.colors` guard, which is exactly what silences the older - rule. - -The new rule judges the SHAPE a value has, and deliberately does not transcribe -objectui's 23-entry map. Two structural facts about the resolver are enough and -neither depends on what the map contains: the `bg-` branch tests the raw value, and -every key is a bare lower-case word matched after `toLowerCase()` and `trim()`. So a -value that is neither `bg-`-prefixed nor a bare alphabetic word once normalised cannot -be a key, whatever the map holds. That makes the rule **sound** — it never accuses a -value the renderer would have resolved, including `'RED'` and `' red '` — and -deliberately **incomplete**: an unknown colour name such as `chartreuse` is shaped -like a key and is passed, pinned as a NON-rule. A hand-copy of another repo's -vocabulary is a second opinion that drifts silently in both directions, and where the -vocabulary should be declared so the two sides cannot drift is a cross-repo question -this change deliberately does not answer. - -Not breaking, and measured rather than assumed: the finding is `warning` severity, -like its sibling. `@objectstack/lint`'s `splitBySeverity` sorts everything that is not -`error` into advisories, so `os build` / `os validate` / `os lint` still exit 0 on their -DEFAULT paths, and the registration-time twin in `@objectstack/objectql` is field-only — -it calls `checkFieldCompleteness` and never the view predicate — and warns without ever -throwing. Nothing that builds today on a default run starts failing, and nothing authored -today is refused. Under `os lint --strict` / `os validate --strict` a warning IS a -failure — that is what the flag is for — so a stack carrying an unresolvable -`rowColor.colors` value, typically a hex, starts failing those strict runs on upgrade; -the fix is the one the finding prescribes: a resolvable colour name (`red`) or a complete -Tailwind background class (`bg-red-200`). - -Blast radius measured over this repo, the five example apps and objectui at the pinned -`.objectui-sha` `53ded82bf7a494f54e344e19099dbf00854b8694`: **zero** authored `colors` -maps reach this rule carrying an unresolvable value — the one shipped map, -`examples/app-showcase`'s task grid, spells all four values as colour names and resolves -clean. The pinned sibling does hold three hex `colors` literals, and they are named here -so the zero is checkable rather than asserted: all three are objectui's OWN React test -fixtures (`ObjectView.rowColorRelay-7218.test.tsx`, in `app-shell` and in `plugin-view`), -they assert a relay by `toEqual`, and they never traverse `checkViewCompleteness` — so -this rule does not judge them and does not change their verdict. diff --git a/.changeset/18801-sharing-rule-note-quotation-rot.md b/.changeset/18801-sharing-rule-note-quotation-rot.md deleted file mode 100644 index c529f5c0264..00000000000 --- a/.changeset/18801-sharing-rule-note-quotation-rot.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`liveness/sharing_rule.json` — the file `_note` stops quoting the `declarative-rbac-seeding` proof-registry entry VERBATIM, so the pointer it hands a reader survives the next rewrite of that entry's prose (#18801). - -The ledgers ship inside this package, so this is a pointer a consumer can actually follow. The note said the entry's `blockedReason` "reads" a specific sentence and quoted it. PR #18797 (`ac720a9865`) rewrote that reason — correctly, because #18587 had made its premise false — and the quoted sentence stopped existing in the very file the note sends a reader to. Measured repo-wide with a fold-proof predicate (whitespace folds and TypeScript `' + '` concatenation seams dissolved before matching, because the registry splits every reason across source literals mid-phrase): the quoted string read **0** on `main`, while the entry id `declarative-rbac-seeding` read **18** in the same run. - -- **The judgement was never wrong; the quotation was.** The seeding does falsify the entry's original premise, and the rewritten reason on the entry now records exactly that — as a real ADR-0054 §3 binding candidate held back by the adoption act. The note still asserts it, in its own words. -- **What replaces the quote is an id, not a better sentence.** `declarative-rbac-seeding` is the entry's key: exactly **1** of the registry's **42** `id:` declarations spells it, and it reads 6 occurrences across 5 lines of `scripts/liveness/proof-registry.mts` — so a reader who greps it lands on the entry rather than on nothing. Quoting prose that changes is what rotted; an id does not rot on someone else's schedule. ⚠️ Measured, not assumed: nothing *asserts* those ids unique — the one other declaration of this id in the tree is `packages/qa/dogfood/test/authz-conformance.matrix.ts`, which names the same proof on purpose. -- **The old premise is paraphrased, deliberately not re-quoted.** A paraphrase of a premise that has already been retired cannot rot: the text it describes is frozen in history and nothing will rewrite it again. -- **The two sibling ledgers already wrote it this way.** `liveness/api.json` and `liveness/qa.json` cite `proof-registry.mts` by name and claim, and quote none of its prose. - -No verdict moved. Every `status`, `verifiedAt`, `evidence`, `producer` and per-row `note` in the file is byte-identical to `main`; the only changed field is `_note`, and `check:liveness` reports `sharing_rule 17 classified (live 16, planned 1)` before and after. diff --git a/.changeset/18835-list-view-unwalked-field-naming-keys.md b/.changeset/18835-list-view-unwalked-field-naming-keys.md deleted file mode 100644 index 899899e334e..00000000000 --- a/.changeset/18835-list-view-unwalked-field-naming-keys.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -fix(lint)!: `list-view-field-unknown` walks the four field-naming keys that had no position row at all - -Clause-②: no (narrowing) - -**BREAKING** — an accept-set narrowing on the list-view authoring surface. Four declared, authorable field-naming keys had no row in `POSITIONS` in `validate-list-view-field-refs.ts`, so a misspelt field name at any of them cleared the schema door, was walked by nothing, and was dropped by the renderer. From this release each is judged, and two of the four gate `validate` and `build`, so a stack that built yesterday with one of those two misspelt does not build now. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. - - - -## The keys, and why the tier is not the same for all four - -All four are `z.string().optional()` on their config schema, and the schema shape is deliberately not what tiers them — the tier is the consequence, read per key off its own `.describe()` and its renderer (measured in objectui `dda8f3815`). - -| key | declared at | tier | what a misspelt name does | -|:--|:--|:--|:--| -| `calendar.allDayField` | `CalendarConfigSchema` | `warning` | `ObjectCalendar` maps each event with `allDay: allDayField ? Boolean(record[allDayField]) : !endDate`, so every row reads `undefined` and no event is banded — and the renderer's own no-end-date inference is switched off by the key's mere presence. Every event still renders, at its start time: one decoration dropped. | -| `gantt.borderColorField` | `GanttConfigSchema` | `warning` | `borderColorRaw = borderColorField ? record[borderColorField] : undefined` leaves `borderColor` undefined for every task. Every bar keeps its fill and renders without its alert outline — `colorField`'s case. | -| `gantt.lockField` | `GanttConfigSchema` | **`error`** | A declared WRITE GUARD that fails OPEN. `locked: lockField ? !!record[lockField] : undefined` reads `undefined` on every row, and the drawer's `recLocked` falls the same way, so every row the author froze becomes draggable, resizable, progress-draggable, link-able, inline-editable and deletable — and the drag persists. | -| `gantt.objectField` | `GanttConfigSchema` | **`error`** | `isSyntheticRow` is `!!objectField && !String(rec[objectField] ?? '').trim()`, so a name no record carries answers TRUE for every row. `onTaskClick` never calls `navigation.handleClick` and `renderRecordOverlay` returns null: no bar in the chart opens a drawer or a detail page. | - -The two `error` rows are a consequence the rule's own severity note did not name and now does: a binding whose job is to RESTRICT or to ROUTE, where the miss is read as "no restriction" / "no route" on every row. Nothing is missing from the picture, which is exactly why it gates — it is the shape Prime Directive #10 names, a capability advertised in the metadata and not delivered by the runtime. Both are also worse DECLARED than omitted, because each renderer guards its behaviour on the key's mere presence. - -## Measured, one list view carrying every walked position, one mutation at a time - -| probe | before | after | -|:--|:--|:--| -| `calendar.allDayField` naming a field that does not exist | silent | `warning` at `views[0].list.calendar.allDayField` | -| `gantt.borderColorField` naming a field that does not exist | silent | `warning` at `views[0].list.gantt.borderColorField` | -| `gantt.lockField` naming a field that does not exist | silent | `error` at `views[0].list.gantt.lockField` | -| `gantt.objectField` naming a field that does not exist | silent | `error` at `views[0].list.gantt.objectField` | -| each of the four naming a REAL field | silent | silent | -| the other 53 walked-position probes | 53 reported, each at its severity | the same 53, each at its same severity | -| the clean fixture carrying all four bound to real fields | 0 findings | 0 findings | - -Over this repository's own tree the finding count is **0 before and 0 after**: no example app, fixture or seed authors any of the four keys at all (`git grep` over every tracked file finds the spec declaration, its own schema tests and the generated reference docs, and nothing else), so nothing existing starts reporting. - -## What an author does about a report - -Nothing is renamed and nothing is removed — every spelling that was valid is still valid, and no stored value has to be rewritten to a different one. What changes is that a name which resolves to no field on the bound object is now reported instead of being dropped in silence. - -There is no mapping to apply, and deliberately so: the correct spelling is whatever the bound object declares, which only that object knows. The remedy is always the same — name a field the object actually has, or drop the key — and the finding carries the object's own field list plus a "did you mean" suggestion, so the message itself names the spelling to write. - -## Scope — what is deliberately NOT changed - -- **No dotted verdict.** The four positions join `POSITIONS` and deliberately not `DOTTED_AXIS`: none of them reaches a query door this rule measured, so a dotted name at one of them is unjudged, exactly as every other renderer binding is. Pinned. -- **No other surface.** `listViews`, `recordTypes` and the other label/field-naming surfaces are untouched; widening to them is a measurement, not a corollary. -- **No new rule id, no message shape change, no severity change for any existing position.** `list-view-field-unknown` gains four more places it can be reported from. diff --git a/.changeset/18877-install-preserves-lifecycle.md b/.changeset/18877-install-preserves-lifecycle.md deleted file mode 100644 index d0535f40cd7..00000000000 --- a/.changeset/18877-install-preserves-lifecycle.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/objectql": patch -"@objectstack/runtime": patch ---- - -Installing a package no longer reverts an operator's most recent enable/disable. The install contract is now 「缺省 = 保持,有旗 = 设置」: an install that was not asked to move the lifecycle state does not move it (#18877). - -`SchemaRegistry.initialDisabledPackageIds` is a boot hydration input — filled once, before any registration, from the durable disable file, and never updated by `enablePackage` / `disablePackage`. It was nevertheless consulted by every `installPackage` call, so once an id was in the boot seed set, every re-install within that boot re-landed it DISABLED whatever the operator had most recently done. Since the durable write started following the row the door returns (#18752), that stopped being memory-only: - -```text -boot 1 operator disables the package → disk lists the id -boot 2 seeded from disk; the package installs disabled - PATCH /packages/:id/enable → 200, registry true, disk CLEARED - install(m, { overwrite: true }) (no flag) → the seed still listed the id - → row disabled, disk written DISABLED -boot 3 the operator's enable is gone, with no error anywhere -``` - -Reachable with nothing exotic: disable → restart → enable in Studio → an SDK upgrade with `overwrite`. - -- **`installPackage` reads the ROW first.** An existing row keeps its own `enabled`, `status` and `statusChangedAt`; the boot seed decides only for an id that has no row yet (boot hydration and a genuinely fresh install). A fresh id the seed never named still lands enabled, the declared default. -- **`enableOnInstall` now sets the state in BOTH directions.** `true` ⇒ `enablePackage`, `false` ⇒ `disablePackage`, and an ABSENT flag makes no lifecycle call at all — previously only `false` was read, and the `true` case was carried by the re-install restamping every row enabled. The bare (unwrapped) body form still honours nothing: no schema declares the key there. -- **`DELETE /packages/:id` clears both records.** The id leaves the boot seed set with its row, and its durable disable entry is cleared, so the next install of that id is a fresh install. Previously the durable record was immortal — a delete left a disable behind that named a package that no longer existed. -- ⚠️ **Behaviour change for a flag-absent re-install of an EXISTING row.** It used to return the package to the declared default (enabled); it now preserves what the row says. An upgrade flow that relied on a re-install to clear a disable must now send `enableOnInstall: true` — the same key, the same door, now honoured in that direction. A fresh install is unaffected. - -Maintainer decision batch #157 item 5, letter C. Item 4 of that ruling re-rules the #18058 F1 pin 「flag-absent re-install clears the durable disable」 to 「preserves」; F1b stands unchanged. - -Clause-②: no diff --git a/.changeset/18881-region-durable-suspension-refusal.md b/.changeset/18881-region-durable-suspension-refusal.md deleted file mode 100644 index 827602e4f76..00000000000 --- a/.changeset/18881-region-durable-suspension-refusal.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A node that **durably suspends inside a structured region body** now FAILS the run with a named refusal that carries the region node, the suspending node and the sub-flow — instead of being read as an ordinary region failure that a `try_catch` could contain, after which the run reported success over a sweep that had processed nothing (#18881, the runtime half of #15646's ruling D). - -An ADR-0031 region body — a `loop` body, a `parallel` branch, a `try_catch` try or catch region, **at any depth** — runs synchronously inside the enclosing run and cannot park it on a durable pause. #3267 ruled that limit 禁. `runRegion` already converted such a suspension, but into a plain `Error`, which is indistinguishable from a node that simply failed. - -Measured on the card's reproduction, `loop { try_catch { map(pausing child) } }`, before this change: - -``` -result.success true // the catch handler ran and "recovered" -run.status completed -summary.failed 0 // over 0 of 10 child runs -``` - -The `map`'s progress state (`.$mapState`) is written into the **enclosing** scope, so the residue a contained refusal leaves is read back as progress by the next entry to the same node: iteration 2 saw `started === collection.length`, ran nothing, and reported success. A sweep that reports green having done nothing is the worst available failure, and it is the one the run-level `failed` counter (#14456) was built to expose. - -What changed: - -- **`FlowRegionSuspensionRefusalError`** (new internal module `region-suspension-refusal.ts`, ⛔ not exported from the package entry) carries `regionNodeId`, `regionKind`, `suspendedNodeId` and `subFlowName` as fields as well as in its message, so a reader never parses the sentence. It is branded as a #3863 guard refusal, so a `fault` edge on the enclosing container cannot route it either. -- **`try_catch` re-throws it** from both the try-attempt arm and the catch-region arm rather than treating it as a region failure, and ⛔ spends no retry attempt on it — re-entering the region would re-enter the pausing node, and the metadata is what is wrong. **`parallel` re-throws it** rather than folding it into its returned (and therefore routable) branch failure. `loop` already re-threw unchanged. -- **One refusal is one failure.** The region node's own frame records the `EXECUTION_ERROR` step and publishes `{$error}`, exactly as any thrown node failure does; every enclosing container the unwind passes through records nothing, so `summary.failed` counts the fault and ⛔ not the nesting depth. - -⛔ **Nothing changes for a region whose nodes complete synchronously.** `loop { map(synchronous child) }`, `parallel { branch: [map(synchronous child)] }` and #15616's regression suite run exactly as before — pinned as explicit controls beside every refusal case, because without them a reader cannot tell "the durable pause is refused" from "the region path was closed off". - -⛔ **No authoring-time rule is added here**: #18688 landed that half in `packages/spec` and it refuses `screen` / `wait` / `approval` / `approval_revise` / `end` inside a region body by type. `map` and `subflow` are deliberately not refused there — whether they pause is decided by the child flow record their `config.flowName` names — which is exactly why the runtime arm has to exist. - -⛔ **No new `error.code`.** The closed `ERROR_CODE_LEDGER` (ADR-0112) lives in `packages/spec`; the refusal is named by its type and its fields, and the step it produces keeps the `EXECUTION_ERROR` code every thrown node failure has always carried. diff --git a/.changeset/18910-listmapconfig-docblock-truth.md b/.changeset/18910-listmapconfig-docblock-truth.md deleted file mode 100644 index 79cb64f8ee9..00000000000 --- a/.changeset/18910-listmapconfig-docblock-truth.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Correct `ListMapConfigSchema`'s account of what the map renderer does with an -undeclared key in `map`. - -The docblock said the renderer "validates `schema.map` against a local zod -schema with exactly these keys, so an extra key here would be dropped there", -and that sentence was the stated rationale for the block being strict. -Re-measured at the `.objectui-sha` pin `53ded82b` by executing the pinned -declarations: that local schema (`ObjectMapConfigSchema`) is a plain `z.object`, -not strict, so an undeclared key parses clean there with no issue and no -warning; `getMapConfig` consults its `safeParse` only to decide whether to -`console.warn` and returns a spread of the authored block. What does drop an -undeclared key on the path this block actually takes is a different instrument -— the hand-listed `FLAT_MAP_CONFIG_KEYS` whitelist in `ListView` / `ObjectView` -— and it drops it in silence. - -The schema is unchanged: same keys, same `strictObject`, same accepted -documents. Only the rationale is corrected, and it is restated so it stands on -its own — nothing downstream reports an undeclared key, so this parse is the -only diagnostic an author ever gets, which is an argument for the strictness -rather than against it. The record's seven objectui anchors now quote the line -they were read at, so `check:objectui-pin-citations` verifies their content -against the pin instead of only checking the sha label. - -Clause-②: no diff --git a/.changeset/18915-published-readme-examples-compile.md b/.changeset/18915-published-readme-examples-compile.md deleted file mode 100644 index 21bc2b3ec76..00000000000 --- a/.changeset/18915-published-readme-examples-compile.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/cli": patch -"@objectstack/client": patch -"@objectstack/client-react": patch -"@objectstack/driver-memory": patch -"@objectstack/driver-mongodb": patch -"@objectstack/driver-turso": patch -"@objectstack/mcp": patch -"@objectstack/observability": patch -"@objectstack/plugin-auth": patch -"@objectstack/rest": patch -"@objectstack/runtime": patch -"@objectstack/service-cache": patch -"@objectstack/service-i18n": patch -"@objectstack/service-job": patch -"@objectstack/service-package": patch -"@objectstack/service-queue": patch -"@objectstack/service-realtime": patch -"@objectstack/service-storage": patch -"@objectstack/spec": patch -"@objectstack/types": patch ---- - -The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. - -`README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. - -The corrections, by class: - -- **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. -- **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. -- **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. -- **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. - -No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. diff --git a/.changeset/18923-dimension-labels-option-arm-prose.md b/.changeset/18923-dimension-labels-option-arm-prose.md deleted file mode 100644 index 739502a99cb..00000000000 --- a/.changeset/18923-dimension-labels-option-arm-prose.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -`dimension-labels.ts` — the module header names the label-resolving option arm by the **property the resolver actually reads** (a declared, non-empty `options` list) instead of by the type name `select` (#18923). - -Doc comment only; it is published in `dist/index.d.ts` and `dist/index.d.cts`, so an upgrading reader's editor hover changes. No behaviour, no export, no schema. - -The header told the reader the arm was a type test: - -``` - * - **select** — grouped by the stored option `value` (e.g. `backlog`), but the - * user-facing text is the option `label` (e.g. `Backlog`). -``` - -The resolver in the same file never reads a type for it. All three decision points spell one predicate — `Array.isArray(meta.options) && meta.options.length > 0` — at `isLabelBearing`, at `resolveLabels` and in `resolveDimensionLabels`'s display pass; `type === 'select'` occurs zero times in the file, while the sibling `type === 'date'` arm shows the file does spell type tests where it means them. - -Naming a type is wrong in both directions, which is why the replacement names the property rather than a longer type list: - -- **It misses fields that do resolve.** `options` is optional on every field in the spec's field schema, so any field that declares one is resolved here whatever its type says. -- **It promises resolution for fields that carry none.** A free-input `tags` field may declare no options at all, and the display pass then leaves its stored value untouched. - -This closes the divergence that opened when the same sentence in `content/docs/data-modeling/analytics.mdx` and `content/docs/ui/dashboards.mdx` was moved to the property reading: the documentation was corrected, and the header the next editor of this file reads first was left behind. diff --git a/.changeset/18931-me-permissions-unrestricted-export-annotation.md b/.changeset/18931-me-permissions-unrestricted-export-annotation.md deleted file mode 100644 index 99f02f2b7a1..00000000000 --- a/.changeset/18931-me-permissions-unrestricted-export-annotation.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/plugin-hono-server": patch ---- - -`/auth/me/permissions` now reports an unrestricted object's effective operation set whenever the export axis withholds `export`, so the Console stops rendering an Export button the server answers `403 EXPORT_NOT_PERMITTED` (#18931). - -`Clause-②: no` - -The endpoint builds its per-object map in four passes — seed, fold, clamp, annotate. `seedSuperUserRestrictedObjects` resolved each registered schema **without** the export slot and skipped every `unrestricted` one; `annotateEffectiveApiOperations` resolves **with** it and iterates existing entries only. Two predicates for one question, and they disagreed on exactly one population: a principal whose only grant is a `'*'` wildcard carrying `modifyAllRecords` and no `allowExport` — which, since #8681 removed the wildcard export grant from the built-in admin sets, is every platform administrator holding no app-authored set. - -For that principal an unrestricted object got no entry, so annotate never saw it and the response said nothing about it at all. The client reads `apiOperations: undefined`, takes the default-allow path #3391 gave it, renders **Export**, and the click is refused. A sibling object declaring `apiMethods` got an entry, an `apiOperations` without `export`, and no button — the same principal, the same session, two answers. - -- **The seed and annotate cannot diverge again.** Since #20134 the seed places an entry for every registered object a super-user wildcard reaches that the merge left without one, and `annotateEffectiveApiOperations` alone decides which entries carry `apiOperations`: an object that is unrestricted **and** keeps `export` gets none — unless its `enable.apiEnabled` is `false`, which is annotated `[]` since #20135 because the REST door answers 404 for every verb on it. -- **The export axis is the only axis this reaches.** Measured across the `enable` shapes an unrestricted object can carry: withholding `export` subtracts `export` and nothing else, and `mode` stays `unrestricted` either way — which is why the old `mode`-only guard could not tell the two cases apart. The CRUD axis needed no annotation and still gets none. -- **What the response gains**: for such a principal, one entry per unrestricted object, each the full closure minus `export`. Its CRUD bits are folded to what its wildcard grants (all four for the built-in admin sets) — the same answer the client already computed by falling back to `'*'`, now stated explicitly rather than inherited. -- **Denial is unchanged.** `enforceExportPermission` → `security.canExport` still answers `403 EXPORT_NOT_PERMITTED`, and no request that was refused is now accepted. This is the affordance half: the channel that is supposed to tell the client now does. diff --git a/.changeset/18965-docs-dir-resolves-per-registered-package.md b/.changeset/18965-docs-dir-resolves-per-registered-package.md deleted file mode 100644 index ad2d16c3e05..00000000000 --- a/.changeset/18965-docs-dir-resolves-per-registered-package.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -**Clause-②: yes** — `os build` accepts a source layout it previously read nothing from, so what an author may write and have collected widens. ⛔ Nothing narrows: every tree that built green still builds green, with the same `docs[]` and the same warnings. - -`os build` now derives **each package's docs directory from the packages the artifact registers**, not from a fixed depth under `src/` (maintainer ruling, decision batch #204 item 5, letter B). - -Before this, the sweep asked one question per direct child of `src/`: does `src/CHILD/docs/` hold Markdown? So a project whose packages sit one level deeper — the shape this repo's own ADR-0130 D4 reference fixture `examples/app-multi-package` has, `src/packages/PKG/` — was invisible to it. A doc at `src/packages/orders/docs/ord_guide.md` was dropped **silently**: no `docs[]` entry, exit 0, and not even the `docs/uncollected-directory` warning, because the sweep never looked there. That is the #18170 defect verbatim, one level down, and after #18431 it was out of reach of both the diagnostic and the collection. - -Both layouts are now one case rather than two: - -``` -src/orders/docs/sales_guide.md -> packages[].manifest.docs (unchanged) -src/packages/orders/docs/sales_guide.md -> packages[].manifest.docs (new) -``` - -**How the directory is found.** A registered package carries no source path — `ArtifactPackageSchema` is a `strictObject` whose only key is the assembled body — so the only thing that can locate one on disk is its NAME, and the two spellings a docs directory is matched against are unchanged: the package's `id`, and the last dot-separated segment of that `id`. ⛔ Never `name` (a display string, free to be re-worded) and ⛔ never `namespace` (ADR-0130 D1 exists so N packages may share one). - -**No second depth was pinned.** The walk descends only in SEARCH of a registered package and stops at the first directory that names one — so a package's own subtree stays its source, and a `docs/` inside it is not a second docs directory. With no `packages[]` there is nothing to search for, so there is no descent at all: a single-package stack is walked exactly one level, its `docs[]` and its warning text byte-for-byte what they were. That is the fence the ruling preserved from batch #147 item 4, held by construction rather than by a branch guarding it. - -**One new refusal.** Depth-free resolution makes one package able to answer to two doc-bearing directories (`src/core/docs` and `src/packages/core/docs` in one tree). Both are reported and neither is collected — the same answer this collector already gives when one directory names two packages. ⛔ It is not merged and ⛔ not silently halved: docs attach by package index, so collecting both would drop one without a word. - -A directory that matches **no** package and one that matches **more than one** keep their existing, distinct diagnostics, now at whatever depth they are found. diff --git a/.changeset/18972-field-scale-renderer-ceiling.md b/.changeset/18972-field-scale-renderer-ceiling.md deleted file mode 100644 index 64af6acff16..00000000000 --- a/.changeset/18972-field-scale-renderer-ceiling.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec)!: `scale` is bounded at the renderer ceiling of 100 (#18972) - -Clause-②: no (narrowing) - -`FieldSchema.scale` — and the inline grid column's own `scale` — were declared as -any non-negative integer with no upper bound. Every renderer that turns a declared -`scale` into fraction digits reaches one of two platform primitives, and both of -them refuse above 100: `Number.prototype.toFixed` throws `RangeError: toFixed() -digits argument must be between 0 and 100`, and `Intl.NumberFormat` throws -`RangeError: maximumFractionDigits value is out of range.` So a spec-valid -declaration was unrenderable by any conforming consumer, and its author got no -signal at publish time — the failure arrived as a render-time crash in someone -else's repository. Both live readers are objectui's: the grid's `computeRow` rounds -a computed cell with `Number(v.toFixed(column.scale))`, and the number cell renderer -passes a field's `scale` straight into `maximumFractionDigits`. - -Both declarations now carry an upper bound of 100, and the refusal says **why** — -it names both primitives, the `RangeError` and the legal maximum — so an author -reads a platform limit they can verify rather than a cap somebody chose. The bound -is the platform's own: at 100 both primitives are measured to succeed, at 101 both -are measured to throw, and a unit test re-measures that boundary on every run -rather than trusting the literal. - -**BREAKING** — a declaration above 100 that parsed clean before is refused at -authoring now. This is a deliberate narrowing of a published accepted set, priced -as such rather than as a tidy-up. The declarations it refuses could only ever have -crashed a renderer: there is no value above 100 that any conforming consumer can -render, which is why the bound is the platform's limit and not a policy number. -`packages/objectql` already carries the consumer-side half of the same fact and -skips its formula rounding past 100, so no read is newly affected. - -Unchanged in both directions: `scale: 100` still parses, `scale: 0` still parses, -absence is still absence, and the malformed-declaration refusals from #8321 -(`scale: -1`, `scale: 2.5`) keep their existing codes and their existing wording. -`precision` is untouched — it is a total digit count that reaches neither -primitive, so the renderer-ceiling argument does not carry to it. - -Shipped as `minor` under the repo's launch-window convention, in which -`check-changeset-no-major` refuses `major` and breaking-ness is carried by this -banner plus the ADR-0087 disposition rather than by the level. - - diff --git a/.changeset/18973-ragflow-reads-declared-adapter-config.md b/.changeset/18973-ragflow-reads-declared-adapter-config.md deleted file mode 100644 index 94d481eb18d..00000000000 --- a/.changeset/18973-ragflow-reads-declared-adapter-config.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/knowledge-ragflow": patch ---- - -The RAGFlow adapter now reads the declared key: a source's RAGFlow binding comes from `adapterConfig.datasetId`, not `options.datasetId`. - -`KnowledgeSourceSchema` declares `adapterConfig` for adapter-specific configuration and is a plain `z.object` — it carries no `.passthrough()`, so any path that parses a source drops `options` before an adapter ever sees it. The adapter read `options` through a cast, which worked only because no path parses a source today. The cast is gone; there is no fallback that also reads `options` (Prime Directive #12 — one strict contract, no lenient consumer). - -Migration, `FROM` → `TO`, one line per source: - -```ts -// FROM -{ id: 'product_docs', adapter: 'ragflow', options: { datasetId: 'rgf_…' } } -// TO -{ id: 'product_docs', adapter: 'ragflow', adapterConfig: { datasetId: 'rgf_…' } } -``` - -The same move applies to `rerankModel`, `similarityThreshold` and `vectorSimilarityWeight`, which the adapter reads from the same bag. A source left on the old spelling is refused by name — `RAGFlow adapter requires source.adapterConfig.datasetId on source ''` — rather than silently retrieving nothing, so the upgrade is self-describing at the first call. Nothing an author could declare is removed: `options` was never a key `KnowledgeSourceSchema` accepted, which is why this carries no ADR-0087 conversion. - -The package's published `README.md` moves with the adapter and now compiles against it — it was the one block of the 44 that #18915 could not repair, because correcting the spelling alone would have compiled and stopped working. - -Clause-②: no diff --git a/.changeset/18975-connector-retry-config-and-request-timeout.md b/.changeset/18975-connector-retry-config-and-request-timeout.md deleted file mode 100644 index 69123d851a9..00000000000 --- a/.changeset/18975-connector-retry-config-and-request-timeout.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-automation': minor -'@objectstack/connector-rest': minor -'@objectstack/connector-openapi': minor ---- - -feat(connectors): a connector's declared `retryConfig` and `requestTimeoutMs` are executed, not just parsed (#18975) - -Clause-②: yes (widening) - -`ConnectorSchema.retryConfig` (eight sub-keys) and the two timeouts beside it -parsed, stored, and reached nothing. An author who wrote a retry policy — the -one `packages/spec/docs/SYNC_ARCHITECTURE.md` points at for a rate-limited -upstream, whose `retryableStatusCodes` default includes `429` — got -configuration that looked applied and did nothing, with no error and no -warning. ADR-0049 owed these keys a decision and ruled **implement**. - -**Where it landed: one wrapper, not a gateway.** `resilientFetch` -(`@objectstack/spec/shared`) already was the platform's outbound-HTTP call for -connectors — it gave every attempt a 30s timeout and a fixed exponential -backoff. What it could not express was the declared policy, so it gains exactly -the knobs that were missing (`strategy`, `backoffMultiplier`, `maxDelayMs`, -`jitter`, `retryOnNetworkError`), each defaulting to the behaviour it already -had. One new function, `connectorFetchOptions()` -(`@objectstack/spec/integration`), is the single mapping from a connector's -declared policy onto those options — one execution site, not one per connector -package. - -**How the authored value gets there.** `ConnectorProviderContext` gains -`retryConfig` and `requestTimeoutMs`, read-only and -resolved from the entry (the automation service parses `retryConfig` so a -factory reads real values instead of re-deriving the schema's defaults), so a -custom provider that does its own I/O can honour them. The built-in HTTP -providers — `rest` and `openapi` — honour them by construction. - -What an author now gets from each key: `strategy` picks the growth shape -(`exponential_backoff` / `linear_backoff` / `fixed_delay` / `no_retry`); -`maxAttempts` bounds the calls (it counts TOTAL attempts with the first -included, the contrast `content/docs/automation/flows.mdx` already draws against -`maxRetries`, and `maxAttempts: 0` still makes the one call and never retries); -`initialDelayMs` and `backoffMultiplier` shape the delay; `maxDelayMs` caps it, -applied after jitter so the declared ceiling is a real one — and an upstream -`Retry-After` longer than that ceiling ends the retry loop and returns the -response, rather than sleeping past a maximum the author declared; -`retryableStatusCodes` both widens and narrows what is retried; -`retryOnNetworkError` governs a thrown attempt; `jitter` can now be turned off; -`requestTimeoutMs` becomes the per-attempt deadline. - -**Two behaviour changes to know about.** A connector that declares a policy now -retries per that policy where it previously did not retry at all — that is the -fix, and a connector that declares none is on exactly its prior behaviour. -Separately, `connector-openapi`'s generated actions went through a naked -`fetch`: unbounded, never retried, and the one built-in HTTP path an authored -policy could never reach. They now go through the same wrapper as -`connector-rest` and `connector-slack`, which gives them the 30s per-attempt -timeout and bounded retry those two already had. - -**⚠️ `connectionTimeoutMs` is NOT made live, deliberately, and is the one thing -the ruling assumed that measurement refused.** A connector's call is a WHATWG -`fetch`, whose only cancellation surface is one `AbortSignal` over the whole -operation; nothing in that interface observes the connection phase separately. -Bounding time-to-response with it would kill a slow-but-connected upstream the -author meant to allow with a large `requestTimeoutMs` — breaking the very -promise the key makes. So this change leaves it unenforced, with the reason -recorded at the mapping and in `packages/spec/liveness/connector.json`, whose -row for it stays `dead`. That left it owed a second, narrower ADR-0049 -decision, and this same release takes it: `connector.connectionTimeoutMs` is -**retired**, and its own entry in this release says what to write instead. The -key never reaches `ConnectorProviderContext` in any release. - -Nine of the ten ledger rows flip `dead` → `live` with the consumer site named; -the tenth is `connectionTimeoutMs`, above. This change itself moves no -declaration: it leaves every key, every bound and every default on the -connector schema as it found them. diff --git a/.changeset/18977-orderby-dual-declaration-cross-reference.md b/.changeset/18977-orderby-dual-declaration-cross-reference.md deleted file mode 100644 index 128263f0386..00000000000 --- a/.changeset/18977-orderby-dual-declaration-cross-reference.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`$orderby` is declared twice — `ODataQuerySchema.$orderby` and `QueryTransportParamsSchema.$orderby` now cross-reference each other, and a pin holds the two accept sets apart (#18977). - -Clause-②: no. No accept set moves and no export is added, removed or renamed: the change is two docblocks in published source (`src/api/odata.zod.ts`, `src/data/data-engine.zod.ts`) plus a new pin test. Measured — `check:generated` reports all 16 generated artifacts up to date, `check:api-surface` and `check:authorable-surface` included. - -The two declarations are **complementary refusals**: each accepts exactly what the other rejects, and neither pointed at the other, so reading one of them carefully and completely still produced the wrong answer about the other. - -| `$orderby` value | `ODataQuerySchema` | `QueryTransportParamsSchema` (`DataEngineSortSchema`) | -|:---|:---|:---| -| `'name desc'` / `'-created_at'` | accepted | REFUSED | -| `['name desc', 'email asc']` | accepted | REFUSED | -| `[{field, order}]` | REFUSED | accepted | -| `{name: 'asc'}` / `{name: 1}` | REFUSED | accepted | - -- **Which one grades a query bag**: `QueryTransportParamsSchema`, reached from `FindDataRequestSchema.query` through `QueryWithTransportSchema` — the schema `POST /data/:object/query` parses its body against. `ODataQuerySchema` grades no runtime door: measured on this tree, its only consumers are the `OData.buildUrl` helper in its own file and its own unit test. -- **The refusal on the transport side is deliberate and stays** — `#18704` settled it: lowering an OData sort *expression* means PARSING, and a second parser beside the door's is how one rule gets two implementations that disagree. Widening either side to close the gap is a decision, not a tidy-up, so this change closes the **reader's** half only. -- **The string forms are not unserved.** `normalizeSortNodes` (`@objectstack/metadata-protocol`) reads `'name desc'`, `'-created_at'` and the `string[]` form at the shared ingress behind `GET /data/:object`, the export route and in-process `findData`. A querystring spelled the OData way works; the same bag sent as a `POST /data/:object/query` body answers `400 VALIDATION_FAILED`. The difference is the door, and neither door is `ODataQuerySchema`. -- **The cost this repairs was already paid.** objectui#9554 was filed, triaged, graded and dispatched against a shipped `object-grid` producer that had been sending the canonical shape all along, because the filing seat read the OData declaration and quoted it correctly. - -`src/api/odata-orderby-dual-declaration.test.ts` is the mechanical half: 25 cases pinning each side's accept set, their disjointness (with the lit control that neither set is empty), and which of the two `FindDataRequestSchema.query` is graded by. Widening or narrowing either declaration turns it red and lands the author on the cross-reference. diff --git a/.changeset/18978-aggregate-surface-scope.md b/.changeset/18978-aggregate-surface-scope.md deleted file mode 100644 index df4d5841580..00000000000 --- a/.changeset/18978-aggregate-surface-scope.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `spec-changes.json`'s aggregate export diff declares the release pair it really spans (#18978) - -Clause-②: yes (widening) — one new OPTIONAL key on a published artifact (`aggregate.surfaceScope`) -and one new optional field on `SpecChangesSchema`. Nothing is renamed, retired or reshaped: the -schema still ACCEPTS a record without it, every existing key keeps its spelling and meaning, and -`perMajor` and the `release` section are byte-identical. Contract-review tier. - -`aggregate.added` / `aggregate.removed` are not registry-derived. A release-time api-surface diff -fills them by comparing the artifact being published against the previously **published** one, so -they span **one release** — while the record they sit in is keyed by protocol major (`from: 10, -to: 17`) and every entry carries only `since: 17` / `removedIn: 17`, with -`perMajor[16 → 17].added` at `0` beside it. Nothing in the file distinguished one minor's slice -from the whole major-boundary delta. - -Measured on the published `@objectstack/spec@17.4.0` Release asset: `aggregate.added` = **225**, -`aggregate.removed` = **51**, every entry `since`/`removedIn` = 17 — and set-identical to a -recomputed `17.3.0 → 17.4.0` diff of the two tarballs' own `api-surface/` snapshots. It was the -minor's delta wearing a major's label. - -**What ships now.** A record whose export arrays are non-empty carries the version pair they were -diffed between: - -```bash -jq '.aggregate | {from, to, surfaceScope, added: (.added | length), removed: (.removed | length)}' \ - node_modules/@objectstack/spec/spec-changes.json -``` - -- `surfaceScope: { fromVersion, toVersion }` present ⇒ `added`/`removed` span exactly that - published-version pair. ⛔ They are **not** the `from` → `to` major delta, and never were. -- `surfaceScope` absent ⇒ the record carries no export diff at all and `added`/`removed` are - empty. ⛔ Read that as "this record does not say", never as "nothing was added between `from` - and `to`" — the same rule the `release` section already states for itself. -- `from` / `to` still answer the major-boundary question for `converted` / `migrated`, which are - registry-derived and unaffected. - -**Refused at the producer and at the publish gate, in both directions.** The generator reads the -previous version off the previous artifact's own `package.json`, omits the arrays loudly when it -cannot read one, and refuses outright to write a non-empty unlabelled array. -`scripts/check-release-spec-changes.mjs` — which until now checked the `release` section and not -the aggregate — recomputes the aggregate's claim from the two tarballs and refuses an absent, -mislabelled or untrue scope. Its self-test roster grows from 15 batteries to 23. - -**Nothing previously honest moved.** The committed registry-only projection and every `perMajor` -record carry no new key at all; the committed `spec-changes.json` changes on its `$comment` line -and nowhere else. The published schema is deliberately not narrowed — every manifest published so -far carries an unscoped diff and must keep parsing. diff --git a/.changeset/18983-connector-header-rate-limit-remedy.md b/.changeset/18983-connector-header-rate-limit-remedy.md deleted file mode 100644 index 3a929a8fd6d..00000000000 --- a/.changeset/18983-connector-header-rate-limit-remedy.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -docs(spec): the connector header no longer teaches `retryConfig` as the remedy for a rate-limited upstream (#18983) - -`packages/spec/src/integration/connector.zod.ts` ships inside this package — -`files[]` carries `src/**/*.zod.ts`, and the file is present in the published -tarball — so its header TSDoc is text consumers read, and the generated -reference page is rendered from it. That header ended its "no outbound rate -limiting" paragraph with "what L3 does declare for a rate-limited upstream is -`retryConfig` — whose `retryableStatusCodes` default `[408, 429, 500, 502, 503, -504]` includes `429` — and `health.circuitBreaker`", which reads as a remedy. - -It is not one. `packages/spec/liveness/connector.json` records all eight -`retryConfig` sub-keys and every `health.circuitBreaker` sub-key as `dead` -(verifiedAt 2026-09-17), and outside `packages/spec` nothing reads either: no -retry loop consumes the strategy, the backoff, the jitter or that status-code -list, so the `429` in it never causes a retry, and no breaker ever opens. An -author who followed that sentence wrote configuration that parses, stores, and -is then silently ignored. - -The sentence now carries the wording PR #18979 landed for the same claim in -`packages/spec/docs/SYNC_ARCHITECTURE.md`: both keys are **declared but -currently unimplemented**, with a pointer to the liveness ledger, and they are -explicitly neither retired — both are still declared and still parse, so an -author writing them sees no error — nor left to the host, since -`ConnectorProviderContext` carries exactly `name`, `label`, `description`, -`icon`, `type`, `providerConfig`, `auth` and `loadPackageFile`, and a provider -factory is therefore never handed either key. - -**Prose only — zero behaviour change.** No schema, declaration, default or -accept set moves, and the keys' fate stays ADR-0049's to rule on rather than -being prejudged here. The generated reference page -`content/docs/references/integration/connector.mdx` follows from `gen:docs`; it -is not published by any package in this workspace. diff --git a/.changeset/18990-viewall-only-permissions-seed.md b/.changeset/18990-viewall-only-permissions-seed.md deleted file mode 100644 index 727fbffa2a5..00000000000 --- a/.changeset/18990-viewall-only-permissions-seed.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-hono-server': patch ---- - -`/auth/me/permissions` now answers a wildcard-only `viewAllRecords` principal instead of staying silent about every object it can reach. - -`seedSuperUserRestrictedObjects` was guarded to `modifyAllRecords` super-users alone. A principal that reaches an object only through a wildcard `viewAllRecords` grant therefore got **no entry at all**: the client fell back to its default-allow path and rendered write and Export affordances the server answers `403 EXPORT_NOT_PERMITTED`. Same silence, same consequence, different principal class from the one framework#18931 closed. - -- **One predicate admits both classes.** The seed now asks the wildcard READ bypass — `viewAllRecords || modifyAllRecords` — which is the same question `foldWildcardSuperUser` already asks to decide whose `allowRead` it pulls true, and the same one `PermissionEvaluator` applies server-side. It is now a single module-local reading both call sites share, so the seed can never materialise an entry for a principal the fold leaves entirely false. -- **A plain wildcard grant carrying neither bypass bit is still not seeded.** That is what makes the admission the read bypass rather than "any wildcard": the fold pulls nothing true for it, so a seeded entry would be an all-false claim with no server behaviour behind it. -- **The seeded entry is the truth, not an overreach.** It starts `{allow*: false}`, the fold pulls `allowRead` true, and the write bits stay false. The seed only ever touches objects with **no explicit entry**, and on those a viewAll-only principal really can only read — so "explicit false" for edit is what is true about it, where the silence it replaces was not. -- **`apiOperations` is attached through the predicate already shared with the modify-all class** — an unrestricted object whose export stays allowed still gets no `apiOperations` (the entry itself is seeded since #20134), because for it the client's default-allow path is already right — except an object with `enable.apiEnabled: false`, annotated `[]` since #20135 because the REST door refuses every verb on it. - -⚠️ **This is a deliberate behaviour change on an existing published channel, ruled rather than inferred.** For a viewAll-only principal a client that reads "no entry" as default-allow now reads an explicit `allowEdit: false` instead. Two pins asserting the old silence (`toBeUndefined` for the viewAll-only principal, one of them added by the framework#18931 PR that pinned this boundary while saying the pin was not a ruling that the silence was correct) are inverted on purpose under that ruling. Payload growth is the same one-entry-per-object framework#18931 accepted, now also for viewAll principals. diff --git a/.changeset/18991-user-export-slot-is-a-real-optin-grant.md b/.changeset/18991-user-export-slot-is-a-real-optin-grant.md deleted file mode 100644 index 344b5241524..00000000000 --- a/.changeset/18991-user-export-slot-is-a-real-optin-grant.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -docs(data): `ResolveApiOptions.userExportAllowed` no longer documents itself as "always `true` this phase" — the user-level export bit is wired, and it is a real opt-in grant that can be `false` (#18991) - -`Clause-②: no` - -⛔ **No behaviour change.** `isLegacyDerivable`, `computeOperations` and `resolveEffectiveApiMethods` are byte-identical; the omitted-option default is still `true` (`opts?.userExportAllowed !== false`), and not one assertion in `api-derivation.test.ts` moved. What changes is two docblocks in `packages/spec/src/data/api-derivation.ts` that made a **false present-tense claim**. - -Both carriers said the same untrue thing, and they said it in a direction that invites reintroducing a defect: - -- `ResolveApiOptions.userExportAllowed` — "Always `true` this phase (there is no user-level export permission bit yet); wiring a real bit in is a zero-contract change". -- the `API_METHOD_DERIVATION` table docblock — "`export` is `list`, additionally gated by the user-level export slot (…, always `true` this phase — the real permission bit is a follow-up, wiring it changes no contract here)". - -The bit exists. `PermissionSetSchema.allowExport` (`src/security/permission.zod.ts`) declares the user-level export axis as an **opt-in grant** — `true` grants export, UNSET or `false` means no export — and the two statements cannot both be true. It is not an aspiration either: `plugin-security`'s `permission-evaluator` resolves `export` as `list ∧ userExportAllowed` and returns `false` from that branch, `plugin-hono-server`'s `/me/permissions` computes the bit and hands it to `resolveEffectiveApiMethods`, and this package's own suite has pinned the `false` arm all along (`export gated off when userExportAllowed=false`). - -An author who trusted the old text would read the parameter as inert and could legitimately simplify it away as dead weight — which is the same defect one level upstream of where it was last found, with no consumer left to notice. Both docblocks now state the axis as it is, name `PermissionSetSchema`'s `allowExport` as the authority on its semantics, and keep the one thing that *is* still true distinct from the one that is not: omitting the option resolves to `true` because a resolve carrying no permission context must not narrow the object's own exposure — that is what lets `apiExposureDenialReason` remain a pure function of `enable` — while a caller holding permission context passes the resolved bit explicitly. - -**Why this publishes rather than taking `skip-changeset`.** One entry of this package's `files[]` moves. `dist/` ships, and the emitted declaration reproduces both corrected docblocks: the packed `dist/data/index.d.ts` carries the `ResolveApiOptions.userExportAllowed` member text and the `API_METHOD_DERIVATION` table text verbatim. A consumer reading it reads different bytes after this change, so the corrected sentence is what reaches them. diff --git a/.changeset/18997-discovery-transactional-batch-honest.md b/.changeset/18997-discovery-transactional-batch-honest.md deleted file mode 100644 index 59d93fb528c..00000000000 --- a/.changeset/18997-discovery-transactional-batch-honest.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -`/discovery` advertises `capabilities.transactionalBatch` from the predicate the atomic-batch refusal already trusts, so the advertisement and the 501 stop disagreeing (#18997). - -`getDiscovery()` derived the bit from the ENGINE alone — `typeof this.engine?.transaction === 'function'` — while `runAtomicBatch` refuses `batchData({ atomic: true })` with `501 NOT_IMPLEMENTED` on `engineCanRollBack(engine)`, which asks the DEFAULT DRIVER as well. `engine.transaction` is a function on every real engine, so the advertisement answered `true` for compositions that then 501 — and the 501's own remedy text sends the caller to that very bit ("probe `capabilities.transactionalBatch` on /discovery first"). The prescribed remedy routed the caller to a signal that was wrong in exactly the case the remedy exists for. - -**What a consumer sees.** Two compositions, measured separately, stop advertising `true` and now advertise `false`: - -- **(a) a default driver with no `beginTransaction` at all** — pre-existing, not introduced by #18063; -- **(b) a default driver that inherits `beginTransaction` and declares `supports.transactionsUnsupported`** — the population #18063 added; the shipped example is `TursoDriver` on its remote transport. - -Both already answered `501 NOT_IMPLEMENTED` to an atomic batch, so nothing that was accepted becomes refused. A client that read `true` and proceeded was taking the 501; it now reads `false` and takes its non-atomic fallback ahead of the failure — which is what probing the capability was for. A client that hard-asserts `transactionalBatch === true` at startup against such a composition fails at startup instead of at the first atomic batch. - -Unchanged in the other direction, and pinned so that "honest" cannot decay into "always `false`": a composition whose default driver **can** roll back still advertises `true`, and so does a host whose driver registry is not inspectable (test doubles, metadata-only hosts), where the engine-level probe is all there is. Measured over all 16 compositions of the four inputs the two predicates read: 0 go `false` → `true`, 3 go `true` → `false`. diff --git a/.changeset/19046-object-grid-page-size-accept-set.md b/.changeset/19046-object-grid-page-size-accept-set.md deleted file mode 100644 index 22f14f5f3fe..00000000000 --- a/.changeset/19046-object-grid-page-size-accept-set.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING for authored metadata** — the `object-grid` page-component door now refuses a page size of `0`, a negative page size and a non-integer page size, at all three of its spellings: `pagination.pageSize`, every `pagination.pageSizeOptions[]` entry, and the flat `pageSize` shorthand (#19046). - -Clause-②: yes (narrowing) - -The accept set shrinks to the one the VIEW arm has ruled all along. `PaginationConfigSchema` (`view.zod.ts`) declares `pageSize: z.number().int().positive()` and pins its refusals by name; `MetadataQuery` and the two marketplace request schemas say `z.number().int().min(1)`, each with its own throwing pin. The `object-grid` door said `pagination: z.unknown()` and `pageSize: z.number()` — the only page-size declaration in the package that accepted `0`, and the one renderers read. - -**It was not theoretical.** Measured at objectui#9853: an authored `pagination.pageSize: 0` reached `ObjectGrid`, went out on the wire as `$top: 0` and rendered ZERO ROWS, with no grouping needed to trigger it — through this arm, with a `success: true` receipt from this schema. The view arm would have refused the same value. objectui#9896 repaired the consumer half (a resolver at every read point, fail-soft, one loud diagnostic); this is the declaration half and is not a prerequisite for it. - -``` -✗ pagination.pageSize: Too small: expected number to be greater than 0 -✗ pageSize: Invalid input: expected int, received number -``` - -### Migration — FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `pagination: { pageSize: 0 }` | `showPagination: false` and no `pagination` bag — the bag's PRESENCE is what enables paging, so `pageSize: 0` never meant "no paging" | -| `pagination: { pageSize: 0 }` (meaning "all rows on one page") | the page size you actually want (`{ pageSize: 100 }`); `0` reached the wire as `$top: 0` and returned nothing | -| `pagination: { pageSizeOptions: [0, 25, 50] }` | `{ pageSizeOptions: [25, 50] }` — drop the `0` entry; selecting it set the fetch window to zero rows | -| `pageSize: 25.5` | `pageSize: 25` — a fractional page size was truncated or forwarded verbatim, depending on the read point | - -The one-line fix is always the same: **write a positive integer, or delete the key and take the renderer's default.** - - - -**⛔ What this deliberately does NOT narrow: the `pagination` bag stays OPEN.** The card's defect is that the two arms disagreed about a page SIZE — not that the bag should become a closed shape. `pagination` is now a `z.looseObject` that validates the two members whose value is a page size and passes every other key through unvalidated, so a sibling key that parsed before still parses and still survives the parse byte-identically (pinned in `component-object-grid-pagination-accept-set.pin.test.ts` §3). Reusing the view arm's `PaginationConfigSchema` here would have refused every sibling key this door has accepted since it was written — the `…` in its own describe says authors write them — which is a wider narrowing than the measured defect and a different decision. `PaginationConfigSchema` itself is unchanged and stays closed; §4 of that pin states both the agreement and the deliberate asymmetry. - -**One second axis, named rather than left to be discovered.** `pagination` moves from `z.unknown()` to an object type, so a non-object value (`pagination: true`) is refused where it used to parse. Measured before narrowing: zero non-object `pagination` values exist on an `object-grid` node in either repository's corpus, the objectui registry has published this input as `type: 'object'` all along (`plugin-grid/src/index.tsx`), so the html tier already answered `type-mismatch` on one, and the renderer reads the key for PRESENCE (`schema.pagination !== undefined`) — which means an authored `pagination: false` used to turn paging ON. That value now gets a located refusal instead of the opposite of what it says. diff --git a/.changeset/19049-nav-item-label-optional-inherited.md b/.changeset/19049-nav-item-label-optional-inherited.md deleted file mode 100644 index 8ce88e53919..00000000000 --- a/.changeset/19049-nav-item-label-optional-inherited.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -spec(ui): a navigation entry may omit `label` — it then inherits its target's CURRENT label at render time (#19049) - -Clause-②: yes (widening) - -`BaseNavItemSchema.label` is `.optional()`. An `app.navigation` entry written without a `label` now parses, and the semantic it parses into is declared on the key itself: **absent means the entry inherits, at render time, the current label of whatever it opens** — the view's label when it names a view and that view is labelled, else the object's / dashboard's label. A label the author *did* write renders verbatim and is never overwritten. - -This executes the maintainer's cloud#2021 ruling (「2021 可以接受有些修改刷新才生效」) as letter **A** on objectui#9868: sync by render-time inheritance, no stored state. The spec moves first because the console reads its navigation contract from here — until now an unnamed entry was not *representable*, so the promise "an unnamed entry shows its target's name" had nowhere to be declared. - -- **Accept-set widening only, on eight branches at once.** `BaseNavItemSchema` is spread (`...BaseNavItemSchema.shape`) into the `object`, `dashboard`, `page`, `url`, `report`, `action`, `component` and `group` nav-item declarations, so the one-line relaxation reaches all eight. The ninth branch, `separator`, spreads nothing and has never carried a `label`. Nothing that parsed before stops parsing: a present `label` is accepted exactly as before, and every other key on the item is untouched. -- **Nothing is stored for the absent case.** There is no new member and no `inherited` flag — the parse adds no key the author did not write. That is the whole point of resolving at render: a target renamed after the entry was authored shows its new name on the next render, where a label materialised at authoring time would be a stale snapshot. Consumers must resolve an absent `label` at render, not at ingest. -- **The rule this relaxes still holds.** *Every real destination must have identity and text* — identity is the target, text is inherited at render. That sentence is recorded in the key's `describe`, so it ships to the reference page and to any tool reading the JSON Schema. -- **The three sibling `label` declarations in this file are unchanged and still required**: `NavigationArea.label`, `AppContextSelector.label` and `App.label`. Each names a container the author is creating rather than a target it could inherit from, so there is nothing for an absent label to resolve against. The ruling covers navigation entries only. - -Downstream, in order: objectui#9868 relaxes its own `packages/types` validator to match, resolves the absent label in the nav renderer, and stops writing `label || pageName` for an unnamed entry; then cloud#2021 stops materialising an inherited label in `apply_blueprint`. diff --git a/.changeset/19054-retire-tenancy-organization-field.md b/.changeset/19054-retire-tenancy-organization-field.md deleted file mode 100644 index 16c36a1f018..00000000000 --- a/.changeset/19054-retire-tenancy-organization-field.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-core': minor -'@objectstack/platform-objects': patch ---- - -**BREAKING** — retire `object.tenancy.organizationField`, the stamp-only column -declaration the whole protocol declared exactly once, on a table this platform ships. - -The key answered "which column says who this platform row is ABOUT", where -`tenancy.tenantField` answers "what is this object WALLED by". The spec's own docblock -stated the consequence: *"For ordinary objects the two coincide and `organizationField` -is never needed."* Measured on `main` before this change, the entire repository declared -it **once** — `packages/platform-objects/src/identity/sys-api-key.object.ts`, the -better-auth credential table — and zero business objects declared it anywhere. Its -readers were three platform-row writers, scope-pinned **by name** (audit stamping, the -approval-row writer, the automation-run recorder), so an application declaration was -inert by construction while still being authorable on every object, which made every -future piece of organization logic owe the question "what if somebody set this?". -ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18, verbatim and untranslated: -「organizationField 撤出可授权面 同意你的建议」. - -## FROM → TO - -| you wrote (17.4 and earlier) | write instead | -| --- | --- | -| `tenancy: { enabled: false, organizationField: 'active_organization_id' }` | `tenancy: { enabled: false }` — delete the key. Nothing read it on an application object | -| `tenancy: { enabled: true, organizationField: 'about_org_id' }` on an object whose tenant column really is `about_org_id` | `tenancy: { enabled: true, tenantField: 'about_org_id' }` — the surviving key both walls the object and stamps its platform rows | -| you declared it to make one platform table's rows stamp differently | nothing to write. That divergence is a platform fact now, not a knob | - -The `tenancy` block is `.strict()`, so the key is **refused** with its prescription -rather than stripped, and `os migrate meta --from 17` lists the mechanical edits for -existing sources. - -## What does NOT change - -The `sys_api_key` divergence is intact, and that is the point of the shape this takes. -The credential table is `managedBy: 'better-auth'`, so `resolveInjectedSystemColumns` -bails before tenancy is consulted and no `organization_id` is ever injected; the column -it really carries is better-auth's `active_organization_id`. Its audit, approval and -automation-run rows still stamp that column. What moved is only where the fact is -written: `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, one row, -keyed by object name and read by the STAMP face alone. The WALL face -(`resolveRecordWallOrganizationField`) never read the key and is untouched, so the -stamp/wall divergence pin stands unchanged. - -⛔ The column is **not** renamed to `organization_id` and must never be: in this platform -"has an `organization_id` column" IS the wall, so the rename would wall the credential -table on an equality that excludes NULL and every pre-existing key would vanish from its -own owner's key list. - -## For `@objectstack/metadata-core` consumers - -`resolveRecordOrganizationField` and `createRecordOrganizationResolver` keep their -signatures and their four-limb precedence. Limb 0 is now keyed by the object's -registered NAME against the platform table instead of by a declaration on the definition: -the engine-bound resolver passes the name it was asked about, and the two-argument -function reads `objectDef.name` when the definition carries one. A caller that fed it a -hand-built definition carrying `tenancy.organizationField` — only reachable by -reimplementing a platform writer — now gets limbs 1 to 4. - -The retirement kit, in the shape the playbook prescribes: - -- the key is DELETED from `TenancyConfigSchema` (the block is a `strictObject`), and a - `TENANCY_RETIRED_KEY_GUIDANCE` row carries the prescription beside the two v15.0 - precedents (`tenancy.strategy`, `tenancy.crossTenantAccess`) -- D2 conversion `object-tenancy-organization-field-removed` (`toMajor: 18`, - `retiredFromLoadPath: true`) strips the key from authored sources and stored - `sys_metadata` rows; D3 wires it into the protocol-18 chain step, and - `RETIRED_KEYS_BY_MAJOR[18]` declares `data/TenancyConfig:organizationField` -- the `authorable-surface/data.json` row is deleted in this same commit — the strict - route's tripwire — with the build computing the guidance-route proof for itself -- the liveness ledger row is deleted, since the key leaves the walked shape entirely -- pin tests: the authored shape is refused with its prescription, and the `sys_api_key` - stamp is pinned end to end beside the closed-set control (the same shape under any - other object name takes the ordinary limbs) - -Clause-②: no - - diff --git a/.changeset/19056-migration-support-floor-16.md b/.changeset/19056-migration-support-floor-16.md deleted file mode 100644 index b9e0ad7e13f..00000000000 --- a/.changeset/19056-migration-support-floor-16.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/cli": patch ---- - -chore(spec)!: the metadata migration chain is supported from protocol 16 — `MIGRATION_SUPPORT_FLOOR` 10 → 16, and `step11`–`step16` retire with it (#19056) - -Clause-②: yes (narrowing) - - - -**BREAKING** for a consumer still authored against protocol **10, 11, 12, 13, 14 -or 15**. Landing in the launch window as `minor` under the lockstep convention. - -Maintainer ruling, 2026-09-18, verbatim and untranslated: - -> 升级只需要支持从 16.0版本开始。 - -「16.0」reads as protocol major 16 — the same unit as the constant -(`PROTOCOL_VERSION` is `17.0.0`, so the package version `17.x` and the protocol -major are not the same number). That reading was put back to the maintainer and -was not contradicted. - -## What changes for you - -`MIGRATION_SUPPORT_FLOOR` — a published export of `@objectstack/spec` — moves -from `10` to `16`. Two consequences, both at the boundary: - -| you call | before | after | -| --- | --- | --- | -| `applyMetaMigrations(stack, N)` for N ∈ 10..15 | replays the chain from N | throws `MigrationFloorError` | -| `os migrate meta --from N` for N ∈ 10..15 | migrates | refuses, naming the floor | -| `applyMetaMigrations(stack, N)` for N ≥ 16 | unchanged | unchanged | -| `MIGRATION_SUPPORT_FLOOR` as a TS literal type | `10` | `16` | - -The fix, and the only one there is: **reach protocol 16 by another path first, -then re-run.** The refusal says so itself — `Cannot migrate from protocol N: the -chain's support floor is 16 (ADR-0087 D3). Upgrade to protocol 16 by another -path first, then re-run.` A stack already at 16 or above is unaffected, and the -16 → 17 and 17 → 18 hops are untouched. - -If you pin `MIGRATION_SUPPORT_FLOOR`'s literal type (`const f: 10 = …`), that -annotation stops compiling. The value was always a release-policy knob, so read -the constant rather than restating it. - -## What this is NOT - -It is **not** a slimming change, and the measurement is the reason to say so. -Counted on `src/migrations/registry.ts` at `e6a03e649` (17,718 lines): - -| block | lines | share | -| --- | ---: | ---: | -| `step11`–`step16` — what leaves | 328 | 1.9% | -| `step17` | 4,699 | 26.5% | -| `step18` | 7,565 | 42.7% | -| the registration map + the two retirement tables | 5,077 | 28.7% | -| file header | 49 | 0.3% | - -Everything but the first row stays. What the raise buys is a **narrower support -promise**: six permanently-replayable chains no longer have to be maintained, -and the CI replay shrinks to the range the project actually promises — 10 of the -98 conversion fixtures leave the chain-replay gate, because the chain no longer -reaches the major that graduated them. - -## What was deliberately NOT removed - -`RETIRED_KEYS_BY_MAJOR` and `RETIRED_DEFS_BY_MAJOR` live in the same file and -are keyed by protocol major, which makes them look like chain state. They are -not, and both are kept whole: - -- the chain never reads either table (`chain.ts` imports the steps and the floor - and nothing else); -- their one non-test reader, `packages/spec/scripts/build-schemas.ts` - (`check:authorable-surface`), folds every major into one set and never - mentions `MIGRATION_SUPPORT_FLOOR`. - -So a row below the floor is still the live proof that its retirement was -declared. Measured by ablation: a row planted under major **11** — a major whose -step this change deletes — was still read and judged, reported as *"(registered -at major 11)"*. Both facts are pinned in -`src/migrations/retired-tables-not-floor-scoped.test.ts` so the next floor move -reads them first. Dropping such a row errors nowhere at the moment it is -dropped; the declared retirement simply stops being declared. - -The D2 conversion registry is untouched for the same reason: every rehydration -seam replays the **full** conversion chain over stored `sys_metadata` rows, -retired entries included, so the protocol-11/13/14/15 conversions keep -converting rows at rest long after the source-side chain stops reaching them. - -## `@objectstack/cli` - -`os migrate meta --help` advertised `--from 10`, `--from 10 --step`, -`--from 11 --to 12` and `--from 10 --out …`. Every one of those refuses after -this change. The examples are now derived from `MIGRATION_SUPPORT_FLOOR`, so the -next floor move cannot leave them advertising commands that throw. diff --git a/.changeset/19057-onnavigate-mode-union.md b/.changeset/19057-onnavigate-mode-union.md deleted file mode 100644 index b1652bf7dbd..00000000000 --- a/.changeset/19057-onnavigate-mode-union.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -React-tier ``: the `onNavigate` declaration becomes -`(recordId, action: 'view' | 'new_window') => void` — a declared value **no branch ever -emitted** is removed, and the value **two reference call sites do emit** is added. - -`REACT_BLOCKS`' ListView overlay declared the second argument as `'view' | 'edit'`. That -sentence was false in both directions. `'edit'` is emitted by no call site in the -reference implementation and read by no branch; `'new_window'` — what a Cmd/Ctrl- or -middle-click, and an authored `navigation: { mode: 'new_window' }`, actually send — was -not declared at all. An author reading this contract wrote a handler with one dead arm -and one missing arm. - -The second argument is a navigation-MODE token with a **closed vocabulary**, and the -declaration now says so. That closedness is not new: the protocol's own retirement note -for `view.list.navigation.view` (removed in 17.5.0, ADR-0049) records that anything -outside the mode vocabulary "matched no branch". What this change corrects is the -membership of the vocabulary, not its closedness. - -## FROM → TO - -| you wrote | write instead | -| --- | --- | -| `onNavigate={(id, action) => { if (action === 'edit') … }}` | delete that arm — nothing ever called it | -| a handler with no `'new_window'` arm | handle `'new_window'`: open the record in a new browser tab. Omitting the arm leaves the modifier-click path doing nothing | -| `onNavigate={(id) => …}` (one argument) | unchanged — the arity is untouched | - -**The one-line fix:** replace the `'edit'` arm with a `'new_window'` arm. - -Scope: this moves a **declaration**, not a type or a runtime check. `REACT_BLOCKS` types -this prop as a documentation string (`ReactBlockDef[]`), so no `.d.ts` signature moves -and nothing that compiles today stops compiling. The behaviour it describes is the -reference implementation's, which already emits exactly these two values; the sibling's -four declaration faces are corrected under objectui#9547 and its bump to -`@objectstack/spec` >= 17.5.0. - -Clause-②: yes diff --git a/.changeset/19061-error-code-ledger-docblock-label.md b/.changeset/19061-error-code-ledger-docblock-label.md deleted file mode 100644 index 40141dada57..00000000000 --- a/.changeset/19061-error-code-ledger-docblock-label.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The error-code ledger's file docblock (`src/api/error-code-ledger.zod.ts`, shipped in the `.d.ts` declaration bundle and in the package's `src/**/*.zod.ts`) no longer names a retired PM-process label. The sentence now reads: registering a code widens the published contract face and is therefore a Clause-② change, door or no door; a code present in `dist` and absent from the ledger is a protocol gap, not a tier question. Documentation only — no code, type, value or export moves, and the generated reference page follows the source (#19061). diff --git a/.changeset/19064-translation-target-object-artifact-reach.md b/.changeset/19064-translation-target-object-artifact-reach.md deleted file mode 100644 index b429bca1135..00000000000 --- a/.changeset/19064-translation-target-object-artifact-reach.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`translation-target-unknown` no longer reports the locale keys a package ships for an object a SIBLING package of the same artifact declares — the OBJECT rung's universe read `stack.objects` alone, while this rule's own docblock already declared the wider `artifactProvidedObjectNames` reach (#19064). - -`os build` runs the rule table per PACKAGE as well as over the union (`compile.ts` step 3b-ii): each package body is judged as its own stack with the artifact's `packages[]` beside it as resolution context (`packageBodyAsStack`, #16611). On that leg `stack.objects` holds ONE package's objects, so a bundle key naming a sibling's object resolved against nothing. Measured on the probe stack (`examples/app-multi-package`'s shape — `core` owns `crm_account`, `orders` reads it and here also translates it), the key produced one `error` at `translations[0]["zh-CN"].objects.crm_account`: - -> Translations are keyed to "crm_account", which no object in this stack defines. The resolver looks up keys derived from the metadata, so this whole subtree is dead weight — every label it carries renders untranslated. -> -> *Rename the key to the object it was written for, drop it, or ignore this if the object is contributed by another installed package. Defined objects: crm_order.* - -That is the remedy that deletes a translation the runtime resolves, at `error`, so the run FAILED on it — and it is byte-identical, but for the name, to the finding a genuine typo produces. ADR-0130 makes the release artifact the co-ownership boundary, so the miss is the RUN's blind spot and not the author's mistake. - -**The precedent is followed, not re-decided.** `validateObjectReferences` closed this exact shape on this exact carrier for object NAMES (#16611 — `artifactProvidedObjectNames` folded into its `resolvable` set). What differs here is the RETURN, and two pins hold it: this rule's universe is keyed by FACTS, not names, so the sibling's fields, options, views, sections and rules are folded WITH the name through the same collector the declaration loop uses. A name-only fold would resolve the object key and then judge the owner's own field keys against an empty fact set — the same false positive one level up — and a wholesale subtree skip (rung 2b's answer, for a target whose declaration is genuinely invisible) would leave the per-package leg unable to see a typo the union leg reports. - -**The control, which is what makes this a narrowing and not a hole.** Widening a universe trades a false positive for a blind spot unless every genuine orphan still reports, so both directions are pinned side by side: the same package judged ALONE still errors (the context is what does the work); a name no entry of the artifact declares is still an `error` with its rule id, and the remedy now enumerates what the artifact provides; a field the sibling does not declare is still an `error` under the now-resolved object; one bundle carrying both a sibling key and a typo reports exactly the typo; an entry with no readable body (a segment reference) makes nothing addressable; and the single-`defineStack` shape is untouched, because `objects` is a stack collection with no `stack.manifest` form to read. - -No schema moved, no export moved, and no accept set moved: this is a lint rule's false-positive set narrowing. `Clause-②: no` diff --git a/.changeset/19065-odata-programmatic-example-checked.md b/.changeset/19065-odata-programmatic-example-checked.md deleted file mode 100644 index c307829d939..00000000000 --- a/.changeset/19065-odata-programmatic-example-checked.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`packages/spec/src/api/odata.zod.ts` — the module docblock's `@example Programmatic Use` block is now type-checked by `check:skill-examples`: it carries an `os:check` marker and the `ODataQuery` import it needs to compile standalone (#19065). - -Clause-②: no - -That block ships twice — inside the tarball as `src/api/odata.zod.ts` (this package's `files[]` lists `src/**/*.zod.ts`; measured with `npm pack --dry-run`) and on the generated reference page `content/docs/references/api/odata.mdx` — and nothing compiled it. An example whose keys contradict the schema declared in the same file could therefore stay green indefinitely, which is the defect #19028 found and #19058 corrected in text only. - -- **The reference page moves by exactly one line.** The marker is machinery and `build-docs.ts` drops it before rendering, so the only visible change on the page is the `import type { ODataQuery } from '@objectstack/spec/api';` line the block needs in order to stand alone. -- **No schema, no accept set and no behaviour moves.** `ODataQuerySchema` is byte-identical. Whether it should refuse undeclared keys instead of stripping them is a separate question about a published accept set and is deliberately untouched here. -- **The sibling `@example OData Query` block stays unmarked, and that is a measurement rather than an omission.** It is an HTTP request under a bare fence, not TypeScript, and the gate recognises only ts/tsx/typescript fences — a marker above it is reported as an orphan, so it would fail the gate rather than check the block. diff --git a/.changeset/19071-between-blank-endpoint-runtime-door.md b/.changeset/19071-between-blank-endpoint-runtime-door.md deleted file mode 100644 index 49b1ce7f3f1..00000000000 --- a/.changeset/19071-between-blank-endpoint-runtime-door.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING for callers** — `parseFilterAST` now refuses a blank `$between` endpoint, exactly as the authoring schema already does. An empty-string or absent (`undefined`) bound, at either side, is refused with `INVALID_FILTER` / 400, and the refusal names the blank side — MIN or MAX, plus the index (#19071). - -Clause-②: no - -## What changed, and why it is the implementation catching up rather than a new rule - -`RANGE_ENDPOINT_DESCRIPTION` — the published endpoint contract shared by both of `$between`'s bounds — has stated since 2026-09-17 that "BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side". That rule shipped at the authoring schema only. The runtime door disagreed with it: `parseFilterAST({ at: { $between: ['', ''] } })` returned the filter unchanged, same object reference, measured on `origin/main` before this change and re-measured after. - -One published sentence therefore had two truth values, decided by which door a caller came through — and the door that passed it is the one that matters most here. A caller that lowers a filter with `parseFilterAST` and hands it straight to a driver (an embedder; this repo's own driver conformance suites) never meets the schema. At every backend a blank bound stops bounding on that side while the range still reads as a complete two-element range, so the query runs with one meaningless boundary and returns rows outside the window its filter names, with no signal at any layer. - -``` -FROM parseFilterAST({ at: { $between: ['2026-01-01', ''] } }) - -> { at: { $between: ['2026-01-01', ''] } } // unchanged, same reference, - // straight on to the driver - -TO parseFilterAST({ at: { $between: ['2026-01-01', ''] } }) - -> throws INVALID_FILTER / 400: - 'Operator "$between" on field "at" requires two non-blank bounds. - Received an empty string at where.at.$between[1] (the MAX bound). …' -``` - -## Migration — FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `{ $between: ['2026-01-01', ''] }` | `{ $between: ['2026-01-01', '2026-12-31'] }` — the bound you meant, written out | -| `{ $between: ['', 100] }` | `{ $between: [0, 100] }` — the lower bound you meant | -| a range that was only ever bounded on ONE side | `{ "$gte": min }` or `{ "$lte": max }` — a one-sided bound is not a range | - -**The one-line fix: write the bound that is missing, or — if only one side was ever meant — drop `$between` and write that side as a scalar comparison.** The same prescription the authoring door already gives, now given at the door an embedder actually reaches. - -## What does NOT change - -- **`null` bounds** keep their own message, refused since 2026-08-31. It prescribes the null PREDICATE, because an author who wrote `null` was reaching for absence and an author who left a bound empty was reaching for a bound — two blank spellings, two intents, two remedies. `null` is also checked first, so a pair that is blank on one side and null on the other keeps the message it has always had. -- **Whitespace-only endpoints** are still accepted, at BOTH doors. The 2026-09-17 ruling is the empty string; the authoring door pins `{ $between: [' ', 'M'] }` as parsing green on purpose, and trimming here would re-open the very split this change closes, in the opposite direction. -- **Falsiness.** `{ $between: [0, 0] }` and `{ $between: ['0', '9'] }` lower exactly as before. The rule is blankness, not falsiness. -- **`$in` / `$nin` members.** A falsy or empty-string MEMBER is a value, not an absence (2026-08-31, `filter-comparand-shape.test.ts`). Only a range ENDPOINT is judged here, and only the `$between` row of that pin moves. -- **Arity**, which was already refused with its own message, and every legal range: numbers, Dates, ISO days, UTC instants, clock times and non-temporal text all lower byte-identically, same object reference. -- **The published export surface.** No export is added, removed or renamed; the refusal rides the existing `$between` arm of the shared comparand-shape door, so the engine's delegating wrapper inherits it unchanged. - - diff --git a/.changeset/19081-reference-carrier-c2-readers.md b/.changeset/19081-reference-carrier-c2-readers.md deleted file mode 100644 index d95ccbb8d7d..00000000000 --- a/.changeset/19081-reference-carrier-c2-readers.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch -"@objectstack/service-analytics": patch -"@objectstack/cli": patch ---- - -Four readers of `FieldSchema.reference` gated the carrier with a truthiness test and then **propagated** it. `FieldSchema.reference` is declared an optional **string**, so the answer a reader owes for a carrier it cannot read is absence — and one of these four did worse than lose the information, it invented a name for it: - -``` -out.push({ key, reference: String(f.reference) }) // -> reference: '[object Object]' -``` - -Each site now reads the carrier through the one arbiter, `referenceCarrierOf`, and catches its refusal **at the site** — so the reader answers absence and reports, instead of aborting. That is the deliberate difference from `@objectstack/objectql`'s cascade seams, which let the same refusal propagate: those assert something positive about the schema on a write path, while these four are best-effort display and diagnostic readers whose own failure handling would have turned one unreadable field into a much wider loss. - -- **`@objectstack/plugin-approvals`** — `resolveLookupFields`. The stringified carrier was handed on as an object name to `engine.find()`, where it could never resolve and the failure was swallowed by the caller's `catch`. The field is now left out of the inbox display enrichment and logged; readable targets are unaffected. It is dropped rather than carried with an absent target because the sole consumer uses `reference` as the object name and has nothing to do with an entry carrying none. -- **`@objectstack/service-analytics`** — the ADR-0021 relationship → target-object resolver. An unreadable carrier became the joined table for a dataset's `include`; the resolver now answers `undefined`, which its existing fallback turns into the compiler's own refusal, plus one warning naming the field. -- **`@objectstack/cli`** — `os doctor`'s circular-dependency and unused-object checks, which put the carrier into a graph node and a name set. Both now report the unreadable carrier as a finding rather than skipping it, because "no circular references detected" and "defined but not referenced" are positive claims that an edge nobody could read cannot support. The same file's `collectViewObjectRefs` already narrowed its carrier this way. - -`null`, `undefined` and `''` are absence, not a wrong shape, and still pass silently at every one of these sites — a field is allowed to name no target. Each site's absence answer and its readable-target answer are pinned alongside the refusal. - -Upgrading: nothing conformant changes. A non-string `reference` is refused by `ObjectSchema.safeParse`, so a value in that shape only ever reaches these readers without having passed parse at all. diff --git a/.changeset/19082-summary-index-unreadable-carrier-loud-skip.md b/.changeset/19082-summary-index-unreadable-carrier-loud-skip.md deleted file mode 100644 index 25e70116b0f..00000000000 --- a/.changeset/19082-summary-index-unreadable-carrier-loud-skip.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -`buildSummaryIndex` no longer drops a declared `summary` field silently when the roll-up's `reference` carrier cannot be read — the skip now reports itself at `error`, naming the field, the consequence and the fix (#19082). - -The child→parent foreign key is resolved by scanning the child object's `master_detail` / `lookup` fields for one whose `reference` names the parent. That comparison read the carrier raw (`cd.reference === parent.name`), so a carrier **no reader can read** — a non-string, where `FieldSchema.reference` declares an optional string — compared `false` against every name, `fkField` stayed unset, and - -```ts -if (!fkField) continue; // can't resolve the relationship — skip -``` - -removed the roll-up from **both** summary indexes. `recomputeSummaries()` then had nothing to do after every insert / update / delete of the child, so the parent's stored summary value kept whatever it held while each of those writes reported success, and nothing anywhere said so. It is the second way this one function invents *"nothing to recompute"*; the first, its registry read, was closed as #9154. - -- **⛔ The resolution rule is deliberately unchanged.** Loosening the comparison would trade a silent stall for a **mis-matched foreign key**, which is more expensive: a roll-up quietly aggregating the wrong children reads exactly like a correct one. PR #18503 recorded this site in its C2 list and the #18550 round left it there on purpose; that boundary still stands. What ends is only the silence. -- **The carrier is read through the one arbiter**, `referenceCarrierOf` — the same accessor #19080 routed the two delete-cascade seams through. Its refusal is **caught** here rather than propagated, because this is a *scan* looking for the foreign key across every relation field: a propagating refusal on one unreadable field would hide a readable sibling that really is the FK, turning a roll-up that works today into a hard failure of every write to that child. -- **`error`, not `warn`**, and said once per index build rather than once per write. A persisted summary that silently stops tracking its children while every write keeps reporting success is the durability class, and the line it prints carries both halves an operator needs: what is not being maintained and will not recompute, and the two ways to fix it — spell the carrier as the target object's name, or name the FK explicitly with `summaryOperations.relationshipField`. -- **Absence is untouched.** `undefined`, `null` and `''` mean "this field names no target", which is a legal thing to declare; they skip silently exactly as before. Every readable carrier resolves exactly as before. - -No schema changed, no key was added or removed, and nothing that resolved before resolves differently now. `engine-summary-index-unreadable-carrier.test.ts` pins both directions — the unreadable carrier reporting its skip, and a normal `reference` still resolving `fkField` — because without the second one, a change that simply stopped resolving anything would look identical to a fix. diff --git a/.changeset/19085-metadata-form-declared-rows.md b/.changeset/19085-metadata-form-declared-rows.md deleted file mode 100644 index ca7f9633308..00000000000 --- a/.changeset/19085-metadata-form-declared-rows.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/platform-objects": patch ---- - -`field.relatedListFilter` and `object.validations` are authorable in the metadata form. Both keys were **declared** by the served schema and offered by **no** form in `METADATA_FORM_REGISTRY`, so the generic metadata form never rendered a row for either and an author's only door was the Source tab — free-text JSON, where a mis-spelled sibling key is written, stored, and refused by the runtime later. - -Measured on the tree before the change: zero rows for either key across every `*.form.ts` in `packages/spec/src`, with a lit control (`maskingRule`, offered twice) and a dark control (a name no form carries) in the same read — so the zero is a reading, not a dead probe. - -**The face each row gets is a measurement, not a preference.** Both keys serve as JSON-Schema **pointer rows**, which is the shape a generic renderer cannot be assumed to resolve: - -- **`field.relatedListFilter` → `widget: 'filter-condition'`.** The served node is `{ $ref: '#/$defs/…' }` onto the recursive Query-DSL `FilterCondition`, whose derivation is `allOf: [open record, { $and/$or/$not }]` with **no top-level `type`** — there is nothing for the generic renderer to derive a control from. `filter-condition` names the FilterCondition wire, and this file already uses it one section down for `summaryOperations.filter`, the sibling `FilterConditionSchema` key. What the hint renders as **today**, measured at the pinned `.objectui-sha`, is the announced **raw-JSON editor carrying the hint** — not a criteria builder: the renderer that consumes this registry is the metadata-admin `SchemaForm`, whose own `WIDGETS` map registers no `filter-condition` (the `FilterConditionField` of that name lives in `@object-ui/fields`, on the ComponentRegistry path `ObjectForm` uses), and with the pointer unresolved neither structural fallback applies, so `resolveFieldFace` lands on `{ kind: 'raw-json', hint }` — the same face `summaryOperations.filter` gets. That editor hands `JSON.parse` output through verbatim and the save door judges it, so the wire is exact either way; the hint is the forward-looking half. ⛔ Deliberately **not** `filter-builder`: that widget consumes a rule **ARRAY** (what `view.filter`, `dataset.filter` and `page.filterBy` store), so routing this key there would write metadata the runtime refuses — the authoring trap this row exists to close, re-created one layer up. `visibleWhen` mirrors the key's own contract text (`lookup` / `master_detail`), a meaningfulness gate rather than a parse gate: `FieldSchema` accepts the key on every type, but the related-list derivation only ever reads it on the child-side FK. -- **`object.validations` → `widget: 'json'`.** The served node is an array whose items are a **double-hop** pointer (`items.$ref` → `$defs/__schema1` → `$defs/__schema2`) landing on a `oneOf` over the six `ValidationRule` members. A repeater would have to resolve both hops **and** pick a union branch before it could render a row; neither half is measured for this node, and a repeater that resolves neither renders an empty row whose values never land — the offer-vs-door defect the reconciliation gate beside it exists to catch. The Zod parse still refuses a malformed rule loudly at publish. Precisely: `json` is in that renderer's passthrough set, but the set is consulted **after** the structural fallbacks, not instead of them — so this row reaches the raw-JSON editor because the unresolved double-hop pointer derives nothing, not because the hint suppresses derivation. Once the pin moves past objectui's pointer resolution the same hint derives an `object-rows` repeater over the first `oneOf` branch; that is the renderer's precedence, not this repo's contract. Same treatment as the sibling structured-array rows `permission.rowLevelSecurity` and `email_template.variables`. Upgrading it to a structured control is a form-face addition, ⛔ not a reconciliation. - -A new pin (`metadata-form-declared-rows.pin.test.ts`) keeps both rows and both faces, and adds a registry-wide assertion — every row of every form, at every depth — that **no** form routes a `FilterCondition`-typed key to the rule-array builder, with a lit control proving the walk reaches both keys before it reports an empty misrouted set. - -⛔ **No wire byte moves and no export changes.** `check:api-surface` is green with no regeneration: `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so the row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles (44 additive lines; the new `en` entries are source text, the translated locales still need translating). diff --git a/.changeset/19088-form-field-scale-renderer-ceiling.md b/.changeset/19088-form-field-scale-renderer-ceiling.md deleted file mode 100644 index 8d07ffabc28..00000000000 --- a/.changeset/19088-form-field-scale-renderer-ceiling.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec)!: the form-row `scale` is bounded at the renderer ceiling of 100 (#19088) - -Clause-②: no (narrowing) - -`FormFieldBaseSchema.scale` — the per-field override a form row carries — was declared as -any non-negative integer with no upper bound. It is the third declaration of `scale` to -reach the same platform ceiling #18972 bounded on the two in `data/field.zod.ts`: every -renderer that turns a declared `scale` into fraction digits reaches one of two platform -primitives, and both refuse above 100. `Number.prototype.toFixed` throws `RangeError: -toFixed() digits argument must be between 0 and 100`, and `Intl.NumberFormat` throws -`RangeError: maximumFractionDigits value is out of range.` So a spec-valid declaration was -unrenderable by any conforming consumer, and its author got no signal at publish time — -the failure arrived as a render-time crash in someone else's repository. The route from -this row to that reader, measured in the sibling checkout at the `.objectui-sha` pin -`53ded82bf7`: plugin-form copies the row's constraint keys onto the runtime field -(`packages/plugin-form/src/sectionFields.ts:220`, `if (fd.scale != null) base.scale = -fd.scale;`), and the number cell renderer hands that value straight to `Intl.NumberFormat` -(`packages/fields/src/index.tsx:661-667`, `maximumFractionDigits: scale ?? 20`). That one -route carries the premise on its own. - -The row now carries that upper bound, and the refusal says **why** — it names both -primitives, the `RangeError` and the legal maximum — so an author reads a platform limit -they can verify rather than a cap somebody chose. The bound is the platform's own: at 100 -both primitives are measured to succeed, at 101 both are measured to throw, and a unit -test re-measures that boundary on every run rather than trusting the literal. - -**BREAKING** — a form-row `scale` above 100 that parsed clean before is refused at -authoring now. This is a deliberate narrowing of a published accepted set, priced as such -rather than as a tidy-up. The declarations it refuses could only ever have crashed a -renderer: there is no value above 100 that any conforming consumer can render, which is -why the bound is the platform's limit and not a policy number. - -Unchanged in both directions: `scale: 100` still parses, `scale: 0` still parses, absence -is still absence, and the malformed-declaration refusals from #8321/#12174 (`scale: -1`, -`scale: 2.5`) keep their existing codes and their existing wording. `precision` is -untouched on this row as on the object-field row — it is a total digit count that reaches -neither primitive, so the renderer-ceiling argument does not carry to it. - -The number itself moves into `src/shared/scale-ceiling.ts`, a spec-internal leaf module -that no package entry re-exports, so no published export moves and `check:api-surface`, -`check:export-origins` and `check:declaration-map` all stay green. `data/field.zod.ts` -keeps the module-private copy #18972 minted; a pin asserts the two sites refuse with -byte-identical text, so the duplication is held equal rather than left to drift, and that -file can adopt the shared module later as a pure delete-and-import. - -Shipped as `minor` under the repo's launch-window convention, in which -`check-changeset-no-major` refuses `major` and breaking-ness is carried by this banner -plus the ADR-0087 disposition rather than by the level. - - diff --git a/.changeset/19098-retire-generate-schema.md b/.changeset/19098-retire-generate-schema.md deleted file mode 100644 index a30b1d71a73..00000000000 --- a/.changeset/19098-retire-generate-schema.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -fix(cli): **BREAKING** — `os generate schema` is retired, and it now says why and points at `os validate` and the per-type JSON Schemas `@objectstack/spec` publishes (#19098) - -Clause-②: no (narrowing) - - - -**⛔ If a script, a Makefile or a CI step in your project runs `os generate schema` -(or `os g schema`), it will now exit 1 and write nothing.** That is the intended -outcome: the command is gone by maintainer ruling, and the failure is how you find -out. Nothing in this repository reads the file it wrote. - -`minor`, not `major`: during the launch window this stack ships breaking changes as -`minor` (pre-1.0 semantics under lockstep versioning — see -`scripts/check-changeset-no-major.mjs`). - -**What the command actually did.** `os generate schema` wrote -`objectstack.schema.json`, a JSON Schema of the whole stack definition for an editor -to check `objectstack.config.ts` against. It projected `ObjectStackDefinitionSchema` -through a bare `z.toJSONSchema`, so the refinements the platform enforces beyond the -shape — a non-blank string, a required one-of, a banned key — were missing from the -file. Measured against the published projection on the tree the ruling was made on, -the file lacked 874 keywords, every one of them an absence. An editor pointed at it -reported a config as valid, and the platform then refused that config. - -**Why it is retired rather than repaired.** A TypeScript configuration is typed by its -own `define*` helper, and `objectstack.config.ts` is typed end to end by -`defineStack`, so no config format this CLI loads is one an editor validates against -a JSON Schema. JSON metadata already has the per-type schemas `@objectstack/spec` -publishes, which carry the published projection. Repairing the command would have -added a permanent public export to `@objectstack/spec` for a file with no reader. The -ruling generates no replacement file. What you see now: - -``` - ✗ `os g schema` was retired — its JSON Schema passed configs the platform refuses (maintainer ruling). - - The file it wrote described only the shape of a stack. Every rule the - platform enforces beyond that shape — a non-blank string, a required - one-of, a banned key — was missing from it, so an editor showed a config - as valid and the platform then refused it. By maintainer ruling it is - retired, not repaired, and no replacement file is generated. - - Check a project against the rules that actually run: - - os validate - - For JSON metadata, point your editor at the per-type schemas that - @objectstack/spec publishes. They state the rules a JSON Schema can - express, and name the ones it cannot under `x-dropped-refinements`: - - node_modules/@objectstack/spec/json-schema//.json - - `objectstack.config.ts` needs neither: `defineStack` types it in your - editor. Delete the `os generate schema` call, and any editor setting - that maps `objectstack.schema.json` — nothing writes that file now. - - Docs: https://objectstack.ai/docs/deployment/cli -``` - -**What to do.** The refusal's pointer is the whole of it. Delete the -`os generate schema` call, and any editor setting that maps `objectstack.schema.json` -(a `json.schemas` or `yaml.schemas` entry, for example). Run `os validate` to check a -project against the rules that actually run. For JSON metadata, point the editor at -`node_modules/@objectstack/spec/json-schema/`, one file per metadata type. There is no -call to rename: nothing replaces the command. - -**Reach outside this repository is NOT MEASURED.** There is no telemetry, so whether -any project runs the command or reads its file is unknown. Inside this repository -nothing does: no reader and no editor mapping of `objectstack.schema.json` exists. -If these release notes also record that `os generate schema` can now write its file, -this retirement supersedes that repair. - -**Two neighbouring answers change with it.** The retirement ledger is now read before -the command routes its sub-commands and before it asks for a ``, which is what -lets `os generate schema` (no name) reach the refusal at all. So `os g agent` with no -name now prints the agent retirement instead of `Missing required argument: `. -The ledger lookup also reads its own keys only: `os g constructor ` used to be -taken for a retired type and crashed with a `TypeError`, and it now falls through to -the ordinary type checks. - -The docs row that advertised the command — "Autocomplete and validation for -`objectstack.config.ts` (via `os generate schema`)" in -`content/docs/api/data-flow.mdx` — is gone, with the diagram's JSON Schema node above -it. diff --git a/.changeset/19101-lazy-schema-keeps-describe.md b/.changeset/19101-lazy-schema-keeps-describe.md deleted file mode 100644 index ad32eb80e47..00000000000 --- a/.changeset/19101-lazy-schema-keeps-describe.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -JSON Schemas converted from a `lazySchema()` reference now carry the `description` the schema authored, the same as an `OS_EAGER_SCHEMAS=1` run (#19101). - -Clause-②: no - -zod reads `.describe()` / `.meta()` from its registry by node identity. A lazily built schema is referenced through a Proxy, while the metadata sits on the real instance behind it, so `z.toJSONSchema` found nothing and dropped the text. The published result depended on the evaluation mode. The Proxy now answers the real instance's metadata to that lookup, less `id`, which stays on the real instance so that zod's duplicate-id refusal is never triggered. - -What changes: descriptions reappear. Nothing else does. Measured lazy against eager, leaf by leaf: - -- `@objectstack/spec/openapi.json`, and the `GET …/openapi.json` document served from it, gains 2 (`ListRecordResponse.data[]` and `BulkRequest.records[]`); -- the `os generate` IDE schema gains 445; -- the approval-node and schemaless node-config schemas are unchanged. - -No other key differs in any of them, and the eager outputs are byte-identical before and after. The accept set does not change: `description` is an annotation, never a validation keyword. diff --git a/.changeset/19116-package-api-unmounted-entries-retired.md b/.changeset/19116-package-api-unmounted-entries-retired.md deleted file mode 100644 index 8485e8b9d03..00000000000 --- a/.changeset/19116-package-api-unmounted-entries-retired.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING** — `PackageApiContracts` loses its three entries that named routes nothing serves: `upgradePackage`, `resolveDependencies` and `uploadArtifact` (#19116). - -A `major`-class change, recorded as `minor` under the launch-window convention. Maintainer ruling 2026-09-23, director seat decision batch #217 item 4, letter A, 「217 同意」; ADR-0049 enforce-or-remove. - -**Why.** Each entry bound a path the composed runtime mounts nowhere — `POST /api/v1/packages/upgrade`, `POST /api/v1/packages/resolve-dependencies` and `POST /api/v1/packages/upload`. The package dispatcher has no route for any of them and `@objectstack/rest` mounts only `/packages/publish` under `/packages`, so a request to any of the three was never answered, while the generated API reference printed all three as live endpoints. Unlike `installPackage`, which was rebound onto the serving `POST /api/v1/packages`, there was no serving door to rebind these onto, and mounting three new capabilities nobody has asked for was ruled out. - -### FROM → TO - -| removed | what to write instead | -| --- | --- | -| `PackageApiContracts.upgradePackage` (`POST /api/v1/packages/upgrade`) | nothing — delete the read and any URL built from it. No route serves a package upgrade. | -| `PackageApiContracts.resolveDependencies` (`POST /api/v1/packages/resolve-dependencies`) | nothing — delete the read and any URL built from it. No route serves dependency resolution. | -| `PackageApiContracts.uploadArtifact` (`POST /api/v1/packages/upload`) | nothing — delete the read and any URL built from it. No route serves an artifact upload. | - -**The one-line fix: delete every read of the three keys, and every request to the three paths.** The compiler finds the reads (`TS2339: Property 'upgradePackage' does not exist`); a hard-coded path has to be searched for. No behaviour is lost — none of those requests was ever answered. - -**What stays.** The four entries whose doors serve — `listPackages`, `getPackage`, `installPackage`, `uninstallPackage` — are unchanged. The per-route request/response schemas (`PackageUpgradeRequestSchema`, `PackageUpgradeResponseSchema`, `ResolveDependenciesRequestSchema`, `ResolveDependenciesResponseSchema`, `UploadArtifactRequestSchema`, `UploadArtifactResponseSchema`, with their types) stay published, now bound to no route; their docblocks no longer name a route. If the platform later serves a package upgrade, dependency-resolution or upload route, its contract entry is declared in the same change that mounts it. - -⚠️ Runtime behaviour is deliberately **unchanged**: nothing ever mounted the three paths or built a route, client or SDK method from the entries, so every request answers exactly as before. The removal retracts a false claim, not a capability. **No deprecation window** (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」). - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** Inside this repository the three paths occurred only in the declaring file, its unit test and the generated reference page, and the pinned objectui checkout names none of the keys, none of the paths and not `PackageApiContracts`; `@objectstack/spec` is published, so readers elsewhere were not measured. - -The ADR-0087 D3 semantic entry `package-api-contracts-unmounted-entries-retired` carries the judgement: a contract-map entry is not metadata, so there is no source for a D2 conversion to rewrite. - -Clause-②: no - - diff --git a/.changeset/19120-install-door-parses-manifest-version.md b/.changeset/19120-install-door-parses-manifest-version.md deleted file mode 100644 index b98ec4e50a4..00000000000 --- a/.changeset/19120-install-door-parses-manifest-version.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime): `POST /api/v1/packages` parses the manifest's `version` leg instead of installing anything it is handed (#19120) - -Clause-②: no (narrowing) - -**BREAKING for callers of the install door** — a manifest with no `version`, or -one whose `version` does not match the declared semantic grammar, is now refused -`400` / `VALIDATION_ERROR`. It used to install and answer `201`. - -The accept set only shrinks back to what the published declaration has always -said. `PackageInstallRequestSchema` binds `manifest: ManifestSchema`, and -`ManifestSchema` declares `version` required with a semantic grammar. The door -parsed nothing at all: `const manifest = body.manifest || body` went straight to -`installPackage`, with an id check as the only gate on the way. That is -«declared ≠ enforced» on a published API contract — and because the install -landed silently, an author could install metadata the platform's own CLI build -step (`os plugin build`) would have refused outright. - -The gate asks the declaration **by reference** — `ManifestSchema.shape.version` -— rather than keeping a copy of the grammar. The version-grammar canon is an -open question on its own card; whichever way it is settled, this door follows it -with no further edit. - -**What is not affected.** Boot-time and in-process installs reach -`SchemaRegistry.installPackage` / `ObjectQL.registerApp` directly and never pass -through this branch, so nothing about how a package is loaded from disk or -registered by a plugin changes. A well-formed manifest installs exactly as -before, on both body forms (wrapped and bare) and on both install limbs (the -protocol primitive and the bare-registry fallback). - -**Scope — the `version` leg alone.** The declaration's own docblock records five -classes this door answers `201` to while the schema refuses them. This change -closes one: `version`. A missing `type`, unknown keys on either body form, a -string-typed `enableOnInstall` / `overwrite`, and install options spelled on the -bare form are each left exactly as they were — measured after the change, all -four still answer `201`. Each is its own reading and its own card. - -**If you are refused.** Give the manifest the `version` the schema has always -required — `version: "1.0.0"`, three dot-separated numbers. The refusal names -the key and shows the shape, so the prescription arrives with the `400` rather -than in a changelog. - - diff --git a/.changeset/19143-dataset-runtime-publish-door.md b/.changeset/19143-dataset-runtime-publish-door.md deleted file mode 100644 index 6c7dd627cb4..00000000000 --- a/.changeset/19143-dataset-runtime-publish-door.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor ---- - -The dataset publish door now judges the dataset. A runtime-created `dataset` reached ZERO author-time rules; it now dispatches the existence rules that were already written for it (#19143). - -`dataset` is a registered metadata type declaring `allowRuntimeCreate: true`, so Studio's designer, REST `/meta` item CRUD and an MCP/AI author may all mint one — and at that door nothing judged it. Measured on `origin/main`: no rule declared `dataset` in `runtimeTypes` (zero, against a lit control returning every other declared type), and `TYPE_TO_STACK_KEY` in `runtime-gate.ts` carried no `dataset` row. The two absences were **consistent rather than contradictory** — the gate filters by `runtimeTypes` before it consults the table — so nothing was mis-wired and CI was green, correctly. What they summed to is that a dataset write built no per-write snapshot and dispatched no rule at all, while the rules that judge a dataset state their own failure mode as a surface that *"renders successfully with empty or wrong numbers"*. An author working only through Studio or MCP has no `os lint` step to fall back on, so for them that door is the only one there is. - -ADR-0049's 「声明即强制」 admits two resolutions and the card chose neither; this takes the first because the measurement says so. The author-time rules for `dataset` **exist**: `validateDatasetReferences` (#14105, `packages/lint/src/validate-dataset-references.ts`), `validateDatasetMeasureAggregates` (#16354) and `validateObjectReferences`' `datasets[].object` rung. The declaration is honoured rather than retired. - -- **`TYPE_TO_STACK_KEY` gains `dataset: 'datasets'`**, and the rules that READ that collection are declared in the same commit — never a mapping ahead of its rules, which is the inert state the table's own `seed: 'data'` note records paying for. Every crossed rule has a door control that fires it through the real gate. -- **`validateDatasetMeasureAggregates` crosses to `CLI_AND_RUNTIME` with `runtimeTypes: ['dataset']`.** Its previous `surfaceReason` named this exact gap as what held it off the door. -- **The reference-integrity suite entry gains `dataset`**, and its per-member axis admits exactly two members — `validateDatasetReferences` and `validateObjectReferences`. Both resolve only against `stack.objects` and `stack.datasets`, the two collections a per-write snapshot carries, so neither opens a missing-collection false-positive channel. They cross together on #7220's reading: an author refused for a dangling dimension field and waved through for a dangling base object cannot predict the door. -- **No new rule and no new finding class.** The rule ids (`dataset-field-unknown`, `dataset-field-not-included`, `dataset-filter-field-unknown`, `dataset-include-unknown`, `measure-aggregate-field-type-refused`, `object-reference-unknown`) and their severities are unchanged — they now reach the door where the author actually is. -- **Findings from a dataset write are name-keyed on the wire** (#10064): `datasets..dimensions[0].field`, never the gate's private snapshot index. `datasets` entered the derived name-keyed set by derivation, with no second edit to remember. -- **Measured before crossing**, at the door's own snapshot shape and differential, over every dataset shipped in this monorepo — **11 datasets** (`platform-objects` 5 over `sys_*`, showcase 4, crm 1, todo 1) judged against 52 platform objects plus each app's own (showcase 22, crm 6, todo 1): **0 findings, 0 advisories, `rulesRun` 2 on every one** — so the zero is a fact about the corpus and not about a door that ran nothing. The same harness's synthetic probe IS refused, with both ids and both name-keyed paths. - -## Migration - -**A dataset publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id and the offending path, name-keyed on the wire — for example `datasets.invoice_metrics.dimensions[0].field` or `datasets.invoice_metrics.measures[1].aggregate` — plus the string that was written. - -To clear a refusal, do one of: - -- point the `dimensions[].field` / `measures[].field` path at a column the base object actually declares (after a Studio label edit the derived API name is the one to use); or -- add the relationship the path traverses to the dataset's `include[]`, for `dataset-field-not-included`; or -- correct the filter KEY, for `dataset-filter-field-unknown`; or -- for `measure-aggregate-field-type-refused`, either aggregate a field of an accepted type or choose an aggregate the field's type accepts (`count` / `count_distinct` accept every type) — the compile leg already refuses that same pair with `400 DATASET_INVALID` once a query is built, so this is the same fix made earlier; or -- for `object-reference-unknown`, point `object` at an object this stack defines, or at a platform object by its full name. - -`os validate` / `os build` / `os lint` already reported every one of these findings at the same severity, so a code-authored stack can be repaired before it ever reaches a publish. A dataset over an object this stack does not define, one that declares no readable field map, and a registry-injected system column are all skipped exactly as they were on the CLI — the door adds no verdict the commands did not already make. A stored dataset already in violation is never charged to an unrelated publish, and republishing a dataset under its own name with the defect removed is clean (#4463 D4). diff --git a/.changeset/19148-undoable-capture-set.md b/.changeset/19148-undoable-capture-set.md deleted file mode 100644 index 021416ef59a..00000000000 --- a/.changeset/19148-undoable-capture-set.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`ActionSchema.undoable` — the published description now names the WRITTEN set, not `patch` alone (#19148). - -**FROM** — "`operation: 'update'` is the declared form of that action — its `patch` names exactly the fields whose prior values are captured." - -**TO** — "`operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set, so nothing anchors the capture there." - -An `operation: 'update'` action writes two sources: the static `patch` AND whatever its `params` collect. On any params-carrying action, "exactly the `patch` fields" is a strict subset of what the action writes, so an Undo built to the old sentence restores part of the change and reports the action as undone. - -- **Prose only — no schema change, no accept/reject outcome moves.** The same author input parses the same way before and after; `Clause-②: no`. -- **The executor already captured the union.** `executeDeclarativeUpdateAction` keys `undoData` off `Object.keys(data)`, `data` being `declarativeUpdateWrite`'s merged bag `{ ...patch, ...params }`. The sentence was the outlier, and the EXECUTOR CONTRACT doc block ~200 lines above in the same file already read "exactly the fields written". -- **One operation, one rule.** The `operation` enum carries exactly one member, `'update'` (`'delete'` and `'custom'` are refused with their reason), so the per-operation capture rule is a one-row rule and is written as one. -- The describe text renders into three generated reference tables (`ui/action`, `data/object`, `kernel/metadata-plugin`), regenerated here; the hand-written protocol page `content/docs/protocol/objectui/actions.mdx` carried the identical claim and is corrected in the same edit. diff --git a/.changeset/19150-declares-collection-pipe-authorable-side.md b/.changeset/19150-declares-collection-pipe-authorable-side.md deleted file mode 100644 index e40ce4689fe..00000000000 --- a/.changeset/19150-declares-collection-pipe-authorable-side.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): `declaresCollection` reads a `pipe` on the side the author writes, so a `z.preprocess`-wrapped collection key cannot silently leave the `objectConflict: 'merge'` refusal set (#19150) - -Clause-②: no - -`objectCollectionKeys()` derives — never transcribes — the object-level keys `composeStacks({ objectConflict: 'merge' })` refuses to combine (#14848), and the reason it derives them is written into its own docblock: a hand-written list "would fail in the silent direction: a collection key added to the object schema tomorrow would fall back to the wholesale replacement this rule exists to refuse". The walker behind it reintroduced exactly that silent direction through the derivation itself. - -`declaresCollection`'s `pipe` arm read only `def.in`. Two constructs compile to the same `pipe` node with OPPOSITE authorable sides: `a.transform(fn)` keeps the accepted input shape in `in`, while `z.preprocess(fn, schema)` puts the transform STAGE in `in` and the real, validated schema in `out`. A preprocess-wrapped collection key therefore resolved to a `transform` node, fell through to `default: return false`, and left the refusal set with nothing anywhere reporting it — the failure shape being a wholesale replacement where a refusal was owed. - -The arm now reads `out` only when `in` unwraps to a transform stage, which is the rule four sibling walkers in this tree already run (`pipeAuthorableSide` in `scripts/lib/zod-graph.ts`, `kernel/metadata-authoring-lint.ts`, `system/metadata-form-zod-reconciliation.test.ts`, and `packages/lint`'s `validate-predicate-path-refs.ts`) rather than a fifth dialect. - -- **`in || out` was measured and declined.** For a genuine `a.transform(fn).pipe(b)` the author writes `a`; reading either side pulls a key whose authored value is a scalar into a refusal set that then names it a collection. The landed rule leaves every `.pipe()` verdict where it was, by construction rather than by fixture choice. -- **No authored metadata changes meaning and no key changes its verdict on today's shape.** Measured over all 43 top-level keys of `ObjectSchema`: exactly one compiles to a `pipe` (`titleFormat`, an `a.transform(fn)` pipe carrying a scalar), and the derived refusal set is byte-identical under the old reading, the landed one and the declined candidate. The invariant is asserted, not claimed: `compose-stacks-collection-pipe-arm.test.ts` fails the day it stops holding. -- **`fields` keeps its exclusion by name.** It is the one collection `'merge'` merges by shallow spread, so its own reading cannot move the set either way. diff --git a/.changeset/19151-assignment-config-catchall-proto-key.md b/.changeset/19151-assignment-config-catchall-proto-key.md deleted file mode 100644 index 24c20d01b2c..00000000000 --- a/.changeset/19151-assignment-config-catchall-proto-key.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING** for authored metadata — an `assignment` flow node's config refuses a **top-level** key named `__proto__`, with a named, located error at parse time, instead of accepting the document and silently returning one without that key (objectstack#19151). - -## Why - -`AssignmentConfigSchema` is deliberately open at the top level: an `assignment` node is exempt from `registerFlow()`'s undeclared-key walk by design, because its top-level keys may themselves be flow variables (the bare legacy `{ : }` config), and the descriptor declares `additionalProperties: true`. That openness is spelled `.catchall(z.unknown())`. - -zod has two open-key branches and both skip a `__proto__` own key before anything author-facing can judge it. `z.record()`'s branch skips it above the key schema — that is objectstack#17852, fixed for the `assignments` map one level down. `handleCatchall` skips it above the **catchall** schema, one function over in the same file. So a flow variable named `__proto__` declared at the top level of an `assignment` node config parsed as SUCCESS and came back missing: the contract accepted a document and handed back a different one, on a surface whose own keys are author-named by design. - -`JSON.parse` is what produces `__proto__` as an own key, so stored flow metadata reaches this door routinely; an object literal's `{ __proto__: … }` sets the prototype instead and never reaches either loop. - -## What is refused, and what is not - -`__proto__` only, in this position as in the sibling one. `constructor`, `prototype` and every other reserved-looking name reach the catchall unskipped and round-trip intact — measured — so they remain legal top-level flow-variable names and nothing narrows for them. The refusal is a `z.preprocess` guard on the raw input (`refuseCatchallProtoKey`, a sibling of `refuseRecordProtoKey` sharing one mechanism), because that is the only place the key is still visible: declaring it in the object's own shape was measured to refuse *every* config, since zod reads a declared key through `input["__proto__"]` and `"__proto__" in input`, which on an ordinary object both answer through the inherited accessor. - -The `assignments` map keeps its own guard. The two are different parsers at different depths and neither covers the other. - -Measured: zero authored use of `__proto__` as a top-level key on an `assignment` node config, across this repo, `examples/` and `objectui` — against a lit control of 100 authored `assignment` node declarations in 26 files here and 11 files there. - -## Known gap, left open on purpose - -Like the sibling guard, this runs at parse time only and does not project into the published JSON Schema (`packages/spec/json-schema/**`) — the general gap tracked as objectstack#18670, which stays open after this change. - -Clause-②: yes (narrowing) - - diff --git a/.changeset/19187-related-list-filter-liveness-flip.md b/.changeset/19187-related-list-filter-liveness-flip.md deleted file mode 100644 index 9b68bd506b3..00000000000 --- a/.changeset/19187-related-list-filter-liveness-flip.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`liveness/field.json` — `field.relatedListFilter` is `live`, and drops the `authorWarn` that had become a false sentence. - -Clause-②: no — no schema key moves, no accept set widens or narrows, no export changes. `FieldSchema.relatedListFilter` accepts exactly what it accepted before; what changes is the ledger's verdict about it and the author-facing advisory the ledger drives. - -The ledgers ship inside this package (`files[]` includes `liveness`), so the changed tarball bytes are the ledger row, the generated `liveness/state-counts.md` counts and the `liveness/README.md` Notes cell. - -- **The row falsified itself.** #8704 seeded `relatedListFilter` `planned` + `authorWarn` as the contract-first spec half of objectui#4664, and wrote the flip condition into its own note: flip to `live` and drop `authorWarn` when that consumer lands. It landed — objectui `d796c8dde` (objectui PR #6946), which `git merge-base --is-ancestor d796c8dde 53ded82bf7` places inside this repo's `.objectui-sha` pin. Both pointers were re-measured AT THAT PIN, the #10068 discipline, not on objectui main: `deriveRelatedLists` puts the authored value on the derived descriptor as `filter`, and `RecordDetailView` writes it onto the synthesized `record:related_list` node, which AND-composes it with `{ [referenceField]: parentId }` while the tab strip's count probe composes the same pair. -- **For an author, the practical read: nothing you write changes, and one warning stops.** `os lint` had been saying 「the auto-derived related list does not apply this filter yet」 about a key the pinned console applies — a true warning costs an author nothing, a false one steers them off a usable key. Authors who trimmed a `relatedListFilter` on that advice can put it back. -- **A `planned` row fails in the one direction no citation check can see.** A `live` row rots when its pointer moves and the gate's file/line/symbol/key-mention checks catch that. A `planned` row cites no consumer, so nothing can rot and nothing re-asks; only the consumer landing falsifies it, and only a reader who follows the sibling repo notices. That asymmetry, not this one key, is what the flip records. -- **`field` now carries no `authorWarn` row at any depth**, which gates `packages/lint`'s field walk off entirely (`if (fieldWarn.size > 0)`). The two ledger-driven pins that used this key as their witness are re-dispositioned in the same change: a silence pin plus an anti-vacuity guard for the verdict case, and a narrowed claim on the #11385 field-walk case. diff --git a/.changeset/19198-approvals-implicit-reference-target.md b/.changeset/19198-approvals-implicit-reference-target.md deleted file mode 100644 index c771a79472d..00000000000 --- a/.changeset/19198-approvals-implicit-reference-target.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -`ApprovalService` inbox display enrichment resolves a reference field's target through `referenceTargetOf` instead of the materialized `reference` carrier, so a `{ type: 'user' }` field authored without one is enriched instead of silently dropped (#19198). - -`resolveLookupFields` admitted `user` fields but required an EXPLICIT `reference` on them. The spec declares exactly the opposite for that type: `IMPLICIT_REFERENCE_TARGETS` (`@objectstack/spec/data`) says a `user` field's target is "a CONSTANT OF THE TYPE, so `reference` on a `user` field materializes that constant; it does not supply it. Metadata authored without it (hand-written JSON, an AI author, a Studio form) is **fully specified, not under-specified**." So the one spelling the contract calls complete was the one the reader refused — and it refused it **silently**: the field was left out of `payload_display`, with no refusal and no diagnostic, and the reviewer read a raw user id where every other reference field showed a name. - -- **The target is now the arbiter's answer, not a carrier read.** `referenceTargetOf` is the same single arbiter the `$expand` gate and the expansion engine already ask (Framework#4443 / cloud#983 fixed the identical defect there); approvals was still reading `field.reference` raw. -- **Nothing else widens.** The admitted types are unchanged (`lookup`, `master_detail`, `user`), so a `lookup` / `master_detail` whose author-chosen target is absent still names nothing, is still left out, and still issues no read — `tree` is deliberately not added. -- **The unreadable-carrier behaviour is unchanged.** `referenceTargetOf` reads the carrier through `referenceCarrierOf`, the throw is still caught per field so one bad carrier cannot drop every reference field of the object, and the warning now names this reader (`ApprovalService.resolveLookupFields`) because the arbiter's own message names itself. -- **No authoring change.** Metadata that already spells `reference: 'sys_user'` resolves to the same target it always did; nobody has to restate the constant. diff --git a/.changeset/19228-pagesize-fetch-ceiling.md b/.changeset/19228-pagesize-fetch-ceiling.md deleted file mode 100644 index d67a04006ca..00000000000 --- a/.changeset/19228-pagesize-fetch-ceiling.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`pagination.pageSize` states what the renderer owes on a view with no pager - -On a kanban, gallery or timeline view there is no pager, so `pagination.pageSize` is the fetch -ceiling. Its description now says so, and names the renderer's two obligations there: bound the -fetch at that number, and, when the filtered set is larger than it, show a visible truncation -signal saying what is on screen is not the whole set. - -The key's accept set and its default (`25`) are unchanged, and no export or authorable key moves -relative to the last published release. - -Clause-②: no diff --git a/.changeset/19228-view-row-limit-route-record.md b/.changeset/19228-view-row-limit-route-record.md deleted file mode 100644 index bd49b28b240..00000000000 --- a/.changeset/19228-view-row-limit-route-record.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): state the row-cap guard `ElementDataSourceGate` implements (#19228) - -Prose and pins only — zero accept-set movement, zero export movement. The same documents parse -to the same values before and after. ⛔ No `.default()` moves. - -## What the published text said, and what an author can actually reach - -`ObjectKanbanPropsSchema.limit` tells authors that a bound view's `pagination.pageSize` fills it -「only when unset」. Measured first-hand at the objectui pin this repo builds against -(`.objectui-sha` = `87af769e9`), that sentence is exactly right for this face, and the describe -now says WHY rather than leaving it to look narrower than the mechanism. - -The gate's branch is `if (!fromView || !isUsableRowLimit(authored))` -(`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`), and `isUsableRowLimit` is -`typeof v === 'number' && Number.isInteger(v) && v > 0` (`:192-194`). Every cap this key ACCEPTS -is one that predicate already calls usable — the accept set is a subset of the usable set — so -across the whole accept set the guard has exactly two outcomes and 「set but not usable」 is -empty. The extra arm, a cap displaced and reported because it is zero, negative or fractional, -is reachable only for a node this contract refuses, so it is recorded in the docblock rather -than in an author-facing sentence. - -The view half is `pagination.pageSize` ALONE on this face. `savedViewLimit` does fall back to a -flat `view.limit` (`core/src/data-scope/element-data-source.ts:237-241`), but that names a -saved-view RECORD as the adapter's `listViews()` returns it — a third face, not an authored view -document. Measured on this tree: `ListViewSchema` REFUSES a flat `limit` with -`unrecognized_keys: ["limit"]`, the verdict a bogus key gets, while the same minimal document -parses with `pagination.pageSize: 50`. No view document -declares a flat `limit` and none carries a tombstone for one. diff --git a/.changeset/19237-action-engine-facade-find-context-subtracted.md b/.changeset/19237-action-engine-facade-find-context-subtracted.md deleted file mode 100644 index b06af620c9d..00000000000 --- a/.changeset/19237-action-engine-facade-find-context-subtracted.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -spec(ui): `ActionEngineFacade.find` no longer accepts a `context` on its query envelope - -Clause-②: no (narrowing) - -`ctx.engine.find(object, query)` takes `Omit` — the -engine's query envelope with exactly one key subtracted. Every other key is -unchanged and still read off the engine's own type by reference. - -**Why.** The action facade is trusted and context-less by design: the runtime -stamps its own elevated `ExecutionContext` last, so a caller-supplied `context` -was overridden, never honoured. The key was nonetheless *declared* on the -parameter, which made this a declared-but-unenforced key on the one thing -`context` carries — identity and tenant. A handler could write -`context: { tenantId: 'org_acme' }`, type-check clean, and get the facade's -context instead: a read its author believes is tenant-scoped, silently broader -than intended. ADR-0049 admits enforce or remove; removal is the exit that -changes no runtime behaviour. - -**Migration.** Delete the key. There is nothing to replace it with, because it -never did anything: a `find` that carried one returned exactly the rows it -returns without one. To scope a read, put the scope in `where`. - -| You wrote | Write instead | -| --- | --- | -| `ctx.engine.find('task', { where: { … }, context: { tenantId } })` | `ctx.engine.find('task', { where: { … } })` | -| `ctx.engine.find('task', { where: { … } })` | unchanged | - -`tsc --noEmit` over a consumer's handlers finds every occurrence, because the -key is now an excess property on a fresh literal. ⚠️ Only where the handler is -annotated with the published `ActionHandlerContext`: an untyped handler (a JS -config body, a local copy of the context type, `(ctx: any)`) still passes the -key and still has it overridden, silently, exactly as before. The runtime arm is -deliberately unchanged — refusing an identity key there is a runtime behaviour -change, not a declaration narrowing. - - diff --git a/.changeset/19248-duplicate-name-justification.md b/.changeset/19248-duplicate-name-justification.md deleted file mode 100644 index edb5ccbf50b..00000000000 --- a/.changeset/19248-duplicate-name-justification.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`collect-docs.ts` records what the `docs/duplicate-name` refusal rests on now that ADR-0048 §3.4 retired its older justification (#19248) - -`docs/duplicate-name` refuses two owners declaring one doc name. The claim it -was once explained by — *"one registration overwrites the other"* — was retired -by ADR-0048, and a refusal whose stated justification no longer exists is worth -examining rather than inheriting second-hand. #19248 examined it. - -**The verdict is that no wording change was warranted**, and the ADR text is -quoted into the rule's own docblock so the next reader does not have to -re-derive it. §3.4 retires a RUNTIME throw and nothing else — *"The -cross-package **throw is retired**; two distinct packages coexist on the same -bare name by construction."* — while keeping, in the same clause, the class -this lint belongs to: *"Authoring-time hygiene — an author shipping two -`page/home` in one package — stays covered by the `naming/namespace-prefix` -lint in `os lint`."* Both sentences are quoted verbatim, checked against -`docs/adr/0048-cross-package-metadata-collision.md` on this branch's base -(`13d52947d8`) rather than recalled. - -The message already said `for authoring hygiene` and already declined the -retired claim by name, so what shipped was correct and stays byte-identical. -What the docblock gains is the ADR's own words, the card number the standing -**severity** disagreement is filed under, and the boundary between the two -questions: §3.4 hands authoring hygiene to a warning-only lint while this one -is `severity: 'error'`, which is a live question about the level and not about -the reason. - -⛔ No behaviour changes. No rule, message, severity or accept set moves; the -only edited bytes are inside one docblock comment. - -**This ships, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/cli`'s published `files[]` is -`["dist","README.md","CHANGELOG.md"]` and the package builds with plain `tsc` -(`tsc -p tsconfig.build.json`, no `removeComments`), so the comment is emitted -into the tarball — measured on the rebuilt artifact: the new clause is present -in `dist/utils/collect-docs.js` (1 occurrence), the replaced spelling is absent -from all of `dist` (0), and `dist/**/*.d.ts` carries 0 of it because the block -sits above a non-exported helper. The rule's own runtime message resolves to -that same file as the positive control. So the published JS bytes move while -the declaration surface does not. diff --git a/.changeset/19249-sys-user-set-manager-action.md b/.changeset/19249-sys-user-set-manager-action.md deleted file mode 100644 index b2e4c84f045..00000000000 --- a/.changeset/19249-sys-user-set-manager-action.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/platform-objects': minor ---- - -feat(platform-objects): declare the `set_user_manager` row action on `sys_user` (#19249) - -`sys_user.manager_id` drives the approvals `{ type: 'manager' }` rung and the ADR-0057 `own_and_reports` read scope, and `POST /api/v1/auth/admin/set-user-manager` (#16678 Phase 3) has been its only product write surface since it landed — with nothing in the Console reaching it. This declares that affordance: a `set_user_manager` row action on `sys_user`, offered from the Users list row menu and the record-detail header, collecting the new manager through an inline `sys_user` lookup and POSTing `{ userId, managerId }` to the admin endpoint. - -Three properties of the declaration are decisions rather than detail: - -- **It posts the admin endpoint, never the generic data API.** `sys_user` is `managedBy: 'better-auth'` and the ADR-0092 D2 managed-update whitelist is `{name, image, locale}`, so a picker writing `manager_id` through `/api/v1/data` would be refused by the identity write guard — correctly — and would read as a Console bug. The field keeps `readonly: true`; the endpoint reaches the column by system context. -- **Its `visible` predicate carries the directory-sync term and not the self-service one.** A directory-owned identity (`source: 'idp_provisioned'`) is refused by the endpoint, so the button is hidden for one — the same term the three self-service identity actions on this object already spell. Their `record.id == ctx.user.id` half is deliberately not carried over: this is an admin action on someone else's row. -- **No second copy of the server's refusals.** Self-assignment, cycle, depth, cross-organization and directory-owned identity are enforced at the write, in one derivation, and surface from there. Nothing is re-derived client-side. - -Additive: no existing action, field or predicate changed. The `manager_id` field and its read-only rendering are untouched, and `sys_business_unit.manager_user_id` (Business Unit Head) is a separate, independent relation that this does not read or write. diff --git a/.changeset/19258-recipient-id-dependson-object-name.md b/.changeset/19258-recipient-id-dependson-object-name.md deleted file mode 100644 index ced78555fb0..00000000000 --- a/.changeset/19258-recipient-id-dependson-object-name.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-sharing": patch ---- - -`sys_sharing_rule.recipient_id` now declares `dependsOn: ['recipient_type', 'object_name']` — every sibling field its `recipient-picker` widget actually reads. - -The picker reads two siblings, not one: `recipient_type` picks the mode (a record picker over `sys_user` / `sys_team` / `sys_business_unit` / `sys_position`), and for the `field` recipient kind (#15072) it reads `object_name` to offer that object's user-valued columns. The declaration named only the first. The neighbouring `criteria_json` field already declares `dependsOn: ['object_name']` for its own `filter-condition` widget, so the key is live and correctly used a few lines up — the omission was an omission. - -Nothing was broken at runtime: the form renderer hands widgets the WHOLE watched record as `dependentValues` rather than a `dependsOn`-scoped slice, which masked the under-declaration. A renderer that ever scoped it — which is exactly what this key asks for — would drop the object name and degrade the `field` recipient mode to a plain text input **in silence**, with no error anywhere. This is the declaration catching up with what is read, so the scoping change can never be the one that breaks it. - -The same commit corrects the `recipient_id` docblock: the picker no longer "has no mapping for that kind and degrades to its text input" — the pinned console (`.objectui-sha` 87af769e, which includes objectui#10049 / commit 23b99585) offers the shared object's user-valued columns for the `field` kind, using a "holds users" predicate that is a clause-for-clause copy of this plugin's own `fieldHoldsUsers`. - -Authors and stored rows are unaffected: no key is added, removed or renamed, no value is newly accepted or refused, and no wire byte moves. diff --git a/.changeset/19264-audit-implicit-reference-target.md b/.changeset/19264-audit-implicit-reference-target.md deleted file mode 100644 index b3ebf6e9168..00000000000 --- a/.changeset/19264-audit-implicit-reference-target.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-audit": patch ---- - -The activity-timeline summary resolves a reference field's target through `referenceTargetOf` instead of the materialized `reference` carrier, so a `trackHistory`'d `{ type: 'user' }` field authored without one is planned, read and rendered as a name instead of silently showing the raw id (#19264). - -`audit-writers.ts` admitted `user` as a reference type and then required an EXPLICIT `reference` on it. The spec declares exactly the opposite for that type: `IMPLICIT_REFERENCE_TARGETS` (`@objectstack/spec/data`) says a `user` field's target is "a CONSTANT OF THE TYPE, so `reference` on a `user` field materializes that constant; it does not supply it. Metadata authored without it (hand-written JSON, an AI author, a Studio form) is **fully specified, not under-specified**." So the one spelling the contract calls complete was the one the reader refused — and it refused it **silently**: the field was simply absent from the read plan, and the timeline rendered `usr_1` where every other reference field showed a name. - -- **Four sites, not two.** The target is the key of the `id → title` map, so it has two ends: the two read planners (`planTrackedLookupReads`, `planMilestoneTokenReads`) build the plan under it and the two renderers (`renderTrackedChangeSummary`, `renderMilestoneSummary`) look the resolved titles back up under it. All four now ask one helper, so repairing the plan alone cannot pay for a read whose result the renderer then fails to find. -- **Nothing else widens.** The admitted types are unchanged (`lookup`, `master_detail`, `user`), so a `lookup` / `master_detail` whose author-chosen target is absent still names nothing, is still left out, and still issues no read — `tree` is deliberately not added. -- **A padded carrier can no longer split the key.** The planners used to `trim()` and the renderers did not, so `reference: ' crm_account '` produced two keys and no title; one helper trims once for both ends. -- **The unreadable-carrier behaviour is unchanged.** `referenceTargetOf` reads the carrier through `referenceCarrierOf`, which throws for an object- or array-valued `reference`; that throw is caught at the helper because this code runs inside `writeAudit`'s summary composition, which is not inside the `try` that guards the audit row write — an escaping `TypeError` would turn a display-enrichment miss into a failure on the audited write's own path. Such a carrier is left out exactly as it was before. -- **No authoring change.** Metadata that already spells `reference: 'sys_user'` resolves to the same target it always did; nobody has to restate the constant. diff --git a/.changeset/19269-liveness-readme-lint-path.md b/.changeset/19269-liveness-readme-lint-path.md deleted file mode 100644 index 1617b7fd08c..00000000000 --- a/.changeset/19269-liveness-readme-lint-path.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`liveness/README.md` — the `authorWarn` section's pointer to the lint that emits author warnings named a path that does not exist; it now names the module's real home, `packages/lint/src/lint-liveness-properties.ts` (#19269). - -The ledgers and this README ship inside this package (`files[]` includes `liveness`), so the pointer an upgrading reader follows is this one. It read `packages/cli/src/utils/lint-liveness-properties.ts`, measured at zero in a full tree listing, while the module it describes — the one that reads these ledgers, emits the advisory warning and never fails the build — sits in `packages/lint/src/`. Nothing else moves: no schema, no export, no verdict, no ledger entry, no runtime behaviour. - -- **This grid has no mechanical reader, which is why it rotted quietly.** `check:liveness` resolves the `evidence` paths inside ledger *entries*; a path written in README prose is checked by nobody, so the pointer stayed wrong through the move with every gate green. The lit control is the rule registration itself: `packages/lint/src/authoring-rules.ts` carries `source: 'packages/lint/src/lint-liveness-properties.ts'` as data, and that is the path this sentence now agrees with. -- **The second pointer in this file is deliberately left alone.** The closing paragraph of the type table says "see lint-liveness-properties.ts" — a bare filename with no directory. It is not stale (the basename resolves uniquely in the tree), and a bare filename carries no directory to rot; giving it one would newly expose it to exactly the failure this change repairs. The full path is stated once, here, where a reader who needs the directory gets it. diff --git a/.changeset/19276-liveness-ledger-unreadable.md b/.changeset/19276-liveness-ledger-unreadable.md deleted file mode 100644 index 81450073ad3..00000000000 --- a/.changeset/19276-liveness-ledger-unreadable.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -`lintLivenessProperties` now reports a liveness ledger it could not read, instead of going silent. - -`loadWarnMap` returned the same empty map for two different facts: "this metadata type's ledger classifies nothing as warn-worthy" and "there is no ledger". A missing `.json` and a file whose JSON is broken both returned an empty map with no log, no throw and no other signal, so losing or corrupting ONE file under the `liveness/` directory `@objectstack/spec` ships switched every author warning for that type off in silence — indistinguishable from that type simply having no warnings. - -The contrast that makes it a defect rather than a design sits one frame up: the DIRECTORY-level failure is loud by construction (the rule returns `[]` and everything depending on it goes red). Loud by directory, silent by file. - -What changes for consumers: - -- A new rule id, `LIVENESS_LEDGER_UNREADABLE` (`'liveness-ledger-unreadable'`), exported from the package root beside the four verdict ids. It is not a fifth verdict: the other four grade a property the ledger DID classify, this one says the classification never arrived, so a finding carrying it means no other finding about that metadata type can be trusted. Compare `f.rule` against the constant rather than retyping the slug. It cannot be silenced per finding: the CLI has no per-rule suppression, and `suppressWarnings` is a dashboard-widget key (`spec/src/ui/dashboard.zod.ts`) while this finding's subject is a ledger rather than an authored item, so there is nothing to carry it. The remedy is the one the finding's own hint names — repair or reinstall `@objectstack/spec`. -- `lintLivenessProperties` raises exactly one such finding per unreadable type, per run — never one per authored item — ahead of the walk's own findings, and keeps walking every type whose ledger IS readable. On an intact installation nothing changes: no ledger is missing, so no finding is added. -- A ledger that parses but is not a ledger (a bare `null`, an array, a scalar, or a document with no `props` record) is the same reported fault. Reading `.props` off a parsed `null` used to be a `TypeError` — a throw from a rule whose contract is that it never throws, through the one input an author cannot influence. - -`authorWarnedProperties` still answers the empty set for a ledger it cannot read — a decision procedure returning a set has no way to report a failed read — and that is unchanged for a missing file and for broken JSON. One input does move: a ledger document that parses to `null` used to make it THROW, and it now returns the empty set like the other two. `os lint` runs both halves in one pass, so the run states the fault once rather than never. diff --git a/.changeset/19277-in-process-install-honours-enable-on-install.md b/.changeset/19277-in-process-install-honours-enable-on-install.md deleted file mode 100644 index 36cfe8431e2..00000000000 --- a/.changeset/19277-in-process-install-honours-enable-on-install.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -The in-process install primitive honours `enableOnInstall` instead of ignoring it (#19277). - -`InstallPackageRequestSchema.enableOnInstall` (`kernel/package-registry.zod.ts`) is the request contract of `ObjectStackProtocol.installPackage` / `MetadataProtocol.installPackage`. The implementation read `request.manifest` and `request.settings` and nothing else, so a caller that asked for `enableOnInstall: false` got an ENABLED install — no refusal, no warning, no effect. That is a declared option the runtime did not deliver, which ADR-0049 (enforce-or-remove) and Prime Directive #10 refuse outright. Ruling batch #153 item 5 letter 1 (#18605) kept this declaration as a COPY of the HTTP request key with the same meaning, so the disposition is enforce, not retire. - -The primitive now applies the same rule the HTTP door applies (maintainer ruling batch #157 item 5 letter C, 「缺省 = 保持,有旗 = 设置」), through the same registry verbs `PATCH /packages/:id/enable` and `PATCH /packages/:id/disable` use: - -```text -enableOnInstall: true ⇒ enablePackage — clears a disable, including a boot-seeded one -enableOnInstall: false ⇒ disablePackage — the row and its `status` both move -enableOnInstall absent ⇒ no lifecycle call at all; the row the registry returned stands -``` - -Absent is a third state, not a synonym for `true`: on a FRESH id the registry still lands the package enabled (the declared default), and on an EXISTING row it preserves whatever that row says (#18877). A non-boolean value is read as absent rather than coerced. - -⚠️ **What this seam does not write, stated rather than implied.** The runtime's durable disabled-package file is keyed by environment (`setPackageDisabled(environmentId, id, disabled)`, `@objectstack/runtime`), and an `InstallPackageRequest` carries no environment, so that record cannot be written from here — the HTTP door owns that half and writes it from the row it returned. `enableOnInstall` through the in-process primitive therefore moves the registry row, which is what every in-process reader serves from, for the life of the process; a caller that needs the choice replayed after a restart goes through the door that owns the durable record. - -No behaviour changes for any caller on the tree: measured across `packages/**`, `examples/**` and `apps/**`, no existing call site sets the key — the HTTP door deliberately calls `installPackage({ manifest, settings })` and performs the flip itself, and `duplicatePackage` passes `{ manifest }` alone. The change is observable only to a caller that sets the key, which until now got silence. - -Clause-②: no diff --git a/.changeset/19289-implicit-reference-target-census.md b/.changeset/19289-implicit-reference-target-census.md deleted file mode 100644 index c2a45ba85d6..00000000000 --- a/.changeset/19289-implicit-reference-target-census.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch -"@objectstack/lint": patch -"@objectstack/rest": patch ---- - -Four consumers of the implicit-reference-target contract resolve a reference field's target through `referenceTargetOf` instead of the materialized `reference` carrier, so a `{ type: 'user' }` field authored without one seeds, serves, and lints as the fully specified metadata the spec says it is (#19289). - -`IMPLICIT_REFERENCE_TARGETS` (`@objectstack/spec/data`) says a `user` field's target is "a CONSTANT OF THE TYPE, so `reference` on a `user` field materializes that constant; it does not supply it. Metadata authored without it (hand-written JSON, an AI author, a Studio form) is **fully specified, not under-specified**." Two arbiters answer two different questions — `referenceCarrierOf` what the carrier says, `referenceTargetOf` what the field points at — and for `user` only the second matches that text. #18550 standardized a population of readers on the first, which is correct wherever a site's own type gate excludes `user` and wrong wherever it does not. This is the census of that population: 17 carrier call sites judged one by one, four repaired. - -Clause-②: no - -Not a widening. It deletes a mistaken refusal of metadata the published contract already declares complete, which the charter files as `no` — 「删已发布契约文本本就否定的误拒本身是 `no`」. No key, alias or spelling is newly accepted anywhere: the target comes from the spec's own constant, never from a second way of writing it. - -- **`@objectstack/rest` — the loud one.** A `publicPicker` on a spec-complete `{ type: 'user' }` field answered `500 LOOKUP_TARGET_MISSING`, so opening a reference picker on a "responsible person" column returned an error page. It now answers `200` over `sys_user`. ⛔ This is not a re-widening of #12920's narrowing: a stored def spelling the target `referenceTo` / `target` / `options.objectName` still resolves nothing and still answers `500`, pinned in both directions. -- **`@objectstack/metadata-protocol` — the silent one, and the one that stored a wrong value.** A seed row's `{ type: 'user' }` field contributed no `dependsOn` edge and never reached `references`, so its natural key was written **verbatim** into a column that holds a record id — the dangling reference `buildDependencyGraph`'s own docblock names as the cause of broken parent joins. ⚠️ Upgrading seed authors: such a field now takes the same path the explicit `reference: 'sys_user'` spelling always took, which includes the failure path — a natural key that resolves to no `sys_user` row now DROPS the whole record, counted, reported and logged at `error`, where it was previously written verbatim. Seed `sys_user` before the referencing object, enable `multiPass`, or fix the key. -- **`@objectstack/lint` — the widest.** `object-graph`'s field slice fed `resolveFieldPath`, whose `RELATIONSHIP_FIELD_TYPES` admits `user`; a carrier-less one answered `hop-untargeted`, which `isUnjudgeable` treats as "the graph could not answer". Every rule in the package that resolves a field path therefore stopped judging any path through such a field, reporting nothing. `validate-field-consumers` separately dropped the `displayField` consumer edge onto `sys_user`, so a field that column displays was reported consumed by nobody. -- **Nothing else widens.** `user` is the only member of `IMPLICIT_REFERENCE_TARGETS`, so a `lookup` / `master_detail` / `tree` whose author-chosen target is absent still names nothing, exactly as before — pinned at every repaired site. -- **The unreadable-carrier behaviour is unchanged.** `referenceTargetOf` reads the carrier through `referenceCarrierOf` **before** it judges the type, so #13053/#18550's `TypeError` on an object- or array-valued `reference` still fires everywhere it fired before. The implicit target is not a fallback that swallows it. -- **No authoring change.** Metadata that already spells `reference: 'sys_user'` resolves to the same target it always did; nobody has to restate the constant, and nobody has to stop restating it. diff --git a/.changeset/19295-meta-types-erased-authoring-mark.md b/.changeset/19295-meta-types-erased-authoring-mark.md deleted file mode 100644 index ccbbe7dbb7a..00000000000 --- a/.changeset/19295-meta-types-erased-authoring-mark.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor ---- - -`GET /meta/types` now marks a member whose AUTHORING arm the output derivation erased, so a consumer can tell an erased authoring type from a member that genuinely admits anything (#19295). - -Every predicate slot the platform serves — `hook.condition`, `field.visibleWhen` / `readonlyWhen` / `requiredWhen`, a flow `edge.condition`, `job.schedule.expression` — composes the expression-input family, a two-arm union whose string arm is a `ZodPipe`. The served derivation is zod's default `io: 'output'`, which describes what comes OUT of the transform, so the arm's own input type is erased and the member is served as - -```json -{ "anyOf": [ {}, { "type": "object", "properties": { "dialect": {}, "source": {} } } ] } -``` - -On the wire `{}` means "admits everything", so a metadata designer could not tell that husk from a member that really does accept any instance, and a condition builder had to veto both. - -Each such husk arm now carries one vendor-prefixed keyword: - -```json -{ "x-objectstack-erased-authoring-input": { "version": 1, "type": "string" } } -``` - -`Clause-②: no` - -- **Read the mark, never the key name.** A consumer that enables a builder by matching `hook.condition` / `visibleWhen` / the rest keeps a second, hand-written copy of that list and drifts the moment a new predicate slot lands. The keyword is the whole contract, and `version` travels inside the value so a consumer gates on the shape it understands rather than on mere presence. -- **It constrains nothing.** JSON Schema ignores an unrecognised keyword, so every document accepts exactly what it accepted before — the payload is byte-different and semantically identical. This is deliberately NOT the blanket `io: 'input'` derivation, which was measured across the served surface and refused as a weakening of a published contract (24 of 26 types answer differently; `required` entries 1132 to 867). That refusal and its pin are untouched. -- **The predicate is structural, and declines on absence of evidence.** Three facts must hold: the emitted subschema admits everything, the zod node behind it is a pipe, and the pipe's input side derives a named `type`. `z.unknown()` and `z.any()` emit `{}` too and are not pipes, so they stay bare — including the ADR-0089 envelope's own `ast`, which sits one level below a marked arm. Measured over the served surface: 78 marked arms across eight types, 187 `{}` nodes left unmarked. -- **`action` carries no mark, and that is the honest answer.** It is the one type served from the `io: 'input'` retry, where a pipe derives from its input side, nothing is erased, and the predicate slot already publishes its real string arm. diff --git a/.changeset/19297-undoable-precise-refusal.md b/.changeset/19297-undoable-precise-refusal.md deleted file mode 100644 index 503ed4ca9af..00000000000 --- a/.changeset/19297-undoable-precise-refusal.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING for authored metadata** — `undoable: true` on a registered `action` is now legal only on a shape some runtime actually fulfils, and refused at parse time everywhere else. - -Clause-②: yes - -The accept set narrows. `undoable` was a plain optional boolean that no refinement read, so it parsed clean on every action shape while only two of them ever produced an Undo — the declared-but-inert case the spec refuses at author time (ADR-0078). - -**The two fulfilled shapes, and which runtime fulfils each** - -| shape | who takes the snapshot | -| --- | --- | -| `operation: 'update'` (with a `patch`) | the framework runtime — the prior value of every field in the merged write bag, `patch` UNDER the collected `params` | -| `type: 'api'` | the pinned console — it builds the undo envelope from `undoable` alone | - -Both stay accepted, byte-identically. Naming the console in the contract is deliberate: the spec is the contract for every runtime including the console, and a closed table of fulfillable combinations is what the declared-is-delivered rule asks for. - -**What is refused** - -`undoable: true` on `type: 'script'` (the default route) or `type: 'url'`, and on the dormant `type: 'flow'` / `'modal'` / `'form'`, in each case without `operation: 'update'`. Nothing reads the flag on those shapes, so it promised an Undo that never appeared. - -``` -✗ undoable: `undoable: true` has no runtime that can fulfil it on this action. An Undo is - captured on exactly two shapes: `operation: 'update'`, where the framework runtime snapshots - the prior value of every field the write bag touches, and `type: 'api'`, which the console - snapshots. … -``` - -**⛔ What is deliberately NOT refused, because it was measured wrong.** Requiring `operation: 'update'` — the obvious repair — would refuse the published `ReassignLeadAction` skill example (`type: 'api'` + `undoable: true`, no `operation`) at import time, since `defineAction` IS `ActionSchema.parse`, and every console api action with undo along with it. The console's two readers gate the undo envelope on `action.undoable` alone with zero reads of `action.operation`, and those same two files are the entire recorded evidence for this package's own liveness verdict `action/undoable: live`. `undoable` absent or `false` is untouched on every type, and the rule lives on `ActionSchema`'s refine chain alone — an inline action is not a registered action. - -### Migration — FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `{ type: 'script', body, undoable: true }` | `{ operation: 'update', patch: { … }, undoable: true }` if the action is a single-record field write, or drop `undoable` and keep the handler | -| `{ type: 'url' \| 'flow' \| 'modal' \| 'form', undoable: true }` | the same action without `undoable` — those routes never had a capture, so behaviour is unchanged | - -⛔ Not mechanically convertible, so this ships as an ADR-0087 D3 structured TODO rather than a D2 conversion: which of the two fulfilled shapes an author meant is an intent no artifact records — a `script` action with an inline handler and an api action calling an endpoint are different dispatches, not two spellings of one — and dropping the flag automatically would remove an Undo the author asked for. - - - -**Published surface.** No export is added, removed or renamed; `ActionType` still carries all six types. The `undoable` `.describe()` and the comment above it are corrected in the same change: both claimed that an action with no `operation` has nothing anchoring the capture, which is false against the pinned console. diff --git a/.changeset/19301-changelog-undoable-capture-set.md b/.changeset/19301-changelog-undoable-capture-set.md deleted file mode 100644 index a51d878dd0f..00000000000 --- a/.changeset/19301-changelog-undoable-capture-set.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the published `effae80` changelog entry states ONE `undoable` capture set — the one that shipped (#19301) - -Clause-②: no - -`packages/spec` ships `CHANGELOG.md` inside its npm tarball (it is named in -`files[]`), so an entry there is a published surface — the text an upgrading -agent greps. The single entry for `effae80: feat(spec): a row action gets the -declarative single-record field write` stated BOTH capture sets, four lines -apart: - -> `undoable` now has its anchor — the patch names exactly the fields whose prior values are captured. - -> `undoable` captures the prior values of exactly the fields written. - -**The second is the one that shipped.** `declarativeUpdateWrite` builds the -write bag as `{ ...patch, ...params }`, and `executeDeclarativeUpdateAction` -keys `undoData` off `Object.keys(data)` over that bag — so on any -params-carrying action the capture is strictly wider than `patch` names. The -same entry's own `patch` bullet already says the static values are "merged -UNDER the values `params` collects (a param of the same name wins)". The first -sentence was wrong when it was written; it is not a record of behaviour that -later changed. - -The first sentence now names the merged write bag, in the wording the settled -`ActionSchema.undoable` description uses. Nothing else in the entry moves and -no other entry is touched: the correction is an **amendment in place**, not an -erratum in a later entry — a reader who greps the old promise lands on this -entry and nowhere else. - -- **Prose only.** No key, export, accept set, refusal, tombstone or generated - artifact moves; the same author input parses the same way before and after. diff --git a/.changeset/19307-permission-set-duplicate-name-refusal-code.md b/.changeset/19307-permission-set-duplicate-name-refusal-code.md deleted file mode 100644 index eb5768f3eb0..00000000000 --- a/.changeset/19307-permission-set-duplicate-name-refusal-code.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -'@objectstack/plugin-security': patch -'@objectstack/spec': minor ---- - -fix(plugin-security): the `sys_permission_set` duplicate-name refusal carries `UNIQUE_VIOLATION`, and the packaged-set lock answers first (#19307) - -Clause-②: yes - -Two halves of one defect on the data door's insert leg for `sys_permission_set` -(`permission-set-projection.ts`), both measured live on `examples/app-showcase` -with a seeded admin over a cookie session. - -**1. The refusal carried no machine-readable code.** It threw a bare `Error` -with `.status = 409` and no `.code`, and the flat `{ error, code }` responder -invents nothing for a producer that declared nothing, so the client got prose: - -``` -POST /api/v1/data/sys_permission_set {"name":"dev_local_set"} -→ 409 {"error":"[Security] permission set 'dev_local_set' already exists","object":"sys_permission_set"} -``` - -ADR-0112's 2026-08-17 amendment closed `error.code` at the flat door too, so a -409 with no code is that contract unhonoured — and a UI that has to branch on -the refusal was pushed back to string-matching. The same request now answers -`409 … "code":"UNIQUE_VIOLATION"`, message byte-identical. - -⚠️ `UNIQUE_VIOLATION` is REUSED, not minted. `sys_permission_set` declares -`{ fields: ['name'], unique: 'organization' }`, so this very collision already -answers `409 UNIQUE_VIOLATION` when the index catches it instead of this -pre-check; a second spelling would make one condition answer two envelopes -depending only on which layer got there first. The ledger gains a provenance -row for `@objectstack/plugin-security` — the union, its casing and every other -package's rows are unchanged, and no schema shape moves. - -**2. It ran BEFORE the packaged-set lock, so the most likely path answered the -less useful of two true refusals.** A package-declared set has a projected row, -so its name is duplicate AND locked at once. An admin who opened the Clone -dialog on a packaged set and typed the base set's own name — the single most -likely thing to type — got `already exists`, which names no remedy, and never -reached `NOT_OVERRIDABLE`, which names the clone path. The lock now runs first: - -``` -POST /api/v1/data/sys_permission_set {"name":"showcase_manager"} -→ 403 {"error":"[Security] Permission set 'showcase_manager' is declared by package - 'com.example.showcase' and is locked … Choose a different name for your set, or clone - 'showcase_manager' …","code":"NOT_OVERRIDABLE","object":"sys_permission_set"} -``` - -**What did NOT move**, measured on the same runtime: an ordinary -(non-package-declared) duplicate **whose provenance the lock can resolve** still -answers the duplicate refusal and not `NOT_OVERRIDABLE` — that qualifier is -load-bearing, and the corner below is the case it excludes; an unauthenticated -write on the same resource still answers `401 UNAUTHENTICATED`; and an `update` -targeting a packaged set answers `403 NOT_OVERRIDABLE` exactly as before. - -⚠️ **One corner moved with the order**: an ordinary duplicate attempted while no -artifact source can answer now takes the lock's fail-closed `unknown` refusal — -`403` `NOT_OVERRIDABLE` (`PackagedPermissionSetProvenanceUnknownError`, "retry -once the metadata layer is readable") — instead of the 409. Both are refusals and -neither writes; it is pinned so the behaviour is declared rather than incidental. - -⚠️ **And the order has a cost, stated rather than discovered**: the lock's probe -(`protocol.getMetaItemLayered`) used to be evaluated only AFTER the duplicate -check passed, so a duplicate insert never paid for it. It is now evaluated -unconditionally, ahead of that check. Two consequences, both deliberate: every -**duplicate** insert on `sys_permission_set` costs one extra metadata round trip -(the accepted path's cost is unchanged — it always paid this probe), and the -duplicate path is now COUPLED to metadata-layer reachability, where before it -answered from the record alone. That coupling is the mechanism behind the corner -above, and it is the price of putting the refusal that names the remedy first. diff --git a/.changeset/19311-collapsed-alias-alone-mapping.md b/.changeset/19311-collapsed-alias-alone-mapping.md deleted file mode 100644 index 860be105421..00000000000 --- a/.changeset/19311-collapsed-alias-alone-mapping.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`ObjectFieldGroup.collapsed` — the deprecated alias's `describe()` now states what the key maps to **on its own**, so the published reference page no longer leaves an author to guess whether `collapsed: true` also needs `collapsible` beside it (#19311). - -`collapse` (ADR-0085) replaced the `collapsible` / `collapsed` boolean pair, and `ObjectSchema.parse` still folds the old pair onto it. That mapping has always been total — a group authored `collapsed: true` and nothing else parses to `collapse: 'collapsed'`, which the enum's own describe spells out as *collapsible, starts closed* — but the alias's describe said only `` Boolean pair with `collapsible`; use the `collapse` enum. ``, and that sentence is what `content/docs/references/data/object.mdx` publishes. The sibling `defaultExpanded` already spelled its mapping out (`true → 'expanded', false → 'collapsed'`); these two aliases did not. - -Measured against the built package, `ObjectSchema.safeParse` on one field group: - -| authored on the group | `collapse` after parse | -| :--- | :--- | -| `collapsed: true` | `'collapsed'` | -| `collapsed: false` | `'none'` | -| `collapsible: true` | `'expanded'` | -| `collapsible: false` | `'none'` | -| `collapsed: true` + `collapsible: false` | `'collapsed'` — `collapsed` outranks | -| an explicit `collapse` | wins; the aliases are not read | - -- **Text only.** No key is added, removed or re-typed, no accept set moves and the normalizer is untouched: the nine probe inputs above parse to the same nine results before and after. -- **`collapsible`'s own describe is left unchanged** and still carries the mirror-image silence about what `collapsible: true` alone means (`'expanded'`). It is reported rather than ridden along on a card that names `collapsed`. -- **This is the object-level `fieldGroups` pair only.** The form-view `sections[].collapsible` / `sections[].collapsed` pair is a different, non-deprecated surface with no alias mapping behind it, and nothing here touches it. diff --git a/.changeset/19311-form-section-collapse-describes.md b/.changeset/19311-form-section-collapse-describes.md deleted file mode 100644 index eec9c7ea1f3..00000000000 --- a/.changeset/19311-form-section-collapse-describes.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`FormSection.collapsible` / `FormSection.collapsed` — both keys now carry a `.describe()`, so the published reference page no longer prints two empty Description cells for two authorable booleans (#19311). - -They were the only keys in `FormSectionSchema` with no contract text at all, sitting between neighbours that have it, and the dependency between them was published nowhere. **Both** are described rather than only `collapsed`: the sibling silence is what made the gap ambiguous in the first place, and describing one of a pair recreates it one key over. - -Measured against the built package, `FormSectionSchema.safeParse` on one section: - -| authored on the section | `collapsible` after parse | `collapsed` after parse | -| :--- | :--- | :--- | -| neither | `false` | `false` | -| `collapsible: true` | `true` | `false` | -| `collapsed: true` | `false` | `true` | -| `collapsed: true` + `collapsible: false` | `false` | `true` | -| both `true` | `true` | `true` | -| both `false` | `false` | `false` | - -- **Parse does NOT normalize the pair, in either direction.** `{ collapsed: true }` parses to `{ collapsible: false, collapsed: true }` verbatim, and `safeParseAsync` agrees. So the implication `collapsed` ⇒ `collapsible` — ruled 2026-09-18, letter A — is a **renderer** rule applied from the declaration, and the describes say exactly that rather than implying a fold the schema does not perform. A consumer reading the parsed `collapsible` is reading what the author typed, never whether a disclosure control renders. -- **This is the opposite of the `ObjectFieldGroup` pair**, where a parse-time mapping really does fold the old booleans onto the ADR-0085 `collapse` enum. The two surfaces share key names and share nothing else; the describes say so. -- **Text only.** No key is added, removed or re-typed, no accept set moves and no refinement changes: `check:authorable-surface` and `check:api-surface` are both green with no delta, and the wizard-step and `group` co-declaration refusals parse identically before and after (only `true` is refused in either place; `false` is accepted in both). diff --git a/.changeset/19320-percent-storage-scale-derivation.md b/.changeset/19320-percent-storage-scale-derivation.md deleted file mode 100644 index 79455e2a1fb..00000000000 --- a/.changeset/19320-percent-storage-scale-derivation.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/spec": minor ---- - -`Clause-②: yes (widening)` - -A `percent` field's declared `scale` is the number of decimal places of the **percentage-point** value as displayed and entered; the **stored** allowance now derives from it. For a fraction-stored percent the record validator's `max_scale` branch accepts `scale + 2` decimal places in the stored fraction (#19320). - -Maintainer ruling batch #161 item 3 letter B (2026-09-18) settles what one word means: `scale: 2` on a percent field is two displayed decimals, so the edit widget offers `12.34` and writes the fraction `0.1234`. The branch compared those four places against the raw declaration and refused the write — an author could declare two displayed decimals and then not write two displayed decimals. - -- **Which fields move**: only a **fraction-stored** percent, i.e. one whose `percentScaleOf` is `fraction` — no declared `max`, or a `max` at or below 1. A **whole-percent** field (`max` above 1) stores the displayed number itself and keeps the declared `scale` exactly, as do `number`, `currency`, `slider` and `rating`. The split is read from the spec's `percentScaleOf`, not re-decided at this seam. -- **Direction, measured in both**: over a 1,950-cell corpus of declaration x written value, **36 cells move from refused to accepted and 0 move the other way**. Nothing that writes today stops writing; no stored value is re-read or re-judged; no migration is implied. -- **`FieldSchema.scale`'s describe states both meanings**, which is the half of the ruling that makes the derivation legible to an author: what the number counts (displayed percentage points) and what it permits in storage (`fraction` ⇒ `scale + 2`, `whole` ⇒ `scale`). The generated field reference page carries the same sentence, and `percentScaleOf`'s docblock points at it rather than restating it. -- **The refusal envelope names the allowance that was applied.** On a fraction-stored `scale: 2` field, `0.12345` is still refused and reports `constraint: { scale: 4, actual: 5 }` — previously it would have read `{ scale: 2, actual: 5 }` on a field that accepts four places, a true refusal described by a false constraint. A consumer asserting the raw declaration back out of a percent field's `max_scale` envelope reads the derived number instead. diff --git a/.changeset/19324-record-stage-index-signature-docblock.md b/.changeset/19324-record-stage-index-signature-docblock.md deleted file mode 100644 index 8df32023bfc..00000000000 --- a/.changeset/19324-record-stage-index-signature-docblock.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/client': patch ---- - -`RecordStagePackageBodySchema`, `AssembledInstalledPackageSchema` and `ObjectStackClient.packages.list` now say, in their published docblocks, that `manifest`'s static type is deliberately an index signature and that the runtime schema is the enforced contract (#19324) - -Clause-②: no - -`AssembledInstalledPackage['manifest']` is `RecordStagePackageBodySchema`, declared `z.ZodType, Record>`. So its published type is an index signature: an authoring-stage `InstalledPackage` assigns to `AssembledInstalledPackage`, and a row whose `manifest` belongs to neither stage type-checks as an `InstalledPackageAtEitherStage`. The maintainer ruled that this is the accepted static contract (#19324, letter 丙). The three declarations now say so where a TypeScript reader meets them: - -- **The runtime schema is the enforced contract.** `InstalledPackageAtEitherStageSchema.safeParse()` refuses a `manifest` that belongs to neither stage. Tell the two stages apart by parsing, never by the static type. -- **Why the type is not inferred.** `tsc` refuses to print the whole metadata vocabulary into the declarations that embed it (TS7056). Dropping the record and artifact stages' annotations and the `ZodRawShape` cast fails the declaration build with TS7056 at `PackageApiContracts`. A named alias would turn `stack.zod` into a shared declaration chunk, the heap failure #14513 recorded. -- **The precise form, if the schema depth ever allows it,** is the one #19324 measured as A2, with its cost recorded at `RecordStagePackageBodySchema`. - -**`@objectstack/client`**: the `packages.list` TSDoc used to call this asymmetry "a KNOWN GAP rather than a design", tracked on #19324, and cited a `stack.zod.ts` line number. It now calls it the accepted static contract, cites `RecordStagePackageBodySchema` by name, and keeps its advice unchanged: narrow a row by parsing it with a `@objectstack/spec` schema, and never by `Array.isArray(pkg.manifest.objects)`. - -This settles what the `@objectstack/client` read-door changeset (#17536) calls "a known gap, tracked as #19324". The gap is not closing under #19324: it is the accepted static contract, and the client pin that records it stays. - -⛔ No behaviour changes. No type, schema, accept set, authorable key or export moves. Only TSDoc and source comments change, and they ship: - -- `@objectstack/spec`'s published `files[]` carries `dist`, where the TSDoc is emitted into the `.d.ts` / `.d.mts` declarations, and `src/**/*.zod.ts`, so both edited files also ship as source. -- `@objectstack/client`'s published `files[]` carries `dist`, where the rewritten paragraph lands in `index.d.ts`, `index.d.mts`, `index.js` and `index.mjs`. diff --git a/.changeset/19327-install-door-residual-split.md b/.changeset/19327-install-door-residual-split.md deleted file mode 100644 index d97845e674e..00000000000 --- a/.changeset/19327-install-door-residual-split.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`PackageInstallBodySchema`'s residual docblock splits clause 1: a manifest missing `version` is refused by the install door now, and only the `type` half is still residual - -Clause-②: no - -The docblock lists the bodies `POST /api/v1/packages` answers `201` to while -the declaration refuses them. Its clause 1 recorded "a manifest missing `type` -and/or `version`" as ONE class. Since #19326 the door parses -`ManifestSchema.shape.version` by reference, so a manifest missing `version` -answers `400` / `VALIDATION_ERROR` and installs nothing; a manifest missing -`type` still answers `201`. Half of the clause had become false. - -The clause is now split: **1a** (missing `version`) is marked CLOSED by #19326 -and names the door-side pin, and **1b** (missing `type`) stays an open residual. -The count of five classes is unchanged and still true, because class 1 stays -open through its `type` half; the count sentence now says so. The paragraph -that quotes the runtime's two door drives is updated too. It quoted the -duplicate-id drive as `{ id: 'pkg-a', name: 'A' }`, but that drive has posted a -`version` since #19326 and the reverse-domain id `com.example.pkg-a` since -#19473, and the door answers `400` to `pkg-a`. The docblock now quotes the body -the drive posts, `{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }`, -which the declaration refuses on `type` alone and the door answers `201`. - -⛔ No behaviour changes. No schema, accept set, export or runtime code moves, -and the other four residual classes are untouched. - -**Why this carries a changeset and not `skip-changeset`.** `@objectstack/spec`'s -`files[]` ships `src/**/*.zod.ts` verbatim, and the docblock is also emitted -into `dist/api/index.d.ts` and `dist/api/index.d.mts`. The published content -changes, even though no line of code does. diff --git a/.changeset/19328-install-door-body-parse.md b/.changeset/19328-install-door-body-parse.md deleted file mode 100644 index d29812e84b6..00000000000 --- a/.changeset/19328-install-door-body-parse.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime): `POST /api/v1/packages` parses the whole body through `PackageInstallBodySchema` instead of reading it key by key (#19328) - -Clause-②: no (narrowing) - -**BREAKING for callers of the install door.** The door now enforces the -WHOLE install-body declaration. A body that `PackageInstallBodySchema` refuses -is refused with `400` / `VALIDATION_ERROR` and installs nothing, whatever the -reason. Every shape below used to answer `201`, and two of the install options -used to be honoured. The list names the shapes a caller is most likely to have -sent, and ends with one bullet for everything else the declaration states. For -each one, change what you send FROM the refused shape TO the declared one: - -- **A manifest with no `type`, in either body form.** FROM - `{ "manifest": { "id": …, "name": …, "version": … } }` (or the same manifest - sent bare) TO the same manifest with a declared `type`: one of `app`, - `plugin`, `ui`, `driver`, `server`, `theme`, `agent`, `objectql`, `module`, - `gateway` or `adapter`, e.g. `"type": "app"`. It used to install anyway. -- **A manifest with no `name`, in either body form.** `name` is the manifest's - other required key besides `id`, `version` and `type`. FROM - `{ "manifest": { "id": …, "version": …, "type": … } }` (or the same manifest - sent bare) TO the same manifest with `"name": "…"`, the human-readable - package name. It used to install anyway, because the door parsed only the - `id` and `version` legs. -- **An unknown key inside the manifest, or on a bare body.** Example: a - transposed `namesapce`. FROM a manifest carrying the undeclared key TO the - manifest with that key removed, or spelled as the declared key it was meant - to be (`namespace`). The refusal names the key. The key used to be STORED - with the package. -- **A string-typed `enableOnInstall` or `overwrite`.** FROM - `"enableOnInstall": "false"` / `"overwrite": "true"` TO JSON booleans, - `"enableOnInstall": false` / `"overwrite": true`. `'false'` used to install a - fresh package ENABLED, which is the opposite of what the caller asked for. - `'true'` for `overwrite` was read as absent, which answered `409` on an - installed id. -- **Install options spelled on the BARE form (`enableOnInstall`, `overwrite`, - `settings`).** FROM `{ "id": …, …, "overwrite": true }` TO the wrapped form, - `{ "manifest": { "id": …, … }, "enableOnInstall": …, "overwrite": …, - "settings": … }`. `overwrite` may also go on the query string instead - (`?overwrite=true`), which a bare body may keep using. Before this change the - door handled these key by key: `enableOnInstall` was ignored, but - **`overwrite: true` and `settings` were HONOURED** (an installed id was - overwritten, and the settings reached the install). All three were also - stored as manifest keys. They are refused now. This is the part of the - change that removes behaviour a caller could have been relying on. -- **Any other constraint `ManifestSchema` declares.** Each one is now enforced - at this door. Before, only the `id` and `version` legs were, and the manifest - was stored as sent. The constraints include: - - the `namespace` grammar: 2–20 characters, starting with a lowercase - letter, with only lowercase letters, digits and underscores; - - the closed value sets of `scope` (`cloud`, `system`, `project`), - `runtime` and `packaging`; - - the declared type of a key (for example a non-string `description`, or a - non-string version in `dependencies`); - - the retired manifest keys `configuration`, `capabilities`, `extensions` and - `loading`, which the declaration keeps only as named refusals; - - unknown keys inside the nested blocks the declaration closes. These are - `contributes` (and its `kinds[]`), `data[]` (each entry is a `SeedSchema` - seed), `navigationContributions[]` (and their items), `engine`, `engines`, - and the structured form of `permissions`. - - FROM the off-declaration value TO the value the declaration states. The - refusal names the path. - -The accept set only shrinks back to what the published declaration has always -said. `PackageInstallBodySchema` in `@objectstack/spec` is a union of two -branches: the wrapped request, or a bare manifest as the whole body. -`ManifestSchema` declares the manifest's required keys (`id`, `name`, -`version`, `type`) and the grammar or value set of the rest. It closes the -manifest, and the nested blocks listed above, against unknown keys. -The wrapped request types `enableOnInstall` and `overwrite` as booleans. Its -docblock says a bare manifest carries no install options: «a caller that needs -an option sends the wrapped form». Until now, the door parsed only two legs of -the manifest (`id` and `version`) and read every other key positionally off -the raw body. That is «declared ≠ enforced» on a published API contract. Now -the door parses the body once through the declared union and reads -`overwrite`, `settings` and `enableOnInstall` off the parsed request. Nothing -in `@objectstack/spec` moves, and neither branch of the union is relaxed. - -**What is not affected.** - -- A well-formed body installs exactly as before, in both forms and on both - install limbs (the protocol primitive and the bare-registry fallback). -- The first-party SDK (`client.packages.install(m, { enableOnInstall, - overwrite, settings })`) already sends the wrapped form with JSON booleans. - Studio's create-package dialog sends `{ manifest }` with a declared `type`. -- The manifest is stored as sent, with no parse-time defaults added. -- The refusals for a missing or malformed `id` and `version` keep their own - sentences and still come first. -- Every request-shape refusal is still answered ahead of the duplicate-id - `409`. -- Boot-time and in-process installs reach `SchemaRegistry.installPackage` / - `ObjectQL.registerApp` directly and never pass through this door. - -**Still accepted, because the declaration accepts it.** An unknown key at the -top level of the wrapped form (`{ manifest, bogus }`) is dropped, not refused. -`PackageInstallRequestSchema` is declared in strip mode, and the door now does -exactly what the declaration says. - -**If you are refused.** The refusal names the path of each failed constraint, -and the form it read the body as. A misplaced install option also gets the wrapped form (and, for -`overwrite`, `?overwrite=true`) spelled out. So the prescription arrives with -the `400` rather than in a changelog. - - diff --git a/.changeset/19331-scalar-form-rows.md b/.changeset/19331-scalar-form-rows.md deleted file mode 100644 index 7504dc1d1ff..00000000000 --- a/.changeset/19331-scalar-form-rows.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/platform-objects": patch ---- - -45 declared-but-unoffered scalar metadata keys are authorable in the metadata form. Each was **declared** by an object-rooted metadata schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY` — so the generic metadata form rendered no row for any of them and an author's only door was the Source tab: free-text JSON, where a mis-spelled sibling key is written, stored, and refused by the runtime later. - -**The population was re-derived, not inherited.** The reconciliation gate's own helper block (`packages/spec/src/system/metadata-form-zod-reconciliation.test.ts`, lines 104-469 verbatim) was run over the live registry with a lit control (`name`, offered by 17 of 17 forms) and a dark control (a fabricated key, 0 forms and 0 schemas) asserted in the same probe. Readings on the tree this change starts from: **142** top-level zod-only keys across the 17 forms once the ADR-0010 provenance overlay is skipped, **94** of them on the 11 object-rooted types (`view` is union-rooted and contributes the other 48), **87** of those graded `live`, and **49** of those resolving to a scalar schema node. After the change the same probe reads 4, which are the four rows deliberately not landed. - -**Four keys are deliberately still unoffered**, each because a control for it would be an authoring trap rather than an offer: - -- `object.displayNameField` — `[DEPRECATED → nameField]`. Its canonical replacement `nameField` lands here; offering the alias beside it would teach an author the retired spelling. -- `app._unpublished` — the schema's own text says `Never authored`: a machine-managed publish gate written by the AI materialization path and cleared by publish-drafts. -- `field.system` — the auto-injected/system-column marker the platform stamps (`applySystemFields`, the search companion). It is read widely on the write path — the record validator skips required and multi-value checks for a flagged column — so a control for it lets an author assert a false provenance that silently disables validation for that field. -- `field.format` — **one `z.string()` key carrying three vocabularies**, so no help text can be written for it until someone rules which one it has. The engine reads it as an **autonumber pattern**: `resolveAutonumberFormat` (`packages/spec/src/data/autonumber-format.ts:196-202`) falls back from `autonumberFormat` to `format`, and `packages/objectql/src/engine.ts:5043-5051` calls it for every `autonumber` field — as does the SQL driver. objectui reads it as a **date display style**, `short` / `relative`, pinned at the `.objectui-sha` this repo builds against by `packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx` and `packages/plugin-detail/src/__tests__/DetailSection.dueLikeReachesTheCell-9729.test.tsx`. The published `describe` names a third — `email`, `phone` — that **nothing measured honours**: an author who follows it on an autonumber field gets the literal string `email` rendered as their number. - -**The control follows the scalar type and the copy states what the runtime enforces**, including what ABSENCE resolves to, which is the half an author cannot read off an enum: `object.sharingModel` says a custom object that omits it resolves to `private`; `field.step` says the write path does not reject a value off the step grid; `action.undoable` says an action with no `operation` has no write set to capture. Nineteen rows carry a `visibleWhen` MEANINGFULNESS gate mirrored from the same key's row in the object designer's quick-add grid — the schema accepts each key whatever the sibling value is, but only some field types, page kinds or action operations ever read it. - -Three enums (`object.managedBy`, `action.execution`, `action.openIn`) deliberately carry **no** inline `options` list: `FormSelectOptionSchema.value` is a system identifier (`^[a-z][a-z0-9_.]*$`), so members such as `system-data`, `engine-owned` or `perRecord` cannot be spelled as option values at all. Those rows derive their enum from the served JSON Schema, which carries every member verbatim, and the meanings ride the help text. - -⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles, whose 90 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills, because those three catalogs are ratcheted against undecided echoes. - -⛔ **The gate that would notice a missing row is NOT landed here.** The top-level `zodOnly` direction of the reconciliation gate stays unwired: turning it on today would turn the remaining absences into red lines with no offers behind them, which is the shape the census round explicitly refused. This change lands offers; the assertion is a separate card. diff --git a/.changeset/19332-g1b-field-action-form-rows.md b/.changeset/19332-g1b-field-action-form-rows.md deleted file mode 100644 index 37991c13344..00000000000 --- a/.changeset/19332-g1b-field-action-form-rows.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/platform-objects": patch ---- - -Clause-②: no - -Sixteen live structured metadata keys are authorable in the metadata form: eleven on the field form — `accept`, `currencyConfig`, `dependsOn`, `lookupColumns`, `lookupFilters`, `readonlyWhen`, `relatedListColumns`, `requiredPermissions`, `requiredWhen`, `storage`, `visibleWhen` — and five on the action form — `bodyExtra`, `description`, `errorMessage`, `patch`, `requiredPermissions`. Each was **declared** by its schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each now has exactly one row, whose control copies a row a registered form already carries for the same node shape: - -- `visibleWhen`, `readonlyWhen`, `requiredWhen` — `type: 'code'`, `language: 'expression'`, the object designer's per-field rows for the same three keys. -- `lookupFilters` — `widget: 'json'`, the object designer's per-field row for the same key; no inline operator list, because `notIn` is not a spellable option value. -- `lookupColumns`, `dependsOn` — `widget: 'json'`, **never** `string-tags`: each is an array of a union (a field name, or an object entry), and the tag widget is a chip input for strings only, which cannot show or edit a stored object entry. -- `accept`, `relatedListColumns`, and both `requiredPermissions` — `widget: 'string-tags'`, the app form's `requiredPermissions` row: a chip input over a plain `string[]`. -- `currencyConfig`, `storage` — a `composite` with declared sub-rows (`currencyMode` as a `dynamic` / `fixed` select, `defaultCurrency`; `notNull`), the shape of the object form's `access` row. -- `patch`, `bodyExtra` — `widget: 'json'` over a string-keyed record. -- `description` — `widget: 'textarea'`, the page form's `description` row, over the same `I18nLabel` node; `errorMessage` — a plain row, the twin of `successMessage`. - -Each type-specific row is gated to the types its runtime reader serves: the media types for `accept`, `currency` for `currencyConfig`, `lookup` / `master_detail` for the picker and related-list rows, those two plus the four option types for `dependsOn`, `operation: 'update'` for `patch` (the parse refuses it anywhere else), and `type: 'api'` for `bodyExtra`. The help text states what the runtime does with each value, including what absence resolves to. The four field-name lists (`relatedListColumns`, `lookupColumns`, `lookupFilters[].field`, `dependsOn`) are free text: no authoring door judges their names today — not the schema parse, not the publish door and not `os validate` — so the help text claims no such refusal. - -⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles, whose 38 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. - -⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers only. diff --git a/.changeset/19332-g2a-fieldgroups-indexes-form-rows.md b/.changeset/19332-g2a-fieldgroups-indexes-form-rows.md deleted file mode 100644 index 58eeaee7646..00000000000 --- a/.changeset/19332-g2a-fieldgroups-indexes-form-rows.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/platform-objects": patch ---- - -Clause-②: no - -Two live structured object keys are authorable in the metadata form: `fieldGroups` and `indexes`. Each was **declared** by `ObjectSchema`, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each is now a `type: 'repeater'` row on the object form whose sub-rows are declared by hand rather than derived from the schema: - -- `fieldGroups` (Basics, beside `highlightFields`) — six sub-rows, one per canonical group key: `key` and `label` (required text), `icon` (text), `description` (textarea), `collapse` (a `none` / `expanded` / `collapsed` select) and `visibleWhen` (`type: 'code'`, `language: 'expression'`, the `fields` grid's predicate rows). The three `[DEPRECATED → collapse]` aliases (`defaultExpanded`, `collapsible`, `collapsed`) are **not** offered; the metadata-form reconciliation ledger records a nested `omit` row for each. The parse still accepts them and derives `collapse` from one only when `collapse` is absent, so a stored entry keeps its meaning, and a `collapse` set in the form outranks any alias it carries. -- `indexes` (Advanced, beside `datasource`) — three sub-rows over the keys the SQL driver reads: `name` (text), `fields` (`widget: 'string-tags'`, required) and `unique`, a select offering **only** `global` and `organization`. The deprecated bare `unique: true` is never offered: a schema-derived control would take the union's first arm and render a switch that writes it. An edit merges into the stored entry, so an index that already carries `true` or `false` keeps it until the author picks a scope, and the select can write only the two values the parse accepts. `type` and `partial` are tombstones and have no row. - -The help text states what the runtime does with each value. `indexes[].fields` is free text, and the schema parse, so a draft save, does not judge its names; `os validate`, `os build`, `os lint` and the publish door refuse a name that is not a field of the object (`object-field-ref-unknown`, #20479, in the same release). A name that is not a stored column, a `formula` field say, makes the SQL driver skip the whole index at sync with an error in the server log, and the help text says exactly that; `os migrate plan` reports the skipped index too (#20432, in the same release). A field group has no field-name list: a field joins a group through its own `group` key. - -The two row schemas also carry a JSON Schema `title` on every property, as every repeater row schema must: `IndexSchema` on `name`, `fields` and `unique`, and `ObjectFieldGroupSchema` on its nine keys, the three deprecated aliases included. A property panel that reads the served schema's titles therefore shows a named column instead of a raw key. Each title is a `.meta({ title })` call and nothing more. - -⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves (its rows, and the titles above in its JSON Schema) and the translation keys `os i18n extract` walks, hence the regenerated `platform-objects` metadata-form bundles. Their 22 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. - -⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers and three nested ledger rows only. diff --git a/.changeset/19332-g2b-remaining-g2-form-rows.md b/.changeset/19332-g2b-remaining-g2-form-rows.md deleted file mode 100644 index 61d9ad9864e..00000000000 --- a/.changeset/19332-g2b-remaining-g2-form-rows.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/platform-objects": patch ---- - -Clause-②: no - -Four more live structured keys are authorable in the metadata forms: `activityMilestones`, `publicSharing` and `userActions` on the object form, and `inlineColumns` on the field form. Each was **declared** by its schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each is now a row whose sub-rows are declared by hand rather than derived from the schema: - -- `activityMilestones` (object form, Advanced, beside `validations`) — a `type: 'repeater'` over the milestone's four keys: `field` (`widget: 'text'`, required), `value` and `summary` (text, required) and `type` (text). `field` pins its widget because the console turns a string sub-row named `field` into a field picker whose catalogue an object draft never fills. -- `publicSharing` (object form, Advanced, after `requiredPermissions`) — a `type: 'composite'` over all six keys of the share-link policy: `enabled` (switch), `allowedAudiences` and `allowedPermissions` (`widget: 'multiselect'` over their enum members), `maxExpiryDays` (number, at least 1), `redactFields` (`widget: 'string-tags'`) and `eligibility` (`type: 'code'`, `language: 'expression'`). -- `userActions` (object form, Advanced, under `managedBy`) — a `type: 'composite'` over the five affordance keys. `create`, `import`, `edit` and `delete` are each a boolean **or** a `{ enabled, visibleWhen, disabledWhen }` object, so they take `widget: 'json'`: the console renders a switch for a new entry or a stored boolean, and the object's own keys for a stored object, and never writes one arm over the other. `exportCsv` is a switch. -- `inlineColumns` (field form, Configuration, beside `inlineTitle`, shown on `master_detail` fields) — a `type: 'repeater'` over a **curated subset** of the twenty keys an inline grid column accepts: `name` (required), `label`, `width` and `defaultHidden`. The metadata-form reconciliation ledger records the nested `subset` row and names what is left to source and why: `type` opts a column out of hydration from the child field, the type-specific keys cannot be gated on a type the column takes from the child field at render, and the rules are copies of the child field's own. - -The help text states what the runtime does with each value, read from its consumer, and claims a refusal only where one exists. A misspelt `publicSharing.redactFields` entry is refused at publish and by `os validate`. `activityMilestones[].field`, a `{token}` in its `summary`, and `inlineColumns[].name` are judged by no authoring door, and their help texts say so and name what happens instead: the milestone never fires, the token renders empty, the column renders as plain text. - -The two new repeaters' row schemas also carry a JSON Schema `title` on every property, as every repeater row schema must: the four keys of an `activityMilestones` entry, and all twenty keys of `InlineGridColumnSchema`. Each title is a `.meta({ title })` call and nothing more. - -⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves (its rows, and the titles above in its JSON Schema) and the translation keys `os i18n extract` walks, hence the regenerated `platform-objects` metadata-form bundles. Their 46 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. - -⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers and one nested ledger row only. diff --git a/.changeset/19339-kernel-install-request-describe-honours.md b/.changeset/19339-kernel-install-request-describe-honours.md deleted file mode 100644 index 72258688e11..00000000000 --- a/.changeset/19339-kernel-install-request-describe-honours.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`kernel/InstallPackageRequest.enableOnInstall` no longer tells authors the in-process primitive ignores the key — it now states the three states that primitive really applies (#19339). - -The declaration's published description read "this protocol primitive does not read it". That was true when it was written and stopped being true when `MetadataProtocol.installPackage` started honouring the key (`482d584121`): `true` enables, `false` disables, and an ABSENT key makes no lifecycle call at all. Nothing went red, because `check:docs` holds the generated reference page equal to the `.describe()` and the two still agreed with each other — internal consistency, not truth. - -Clause-②: no - -**What moves** - -The `.describe()` text of one key, the doc block above it, and the two reference pages generated from that text (`references/api/protocol.mdx`, `references/kernel/package-registry.mdx`). It now reads: "restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive honours it on the registry row: true enables, false disables, absent makes no lifecycle call". - -The scope word "on the registry row" is load-bearing and is spelled out in the doc block: the durable disabled-package file is keyed by environment, which an `InstallPackageRequest` does not carry, so this seam moves the registry row for the life of the process and `POST /api/v1/packages` still owns the record that survives a restart. - -**What does not move** - -No key is added, removed, renamed or retyped, and no default changes — the accept set is byte-for-byte what it was, and `api-surface`, `authorable-surface` and `authorable-defaults` are all unchanged. `PackageInstallRequestSchema` (`api/package-api.zod.ts`) remains the one authority for this key, and the parity pin that holds the copy to it is untouched. diff --git a/.changeset/19339-package-api-install-door-denial.md b/.changeset/19339-package-api-install-door-denial.md deleted file mode 100644 index 4383f1df1d7..00000000000 --- a/.changeset/19339-package-api-install-door-denial.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The install door's doc block no longer denies that the in-process protocol primitive reads `enableOnInstall` (#19339). - -`PackageInstallRequestSchema.enableOnInstall` (`api/package-api.zod.ts`) carries the map to the other two declarations of this key, and its entry for the kernel copy read: "its own implementation does not read it, and this door does not forward it down that seam". That was true when it was written and stopped being true when `MetadataProtocol.installPackage` started honouring the key (`482d584121`). Nothing went red — no gate compares a sentence against an implementation — and the text ships: `src/**/*.zod.ts` is in this package's `files[]`, and the comment survives into `dist/api/index.js` and `dist/browser/api/index.mjs`. - -Clause-②: no - -**Only one half of the sentence was false.** It is a compound claim about two layers, and they were re-derived separately from the source rather than rewritten together: - -- `MetadataProtocol.installPackage` (`packages/metadata-protocol/src/protocol.ts`, the `requestedEnabled` arms) now reads the key: `true` enables, `false` disables, an absent key makes no lifecycle call at all. That half is corrected, and scoped — the primitive moves the **registry row**, for the life of the process. -- "this door does not forward it down that seam" is **still true** on `main` and is kept: `handlePackages` (`packages/runtime/src/domains/packages.ts`) calls `installPackage({ manifest, settings })` and performs the enable/disable flip itself, then writes the durable record from the row it returned. Correcting that clause would have swapped one false sentence for another. - -The scope words are load-bearing: the durable disabled-package record is keyed by environment (`setPackageDisabled(environmentId, …)`), which an `InstallPackageRequest` does not carry, so `POST /api/v1/packages` still owns the half that survives a restart. - -**What does not move.** No key is added, removed, renamed or retyped, and no default changes: the accept set is byte-for-byte what it was, `check:api-surface` and `check:authorable-surface` are green with no diff, and no generated reference page changes — this text is a TSDoc block, not a `.describe()`, so `check:generated` reports all 15 artifacts up to date without a regeneration. The declaration is `no` on both limbs: nothing is widened and nothing is retired. diff --git a/.changeset/19346-project-fields-prototype-name-ban.md b/.changeset/19346-project-fields-prototype-name-ban.md deleted file mode 100644 index 0958e71e27a..00000000000 --- a/.changeset/19346-project-fields-prototype-name-ban.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -**BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the `constructor` / `prototype` field-name ban that `ObjectSchema.fields` has always enforced, so a validator reading the published files stops answering PASS on `{"constructor":{"type":"text","label":"R"}}` at `data/Object.properties.fields` — a document the runtime refuses by name (#19346; #18670 item 2, through the `banned-keys` arm). - -Clause-②: yes (narrowing) - -**No arm joins the closed list.** The ban is over a FINITE list of two names, which is exactly what the existing `banned-keys` arm expresses, so this is a call site moving onto a declared pattern rather than a new public-contract decision. What moved is WHERE the refusal is written: from a `.refine()` on the record's KEY schema to a record-level `bannedKeys(['constructor', 'prototype'])` inside the existing `refuseRecordProtoKey(...)` wrapper. A key-schema `.refine()` is a `custom` check, and `z.toJSONSchema()` has no arm for one, so that rule reached the runtime and never the file. - -**The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json` goes from 204 entries / 569 sites to **204 entries / 560 sites** — nine site deletions, no entry deletions (every one of the nine schemas keeps other rows), and **0 sites added anywhere**: - -| ledger entry | row deleted | -|:---|:---| -| `api/AssembledInstalledPackage` | `manifest.objects.element.fields.out.keyType` | -| `api/GetInstalledPackageResponse` | `data.options[1].manifest.objects.element.fields.out.keyType` | -| `api/InstalledPackageAtEitherStage` | `options[1].manifest.objects.element.fields.out.keyType` | -| `api/ListInstalledPackagesResponse` | `data.packages.element.options[1].manifest.objects.element.fields.out.keyType` | -| `api/ObjectDefinitionResponse` | `data.fields.out.keyType` | -| `data/Object` | `fields.out.keyType` | -| `system/ChangeSet` | `operations.element.options[3].object.fields.out.keyType` | -| `system/CreateObjectOperation` | `object.fields.out.keyType` | -| `system/MigrationOperation` | `options[3].object.fields.out.keyType` | - -Generator census after: **560 dropped across 204 published schemas, 366 projected** — 224 `non-blank-string`, 129 `required-one-of`, **11 `banned-keys`** (2 before), 2 `dependent-required` — 9 undecidable. Across the published tree, **1524 of 1535 files are byte-identical**: the nine carriers above each gain the ban and lose their matching `x-dropped-refinements` row, and the remaining two are the bundle (`objectstack.json`) and the build-input hash. - -**⛔ The set of documents the runtime accepts does not move.** The arm is EXACT rather than approximate: a JSON object's properties are exactly its own enumerable string-keyed ones and `propertyNames` judges exactly those names, and `bannedKeys` reads OWN properties and never `key in value` — which is what the key schema judged too, since a record's key loop only ever visits own keys. It is presence and never value: a banned key present with a `null` value is present on both sides. Measured with ajv 8 (draft 2020-12) on the generated `data/Object.json`, before and after, the verdict vector moves in one direction only — `{"constructor": …}` and `{"prototype": …}` go `true` to `false`, while an ordinary document and the near-miss controls `{"constructors": …}` and `{"to_string": …}` are accepted on both sides. - -**⚠️ What DOES move is the refusal's location, and a consumer will see it at BOTH layers** — the raw zod issue, and the published `{field, code, message}` envelope every REST / data-API client reads (ADR-0114, built by `api/zod-issues-to-fields.ts`). Measured on this tree by parsing `{"name":"lead","label":"Lead","fields":{"title":{…},"constructor":{…}}}` with the schema before and after: - -| layer | | before | after | -|:---|:---|:---|:---| -| raw zod issue | `path` | `['fields', '']` | `['fields']` | -| raw zod issue | `code` | `invalid_key` | `custom` | -| raw zod issue | where the reason text sits | nested one level down, under zod's fixed "Invalid key in record" | the issue's own `message` | -| published envelope | entries | **2** | **1** | -| published envelope | `field` | `fields.constructor` on both entries | `fields` | -| published envelope | `code` | `invalid_shape` (zod's "Invalid key in record") **and** `invalid_value` (the reason) | `invalid_value` alone | - -⚠️ `invalid_shape` is a member of the published `FieldErrorCode` vocabulary and it no longer appears for this refusal at all. A client that branched on `invalid_shape` to detect a rejected field NAME must branch on `invalid_value` at `field: "fields"` instead, and must stop expecting two entries where it now receives one. - -The message text is unchanged and still names both reserved words in full. The fix for a consumer that keyed on the old shape: match the issue at path `fields` with code `custom` — `field: "fields"`, `code: "invalid_value"` in the envelope — and read its `message` directly, instead of descending into an `invalid_key` issue's nested `issues[0]`. This is the cost of the projection: the closed list can only publish a RECORD-level predicate, and `.refine()` carries no per-key path, so a located-per-key refusal and a published refusal cannot both be had from one rule. The ban list is closed and two names long, so the slot is still named and the two candidate keys are both named in the message. - -**⛔ `__proto__` is untouched, and it is a third name rather than a third case.** Its guard is `refuseRecordProtoKey`'s `z.preprocess` on the raw input, because zod's record parser skips that one name with an unconditional `continue` ABOVE the key schema — no schema, and therefore no projection, can ever see it. It holds no ledger row and gains no published keyword here. This change reaches two of the three names, never three. - - diff --git a/.changeset/19349-translation-target-collection-artifact-reach.md b/.changeset/19349-translation-target-collection-artifact-reach.md deleted file mode 100644 index 944327e83f4..00000000000 --- a/.changeset/19349-translation-target-collection-artifact-reach.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`translation-target-unknown` no longer reports the locale keys a package ships for a view, page, action, app, dashboard or flow a SIBLING package of the same artifact declares. Six collection rungs built their universe from the top-level collection alone, while the same file already read `packages[].manifest.…` for `navigationContributions` (#18442), `objectExtensions` (#18441) and `objects` (#19064) — so the capability was present and these rungs did not use it (#19349). - -`os build` runs the rule table per PACKAGE as well as over the union (`compile.ts` step 3b-ii): each package body is judged as its own stack with the artifact's `packages[]` beside it as resolution context (`packageBodyAsStack`, #16611). On that leg each `stack.` holds ONE package's declarations. Measured on a throwaway probe before anything was touched, each level produced exactly one `error`, at `translations[0]["zh-CN"].dashboards.crm_overview`, `….flows.lead_conversion`, `….globalActions.export_all`, `….objects.crm_order._views.board`, `….objects.crm_order._tabs.mine` and `….apps.crm_app` — each carrying a remedy (`or drop it`) that deletes a translation the runtime honours, at a severity that FAILS the run. - -**The carrier is proven rather than assumed.** Every one of these keys carries disposition `concat` in `COMPOSE_KEY_DISPOSITIONS`, which is precisely what puts it inside `ASSEMBLED_PACKAGE_BODY_DISPOSITIONS` and so inside an ADR-0130 D4 entry's assembled body — the same proof the `objectExtensions` and `objects` folds rest on. ADR-0130 makes the release artifact the co-ownership boundary, so the miss is the RUN's blind spot and not the author's mistake. - -**Records, not names — and for three different reasons, not one assumption applied six times.** `dashboards`, `flows` and `apps` are keyed by their own name and carry a sub-rung derived from the record (widget ids and header `actionUrl`s, screen node ids and their field names, navigation ids), so a name-only fold would resolve the top key and then judge that sub-rung against an empty set. The `actions` record is itself read downstream by `checkActionParams`, and it carries the owner that keeps an object-bound action under `objects.._actions` instead of making it globally addressable. `views` and `pages` have no bundle rung of their own at all — they contribute `_views`, `_sections` and `_tabs` facts under the object they bind to — so the record is the only thing carrying both the fact and its binding. - -**`apps` is the half #18442 did not cover.** That change reads `navigationContributions`, so an app became addressable only where THIS package contributes into it; a sibling's `apps[]` declaration was invisible either way. With no contribution the app NAME was the orphan; with one, the name resolved through #18442 while the owner's own navigation ids were orphans — and were diagnosed "this stack contributes no such item", advising a move to a package sitting in the same artifact. Folding the records before the contributed-only pass closes both halves and restores the declared-app diagnosis, while an app owned OUTSIDE the artifact keeps #18442's wording. - -**The controls, which are what make this a narrowing and not a hole.** Every rung pins both directions side by side: the same bundle judged ALONE still errors (so "no findings" cannot be confused with the rung going quiet); a name no entry of the artifact declares still errors with its rule id and a remedy that now enumerates what the artifact provides; every sub-rung stays judged against the sibling's declaration, so a typo under a resolved dashboard, flow, screen, app or object is still an `error`; an object-bound sibling action keyed under `globalActions` still errors with its routing message; where both packages declare the same name the declaration being judged keeps the slot, so a sibling's widget ids do not become addressable under this package's dashboard; an entry with no readable body (a segment reference) makes nothing addressable on any rung; and the single-`defineStack` shape is untouched, because all six are stack collections with no `stack.manifest` form to read. - -No schema moved, no export moved, and no accept set moved: this is a lint rule's false-positive set narrowing. `Clause-②: no` diff --git a/.changeset/19359-collect-docs-header-justification.md b/.changeset/19359-collect-docs-header-justification.md deleted file mode 100644 index ead5e5d9758..00000000000 --- a/.changeset/19359-collect-docs-header-justification.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`collect-docs.ts`'s module header no longer gives the retired ADR-0048 claim as the reason for the doc naming lints (#19359) - -The header explained the naming lints with *"the metadata registry key carries -no package coordinate, so a bare-name collision silently overwrites across -packages"*. That is ADR-0048 §1.1 **context**, and the same ADR's §3.3/§3.4 -overturned it: the write is already composite-keyed (§1.2 — *"The silence is in -the read, not the write"*), and *"The cross-package **throw is retired**; two -distinct packages coexist on the same bare name by construction."* The file -already declined that sentence by name 900 lines below, in -`lintDocNamesAcrossOwners` (#19248) — the correction just never reached the top -of the file. - -**That one sentence justified two rules, and they do not rest on the same -thing**, so it was not carried up verbatim: - -- `docs/duplicate-name` rests on **authoring hygiene**, the class ADR-0048 §3.4 - keeps by name. The header now points at `lintDocNamesAcrossOwners` instead of - restating it — a second copy of a justification is how the first one went - stale. -- `docs/namespace-prefix` / `docs/namespace-required` rest on the **flat link - namespace**, which ADR-0048 never touched. A doc link is `[text](./NAME.md)`, - a bare name with nowhere to put a package coordinate, flat on purpose so an - editor or a GitHub preview resolves it natively (ADR-0046 §3.1/§3.3) — and - this module keys on the prefix to tell a same-package link, which it checks, - from a cross-package one, which it defers to publish. ADR-0048 §3.3 repaired - metadata reads by ADDING a package-id argument to `getItem`; the link form has - nowhere to put one, so nothing §3.4 retired was ever load-bearing here. - -⛔ No behaviour changes. No rule, message, severity or accept set moves; the -only edited bytes are comment bytes. - -**This ships, which is why it carries a changeset rather than -`skip-changeset`** — re-measured on this branch's rebuilt artifact rather than -inherited from #19248. `@objectstack/cli`'s published `files[]` is -`["dist","README.md","CHANGELOG.md"]` and the package builds with plain `tsc` -(`tsc -p tsconfig.build.json`; `removeComments` appears nowhere in the package -or the root configs), so comments are emitted into the tarball. Measured after -`turbo run build --filter=@objectstack/cli`: `npm pack --dry-run` lists -`dist/utils/collect-docs.js` at 50.5 kB among 537 files; the new clause is -present there (1 occurrence); the old bullet spelling is absent from all of -`dist` (0); the retired phrase now occurs exactly once in `dist`, inside the -quotation that declines it. `dist/**/*.d.ts` carries 0 occurrences, because the -block sits above the imports rather than on an exported symbol — so the -published JS bytes move while the declaration surface does not. diff --git a/.changeset/19365-automation-runs-cursor-hasmore.md b/.changeset/19365-automation-runs-cursor-hasmore.md deleted file mode 100644 index 2d6e29a9924..00000000000 --- a/.changeset/19365-automation-runs-cursor-hasmore.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/runtime': minor -'@objectstack/service-automation': minor -'@objectstack/client': minor ---- - -feat(automation): `GET /automation/:name/runs` retires `cursor` and computes `hasMore` (#19543) - -This door declared a pagination parameter it never spent and then reported, as a -literal, that there was nothing more to fetch. Both halves are closed here, per -the maintainer-approved ruling of 2026-09-21 (decision batch #204 item 2, -letter C of three). - -**BREAKING** — `cursor` no longer parses on `ListRunsRequestSchema`, its slot -is gone from `IAutomationService.listRuns`, and `@objectstack/client` no longer -declares or sends it on any of the three run-list surfaces -(`automation.runs.list`, `automation.listRuns`, -`client.environment(id).automation.listRuns`). It was declared on the wire, -*validated* at the boundary, forwarded into the service contract, appended by -the SDK, and read by no implementation. No emit site has ever written the -response half `nextCursor`, and the only ordering this door has is a required -but non-unique `startedAt` timestamp that nothing ever minted a resume point -from — so a caller looping "until the cursor runs out" re-read the first and -only window forever, with no error. - -``` -FROM ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' }) - -> { name: 'f', limit: 20, cursor: 'n_007' } // forwarded, then dropped - -TO ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' }) - -> throws: '`cursor` was removed from GET /api/v1/automation/:name/runs in - @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) …' -``` - -`cursor` is a `retiredKey()` tombstone rather than a deletion: the request -schema is not `.strict()`, so a bare deletion would have made Zod silently strip -whatever a generated client kept sending — a clean parse and a parameter that -never takes effect, which is this defect re-created one layer down (ADR-0104). -Writing the key is now a `tsc` error and a parse error carrying the -prescription. - -**The SDK is retired in the same stroke, and that is what makes the sentence -above true.** Retiring the key in the schema alone would have left the one -generated client this repo ships typing it `string` and sending it into a route -that no longer reads it — the exact ADR-0104 shape the tombstone exists to -prevent, re-created one layer down, for the channel most callers actually reach -this door through. So the option is gone from all three surfaces and no -`?cursor=` is appended on any of them; an untyped caller cannot smuggle it past -the retired schema either, which is pinned. Same call as when #6361 retired the -notifications `cursor`: the client dropped the option and recorded the removal -in its docblock. - -``` -FROM client.automation.runs.list('f', { limit: 5, cursor: 'abc' }) - -> GET …/automation/f/runs?limit=5&cursor=abc // the key is dropped server-side - -TO client.automation.runs.list('f', { limit: 5 }) - -> GET …/automation/f/runs?limit=5 - // `{ cursor }` is now a TS2353 excess-property error; widen `limit` - // (1..100) and read `hasMore` instead. -``` - -**⛔ `limit` is NOT retired, and its `.default(20)` stays.** The sibling -`/packages` door retired *its* `limit` alongside `cursor` (#17667) because -nothing read it. That does not transfer, and the ruling says so explicitly: here -`limit` is read end to end — the HTTP boundary enforces the declared `1..100` -range read off the schema itself, the service takes it as an option, and the -engine spends it as the run store's history window. Retiring it would have been -a regression, not a narrowing. - -**`hasMore` is now computed, and this is a behaviour change callers can see.** -The door shipped `{ runs, hasMore: false }` with the `false` written as a -literal, beside a list the engine had already cut with `.slice(0, limit)`. A -caller asking for one row of a thousand was handed one row and told that was all -of them. A request whose window is shorter than the matching run set now -receives `hasMore: true` where it previously received `false`; a caller that -read `false` as "this is the whole history" was always wrong and is now told so. -`nextCursor` stays absent — nothing mints one. - -Read the new `false` with **one qualification**: unfiltered it is exact, but -under `?status=` it means "no further match inside the window that was scanned" -rather than "none exists", because the durable history source has no status slot -and the window is taken before the filter is applied. Pushing the filter down is -a `RunStore` contract change this card did not scope. The published -`RunListResult.hasMore` docblock and the response schema's own description both -carry that qualification, so a consumer meets it where they meet the field. - -**How truncation is established, because the obvious signal is wrong.** -`runs.length === limit` cannot tell a flow holding exactly `limit` runs from one -holding ten thousand; the two windows are byte-identical. So -`AutomationEngine` over-reads its history source by exactly one row and compares -the merged, filtered, ordered set against the caller's window. -`RunStore.listHistory`'s signature is deliberately unchanged — over-reading is -expressible in the `limit` it already takes. - -**New:** `IAutomationService.listRunsPage`, an optional member returning -`{ runs, hasMore }` (the shape `IExportService.listExportJobs` already uses, -minus the cursor nothing mints), plus the exported `RunListResult`. The engine -implements it and `listRuns` is its `runs` half, so there is one implementation -and no second copy to rot. A deployment whose automation service does not -implement it answers `501` naming the member, never a `200` carrying a guessed -`hasMore`. - -**One strictness regression, stated because it reverses a recorded decision.** -`?cursor=a&cursor=b` used to answer `400 VALIDATION_FAILED` and now answers -`200` with the key ignored, like any other unrecognised query name. #7300 -validated the key rather than deciding it, so that a future cursor -implementation would not be the one to discover the type was unenforced; this -ruling decides it instead — there will be no cursor implementation on this -door — so the refusal would be validating a key the contract no longer has. -This route declares no closed query-parameter set, so an unrecognised name has -never been refused here on its own account. - -Clause-②: yes - - diff --git a/.changeset/19370-role-word-crosses-whole.md b/.changeset/19370-role-word-crosses-whole.md deleted file mode 100644 index 60a1f3a65ea..00000000000 --- a/.changeset/19370-role-word-crosses-whole.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -**BREAKING for runtime metadata writes** — the ADR-0090 D3 vocabulary freeze (`security-role-word`) now runs at the runtime publish gate for all six collections it judges, so `position` and `app` writes are gated for the first time (#19370) - -Clause-②: no (narrowing) - -`validateSecurityRoleWord` moves from `surfaces: ['cli']` to -`['cli', 'runtime-publish']` and declares -`runtimeTypes: ['object', 'permission', 'book', 'position', 'app']` — the write -type of every collection it judges. `position` and `app` join -`TYPE_TO_STACK_KEY` in `runtime-gate.ts` so the gate can build a per-write -snapshot for them. - -**The refusal set grows.** A runtime metadata write — Studio's designer, REST -`/meta`, an MCP/AI author — that carries the reserved word `role` in a -security-relevant identifier or label is now refused with the 422 lint envelope -instead of stored. Concretely, these used to succeed at that door and no longer -do: - -- an object, field, action or field-group header named or labelled for `role`; -- a permission set named or labelled for `role` (e.g. `role_manager`); -- a documentation book named or labelled for `role`; -- a **position** named or labelled for `role` (e.g. `sales_role`); -- an **app** named or labelled for `role` (e.g. `role_hub`). - -The platform vocabulary the rule freezes is unchanged and so is its fix-it text: -`permission_set` for capability, `position` for distribution, `business_unit` -for hierarchy. Nothing is renamed, retired or added — this is the same rule, -with the same rule id and the same findings, now enforced at the fourth door as -well as by `os validate` / `os build` / `os lint`. - -**Nothing changes for the three CLI commands.** Both security entries have run -on all three since the #8310 split, and their union is byte-identical to before. - -**Stored rows are untouched** (#4463 D4: the gate blocks new writes, never the -read path), and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` remains the migration-window -escape hatch for a tenant that authored one of these names before this landed. - -Why the two types were held back until now, and why the wait ended: `position` -and `app` are `allowRuntimeCreate: true`, so a position called `sales_role` could -be minted through the one entrance a tenant has while an object of that name was -refused. Under #7220 one rule id sits on ONE side of the wall, so the rule was -split out and held back whole rather than wired for a subset of its collections. -Mapping the two write types is what lets it cross, also whole. - -Deliberately NOT done: carrying `positions` / `apps` as `RuntimeStackContext` -collections. A collection joins that context because some rule resolves -references into it; this rule resolves nothing — it judges each identifier and -label on its own — so a sibling position tells it nothing about the written one -and its finding cancels in the gate's differential either way. Carrying them -would cost the publish door one indexed `sys_metadata` read per write for no -verdict change. - - diff --git a/.changeset/19377-between-field-endpoint-runtime-door.md b/.changeset/19377-between-field-endpoint-runtime-door.md deleted file mode 100644 index f5e15197241..00000000000 --- a/.changeset/19377-between-field-endpoint-runtime-door.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING for callers** — `parseFilterAST` now refuses a `{ $field }` column reference as a `$between` endpoint, exactly as the authoring schema has since 2026-08-11. A reference at either bound is refused with `INVALID_FILTER` / 400, and the refusal names the side — MIN or MAX, plus the index (#19377). - -Clause-②: yes - -## What changed, and why it is the implementation catching up rather than a new rule - -`RANGE_ENDPOINT_DESCRIPTION` — the published endpoint contract shared by both of `$between`'s bounds — has stated verbatim since 2026-08-11 that "A { $field } reference is NOT an endpoint shape: no backend resolves one inside a list". The ruling that wrote it (ADR-0049 enforce-or-remove) removed `FieldReferenceSchema` from both endpoint unions, and it shipped at the authoring schema alone. The runtime door disagreed with it: `parseFilterAST({ f: { $between: [{ $field: 'a' }, 'M'] } })` returned the filter unchanged, same object reference, measured on `origin/main` before this change and re-measured after. - -One published sentence therefore had two truth values, decided by which door a caller came through — and the door that passed it is the one an embedder reaches by handing a lowered filter straight to a driver. There, nothing resolves the reference: the in-memory matchers compare the raw reference OBJECT and the range silently matches nothing, while both SQL faces refuse the position. A filter that names a window and answers no rows, or 400s one layer down, is what a caller got instead of a refusal they could act on. - -``` -FROM parseFilterAST({ close_date: { $between: [{ $field: 'contract.start' }, '2026-12-31'] } }) - -> the same object, unchanged, straight on to the driver - -TO throws INVALID_FILTER / 400: - 'Operator "$between" on field "close_date" does not accept a { "$field": … } - reference as an endpoint (at where.close_date.$between[0], the MIN bound). …' -``` - -## Migration — FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `{ $between: [{ $field: 'contract.start' }, '2026-12-31'] }` | `{ $between: ['2026-01-01', '2026-12-31'] }` — the literal bound the range was meant to stop at | -| a range that was genuinely meant to be column-to-column | `{ "$gte": { "$field": "a" }, "$lte": { "$field": "b" } }` — two scalar bounds, the position that compiles on every face | - -**The one-line fix: write the literal bound, or — if the range really was column-to-column — drop `$between` and write the two bounds separately as `$gte` / `$lte`.** Nothing was evaluating the old filter, so treat the replacement as a new one and test it: at every backend the reference range either matched nothing or was refused. - -## What does NOT change - -- **A `{ $field }` reference as the WHOLE comparand of `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`.** That is #5222's shipped column-to-column capability, it is the alternative this refusal prescribes, and it lowers exactly as before — pinned by a lit control in the same file. -- **Every legal range.** Numbers, Dates, ISO days, UTC instants, clock times and non-temporal text all lower byte-identically, same object reference. -- **The three older endpoint carve-outs.** Arity, `null` (2026-08-31) and blank (2026-09-17) are checked first, so a pair carrying one of those keeps the message and the prescription it already had — an author who wrote `null` is still sent to the null predicate, not to a scalar comparison. -- **A plain object that is not a reference** keeps the comparand-TYPE door's own sentence, one step further on. -- **`$in` / `$nin` members.** The same 2026-08-11 decision rules a reference out of those positions too and `SET_MEMBER_DESCRIPTION` publishes it, but that is a second split over a different published sentence; it is measured and filed separately, and this change deliberately does not move it. -- **The published export surface.** No export is added, removed or renamed; the refusal rides the existing `$between` arm of the shared comparand-shape door, so the engine's delegating wrapper inherits it unchanged. - - diff --git a/.changeset/19383-environments-any-family-membership-pin.md b/.changeset/19383-environments-any-family-membership-pin.md deleted file mode 100644 index 8025cfbf56a..00000000000 --- a/.changeset/19383-environments-any-family-membership-pin.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/client": patch ---- - -`ObjectStackClient.environments` — the docblock that licenses the namespace's erased `any` now names where the family is enumerated, and the enumeration exists (#19383). - -Fourteen methods on `environments.*` and the nested `environments.packages.*` return types that **contain** `any` and carry **no return annotation**. They are deliberate: the `/api/v1/cloud/*` control plane speaks snake_case, its row contracts left this repo with `@objectstack/spec/cloud`, and binding them here would typecheck and be false. Nothing mechanical held the family, though — `check:exported-any-returns` asks whether an awaited return type **IS** `any` and never whether it **CONTAINS** one (a documented scope that buys the gate zero false positives), and with no annotation on any signature line there is no text for a search to find. A 15th such method landed silently green under a paragraph that licensed it in advance. - -- **What changed for a consumer**: one paragraph of published TSDoc on `environments`. It bounds the licence — the family is enumerated by name in the package's own `environments-any-family.pin.test.ts`, and a method that pin does not list is not covered by the paragraph. No export, signature, envelope key or runtime behaviour moves; the emitted declarations are otherwise byte-identical. -- **Pinned by membership, not by a CONTAINS-any detector.** A `ts.createProgram` + `TypeChecker` census over the SDK surface shows CONTAINS-any has no canonical boundary here: the population is a function of how many hops the walk is allowed (24 at three, 43 at four, 57 at five and six), an unbounded walk does not terminate, and 144 callables are still unexplored at six hops — so such a gate's green would mean "no `any` within N hops", never "no `any`". -- **And a package-wide rule would refuse the protected class.** The only other unannotated `any`-containing sites on the surface are `organizations.list` (better-auth organisation `metadata`) and `oauth.applications.list` (`Record[]`), which is precisely the caller-shaped class the ratchet's ledger protects by name. diff --git a/.changeset/19387-package-registry-mount.md b/.changeset/19387-package-registry-mount.md deleted file mode 100644 index 773433fc1ee..00000000000 --- a/.changeset/19387-package-registry-mount.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/metadata-protocol': patch ---- - -fix(cli): `objectstack serve` mounts the always-on `package-registry` capability, so a package created through the API survives a restart on a stock boot (#19387) - -Clause-②: no - -`package-registry` has been on the always-on slate (`PLATFORM_ALWAYS_ON_CAPABILITIES`) since the `marketplace` / `package-registry` split, and `serve` appended it to every app's `requires`. But `Serve.CAPABILITY_PROVIDERS` did not key it, and the resolver's no-provider branch says nothing about a token the app did not declare itself. So an app that did not declare `requires: ['marketplace']` got no `package` service. `POST /api/v1/packages` answered `201`, printed `no 'package' service — '…' registered in-memory only (will not survive a restart)`, and `GET /api/v1/packages/:id` answered `404` after a restart. - -- **`package-registry` now mounts `PackageServicePlugin`** from `@objectstack/service-package`, the provider the spec's `PLATFORM_CAPABILITY_PROVIDERS` row declares for it. A stock boot creates `sys_packages` and replays it at start, so installs and manifest edits made through the API persist. -- **Apps that declare `marketplace` boot as before, with one `PackageServicePlugin`.** `marketplace` resolves to the same provider. The capability resolver now remembers the providers it has mounted itself, so the always-on token does not mount a second copy. Without that change, a declarer's boot would print `Plugin superseded: 'package-service'`. -- **A stock database gains one table, `sys_packages`.** `PackageServicePlugin` creates it with raw DDL, as it already did for `marketplace` declarers. On the in-memory driver (`memory://`), which has no raw SQL, the boot now logs that the DDL was not run and that package hydration was skipped. Packages there last only as long as the process, as before. -- `--preset minimal` still opts out of the whole slate. `protocol.installPackage` keeps its in-memory-only branch as the documented degraded path for hosts that mount no provider. -- **`@objectstack/metadata-protocol`: the `installPackage` docblock no longer says the runtime half is missing.** It used to say that a stock boot still took the in-memory-only branch. It now says that `objectstack serve` mounts `PackageServicePlugin` for `package-registry`, so a stock boot persists, and that the in-memory-only branch is for hosts that mount no provider. The docblock ships in `dist`. No behaviour changes. diff --git a/.changeset/19394-packages-list-door-enabled-filter.md b/.changeset/19394-packages-list-door-enabled-filter.md deleted file mode 100644 index 54288efc1b7..00000000000 --- a/.changeset/19394-packages-list-door-enabled-filter.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime): `GET /api/v1/packages` honours the declared `enabled` query parameter (#19394) - -Clause-②: no (narrowing) - -**BREAKING for callers of the packages list door** — `?enabled=` is now read. -A request that supplies it gets a filtered list instead of the whole one, and a -value the declared type does not admit is refused `400` / `VALIDATION_FAILED` -instead of being dropped. Both used to answer `200` with every installed -package. - -The accept set only shrinks back to what the published declaration has always -said. `ListInstalledPackagesRequestSchema` declares -`enabled: z.boolean().optional()` and the serving door never read the key, so a -caller filtering an installed-package list by `enabled` was handed the -unfiltered list with no refusal and no warning — «declared ≠ enforced» in the -silent direction, which nothing in the status, the headers or the body -distinguishes from a request served as asked. - -**The semantics are the declaration's, not a plausible reading of it.** Absent -means NO filter, and it stays reachable: `.optional()` carries no `.default()`, -so an absent key is an absent key and never collapses into `false`. An explicit -`enabled=false` is a filter and selects the disabled rows only — so "omitted" -and "false" are two different requests, which is the distinction the first-party -SDK already spells (`client.packages.list({ enabled })` sends the key only when -it is not `undefined`). `enabled` is read off the row's own `enabled` state, not -off `status`; the two are independent keys on `InstalledPackageSchema` and a row -carrying no `enabled` at all counts as enabled, which is that schema's declared -`.default(true)`. - -The coercion is the repo's one parser for a query parameter declared -`z.boolean()` (`parseBooleanParam`), the same one `GET /api/v1/notifications` -reads its identically-declared `read` with — so the wire has exactly the two -spellings the type has, and a third (`enabled=1`, `enabled=yes`, an empty -`enabled=`) is refused rather than guessed. A repeated `?enabled=a&enabled=b` is -refused with the sentence this door already uses for a repeated `?version=`. - -**What is not affected.** `status` and `type` filter exactly as before, an -unmatched filter still selects nothing rather than erroring, and `hasMore` stays -the constant `false` it became when the request-side pagination keys were -retired. Boot-time and in-process installs never reach this branch. - -**If you are refused.** Send `enabled=true` or `enabled=false`, the two -spellings the schema has always declared, or drop the key to get every row back. - - diff --git a/.changeset/19403-action-body-panel-en-echo-decisions.md b/.changeset/19403-action-body-panel-en-echo-decisions.md deleted file mode 100644 index 00640e723da..00000000000 --- a/.changeset/19403-action-body-panel-en-echo-decisions.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -The `action` metadata-form panel no longer prints English field names and tooltips to a Chinese, Japanese or Spanish author: six keys — twelve string leaves — were decided leaf by leaf and rendered in `zh-CN`, `ja-JP` and `es-ES` (#19403). - -The five children of the `body` composite (`Language`, `Source`, `Capabilities`, `Timeout Ms`, `Memory Mb`) and the `Ai` exposure block read their English source in all three locales, `helpText` included. An en-echo is not automatically a defect, so each leaf carries a recorded reason in `action-body-panel-echo-decisions.test.ts` rather than a bulk rewrite — and four of the six keys had an **authored twin at the same schema key**: `hookForm` and `actionForm` declare the same composite over `HookBodySchema`, rendered on the hook panel and echoing here, byte-identical in `en`. `memoryMb` echoes on both panels, so that row records that it has no twin evidence and composes from the sibling key instead. - -- **Machine tokens stay English, checked at the schema before a word was rendered.** `body.language.helpText` names `expression` and `js` — the two `z.literal` discriminators of `HookBodySchema`. `body.capabilities.helpText` names `api.read`, `api.write`, `crypto.uuid` and `log` — four of the five `HookBodyCapability` enum members. `ai.helpText` names `ai.exposed=true` and `ai.description`, the two canonical keys of `ActionAiSchema`, a `strictObject` that declares five aliases of `exposed`. Rendering any of them would tell an author in their own language to write a value the schema refuses. All kept, alongside `import`, `ctx` and the schema bounds `256` and `40`. -- **Values only.** No key was added or removed: each translated bundle changes 12 values, the full flattened key sets are identical on all four bundles (893 keys, 0 added, 0 removed), and `en` is untouched. Regenerated with `pnpm i18n:extract`, which dropped the 12 provenance rows per locale that recorded these leaves as unauthored extractor fills and added none. -- **The panel is now pinned by a derived population**, and a new cross-panel assertion holds the five shared `HookBodySchema` children to ONE rendering across both forms that declare them — a disagreement no single-panel pin can see, because it lives between two derivations. - -Measured on the metadata-form catalogs: label keys echoing in all three locales fall **29 → 23** while the genuinely-translated control rises **509 → 515** (`zh-CN`) and **493 → 499** (`ja-JP`, `es-ES`), same population, same run. diff --git a/.changeset/19403-bare-type-display-en-echo-decisions.md b/.changeset/19403-bare-type-display-en-echo-decisions.md deleted file mode 100644 index df8b83a0111..00000000000 --- a/.changeset/19403-bare-type-display-en-echo-decisions.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -The metadata-type chooser no longer offers a Chinese, Japanese or Spanish author six entries written in English: the display pairs of `seed`, `mapping`, `api`, `doc`, `book` and `capability` — six keys, twelve string leaves — were decided leaf by leaf and rendered in `zh-CN`, `ja-JP` and `es-ES` (#19403). These six are the **bare** metadata types: they declare no form at all, so their registry `label` and `description` are the only strings an author ever sees for them, and until now every one of those strings was its English source. - -An en-echo is not automatically a defect, so every leaf carries a recorded reason in `bare-type-display-echo-decisions.test.ts`, naming the authored leaf its wording came from — and saying so out loud where no authored twin exists (`seed`, `book` and the word "export" have none, and the rows state that rather than leaning on one). - -- **The population is derived, not listed.** The ledger walks `DEFAULT_METADATA_TYPE_REGISTRY` crossed with one predicate read off the catalog's shape — a type is *bare* when its entry has no `fields` and no `sections` — so a metadata type added later with an unauthored display pair lands in the population automatically. Ten types qualify; the six decided here are the six that echoed, and the other four (`job`, `datasource`, `external_catalog`, `translation`) are already authored and stay in the walk as its control. -- **Machine tokens stay English, and five near-misses were read at the schema before a word was rendered.** `package` in two of the descriptions *is* a legal `_provenance` value and the schemas really do accept it — cleared, because no form asks an author for an envelope key and these types have no form at all. `rename` is **not** a `TransformType` member (but `map` is, and "field mapping" contains it — the token guard judges word boundaries, not substrings); `publish` is not a `SeedMode`; `pipeline` is not an api `type`; `groups` is a key that takes an array. `HTTP`, `URL`, `Markdown`, `API`, `ADR-0121`, `ADR-0046 §6`, `ADR-0066 D1` are kept verbatim in all three locales. Every reading is asserted against the live schema rather than described. -- **"Capability" is decided per meaning, in three positions.** The metadata type takes 能力 / ケイパビリティ / Capacidad, agreeing with the `body.capabilities` token list by re-deriving from the same objects-catalog evidence rather than borrowing its decision, and differing from it in Spanish number because this leaf names one capability. The object panel's `Capabilities` section — feature toggles, a different concept under the same English word — is deliberately untouched, and both the agreement and the non-agreement are asserted. -- **Values only.** No key was added or removed: the full flattened key sets are identical on all four bundles (893 keys, 0 added, 0 removed) with a negative control proving the comparator sees a one-key delta in both directions, and `en` is untouched. Regenerated with `pnpm i18n:extract`, which dropped exactly the 12 provenance rows per locale that recorded these leaves as unauthored extractor fills, and added none — leaving **no** metadata type's display pair recorded as a fill in any locale. - -Measured on the metadata-form catalogs: label keys echoing in all three locales fall **18 → 12** while the genuinely-translated control rises **520 → 526** (`zh-CN`) and **504 → 510** (`ja-JP`, `es-ES`), same population (893 string leaves, 538 of them labels), same run. The decidable remainder — a label plus its sibling `description`/`helpText` — falls **29 → 17**, because this round decides six labels *and* six descriptions and only the labels move the headline. diff --git a/.changeset/19403-field-panel-en-echo-decisions.md b/.changeset/19403-field-panel-en-echo-decisions.md deleted file mode 100644 index f010948e712..00000000000 --- a/.changeset/19403-field-panel-en-echo-decisions.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -The standalone `field` metadata-form panel no longer prints English column heads and tooltips to a Chinese, Japanese or Spanish author: nine keys — eighteen string leaves — were decided leaf by leaf and rendered in `zh-CN`, `ja-JP` and `es-ES` (#19403). - -`Placeholder`, `Value Domain`, `Rows`, `Related List Filter` and the five row properties of `summaryOperations` (`Object`, `Function`, `Field`, `Relationship Field`, `Filter`) read their English source in all three locales, `helpText` included. An en-echo is not automatically a defect, so each leaf carries a recorded reason in `field-panel-echo-decisions.test.ts` rather than a bulk rewrite — and fourteen of the eighteen had an **authored twin at the same key path**: `object.fields.fields.*` is the same field editor embedded in the object panel, rendered there and echoing here, with seven of the twins byte-identical in `en`. - -- **Machine tokens stay English, checked at the schema before a word was rendered.** `valueDomain.helpText` names `iana_time_zone`, `iso_4217_currency` and `iso_3166_alpha2` — the three members of `ValueDomainSchema`, a `z.enum`. Rendering them as words would tell an author in their own language to write a token the schema refuses. Kept, as are `count` (a `summaryOperations.function` enum member), the spec key `inlineHelpText`, the operator `AND` and the worked example `status == received`. -- **Values only.** No key was added or removed: the three translated bundles are 18 insertions / 18 deletions each, the full flattened key sets are identical on all four bundles (893 keys, 0 added, 0 removed), and `en` is untouched. Regenerated with `pnpm i18n:extract`, which dropped the 18 provenance rows per locale that recorded these leaves as unauthored extractor fills. -- **The panel is now pinned by a derived population**, so a key added to `fieldForm` tomorrow is judged on the day it lands rather than a round later. - -Measured on the metadata-form catalogs: label keys echoing in all three locales fall **38 → 29** while the genuinely-translated control rises **500 → 509** (`zh-CN`) and **484 → 493** (`ja-JP`, `es-ES`), same population, same run. diff --git a/.changeset/19403-hook-execution-panel-en-echo-decisions.md b/.changeset/19403-hook-execution-panel-en-echo-decisions.md deleted file mode 100644 index 1ea67a76093..00000000000 --- a/.changeset/19403-hook-execution-panel-en-echo-decisions.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -The `hook` metadata-form panel no longer prints English field names and tooltips to a Chinese, Japanese or Spanish author: five keys — ten string leaves — were decided leaf by leaf and rendered in `zh-CN`, `ja-JP` and `es-ES` (#19403). With them the panel is finished: **zero** of its 47 string leaves now echoes its English source in all three locales. - -The keys are the execution controls — `Retry Policy` and its `Max Retries` / `Backoff Ms` children, the hook-level `Timeout Ms`, and `Memory Mb` on the `body` composite. Four of the five labels are the extractor's humanize of a camelCase key rather than authored English, so each is rendered as the concept with its unit in a parenthetical (`Backoff Ms` → 退避(毫秒) / バックオフ(ms) / Retroceso (ms)) instead of being touched up in English. An en-echo is not automatically a defect, so every leaf carries a recorded reason in `hook-execution-panel-echo-decisions.test.ts`, naming the authored leaf its wording came from. - -- **Machine tokens stay English — and one near-miss was read at the schema and cleared.** `timeoutMs.helpText` reads "Abort the hook after N milliseconds", and `abort` *is* a legal value of `HookSchema.onError` (`z.enum(['abort','log'])`) — but the key this tooltip describes takes a **number**, so no rendered word can land in it, and the schema's own description uses the word as the runtime's verb. Rendered. `retryPolicy.helpText` names `async`, which is `z.boolean().default(false)`, not an enum member — rendered, taking the panel's own authored 异步 / 非同期 / Asíncrono. The placeholder `N` and the bound `256` stay as written, and all four readings are asserted against the live `HookSchema` rather than described. -- **`body.capabilities` is reworded on both panels that declare it.** zh-CN 功能 and ja-JP 機能 read "feature" for what is a capability **token** from the `HookBodyCapability` enum. Every authored leaf of the objects catalog that names a capability renders it 能力 (zh-CN) and ケイパビリティ (ja-JP), so those are the words now used — on `hook` **and** `action` in one act, because both forms declare the same schema key. es-ES already read Capacidades and is unchanged. -- **Values only.** No key was added or removed: the full flattened key sets are identical on all four bundles (893 keys, 0 added, 0 removed) with a negative control proving the comparator sees a one-key delta, and `en` is untouched. Regenerated with `pnpm i18n:extract`, which dropped exactly the 10 provenance rows per locale that recorded these leaves as unauthored extractor fills, and added none. -- **A debt the previous round wrote down is paid.** Its cross-panel invariant compared 30 `HookBodySchema` pairs and excluded six of them, because `hook.fields.body.memoryMb` was an echo on both panels and an unauthored twin carries no evidence. Deciding it here empties the exclusion set: 30 of 30 pairs compared, `memoryMb` guarded on both panels. - -Measured on the metadata-form catalogs: label keys echoing in all three locales fall **23 → 18** while the genuinely-translated control rises **515 → 520** (`zh-CN`) and **499 → 504** (`ja-JP`, `es-ES`), same population (893 string leaves, 538 of them labels), same run. diff --git a/.changeset/19403-object-collapsed-sections-en-echo-decisions.md b/.changeset/19403-object-collapsed-sections-en-echo-decisions.md deleted file mode 100644 index d74b6b08242..00000000000 --- a/.changeset/19403-object-collapsed-sections-en-echo-decisions.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -The object editor's Capabilities panel and its `Validations` row no longer read English to a Chinese, Japanese or Spanish author: the eleven string leaves of `object.fields.enable.*` and `object.fields.validations.*` — nine labels and two helpTexts — were decided leaf by leaf and rendered in `zh-CN`, `ja-JP` and `es-ES` (#19403). These sit in the two sections `objectForm` ships collapsed, and until now every one of them was its English source in all three locales. - -An en-echo is not automatically a defect, so every leaf carries a recorded reason in `object-collapsed-sections-echo-decisions.test.ts`, naming the authored leaf its wording came from — and saying so out loud where no authored twin exists (`feeds` and `clone` have none, and the rows state that rather than leaning on one). - -- **One schema key, one rendering — and two of them were already rendered.** `object.fields['fields.trackHistory'].label` and `object.fields['fields.searchable'].label` carry the identical English strings one section away and were already 历史跟踪 / 履歴追跡 / Seguimiento de historial and 可搜索 / 検索可能 / Buscable. Those words are copied, not composed, and the copy is asserted, so the positions can only ever move together — `field.fields.searchable.label`, a third position on another panel, included. That pair is also the round's sharpest evidence the echoes were unauthored fills: the same English, the same schema key, one position authored and the other a byte copy. -- **The phantom-translation shortcut was refused, in writing.** `enable.apiEnabled.label` is `"Api Enabled"` — an extractor humanize, because `objectForm` declares no label there — and its correct English is `"API Enabled"`. Fixing the English would have differed in bytes, satisfied the echo predicate in all three locales and dropped the card's census while telling a `zh-CN` author nothing. It is not done here; the row is decided against the concept, and the ledger asserts that none of the three renderings is either English spelling. -- **ADR-0020 and the validations schema were read at the schema before a word was rendered.** The helpText's worked JSON example is kept byte-identical in every locale (this catalog's own convention, and load-bearing here because `type: "script"` is a literal member of the `ValidationRuleSchema` discriminator). Asserted against the live schema: `validations` takes an array and refuses a bare object, the example parses verbatim, `state_machine` is a member whose payload is a `transitions` table, and the `workflow` shape ADR-0020 retired is refused — as is `State-machine`, the hyphenated spelling the prose itself uses, which is why the prose is rendered rather than kept. `ADR-0020` and `API` stay verbatim, guarded on word boundaries rather than substrings. -- **The population is derived from the FORM, not from key names.** The ledger walks `objectForm` crossed with one predicate read off its shape — a section is in when the form ships it `collapsed: true` — by the same recursion the extractor uses to emit these keys. Two of four sections qualify, 45 leaves. A field added to either lands in the population automatically. Three controls run in the same walk: the 94 open-section leaves are excluded (with `fields.placeholder`, a key #19403's body samples, asserted out by name); `datasource` is *inside* the population and comes back non-echoing in all three locales; and the 32 `lifecycle.*` leaves come back echoing in `ja-JP` and `es-ES` only — a panel the card's all-three predicate reads as zero — carried as a declared, shrink-only deferral instead of being silently excluded. -- **Values only.** No key was added or removed: the full flattened key sets are identical on all four bundles (893 keys, 0 added, 0 removed) with a negative control proving the comparator sees a one-key delta in both directions, and `en` is untouched. Regenerated with `pnpm i18n:extract`, which dropped exactly the 11 provenance rows per locale that recorded these leaves as unauthored extractor fills, and added none. - -Measured on the metadata-form catalogs: label keys echoing in all three locales fall **12 → 3** while the genuinely-translated control rises **526 → 535** (`zh-CN`) and **510 → 519** (`ja-JP`, `es-ES`), same population (893 string leaves, 538 of them labels), same run. The decidable remainder — every string leaf, `helpText` included — falls **17 → 6**, because this round decides nine labels *and* two helpTexts and only the labels move the headline. diff --git a/.changeset/19403-page-interface-panel-en-echoes.md b/.changeset/19403-page-interface-panel-en-echoes.md deleted file mode 100644 index fe0d744e15f..00000000000 --- a/.changeset/19403-page-interface-panel-en-echoes.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -Decide the `page` Interface panel's en-echoes per leaf and render the decided ones in `zh-CN` / `ja-JP` / `es-ES` - -`page.fields['interfaceConfig*']` and the `page.sections.interface` heading shipped their English source byte-for-byte in all three translated metadata-form catalogs — 15 keys, 30 string leaves, the panel every list page is authored on. Each leaf was judged on its own evidence rather than translated wholesale: the `view` panel is the authored twin for most of them, `Interface`, `Airtable`, the `interfaceConfig` key names, the `Grid / Kanban / Calendar` renderer tokens and the `filter-mode` option labels stay English, and `interfaceConfig.source` departs from both of this catalog's same-string precedents because it names the page's data binding rather than source code or provenance. - -The verdicts and their reasons are pinned in `page-interface-panel-echo-decisions.test.ts`, whose population is derived from the `en` catalog, so a re-fill or a key added to the panel is red on the day it lands. diff --git a/.changeset/19403-report-form-en-echoes-round9.md b/.changeset/19403-report-form-en-echoes-round9.md deleted file mode 100644 index f5ce0b5180c..00000000000 --- a/.changeset/19403-report-form-en-echoes-round9.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -Report metadata-form panel — the dataset-binding section heading, its semantic-layer description and the `drilldown` / `runtimeFilter` label and helpText pairs are now rendered in `zh-CN`, `ja-JP` and `es-ES` instead of shipping their English source (#19403). - -Six `en` leaves × three locales = 18 locale-leaves, decided **one at a time** rather than swept: an en-echo is not automatically a defect, so each carries a recorded verdict, its per-locale reason and the `en` source it was judged against, in `report-form-echo-decisions.test.ts`. The bundles and their `*.source-hashes.generated.ts` companions were regenerated with `pnpm i18n:extract`; key sets are unchanged (893 → 893, 0 added, 0 removed, `en` values changed 0) and the provenance companions dropped exactly those six rows per locale and added none. - -- **`runtimeFilter` is a byte copy of an authored twin.** `report.fields['blocks.runtimeFilter']` is the same schema key one repeater level down, where the form declares `label: 'Runtime Filter'` and a translator had already written 运行时筛选 / 実行時フィルター / Filtro en tiempo de ejecución. The top-level position is now the same three words, and the copy is asserted, so the two can only move together. -- **The semantic-layer claim was read at the schema before a word was rendered.** "Values are the dataset's measures; rows are its dimensions" is pinned: `DatasetSchema` declares `dimensions`/`measures` and refuses `values`/`rows`; `ReportSchema` does the reverse; and the joined-block alias table states the mapping itself (`measures` → `values`, `dimensions` → `rows`). -- **The phantom-translation shortcut is unavailable here, and that is asserted.** `reportForm` declares no label on either field, so both English strings are the extractor's humanize — but the humanize already lands on correct English, so no touch-up could satisfy the echo predicate; only a translation can. - -⚠️ This empties the card's headline predicate — zero `.label` keys now echo in all three locales — and that zero is a property of the **predicate**, not of the surface: `object.fields.lifecycle.*` still ships 32 English leaves to `ja-JP` and to `es-ES` (64 locale-leaves) that the all-three reading cannot see. diff --git a/.changeset/19407-packages-list-tombstone-third-filter.md b/.changeset/19407-packages-list-tombstone-third-filter.md deleted file mode 100644 index 4e22a54fe5a..00000000000 --- a/.changeset/19407-packages-list-tombstone-third-filter.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the `/packages` list-door tombstone enumerates all three filters that door reads (#19407) - -`limit` and `cursor` on `ListInstalledPackagesRequestSchema` both raise -`PACKAGES_LIST_PAGINATION_REMOVED`, and that prescription enumerated the serving -door's filters as `status` / `type`. The door reads a **third**, `enabled` -(`readEnabledFilter`, `packages/runtime/src/domains/packages.ts`), so the -prescription named two of three and pointed an upgrader at a narrower answer -than the route actually offers. - -**Incomplete, not wrong — and only the enumeration moves.** The sentence's -load-bearing claim, *no page was ever withheld and no continuation token was -ever minted*, is untouched and stays true: `enabled` filters rows, it does not -paginate. The removability argument, the `.default(50)` passage and the -`hasMore` passage are byte-identical. Nor did the sentence ever assert that -`enabled` was unavailable — it enumerated, it did not exclude — so nothing here -reverses a claim. - -``` -FROM … the serving door filters on `status` / `type` and then returns every - remaining row … - … Filter with `status` and `type` instead of asking for a window. - -TO … the serving door filters on `status` / `type` / `enabled` and then - returns every remaining row … - … Filter with `status`, `type` and `enabled` instead of asking for a window. -``` - -The same repair lands on every carrier of the sentence inside the spec: the -tombstone string, and the ADR-0087 D3 semantic entry -`packages-list-pagination-retired` in both its `replacement` (what to use -instead) and its `reason` (what the door reads). The generated migration -registry and the generated reference page follow from the repo's own -generators. - -No accept set moves, no key is added or removed, and no type changes: `limit` -and `cursor` stay `retiredKey()` tombstones typed `never`, and `enabled` was -already declared and already published as a filter on this request. diff --git a/.changeset/19413-data-delete-human-arm-reads-the-flag.md b/.changeset/19413-data-delete-human-arm-reads-the-flag.md deleted file mode 100644 index eea18506f1d..00000000000 --- a/.changeset/19413-data-delete-human-arm-reads-the-flag.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os data delete`'s human-readable arm reads the server's `success` flag instead of always printing "Record deleted" (#19413) - -Clause-②: no - -`os data delete` renders three output faces from one call. The `--format json` -and `--format yaml` arms lower the server's `DeleteDataResponse.success` into -their own `deleted` key — that is what #5638 landed, and its note is still in -the command. The default human-readable arm did not read it at all: it printed -`Record deleted: ` unconditionally. One command, one call, two output -formats able to state opposite facts about whether a row is gone. - -**What changes.** When the server answers `success: false`, the default arm now -prints a warning instead of a success line: - -``` -FROM ✓ Record deleted: rec_1 (whatever the server said) - -TO ✓ Record deleted: rec_1 (success: true — unchanged) - ⚠ Not deleted: rec_1 — the server reported the deletion did not happen - (success: false) -``` - -**The exit code does not move — on either arm, in any format.** The two machine -arms already publish `success: true`, the CLI envelope's *"the command -completed"* flag, beside `deleted: false`, and they exit `0`. Moving only the -human arm off `0` would re-create this very defect one layer down: the same -call exiting `0` under `--format json` and non-zero by default. Moving it on -all three arms would narrow a published CLI accept set — a script that succeeds -today would start failing — which is a contract change and not this fix. So -this is a `patch`, not a `minor` with a breaking banner, and the new pin -asserts `0` in both directions so the next change cannot move it silently. - -**This is reachable on `main`, not hypothetical.** #19411 landed while this was -being written. `MetadataProtocol.deleteData` used to return the literal -`success: true`, so the single-record door could not answer `false` at all and -the unconditional print was merely wrong on its own terms; it now returns -`success: deleted !== 0`, and a package-declared `sys_permission_set` — whose -delete is an ADR-0005 reset that re-projects the row rather than removing it — -answers `success: false` on the live door. From that commit on, -`os data delete sys_permission_set ` printed `Record deleted` for a row the -same command's `--format json` arm reported as `deleted: false`. The -`--format json` and `--format yaml` bytes are untouched in both directions. - -**The flag is read as `=== false`, not as falsiness** — the same reading -`MetadataProtocol.deleteData` takes of the driver contract. `false` is the -protocol's positive *"no row was deleted"* value; an absent or `undefined` flag -from an off-contract server is no signal at all, and turning "no signal" into -"not deleted" would make the CLI deny deletions that really happened. - -**Why this carries a changeset rather than `skip-changeset`.** Measured on the -built tree: `@objectstack/cli`'s published `files[]` ships `dist`, and the new -sentence is present in `dist/commands/data/delete.js` after a build, with the -unchanged success sentence from the same file as the lit control. The new test -file is absent from `dist` entirely, as the negative control. diff --git a/.changeset/19417-install-door-parses-manifest-id.md b/.changeset/19417-install-door-parses-manifest-id.md deleted file mode 100644 index cb17648bfbb..00000000000 --- a/.changeset/19417-install-door-parses-manifest-id.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime): `POST /api/v1/packages` parses the manifest's `id` leg instead of reading it positionally (#19417) - -Clause-②: no (narrowing) - -**BREAKING for callers of the install door** — a manifest whose `id` is not -reverse-domain notation is now refused `400` / `VALIDATION_ERROR`. It used to -install and answer `201`. - -The accept set only shrinks back to what the published declaration has always -said. `MANIFEST_ID_PATTERN` is declared once in -`packages/spec/src/kernel/manifest.zod.ts` and referenced by both faces of one -identity — `ManifestSchema.id`, what an author writes, and -`PackageSchema.manifestId`, what the registry stores and publishes by. The door -read `manifest.id` POSITIONALLY (`typeof manifest?.id === 'string' ? -manifest.id.trim() : ''`) and parsed nothing, so `id: 'pkg-a'` installed and -answered `201` while `defineStack()`, `os build`, `os validate` and the publish -face all refused the same id. The author was handed a package that could never -be rebuilt or published. That is «declared ≠ enforced» on a published API -contract — and nothing in `packages/spec` moves for it: the declaration was -already right. - -The gate asks the declaration **by reference** — `ManifestSchema.shape.id` — -rather than keeping a copy of the grammar, exactly as the `version` leg beside -it does, so a future move of the reverse-domain rule reaches this door with no -further edit. The sentence the caller reads is the declaration's own -(`manifestIdRefusal`), **surfaced rather than reworded**: it names the key, -echoes the value, lists the two examples, and carries a suggestion arm that -verifies its candidate against the pattern before offering it. Posting -`id: 'pkg-a'` now answers, in the response envelope's `error.message`: - -```text -Invalid package id 'pkg-a' on `manifest.id`. Expected reverse-domain notation -('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments -of letters, digits and inner hyphens; a segment may not open with a hyphen; -underscores are not admitted. Did you mean 'com.example.pkg-a'? -``` - -**What is not affected.** Boot-time and in-process installs reach -`SchemaRegistry.installPackage` / `ObjectQL.registerApp` directly and never pass -through this branch, so nothing about how a package is loaded from disk or -registered by a plugin changes. A conforming manifest installs exactly as -before, on both body forms (wrapped and bare) and on both install limbs (the -protocol primitive and the bare-registry fallback). - -**The `Package id is required` sentence does NOT move.** `''` fails -`MANIFEST_ID_PATTERN` too, so where this gate sits decides whether a published -message changes or only the accept set does. It is ordered AFTER the existing -`!pkgId` check: an absent, empty, whitespace-only or non-string `id` still -answers `400 Package id is required`, never the schema's sentence — which on -that input is the one case where the suggestion arm has nothing to offer. -Measured both directions. It is ordered BEFORE the `version` gate for the -mirror-image reason: that refusal's sentence names the id it prescribes for, and -prescribing a `version` repair for an id that can never be legal sends the -author round twice. - -**Scope — the `id` leg alone.** The declaration's residual docblock records the -classes this door answers `201` to while `PackageInstallBodySchema` refuses -them. This change closes one: `id`. A missing `type`, unknown keys on either -body form, a string-typed `enableOnInstall` / `overwrite`, and install options -spelled on the bare form are each left exactly as they were — measured after the -change, all seven spellings of those four classes still answer `201`, against a -`pkg-a` control that answers `400` and a conforming control that answers `201`. -Each is its own reading and its own card. Closing them is the one call this -handler still pointedly does not make, `PackageInstallBodySchema.safeParse(body)`. - -**If you are refused.** Give the manifest an id in reverse-domain notation — -lowercase dot-separated segments, hyphens allowed inside a segment, underscores -not. The refusal names the key, echoes what you wrote and, where a mechanical -repair exists, offers one it has already checked against the rule, so the -prescription arrives with the `400` rather than in a changelog. - - diff --git a/.changeset/19417-protocol-install-primitive-parses-manifest-id.md b/.changeset/19417-protocol-install-primitive-parses-manifest-id.md deleted file mode 100644 index a84a714d7d2..00000000000 --- a/.changeset/19417-protocol-install-primitive-parses-manifest-id.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol): the protocol install primitive parses the manifest's `id` leg, and the duplicate door parses its target id (#19417) - -Clause-②: no (narrowing) - -**BREAKING for callers of the protocol install and duplicate doors** — -`ObjectStackProtocolImplementation.installPackage` and `duplicatePackage` now -refuse a package id that is not reverse-domain notation, throwing a `400`-tagged -error carrying the declaration's own sentence. Both used to install and report -success. - -The accept set only shrinks back to what the published declaration has always -said. `MANIFEST_ID_PATTERN` is declared once in -`packages/spec/src/kernel/manifest.zod.ts` and referenced by both faces of one -identity — `ManifestSchema.id`, what an author writes, and -`PackageSchema.manifestId`, what the registry stores and publishes by. -`installPackage` parsed nothing at all: it spread the request into `any` and -handed it to `SchemaRegistry.installPackage` with a second `as any`, so -`id: 'pkg-a'` — or `com.example.my_erp` — installed and PERSISTED while -`defineStack()`, `os build`, `os validate` and the publish face all refused the -same id. That is «declared ≠ enforced» on a published contract, and nothing in -`packages/spec` moves for it: the declaration was already right. - -**Why the primitive and not only a door.** #19473 landed the same parse at the -HTTP door (`POST /api/v1/packages`). That door is ONE caller of this primitive — -it routes through `protocol.installPackage` whenever the protocol service -resolves. `duplicatePackage` is a second, and an embedder holding the protocol -object is a third. A gate on one door buys that door; this one is on the method -every caller passes through. - -The gate asks the declaration **by reference** — `ManifestSchema.shape.id` — -rather than keeping a copy of the grammar, so a future move of the -reverse-domain rule reaches this seam with no further edit. The sentence the -caller reads is the declaration's own (`manifestIdRefusal`), **surfaced rather -than reworded**: it names the key, echoes the value, lists the two examples and -carries a suggestion arm that verifies its candidate against the pattern before -offering it. Installing `id: 'com.example.my_erp'` now throws, with: - -```text -Invalid package id 'com.example.my_erp' on `manifest.id`. Expected -reverse-domain notation ('com.steedos.crm', 'org.apache.superset') — lowercase -dot-separated segments of letters, digits and inner hyphens; a segment may not -open with a hyphen; underscores are not admitted. Did you mean -'com.example.my-erp'? -``` - -**The duplicate door refuses BEFORE it mints anything.** `duplicatePackage` -builds its target manifest and writes it through `installPackage` inside a -deliberately best-effort `catch {}` — a refusal raised only there would be -swallowed and the caller would read `success: true` on a package with no -manifest row. So the target id is parsed at the top of the method, ahead of the -row scan and ahead of the copy loop, and the refusal names the key the caller -actually wrote (`targetPackageId`). - -**One assumption, one implementation.** The duplicate door derived both -namespaces with a raw `id.split('.').pop()` while `installPackage` derived the -same default with the spec helper `deriveNamespaceFromPackageId`, which -sanitises to the namespace charset, truncates to 20 and answers `null` when -nothing valid comes out. That mattered: the target namespace is spliced into -every copied object name as `${namespace}_${short}`, and an object name is -`/^[a-z_][a-z0-9_]*$/`. The Studio's own default duplicate id — -`-copy` — therefore minted `leave-copy_ticket`, a name the object -declaration refuses. Both sides now use the helper, so a duplicate of -`com.example.leave` into `com.example.leave-copy` is namespaced `leave_copy`. -An explicitly declared `targetNamespace` still wins untouched; when neither an -explicit nor a derivable namespace exists the door refuses loudly, naming -`targetNamespace` as the remedy, instead of renaming rows with an empty prefix. - -**What is not affected.** Boot-time and in-process installs that reach -`SchemaRegistry.installPackage` / `ObjectQL.registerApp` directly never pass -through this primitive, so nothing about how a package is loaded from disk or -registered by a plugin changes. A conforming manifest installs exactly as -before, versionless and namespace-less manifests included — the version default -and the namespace default still run, now behind the id gate rather than ahead of -it. - -**Scope — the `id` leg alone.** `InstallPackageRequestSchema` / `ManifestSchema` -are still not parsed whole here. The residual classes the HTTP door's own -docblock records are untouched by this change and are each their own narrowing -of a published contract. - -**If you are refused.** Give the package an id in reverse-domain notation — -lowercase dot-separated segments, hyphens allowed inside a segment, underscores -not. The refusal names the key, echoes what you wrote and, where a mechanical -repair exists, offers one it has already checked against the rule, so the -prescription arrives with the failure rather than in a changelog. - - diff --git a/.changeset/19430-user-filters-element-tokens.md b/.changeset/19430-user-filters-element-tokens.md deleted file mode 100644 index b7324e74b86..00000000000 --- a/.changeset/19430-user-filters-element-tokens.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -Keep the `userFilters` element tokens English in the translated metadata-form tooltips - -`metadataForms.view.fields.userFilters.helpText` names the legal values of `UserFiltersSchema.element`, a strict `z.enum(['dropdown', 'tabs', 'toggle'])`, and it is the only place the `view` panel names them at all. All three translated catalogs rendered those tokens as ordinary words — 下拉 / 标签页 / 开关, ドロップダウン / タブ / トグル, desplegable / pestañas / interruptor — so an author working in a translated locale was shown a value the schema refuses. - -The enum values are now verbatim English inside the translated sentence and the prose around them stays translated. The `page` Interface panel is a neighbour here, not a precedent: its tooltip keeps `None / Tabs / Dropdown` English too, but those are the `filter-mode` widget's UI mode names — capitalised, and `z.enum` is case-sensitive, so the enum refuses all three; `None` stands for the absence of the config rather than a value; and `toggle` is deliberately not offered there. What this change keeps verbatim is the enum's own tokens, which is the stricter requirement, because they are values an author types. - -`user-filters-element-tokens.test.ts` pins the repaired leaf in the three locales. It derives the accepted set from `UserFiltersSchema` and asserts set equality against it, so a value added to the enum reddens instead of going unnamed; it requires each tooltip to name that set and nothing else; and it holds each translated sentence's prose at both ends, so the assertion cannot be satisfied by copying the English sentence back in. diff --git a/.changeset/19441-plugin-security-ledger-rows.md b/.changeset/19441-plugin-security-ledger-rows.md deleted file mode 100644 index 772c8ddfc4e..00000000000 --- a/.changeset/19441-plugin-security-ledger-rows.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`ERROR_CODE_LEDGER['@objectstack/plugin-security']` now lists the three codes the package stamps as class fields and ships in `dist`: `INVALID_STATE` (`PermissionSetOverlayStateError`, 409), `NOT_FOUND` (`PermissionSetNotFoundError`, 404) and `NOT_OVERRIDABLE` (`PackagedPermissionSetLockedError` and `PackagedPermissionSetProvenanceUnknownError`, 403) (#19441). - -Clause-②: yes - -Provenance, not identity: each code was already registered under another package (`@objectstack/rest`, `@objectstack/metadata-protocol`), so the `ErrorCode` union, the wire, and every other package's rows are unchanged. What widens is the per-package face a consumer reads from `ERROR_CODE_LEDGER['@objectstack/plugin-security']`. The `NOT_FOUND` synonym waiver's `reason` text now names plugin-security among its emitters; its `code` and `shadows` are unchanged. Nothing to migrate. diff --git a/.changeset/19452-batch-causal-row-located-by-fault.md b/.changeset/19452-batch-causal-row-located-by-fault.md deleted file mode 100644 index d11523bb1a2..00000000000 --- a/.changeset/19452-batch-causal-row-located-by-fault.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-protocol): a stopped or rolled-back bulk batch names the row that actually failed - -`reconcileStoppedBatch` and `buildRolledBackBatchResponse` — the two builders every one of the three bulk-write faces (`batchData`, `updateManyData`, `deleteManyData`) reports through — located the causal row with `findIndex(r => !r.success)`. That encoded an invariant: **`!success` means this row failed, and it carries `errors[0]`.** - -That invariant stopped holding when a matched-but-deliberately-not-removed row started answering `success: false` with no `errors` entry — correctly, because a surviving record is an outcome, not a fault. The locator could then land on that survivor, `errors?.[0]?.message` was `undefined`, and the message named the **wrong index** while calling the real error — sitting in the same array — 「unknown error」. - -Measured on the unfixed tree: - -- non-atomic `deleteMany ['survivor', 'missing', 'other']` — the un-attempted row answered `NOT_ATTEMPTED` *"record 0 failed — unknown error; the batch stopped there. …"* while record **1** is what threw; -- atomic `[t1, survivor, t3]` — the rolled-back rows answered `ROLLED_BACK` *"record 1 failed — unknown error"* for a row that **survived**; -- atomic `[t3, survivor, missing, t2]` — `ROLLED_BACK` said *"record 1 failed — unknown error"* and `NOT_ATTEMPTED` said *"atomic batch aborted by record 1"*, both naming the survivor while record **2** threw. - -Both builders now share one locator, `locateBatchCause`, which finds the row by its recorded **fault** — the row's `errors[]` entry. That is the one per-row value whose declared meaning is a failure: `BatchOperationResultSchema.errors` is documented as *"Array of errors if operation failed"*, and the v17 migration entry publishes `row.errors?.[0]?.message` / `.code` to consumers as exactly that read. Its codes are drawn from the closed `StandardErrorCode ∪ ERROR_CODE_LEDGER` vocabulary, so an unregistered code fails `BatchOperationResultSchema.parse` — giving a non-fault ending an `errors[]` entry is a ledger widening in `packages/spec`, not something a call site can do on its own. `ApiError.message` is required, so a located cause always has text and the 「unknown error」 fallback is **deleted** rather than merely unreached. - -The scan runs from the end of the attempted rows, because a run ends *at* the row it stops on: every stop is a `break` in a loop's `catch`, immediately after that row was pushed. A fault that does not stop the run (the `Unknown operation:` arm records one and keeps going) therefore cannot shadow the row that did. - -One ending has no fault to quote at all — an atomic batch aborted by a lone survivor, where `runAtomicBatch` rolls back on `failed > 0` and nothing ever threw. The rolled-back rows now read *"record 1 did not succeed"*: the row that stopped the batch committing, named as what it is rather than as a failure with an unknown cause. - -No envelope field, per-row code, status or count changes; `succeeded` and `failed` still partition `results`. What changes is which row two message strings name, and both of them stop inventing an error that is not there. Clients branch on `errors[0].code`, which is unchanged — the row classification itself was never wrong. - -`Clause-②: no` — nothing authorable moves: no `packages/spec` key, export, accept set or stored shape changes, and the per-row code vocabulary is untouched. diff --git a/.changeset/19461-admin-scope-business-unit-blank-refused.md b/.changeset/19461-admin-scope-business-unit-blank-refused.md deleted file mode 100644 index 9d88aace95e..00000000000 --- a/.changeset/19461-admin-scope-business-unit-blank-refused.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING for authored metadata** — a delegated-admin scope's `businessUnit` (`AdminScopeSchema`, reached as `adminScope.businessUnit` on a permission set) must now name a business unit. An empty or whitespace-only value is refused at parse, at the key's own path, with a message naming what a valid anchor is: the `sys_business_unit.name` of the root of the delegated subtree (#19461). - -Clause-②: yes - -Maintainer ruling A on decision batch #217 item 1, 2026-09-23 「217 同意」. - -## What changed, and why - -`businessUnit` is the scope's one required key, and every other key of the scope is scoped to it. It was declared as a bare string with no minimum, so `{ businessUnit: '' }` and `{ businessUnit: ' ' }` parsed green: the requirement was satisfied by a value that names no business unit. The delegated-admin gate looks the anchor up by exact name, so a blank anchor resolved to an empty subtree. No escalation was measured; the defect is a declaration that did not enforce what it declared. The likeliest author of a blank anchor is an AI that knew the key was required and did not yet know the unit, and until now the platform answered "accepted". - -``` -FROM AdminScopeSchema.safeParse({ businessUnit: '' }) - -> { success: true } - -TO AdminScopeSchema.safeParse({ businessUnit: '' }) - -> { success: false, - issues: [{ code: 'custom', path: ['businessUnit'], - message: 'A blank businessUnit is not a delegation boundary: businessUnit is - the sys_business_unit.name (machine name) of the business unit at - the root of the subtree this scope delegates, …' }] } -``` - -The refusal is a non-transforming refinement: nothing is trimmed. The metadata save path stores the submitted body as written, so a trimming schema would validate one string and store another. A real name parses byte-identical. The published JSON Schema states the same rule (`minLength: 1` and a non-whitespace `pattern`). - -## Migration — FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `adminScope: { businessUnit: '' }` | `adminScope: { businessUnit: 'north_america' }`, using the machine name of the unit at the root of the subtree | -| `adminScope: { businessUnit: ' ' }` (or a tab or newline) | the same: the root unit's `sys_business_unit.name` | -| a blank-anchored scope on a set that should not delegate at all | remove `adminScope` from the permission set | - -**The one-line fix: set `businessUnit` to the `sys_business_unit.name` of the business unit at the root of the delegated subtree, or remove `adminScope` if the set should not delegate administration.** This cannot be converted automatically, because a blank names no unit and the intended root cannot be inferred. So this ships as an ADR-0087 D3 structured TODO with **no D2 conversion**. - - - -## Stored permission sets - -- **Stored scopes are not rewritten.** Reads do not re-validate stored rows, so no stored permission set becomes unreadable. A stored blank anchor loads and resolves exactly as before. -- **The next write refuses it.** A Setup or data-door edit of a permission set whose stored scope has a blank anchor answers `422 INVALID_METADATA` naming `adminScope.businessUnit`, until the anchor is named or the scope removed. -- **The boot reconciliation backfill reports it.** A legacy `sys_permission_set` record with no metadata definition and a blank anchor is not backfilled. It is reported on every boot through the existing ADR-0094 D4 durability `ERROR`, which names the record and the offending key, until the record is fixed or deleted. Restoring a trashed blank-anchored set brings the record back and reports the missing definition at `ERROR` the same way. No path skips the row. -- **A clean boot is not a completed sweep.** A definition already stored in `sys_metadata` says nothing until it is written again, so search stored permission sets and the `admin_scope` column for a blank `businessUnit`. - -## What does NOT change - -- **An absent `businessUnit`** is refused exactly as before, with its own `invalid_type` issue. -- **A real name with surrounding whitespace** is not judged by this change. It parses and is stored byte-identical. -- **The other keys of the scope** (`includeSubtree`, `manageAssignments`, `manageBindings`, `authorEnvironmentSets`, `assignablePermissionSets`) are untouched. -- **The published export surface.** No export is added, removed or renamed, and the `AdminScope` / `AdminScopeParsed` types are unchanged. diff --git a/.changeset/19474-six-inert-runtime-create-doors.md b/.changeset/19474-six-inert-runtime-create-doors.md deleted file mode 100644 index baa8a8faf51..00000000000 --- a/.changeset/19474-six-inert-runtime-create-doors.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -**BREAKING for runtime metadata writes** — five metadata write doors that dispatched NOTHING now dispatch the rules already written for them, and three of the five judge what walks through. `action`, `hook`, `report`, `email_template` and `mapping` each declared `allowRuntimeCreate: true` and reached ZERO author-time rules at the runtime publish gate; `action`, `hook` and `report` publishes that used to succeed can now be refused, while `email_template` and `mapping` are wired to a ledger-driven rule that warns on nothing today (#19542) - -Clause-②: no (narrowing) - -Each of these is a registered metadata type declaring `allowRuntimeCreate: true`, so Studio's designer, REST `/meta` item CRUD and an MCP/AI author may all mint one — and at that door nothing judged any of them. Measured on `origin/main`: no rule declared any of them in `runtimeTypes`, so the gate filtered them out before it ever consulted `TYPE_TO_STACK_KEY`. `action` and `hook` already HAD their stack-key rows, which made the two absences **consistent rather than contradictory** — the gate filters by `runtimeTypes` first — so nothing was mis-wired and CI was green, correctly. What they summed to is that a write of any of them built no per-write snapshot and ran no rule at all. An author working only through Studio or MCP has no `os lint` step to fall back on, so for them that door is the only one there is. - -ADR-0049's 「声明即强制」 admits two resolutions — honour the declaration, or retire it — and the ruling on #19275 took the first for these six, by evidence group. The rules exist; this is the wiring that reaches them. - -- **`validateStackExpressions` crosses to `action` and `hook`.** It judges the written action's own `visible` / `disabled` CEL and the written hook's own `condition`, resolving `record.` against `objects` — the one collection every snapshot carries. The action/hook BODY rules deliberately do **not** cross with them: they parse authored JS through `typescript`/`sucrase`, the two dependencies `runtime-lazy-deps.test.ts` pins off the kernel boot path outright, and an action/hook write is exactly the snapshot that would carry a body for them to parse. -- **`validatePresetComparands` and `validateEmptyCombinators` cross to `report`, together.** Both judge the same authored filter literal on the same `reports` scan surface, so on #7220's reading they cross or they do not — an author refused for a bad preset comparand and waved through for a literal `$and: []` on the same report could not predict the door. -- **The reference-integrity suite entry gains `report`**, and its per-member axis admits exactly ONE member: `validateChartBindings` (it resolves the report's `dataset` / `rows` / `columns` / `values` against `stack.datasets`, a carried collection). -- **`lintLivenessProperties` crosses to `email_template` and `mapping` only.** The `RUNTIME_OBJECT_ADVISORY_VOLUME` reason that held it back is about the OBJECT write door (~8 advisories per object write, rendered in Studio); `object` is deliberately not declared, so that reason is untouched and still holds for every type left off. -- **`TYPE_TO_STACK_KEY` gains `report` / `email_template` / `mapping`**, never ahead of their rules — the inert state the table's own `seed: 'data'` note records paying for. Every crossed rule has a door control that fires it through the real gate (`runtime-gate.inert-type-writes.test.ts`), and each control was shown to be load-bearing by reverting its declaration and watching it go red. -- **No new rule and no new finding class.** The rule ids (`expression-invalid`, `chart-dataset-unknown`, `chart-dimension-unknown`, `filter-empty-combinator`, `filter-preset-comparand`) and their severities are unchanged — they now reach the door where the author actually is. -- **Measured before crossing**, at the door's own snapshot shape and differential, over every item of these types shipped in this monorepo: **79 actions** (showcase 70, todo 8, crm 1), **6 hooks** (showcase 4, todo 1, crm 1), **9 reports** (showcase 4, todo 5 — 5 of them carrying an authored filter key, so the filter rules were non-vacuously exercised), **1 email template** and **1 mapping** — **0 findings** on every one, with lit synthetic probes refused per rule. - -## Two readings that are part of the deliverable, not omissions - -**`email_template` and `mapping` are wired and SILENT.** Their bridge, `lintLivenessProperties`, is ledger-driven and skips a type whose warn map is empty; `packages/spec/liveness/email_template.json` is 13 props / **0** warn keys and `mapping.json` is 7 / **0** (lit control on the same instrument: `tool.json` 6/1, `object.json` 35/1). The ruling dispatched the wiring and **no ledger-population work** — 「the empty warn maps stay empty until a real property needs a row — zero pull, the wiring is the whole deliverable」 — so this is the ruled end state. Both halves are pinned: that the rule is dispatched, and that it judges nothing today. The day a property earns an `authorWarn` row the door lights up with no second edit. - -**`skill` — the fourth type of group A — is NOT wired, and for it that IS the deliverable.** Its bridge, `validateAiToolReferences`, resolves into `stack.tools` and `stack.actions`; a per-write snapshot carries `objects` (so an object-level `action_NAME` resolves) but neither of those, so at that door the rule has no truthful `unresolved` verdict at all — only its clean answers are reliable. Measured on the shipped corpus rather than synthetically: `app-showcase`'s single AI-exposed action exists at STACK level only, and a skill naming it is advised `ai-skill-tool-unresolved` at the door while the same rule over the whole stack answers `[]`. That advisory reaches `SaveMetaItemResponseSchema.advisories` and renders in Studio, with a hint prescribing exactly what the author had already done — so the card's own acceptance («a good write passes») does not hold for `skill`. The type therefore takes the ruling's own group B treatment of `tool`, the same universe obstacle read from the other side: **a reading first, not a wiring**. Crossing it needs `actions` / `tools` carried in `RuntimeStackContext` plus a `CLOSURE_CONTEXT_KEY_BY_TYPE` row and two more door gathers in `@objectstack/metadata-protocol` — a second package, a snapshot widening paid on every gated write, and its own card. Both halves of the wiring are held ABSENT by pins, with the measurement kept executable beside them. - -## The refusal set grows — and there is no FROM → TO, because nothing changed spelling - -A runtime metadata write — Studio's designer, REST `/meta`, an MCP/AI author — of an `action`, `hook` or `report` that carries one of the defects below is now refused with the 422 lint envelope instead of stored. Concretely, these used to succeed at that door and no longer do: - -- an action whose `visible` / `disabled` CEL does not parse, or names a field its bound object does not declare; -- a hook whose `condition` does the same; -- a report binding a dataset nothing declares, or grouping by a dimension or measure its dataset does not declare; -- a report whose filter carries a literal empty combinator (`$and: []`, `$or: []`, `$not: {}`); -- a report filtering by a dashboard date-range PRESET name (`last_30_days`, …) as if it were a value. - -⚠️ **No metadata needs rewriting to a new spelling, and none is being retired.** Every one of those was ALREADY refused by `os build`, `os validate` and `os lint` — the rules, their ids, their severities and their fix-it text are unchanged since they landed. What widens is the set of doors each runs at. A tenant whose stored metadata carries one of these defects has metadata that was never valid; the refusal envelope names the rule id, the path and the offending string, and the rule's own `hint` carries the correction at the moment it is needed. There is nothing for `objectstack migrate meta` to reach and no ledger entry to make. - -`skill` writes are unchanged — the type is not gated by this change. `email_template` and `mapping` writes are unchanged in behaviour today: their rule is dispatched and judges nothing until a ledger row lands. - -The gate's differential keeps all of this honest in the one direction that matters: a STORED sibling already in violation is never charged to this write (#4463 D4). - - diff --git a/.changeset/19482-doc-nav-item.md b/.changeset/19482-doc-nav-item.md deleted file mode 100644 index e3537bc3cf0..00000000000 --- a/.changeset/19482-doc-nav-item.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/cli": minor ---- - -**Clause-②: yes (widening)** — a new member (`type: 'doc'`) on the published, strict navigation-item union, and a new exported schema (`DocNavItemSchema`), so the accept set an app author writes against grows. Nothing previously admitted is refused: the `docs/nav-target` build rule judges only `doc` items, which no stack could carry before. Contract-review tier. - -A documentation entry on the app menu: the new `type: 'doc'` navigation item (`DocNavItemSchema`, ADR-0046) targets a `book` and/or a `doc`, and at least one is required. - -```ts -{ id: 'nav_help', type: 'doc', label: 'Help Centre', book: 'crm_manual' } // opens the book -{ id: 'nav_guide', type: 'doc', doc: 'crm_lead_guide' } // opens that page -{ id: 'nav_both', type: 'doc', book: 'crm_manual', doc: 'crm_lead_guide' } // that page, in that book -``` - -- **`book` alone** opens the book at its first readable page with the book sidebar. Membership is derived by the book's group rules, so a doc added later that matches a rule appears under the entry with no navigation edit. The package id also names a book — the package's implicit book. -- **`doc` alone** opens that page; its book context is the doc's own book, else the package's implicit book. `doc` is a doc NAME (the source filename stem, lowercase snake_case): `crm_lead_guide.md` or `docs/crm_lead_guide` is refused. -- **Neither** is refused when the app is parsed, with a message naming both keys. The rule also reaches the published JSON Schema (`json-schema/ui/DocNavItem.json`) as an `anyOf` of `required`, so a validator reading the schema refuses the same shape. -- **Audience**: the entry has no gate of its own — it inherits the docs audience gate. A `book` entry shows the member only the pages they may read, and is not shown to a member who may read none; a `doc` entry the member may not read is not shown. `visible` / `requiredPermissions` can only narrow that further. -- **`os build` / `os validate` / `os lint`** refuse a `doc` entry whose `book` or `doc` names nothing in the package (new rule `docs/nav-target`, with a did-you-mean). This runs in the docs step because that is where docs from `src/docs/*.md` join the artifact. It checks app `navigation`, `areas[].navigation` and `manifest.navigationContributions`. -- Near-misses are answered: `docName` → `doc`, `bookName` → `book`, and `book` / `doc` written on another item type points at `type: 'doc'`. - -The console renders the new entry in a later objectui release; until then a `doc` item parses and publishes, but the menu does not show it. diff --git a/.changeset/19489-oauth-private-host-transport-rule.md b/.changeset/19489-oauth-private-host-transport-rule.md deleted file mode 100644 index 279164eb55a..00000000000 --- a/.changeset/19489-oauth-private-host-transport-rule.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/plugin-auth": minor ---- - -MCP OAuth is eligible over plain HTTP when the deployment's own host is loopback **or a private / link-local address** — RFC 1918 `10/8`, `172.16/12`, `192.168/16`; RFC 4193 `fc00::/7`; `169.254/16`, `fe80::/10`. A **public** host keeps TLS-required and fail-closed, exactly as before (#19489). - -Clause-②: no - -Shape ruled by the maintainer (2026-09-21), with the semantics of Keycloak's `sslRequired=external`: 「要(开发模式)」 and 「19342 同意兼容」. Reasoning of record — a deployment that already serves its login form and session cookies over plain HTTP gains nothing from OAuth refusing plain HTTP; the refusal only removes MCP from that deployment. An intranet install and a developer's `os dev` bound to a LAN address are the same case, so development mode is **subsumed** and there is no separate dev-mode branch. - -- **⛔ No configuration key and ⛔ no environment variable.** A switch would be reachable on a public host, which is exactly the deployment this rule must keep refusing. `OS_ALLOW_INSECURE_OAUTH_HTTP` is not introduced in any form. -- **The public arm is unchanged.** `http://example.com` and `http://203.0.113.5` are refused as before; the MCP endpoint stays API-key-only and no OAuth metadata is advertised. Addresses that merely look private are refused with it: `172.15.x` / `172.32.x` fall outside RFC 1918, `fec0::/10` site-local (RFC 3879) falls outside `fc00::/7`, and a hostname that only begins with a private IPv4 string (`10.0.0.5.evil.com`) is a name, not an address. -- **A non-IP hostname over plain HTTP stays refused**, deliberately: the rule judges the host **literal** of the deployment's own canonical origin. `crm.corp` and `host.docker.internal` are not IP literals, so configure the base URL on the private address the deployment already binds (`http://192.168.1.10:3000`). Resolving the name in DNS was rejected — it makes a synchronous predicate depend on a round trip whose answer can be rebound — and judging the requester's peer address, which is what Keycloak does, was rejected because it cannot decide what a deployment **advertises** at mount time and because a plain-HTTP reverse proxy on a public address makes every requester look internal. -- **One loud startup line on every plain-HTTP deployment** — the rule's verdict decides WHICH sentence, not whether one is emitted. On an origin it ACCEPTS: `OAuth is served UNENCRYPTED`, followed by the issuer URL and what crosses the wire in the clear. On a PUBLIC plain-HTTP origin, whose OAuth track this same rule leaves dark: the separate line `OAuth discovery is served over PUBLIC plain HTTP`, naming the `.well-known` authorization-server documents this deployment publishes over an unencrypted public origin, stating that the MCP OAuth track is disabled and that TLS is the remedy. The accepted-transport sentence is never printed there — it would assert a transport this deployment was refused. Neither line under TLS. The maintainer worded the notice 「OAuth 未加密:仅限可信内网」; that sentence is carried as the line's meaning and kept verbatim in the code comment beside the call, the emitted strings being English per this repository's convention. - -Nothing an author writes changes: `isOAuthEligibleBaseUrl` keeps its signature, no export is added or removed, and no published payload gains a key. A deployment on a public host sees no behaviour change at all. diff --git a/.changeset/19503-pin-bump-describe-corrections.md b/.changeset/19503-pin-bump-describe-corrections.md deleted file mode 100644 index b8af1d3e6cb..00000000000 --- a/.changeset/19503-pin-bump-describe-corrections.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -One published `describe` sentence that dates itself to the `.objectui-sha` pin is re-pointed to the pin this release builds against, objectui `62597c588072`, after being re-read there (#19503). - -`Clause-②: no` - -- `FormField.span`: the `'auto'` clause says that at the pin this repo builds against, only textarea, markdown, html, richtext and repeater resolve to the full column count. It named `87af769e9`. Re-read at `62597c588`, the claim still holds: `plugin-form`'s `WIDE_FIELD_TYPES` is unchanged (it shifted one line when an import was added above it; repeater still reaches it through `field:grid`), and `form.tsx`'s `spanLadderFor` is byte-identical, so `'full'` is still the whole row at every multi-column tier. Only the pin the sentence names moves. - -No key, default, enum member or export moves: the same authored metadata is accepted and refused as before, and `content/docs/references/ui/view.mdx` is regenerated from the sentence. diff --git a/.changeset/19507-template-loader-docblock-best-match.md b/.changeset/19507-template-loader-docblock-best-match.md deleted file mode 100644 index 8eab7f2681f..00000000000 --- a/.changeset/19507-template-loader-docblock-best-match.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/plugin-email': patch ---- - -docs(plugin-email): the `TemplateLoader` docblock opened on a "best match" its own next paragraph denies (#19507) - -Clause-②: no — no accept set moves, no published payload key changes, no -export is added or removed. The corrected prose ships as JSDoc in -`@objectstack/plugin-email`'s `dist/index.d.ts` (the package publishes `dist`), -which is why this is a changeset rather than `skip-changeset`. - -`TemplateLoader`'s docblock in `packages/plugins/plugin-email/src/email-service.ts` -contradicted itself inside one paragraph. It opened with *"Returns the -best-matching row for `(name, locale)`"* and then, two lines later, correctly -said *"`locale` set → an EXACT match for that locale, or `null`"*. -`SendTemplateInput.template` (`packages/spec/src/contracts/email-service.ts`) -declares the opposite of the opening in as many words: there is no "best match" -and no language-subtag folding, the locale row is resolved by the exact ladder. - -The opening now states what `createSysEmailTemplateLoader` implements, measured -against the code at this branch's base rather than against any transcription of -it — `load(name, locale)`: - -- `locale` given ⇒ `first({ name, locale }, BY_ID)`, an exact `(name, locale)` - match ordered by `id`, or `null`; -- `locale` absent ⇒ `first({ name, locale: 'en-US' }, BY_ID)` first, and only - when that misses, `first({ name }, BY_LOCALE)` — the bundle's lowest locale - tag, ordered; -- no branch asks the store to pick a locale, and none folds a subtag. - -This is the fourth shipped carrier of the same false declaration and the first -outside the set #18499 enumerated: that probe was written as the literal strings -`best-matching locale` / `picks the best`, and this sentence says -"best-matching **row**", so it was never in the hit set. A carrier set built -from literal strings is blind to its own synonyms. - -No resolution behaviour changes: the edit is prose. `createSysEmailTemplateLoader`, -the `sendTemplate` ladder and every `where` clause are untouched. diff --git a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md deleted file mode 100644 index c4a3316ccca..00000000000 --- a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: the filter doors refuse the three shapes they already declared refused — a scalar operator's array, an `icontains` comparand the conformance table rejects, and an ungated `defaultFilters` (#19514) - -**BREAKING** — three accept-set narrowings on published authoring surfaces, each pulling the door back to what this package already declared somewhere an author's parse never reached. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying any of these shapes keeps loading and keeps rendering exactly as it does today; what changes is that RE-SAVING it is refused, at the key that carries the mistake. The hand-migration prescriptions are registered under protocol major 18 as `view-filter-rule-scalar-operator-array-refused`, `filter-icontains-comparand-refused-at-parse` and `object-grid-default-filters-rule-array`. - -Direction set by objectui#9050's ruling C′, quoted untranslated: 「the differences are the protocol's to close」. - -## 1. A scalar operator carrying an ARRAY is refused - -`ViewFilterRuleSchema.value` has carried this sentence in its published description since the operator/value coupling landed: *the accepted SHAPE depends on the operator: `in` / `not_in` take an array, `between` takes exactly [min, max], **every other operator takes a scalar**.* The refinement that implements the coupling returned early for every operator that was neither a list operator nor `between`, so from the day the coupling landed until this change the entire scalar class was declared and not judged. - -⚠️ **This reverses a reading the code recorded**, and the reversal is the substance. The scalar-operator array was listed as deliberately accepted because it *"lowers to a bare `{ field: value }` deep-equality comparand, which every backend answers"*. The backends a lowered view rule reaches at this release do not agree — so each is named, in the present tense (how each cell was measured, and which were not, is stated under the table): - -| backend | what it does with the lowered `{ tags: ['a'] }` | -|:--|:--| -| the SQL family: `driver-sql`, the `driver-turso` / `driver-sqlite-wasm` drivers built on it, and turso's remote transport | **REFUSES** — the bare `{ field: value }` loop asserts the comparand against its own scalar-operator set, an array is none of the six accepted comparand types (`a string, number, bigint, boolean, null or Date`), and it comes back as the withheld `INVALID_FILTER` / 400 envelope | -| `driver-memory` | **REFUSES** — the same shape in the same envelope | -| `driver-mongodb` | **ANSWERS** — `translateFilter` passes the array through unchanged and the engine's shared comparand doors pass the shape, so the server applies MongoDB's equality rule for an array operand: a row matches when its stored array **equals** `['a']` **or holds `['a']` as an element**, and a row storing the scalar `'a'` does not (mingo 7.2.4, over `['a']`, `'a'`, `['a', 'b']`, `['b', 'a']`, `[['a'], 'x']`, `[['a']]` and `'b'`, selects `['a']`, `[['a'], 'x']` and `[['a']]`); a live `mongod` is NOT MEASURED | - -How each cell was measured. Run for this change on the lowered `{ tags: ['a'] }`, each beside a scalar and an `$in` control: `driver-sql` on SQLite, `driver-memory`, `driver-mongodb`'s `translateFilter`, and mingo 7.2.4 for MongoDB's rule. ⚠️ NOT MEASURED: MySQL, a live Turso server, and a live `mongod` — the MongoDB row is read at the driver's compile face, at the engine's shared comparand doors and through mingo. - -**None reads the array as the scalar the operator declares.** `driver-mongodb` returns rows — but for a different predicate, and only on an array-valued field, so it reads as a true statement about data the rule never asked for (a live `mongod` is NOT MEASURED). Earlier releases are a separate question, and only partly measured: `driver-memory` refuses the shape from 17.4.0, while its published 17.3.0 returned the row stored as `['a']` (run in this change's review; which other rows it selected, nested arrays included, is NOT MEASURED); whether any earlier SQL-family release answered the shape is NOT MEASURED. - -Two carve-outs are kept and pinned, because a narrowing that runs past the query path is the mirror-image defect: an **omitted** value still parses (`value` is optional), and the four **valueless** operators (`is_empty` / `is_not_empty` / `is_null` / `is_not_null`) still accept anything in the value position — they take their direction from the operator NAME, the lowering discards the value, and the ObjectUI client deliberately sends a truthy placeholder there. - -## 2. The `icontains` comparands the platform's own table declares refused - -`@objectstack/spec/data`'s `FILTER_TEXT_CASES` declares two REJECTION rows for the case-insensitive contains operator — an **empty** comparand and a **non-string** one, each `code: 'INVALID_FILTER'`. All five driver packages run both rows in their own suites, and the drivers re-run for this change — `driver-sql` on SQLite, `driver-memory`, `driver-mongodb`'s `translateFilter` — each refuse both comparands with `INVALID_FILTER` / 400; the formula matcher does not refuse them, it answers `false` for every row. Nothing applied them at parse, on either vocabulary, so the protocol declared the refusal and then admitted the document that would hit it. Both doors now refuse: the `$` dialect's `FilterConditionSchema` and the view vocabulary's `icontains` arm. - -The predicate is **derived from the table, not transcribed beside it** — both doors call the published `isRefusedTextComparand` and `textComparandRefusalReason`, so a row added to `FILTER_TEXT_CASES` reaches both doors with no edit at either, and the reason an author reads at authoring time is byte-identical to the one three shipped consumer faces already show at query time. `$contains`, `$startsWith`, `$endsWith`, `$like` and `$ilike` are untouched, because widening by analogy is the table's decision and not a door's. - -One asymmetry between the two vocabularies, and it is a fact about them rather than an extra rule: a view rule's `value` is optional, so an **absent** comparand is left unjudged there; the `$` dialect has no absent, so an explicit `undefined` in a comparand slot is the refused non-string shape. - -## 3. `object-grid`'s `defaultFilters` carries `filter`'s declaration - -The key is described as *"Legacy base-filter fallback, read only when `filter` is absent"* — the same value in the same role as `filter`, read through the same lowering sink. `filter` converged on the `ViewFilterRule` array with the rest of its family; this key was not named by that ruling and kept `z.unknown()`, so the block had one declared door and one undeclared door onto one seam, and the parse receipt said nothing about what the grid would then do with the value. In the objectui version this release pins (`.objectui-sha` pin `87af769e9a`), `ObjectGrid` lowers `defaultFilters` through `toFilterNode` whenever `filter` lowers to nothing, and what that does depends on the shape: - -- the **record form** and the **AST tuple array** are lowered and **applied** as declared; -- a **bare string** or a **number** is **dropped** without a word, so the grid sends no filter and lists its rows unfiltered; -- a **list of malformed rules** is **refused** — on the wire with 400 `INVALID_FILTER`, or by the client before any request for the value shapes it judges itself. - -⛔ **Narrowed, not retired.** Refusing the key outright is a removal of an accepted shape and needs its own ruling. The deprecation already stated in the description is unchanged: prefer `filter`. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `{ field: 'tags', operator: 'equals', value: ['a'] }` | `{ field: 'tags', operator: 'equals', value: 'a' }` — or `operator: 'in'` if membership was meant | -| `{ field: 'name', operator: 'icontains', value: '' }` | delete the condition — every value contains the empty substring | -| `{ field: 'name', operator: 'icontains', value: 42 }` | `value: '42'`, if a substring match on those two characters was really meant | -| `{ name: { $icontains: '' } }` | delete the condition | -| `{ name: { $icontains: 42 } }` | `{ name: { $icontains: '42' } }` | -| `defaultFilters: { status: 'active' }` | `defaultFilters: [{ field: 'status', operator: 'equals', value: 'active' }]` — better, move it to `filter` and delete the key | -| `defaultFilters: [['owner_id', '=', '{current_user_id}']]` | `defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` | - -What the rewrite changes on the page differs by row, so read them apart: - -- **The scalar-operator array.** How releases before this one answered it is set out in § 1 and is only partly measured. Re-check what each view is supposed to show rather than assuming the old result set was correct. -- **The two `icontains` comparands.** At this release each of the five driver packages answers both with a 400 and the formula matcher excludes every row; how earlier releases answered them is NOT MEASURED. -- **`defaultFilters`.** The record form and the AST tuple array were applied as declared in the pinned objectui, so for them the rewrite is a spelling change. A bare string or a number was dropped, so that grid has been listing its rows unfiltered — decide which rows it should show before writing the rule. Beside a non-empty `filter`, deleting `defaultFilters` is the whole migration; beside `filter: []` the grid reads `defaultFilters`, so move its rules onto `filter` rather than deleting them. - -The one to read closest is a one-element array: its two corrected spellings — `value: 'won'` on `equals`, and `operator: 'in'` with `value: ['won']` — select the same rows, so the result set cannot tell you which the metadata meant, and only the author knows. - -## Who is affected, measured - -Nothing in this repository authored any of the three shapes. Two fixtures pinned the old accept set and were re-judged rather than rewritten by rote: one asserted that a scalar-operator array parses (it pinned the reading paragraph 1 reverses), and one parsed an ObjectQL AST tuple array on `defaultFilters` to prove the key is HONOURED — that subject survives, on the rule array, with the tuple array's refusal pinned beside it. The full `@objectstack/spec` suite is green, and `check:api-surface` reports no export moved: no symbol is added, removed or renamed by this change. - -Clause-②: no (narrowing) — no key is added, removed or renamed, no exported symbol moves, and no new published vocabulary is introduced (the two operator sets the refusals name are the ones already exported, and the two the checks needed for themselves are deliberately module-private). Every one of the three accept sets narrows back to what this package had already declared: a published `.describe()` for the first, a published conformance table for the second, and the sibling key's own declaration for the third. - - diff --git a/.changeset/19523-blank-endpoint-entry-carriers.md b/.changeset/19523-blank-endpoint-entry-carriers.md deleted file mode 100644 index 938504766fe..00000000000 --- a/.changeset/19523-blank-endpoint-entry-carriers.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`packages/spec/src/migrations/registry.ts` — the shipped ADR-0087 semantic entry `filter-between-blank-endpoint-refused` told an upgrader that `FieldOperatorsSchema.safeParse` **and re-saving the document** both make the sweep mechanical, over a carrier list of six slots. Measured: re-saving is mechanical on **none** of the stored carriers, so an upgrader who re-saved every dashboard, dataset and report found no blank endpoint refused and concluded the sweep was done. The entry's `surface`, `reason` and `acceptanceCriteria` now say what is actually judged where, and the sibling entry `filter-between-field-reference-endpoint-refused` — measured to carry the identical clause in its own spelling — is corrected the same way (#19523). - -Clause-②: no - -No behaviour moves: nothing about what the platform refuses changes. No schema or accept set is touched, and no export is added, removed or retyped (`check:api-surface` reads the public surface unchanged); every line this change edits in `registry.ts` is a string literal inside the two step-18 entries that the exported `MIGRATIONS_BY_MAJOR` carries, so what moves in `dist` is prose. What changes is what the document tells a human to do about it. - -- **The split the entry now draws.** (a) Refused at save: the enforced `FieldOperatorsSchema` / `RangeOperatorSchema` copy itself, reached by a caller that validates a filter against it directly, and the `NormalizedFilter` AST. (b) Not judged at save: every stored metadata carrier — and that is **both** authoring dialects, not only the loose one. A dashboard widget filter, a dashboard options-source filter, a dataset filter, a dataset measure filter, a report `runtimeFilter`, a rollup `summaryOperations` filter and a `relatedListFilter` are typed `FilterConditionSchema`; a view, page or component filter **rule** is `ViewFilterRuleSchema`, whose value check judges arity and not blankness. For (b) the detectors are the grep the entry already prescribes and **executing** the surface, where the engine comparand-shape door answers `INVALID_FILTER` / 400 naming the index and the side. -- **What those same slots DO judge.** `FilterConditionSchema` carries `.superRefine(checkBarePresetOrderingComparands)`, so a bare date-range preset name in an ordering position is refused on this carrier set: measured at this head, `{ close_date: { $gt: 'today' } }` is refused at `close_date.$gt` and `{ close_date: { $between: ['today', '2026-12-31'] } }` at `close_date.$between.0` — the latter through an otherwise green `DashboardWidgetSchema` document at `filter.close_date.$between.0`, where the blank endpoint on that same widget stays green. This carrier set is not the edge of the rule, because the refinement rides the schema rather than these slots: `QuerySchema` refuses `where: { close_date: { $gt: 'today' } }` at `where.close_date.$gt`, and `BlueprintSummaryOperationsSchema` refuses the same map as its `filter` at `filter.close_date.$gt`, while `$gt: '2026-01-01'` stays green through both. What these carriers never judge is a `$between` endpoint for **blankness** or for **arity**, and that narrower clause is what the entry now carries; the sibling entry `filter-preset-ordering-comparand-refused` states the same rule from its own side. -- **The card's own control was wrong in the safe direction, and it was re-derived rather than inherited.** `ViewFilterRuleSchema.safeParse({ field, operator: 'between', value })` reads green on `['2026-01-01', '2026-12-31']` and green on `['2026-01-01', '']`, and refused on `['2026-01-01']` and on `['2026-01-01', { $field: 'x' }]` — so the rule dialect admits a blank endpoint, with two firing controls proving the same door is live. The same four verdicts hold through a fully-green `ListView` document at `filter.0.value`. -- **The sibling entry carried the identical clause, and it is corrected here too.** `filter-between-field-reference-endpoint-refused` described the same `FilterConditionSchema` slots as a shape “that never judges an operator map”. Measured at this head through an otherwise-green `DashboardWidgetSchema` document: a `{ $field }` reference endpoint in `$between` parses GREEN — as do a one-element, a three-element and an empty `$between` — while a preset endpoint on that same widget is refused at `filter.close_date.$between.0` and a preset `$gt` comparand at `filter.close_date.$gt`. Its clause now names what those carriers never judge — a `$between` endpoint for a COLUMN REFERENCE or for ARITY — with the live preset rule named beside it, and its `acceptanceCriteria` stops offering “those slots are `FilterConditionSchema`” as the reason a document parses green. Reach, counted over the four bundles: the bare `never judges an operator map` read 4 before this change and 0 after, and no entry carries it any more: in the tree it survives only in this changeset, which quotes the removed clause — while `a $between endpoint for a COLUMN REFERENCE or for ARITY` and the widget sentence quoting `contract.start` each moved 0 → 4, against a dark control (`… or for BLANKNESS`) reading 0 on both sides. -- **Why this is a publishing change at all.** `src/migrations/entries/**` is generator input: it is not in this package's `files[]` (only `entries/README.md` ships) and nothing imports it but the generator and two pin tests. The registry is what `chain.ts` and `index.ts` import and what `dist` is built from, so of the two, only the registry carries the corrected prose into `dist`: measured on this branch, the old sentence read 4 hits across `dist/index.js`, `dist/index.mjs`, `dist/browser/index.js` and `dist/browser/index.mjs` before the regeneration and 0 after, and two sentences unique to the corrected text moved 0 → 4 in the same four files. diff --git a/.changeset/19527-skill-tool-verdict-whole-stack.md b/.changeset/19527-skill-tool-verdict-whole-stack.md deleted file mode 100644 index 9e092911b51..00000000000 --- a/.changeset/19527-skill-tool-verdict-whole-stack.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -`ai-skill-tool-unresolved` is a whole-stack verdict by design: `os validate` / `os lint` / `os build` report it, and the runtime publish gate never does (#19527) - -A skill's `tools[]` entries resolve against three sources: `stack.tools`, the -platform tool registry, and the `action_` tools materialised from -AI-exposed actions. The runtime publish gate judges one written item against a -per-write snapshot that carries neither `stack.tools` nor `stack.actions`, and -no snapshot can carry a tool that a runtime plugin registers outside the -registry. At that door the rule could only ever produce a false -`ai-skill-tool-unresolved`: a skill naming a real stack-level action would be -told the tool does not exist. The ruling on #19527 (letter B) places this -check at the whole-stack rule, following ADR-0109 Decision §3, and keeps the -per-write snapshot as it is. - -`skill` was already outside the runtime gate. The earlier comments called that -a temporary hold-out, pending a wider snapshot. They now describe it as the -design, so no later change should add `actions` / `tools` to the snapshot in -order to move this check onto the door. New tests pin both halves: - -- a `skill` write through the runtime gate gets no tool-reference finding. This - holds for a stack-level action tool, a plugin-registered tool, and a tool - that exists nowhere. No other write type can put a skill into a door - snapshot; -- `validate`, `build` and `lint` still report a tool that exists nowhere, at - warning severity, and do not report the stack-level action that resolves. - -⛔ No behaviour changes. Before and after this change, the runtime gate runs no -rule for a `skill` write, and the whole-stack commands report the same -findings. No authorable key, accept set or export changes. - -**This ships, so it carries a changeset rather than `skip-changeset`.** -`@objectstack/lint` publishes `dist`, and its build keeps source comments. A -rebuilt artifact shows the reworded block in `dist/index.js`, -`dist/index.cjs`, `dist/runtime.js` and `dist/runtime.cjs`, and the old -wording (「held out on a measurement」) in none of them. So the published JS -bytes change, but behaviour and the declaration surface do not. diff --git a/.changeset/19543-list-doors-3-4.md b/.changeset/19543-list-doors-3-4.md deleted file mode 100644 index ba2b6ca0b8f..00000000000 --- a/.changeset/19543-list-doors-3-4.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/client': minor -'@objectstack/runtime': minor ---- - -feat!: retire the `GET /api/v1/automation` flow list in favour of `GET /api/v1/meta/flow`; `ListAiConversationsResponse` declares `hasMore` (#19543) - -**BREAKING** — two sibling list doors that declared paging nobody honoured. - -**The flow list is retired, with no alias and no transition window** (maintainer -ruling: 「退役,统一走 /meta/flow」). Its contract described a capability no build -ever delivered: the request declared `status`, `type`, `limit` (default 50) and -`cursor`, and the route read none of them; the response declared `FlowSummary` -rows with `total`, `nextCursor` and `hasMore`, and the route answered bare flow -names beside a literal `hasMore: false`. Measured before removal on the main branch -of this repository and cloud, and on objectui at its pinned commit and at main: -zero callers of the route or of `client.automation.list` outside their own tests, -while the Console flow-runs page and the Setup packaged-automation page already -read `GET /api/v1/meta/flow`. - -FROM → TO, per surface: - -- `GET /api/v1/automation` (and its environment-scoped twin) → no longer mounted - for `GET`. `POST /api/v1/automation` (create a flow) still lives at that path, so - on the default Hono host a `GET` there answers the host's standard method - mismatch — `405 METHOD_NOT_ALLOWED` with `Allow: POST` — the same answer any - POST-only path gets. A transport that forwards every automation path to the - dispatcher (the `@objectstack/hono` catch-all) is told the domain does not handle - it and answers its own not-found `404`. Fix: read `GET /api/v1/meta/flow`; - flows are metadata (ADR-0106), and it answers full definitions, so map each item - to its `name` if you only need names. Per-flow runtime enablement and trigger - binding is `GET /api/v1/automation/_status`, unchanged. -- `client.automation.list` (`@objectstack/client`) → removed; calling it is a - compile error. Fix: `client.meta.getItems('flow')`, or - `client.automation.getRuntimeStatus()` for the enabled/bound state. -- `ListFlowsRequestSchema`, `ListFlowsResponseSchema`, `FlowSummarySchema` and the - types `ListFlowsRequest`, `ListFlowsRequestParsed`, `ListFlowsResponse`, - `ListFlowsResponseParsed`, `FlowSummary` (`@objectstack/spec/api`) → removed, - no replacement export (TS2305 on import). Fix: delete the import; the flow - definition type is `Flow` from `@objectstack/spec/automation`. -- `AutomationApiContracts.listFlows` → removed; the map has eight entries, none of - them a `GET` at the bare path. Every other automation route is unchanged. - -**`ListAiConversationsResponseSchema` gains a required `hasMore`** (the spec half -of the same card; the server half is objectstack-ai/cloud#2426). The list is -declared **newest first** and pages by keyset: `cursor` is the `id` of the last -conversation the caller already holds, and `hasMore` says whether another page -follows. `hasMore` is required rather than optional so a server that does not -compute it is off-contract instead of silently spec-valid; no `nextCursor` is -declared, because the next cursor is the last conversation's id, already on the -page. Who notices: code that constructs a `ListAiConversationsResponse` must now -set `hasMore`, and a response parsed with the schema is refused without it. -`client.ai.conversations.list()` is unchanged — it still resolves to the -conversation array. - -Breaking ships as `minor` per the launch-window convention -(`scripts/check-changeset-no-major.mjs`). - -**Clause-②: yes (narrowing)** — the conversation list's response surface gains a -declared `hasMore`; a route, an SDK method, three published schemas with their five -types and a contract entry are removed, and a conversation-list response without -`hasMore` is now refused. - - diff --git a/.changeset/19567-client-limit-guards.md b/.changeset/19567-client-limit-guards.md deleted file mode 100644 index aad69a61731..00000000000 --- a/.changeset/19567-client-limit-guards.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/client': minor ---- - -fix(client)!: every `limit` query-parameter emitter sends what the caller wrote, so `{ limit: 0 }` is no longer silently swapped for the server's default window on three methods (#19567) - -Clause-②: no (narrowing) - - - -**BREAKING** — a narrowing, shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; the breaking-ness is carried by -this banner and the ADR-0087 disposition above, not by the level). A call that used -to answer `200` can now answer `400`. - -**What changed.** The SDK set the `limit` query parameter behind three different -guards, so one input got a different answer depending on the method. Seven methods -already sent every value except `undefined`. Three used a truthy test, so `0` and -`NaN` never left the client and the server answered `200` with its default window — -rows the caller did not ask for. Four used `!= null`, which dropped an untyped `null` -as the truthy three did, while the seven others sent it as the text `null`. All -fourteen emitters now leave only an absent (`undefined`) `limit` off the wire and send -everything else as written; the door that declares the bound decides. The SDK itself -still does not validate `limit`. - -| method | what changes on the wire | -|:--|:--| -| `automation.runs.list` | `0`, `NaN` and `null` are now sent; the door declares `1..100` and refuses all three with `400 VALIDATION_FAILED` | -| `notifications.list` | `0`, `NaN` and `null` are now sent; the inbox clamps `0` to one row (its declared clamp into `1..200`) and refuses `NaN` / `null` with `400` | -| `environments.listRevisions` | `0`, `NaN` and `null` are now sent to the control-plane door | -| `automation.listRuns`, `environment(id).automation.listRuns` | an untyped `null` is now sent and refused with `400` (`0` and `NaN` were already sent) | -| `data.listImportJobs`, `environment(id).data.listImportJobs` | an untyped `null` is now sent; the door reads it as its default of 50, so the answer does not move | - -`meta.getHistory`, `meta.getAudit`, `search`, `ai.conversations.list`, -`ai.pendingActions.list`, `data.export` and `environment(id).meta.getHistory` -already sent every value except `undefined`, and are unchanged. - -**If you relied on the old behaviour:** a call that passed `limit: 0` (or `null`) to -mean "the server's default window" should leave `limit` out instead. Every `limit` -here is typed `number | undefined`, so `null` only reaches these methods through an -untyped caller. diff --git a/.changeset/19568-datasource-runtime-create-door.md b/.changeset/19568-datasource-runtime-create-door.md deleted file mode 100644 index b89ec79ca59..00000000000 --- a/.changeset/19568-datasource-runtime-create-door.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -The runtime publish gate now judges `datasource` writes: `lintLivenessProperties` declares the type in `runtimeTypes` and `TYPE_TO_STACK_KEY` maps it onto `datasources`, so a datasource minted through Studio, REST `/meta` or an MCP/AI author reaches an authoring rule for the first time. - -Clause-②: yes (widening) — one metadata type joins an existing rule's declared roster. No schema key, export or closed-set member is added, nothing previously admitted is refused, and no authored document changes meaning. - -`DEFAULT_METADATA_TYPE_REGISTRY` has declared `datasource` with `allowRuntimeCreate: true` since ADR-0015's Addendum, and no rule named it in `runtimeTypes` — so the gate filtered a datasource write out before it consulted the stack-key table, and the write built no snapshot and ran no rule at all. It is the ADR-0049 declared-not-enforced shape, missed by the census that graded ten sibling types because its registry entry is the only multi-line one and a single-line reader of the registry cannot see it. - -- **The group was measured, not inherited**, because this type sat outside the ten the ruling graded. **Retirement is refuted**: that arm is for a declaration with no stack collection to create into, and `datasources` is a first-class collection with a live runtime create path. **The `skill` hold-out is refuted too**, which is the half that decided it — `skill` stayed out because its bridge resolves references into `stack.tools` / `stack.actions`, collections the door's snapshot does not carry, so the door reached a verdict the whole stack does not share. This rule resolves into nothing: it judges each written item's own top-level keys against that type's liveness ledger, and the door's verdict and the whole-stack verdict are pinned as the identical value. -- **⚠️ Dispatched and silent, on purpose.** `packages/spec/liveness/datasource.json` carries 0 warn keys, so no datasource document can be advised at this door today — the `email_template` / `mapping` end state exactly, under the same fence: the wiring is the whole deliverable and ⛔ no ledger-population work rides with it. The day a datasource property earns an `authorWarn` row the door lights up with no second edit. The silence is pinned beside a lit control on the same instrument in the same process, so it can never be read as a broken dispatch or an unresolvable ledger directory. -- **The stack key is proved behaviourally**, which its two ledger-driven siblings could not manage: `runtime-gate.datasource-writes.test.ts` drives the real rule through its ledger-directory seam over a stack built at `stackKeyForType('datasource')` itself, with the wrong-key leg asserted beside it, so the `seed: 'data'` failure shape — a mapping onto a collection nothing reads — reds a case here rather than riding on one string assertion. -- **Nothing else widened.** A datasource write reaches this one rule and no reference-integrity member; `translation`, `tool`, `doc`, `external_catalog` and `skill` keep their empty rosters, each awaiting its own reading or retirement. diff --git a/.changeset/19571-plaintext-oauth-public-host-notice.md b/.changeset/19571-plaintext-oauth-public-host-notice.md deleted file mode 100644 index 88c537297e6..00000000000 --- a/.changeset/19571-plaintext-oauth-public-host-notice.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -A deployment that serves its OAuth authorization-server discovery documents over **public plain HTTP** now says so at startup. Previously it was the quietest configuration on the box: the `.well-known` discovery routes go up regardless of transport, while the only line that mentioned the refused transport sat inside the MCP-surface condition — so a public plain-HTTP boot with `OS_MCP_SERVER_ENABLED=false` emitted no warning at all. - -Eligibility now decides **which** sentence is emitted, never **whether** one is: - -- an origin the transport rule ACCEPTS (loopback / private / link-local) keeps its line, `OAuth is served UNENCRYPTED`; -- an origin it REFUSES (a public host) gets a new, distinct line, `OAuth discovery is served over PUBLIC plain HTTP`, naming the issuer and the discovery documents, stating that the MCP OAuth track is disabled, and pointing at TLS as the remedy. - -Both are emitted once, at mount; neither under TLS. ⛔ No configuration key and ⛔ no environment variable gates either line. - -**No admission decision changes.** `isOAuthEligibleBaseUrl` and every transport-rule predicate are untouched, the discovery routes are mounted exactly where and when they were before, no export is added or removed, and no published payload gains a key. The change is two log lines and a comment. - -The startup line is also now written in English, this repository's convention for code artefacts. The maintainer's wording 「OAuth 未加密:仅限可信内网」 is carried as the sentence's meaning and kept verbatim in the code comment beside the call. - -Clause-②: no diff --git a/.changeset/19576-install-local-id-gate.md b/.changeset/19576-install-local-id-gate.md deleted file mode 100644 index df6719782d3..00000000000 --- a/.changeset/19576-install-local-id-gate.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -'@objectstack/cloud-connection': minor ---- - -fix(cloud-connection): `POST /api/v1/marketplace/install-local` parses the package id it installs through the manifest declaration (#19576) - -Clause-②: no (narrowing) - -**BREAKING for callers of the install-local door** — a manifest whose `id` is -not reverse-domain notation is now refused `PLUGIN_MANIFEST_INVALID` (`400` for -an inline manifest, `502` for a cloud-fetched snapshot) before anything is -registered or written. It used to install and answer `200`. - -The accept set only shrinks back to what the published declaration has always -said. `MANIFEST_ID_PATTERN` (`@objectstack/spec/kernel`) is the one declaration -of a package id, and the other two package-install doors — `POST -/api/v1/packages` and the protocol install primitive — already refuse the ids it -refuses. This door is a separate path: it never calls the protocol primitive, -so neither gate covered it. It derived the id as `manifest.id ?? manifest.name` -and parsed nothing, so `late-app`, `com.example.my_erp`, a number, or a manifest -carrying only a `name` installed cleanly and became the key for the on-disk -ledger entry and for every `:manifestId` route. - -The door now asks the declaration **by reference** — `ManifestSchema.shape.id` -— at the one point where the inline branch (after a compiled bundle is -flattened) and the cloud branch have converged, ahead of the `409 -MANIFEST_CONFLICT` collision check, the posture gate, the hot-register and the -ledger write. The sentence the caller reads is the declaration's own -(`manifestIdRefusal`), surfaced rather than reworded. Posting `id: 'late-app'` -now answers, in `error.message`: - -```text -Invalid package id 'late-app' on `manifest.id`. Expected reverse-domain notation -('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments -of letters, digits and inner hyphens; a segment may not open with a hyphen; -underscores are not admitted. Did you mean 'com.example.late-app'? -``` - -**`manifest.name` is no longer read as an id.** `ManifestSchema` declares `id`; -`name` is a display label with no pattern. A manifest with no `id` is refused -with the same sentence, naming `manifest.id`. The inline branch's earlier -message for that case — which said the manifest needed an `"id"` or a `"name"` -— is gone with the fallback it described. - -**What is not affected.** A conforming id installs exactly as before, on both -branches and for both the flat and the compiled-bundle shape. Ledger entries -already on disk are not re-judged: an entry an older build installed under an -id the declaration refuses still rehydrates at boot and can still be removed -with `DELETE /api/v1/marketplace/install-local/:manifestId`; only a fresh -install under that id is refused. Boot-time and in-process registration -(`manifest.register`, `AppPlugin`) never passes through this door. - -**If you are refused.** Give the manifest an `id` in reverse-domain notation — -lowercase dot-separated segments, hyphens allowed inside a segment, underscores -not. The refusal names the key, echoes what was sent and, where a mechanical -repair exists, offers one it has already checked against the rule. Artifacts -built by `os build` from `defineStack()` already carry a conforming id, because -the same declaration refuses anything else at build time. - - diff --git a/.changeset/19577-duplicate-package-explicit-namespace.md b/.changeset/19577-duplicate-package-explicit-namespace.md deleted file mode 100644 index d6943a913d4..00000000000 --- a/.changeset/19577-duplicate-package-explicit-namespace.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol): `duplicatePackage` parses an explicit `targetNamespace` through the manifest namespace declaration instead of taking it raw (#19577) - -Clause-②: no (narrowing) - -**BREAKING for callers of `duplicatePackage` / `POST /api/v1/packages/:id/duplicate`** — an explicit `targetNamespace` outside the `manifest.namespace` declaration (`/^[a-z][a-z0-9_]{1,19}$/`) is now refused with a `400` before anything is copied, where it used to be accepted verbatim. Refused now: a hyphen or an uppercase letter (`my-ns`, `MyNs`), a leading digit or underscore (`1leave`, `_leave`), a single character (`l`), more than 20 characters, and surrounding whitespace. Some of these (`l`, `_leave`, a 21-character value) still yield legal object names, so they used to be copied, under a `manifest.namespace` the declaration refuses. Every conforming value — and every call that omits `targetNamespace` — duplicates exactly as before. - -`ObjectStackProtocolImplementation.duplicatePackage` (and so `POST /api/v1/packages/:id/duplicate`, which forwards the body's `targetNamespace` verbatim) resolved its target namespace as `request.targetNamespace ?? deriveNamespaceFromPackageId(request.targetPackageId)`. The derived default already had to satisfy the namespace charset; the explicit value crossed no gate at all. That value is written as the copy's `manifest.namespace` and spliced into every copied object name as `${namespace}_${short}`, so `targetNamespace: 'my-ns'` minted `my-ns_ticket` — a name the object declaration (`/^[a-z_][a-z0-9_]*$/`) refuses — under a manifest namespace the manifest declaration refuses. - -- **One parse for both branches.** Whichever branch answered, the resolved namespace is now parsed by `ManifestSchema.shape.namespace` (`@objectstack/spec/kernel`) — the declaration itself, by reference, not a copied regex — before the source rows are scanned and before the target package record is minted, so a refusal never leaves an empty shell behind. -- **Refused, not sanitised.** An explicit value the declaration refuses is refused; it is never rewritten the way the derivation sanitises an id, because a copy landing under a namespace the caller did not write is a silent rewrite. -- **The sentence is the declaration's.** The refusal names the key and echoes the value, then carries the declaration's own rule text: `Invalid package namespace 'my-ns' on \`targetNamespace\`. Namespace must be 2-20 chars, lowercase alphanumeric + underscore. …`. The derived branch's refusal (an id whose final segment cannot carry the charset) now carries the same declaration sentence after its `Pass \`targetNamespace\` explicitly.` remedy, replacing a reworded one. -- **No new error code.** Both refusals throw with `statusCode: 400` and no `code`, so an HTTP boundary answers `400 VALIDATION_ERROR`, the status-derived code the derived branch already answered. - -**If you are refused:** pass a `targetNamespace` of 2–20 characters that starts with a lowercase letter and continues with lowercase letters, digits or underscores (`leave_copy`, not `leave-copy`), or omit it and let the door derive one from `targetPackageId`. Every conforming value duplicates exactly as before. - - diff --git a/.changeset/19578-isecurityservice-effective-object-permission-reader.md b/.changeset/19578-isecurityservice-effective-object-permission-reader.md deleted file mode 100644 index 83bcaa54b94..00000000000 --- a/.changeset/19578-isecurityservice-effective-object-permission-reader.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**`ISecurityService` gains the effective-object-permission reader.** - -`getEffectiveObjectPermissions(context?)` answers the server-resolved effective object-permission -map for a caller — object name -> `EffectiveObjectPermission` — which is the `objects` slot of the -published `/auth/me/permissions` response (`GetEffectivePermissionsResponseSchema`): the caller's -permission sets merged most-permissively, the super-user folds applied, each entry annotated with -its effective API-operation set. Purely additive: the member is OPTIONAL, nothing is renamed, -narrowed or removed, and a security service that omits it still satisfies the contract. - -Clause-②: yes (widening) - -**Why it is a reader on the service rather than a merge each consumer does.** The existing -`resolvePermissionSetsForContext` deliberately leaves the merge to the caller, because two consumers -legitimately project *different* subsets of the same sets. This map is the projection two consumers -need to be *identical*: the effective map `/auth/me/permissions` serves is also the map the -permission predicate `current_user.can(object, verb)` reads through `EvalContext.permissions`, and -that consumer cannot tell a wrong map from a right one. `@objectstack/formula` already states the -hazard at its own door — a hand-built permission map "has no shape of its own to be wrong against: -it parses, `can()` answers from it, and the answer is a confident silent denial". One producer -removes the second copy before it is written. - -**Three properties of the contract, each load-bearing:** - -- **The WHOLE map, with no object parameter.** An entry the map omits reads as "no grant" and - answers `false`, which is indistinguishable from a measured denial — so a caller may not narrow - the map to the objects it expects to be asked about. A predicate names its objects in its own - source; the site assembling the context does not know them. -- **It THROWS on resolution failure and never degrades to `{}`.** An empty map is a *real* answer - here (this subject holds nothing), so a failure returning it would publish a denial of everything - as a measured fact. Callers fail closed on the throw, exactly as they must for - `resolvePermissionSetNames` and `resolvePermissionSetsForContext`. -- **Absence is a defined state.** Consumers feature-detect - (`typeof svc.getEffectiveObjectPermissions === 'function'`), and the fallback is NOT an empty map - and NOT a locally merged one: a caller that cannot get this answer passes no permission data at - all, leaving a permission-gated predicate loudly unevaluable instead of quietly denied. - -Request-scoped: resolve it once per request, never per evaluation (the map is pinned data an -evaluator re-reads for free) and never cached across requests (a grant may since have been revoked). - -**This is the declaration only.** The engine-side threading — populating `EvalContext.permissions` -from this reader at each `current_user`-bound predicate evaluation site, and the -`packages/objectql` / `plugin-security` wiring — lands separately under the same maintainer ruling, -which orders a census of those sites first. diff --git a/.changeset/19579-absent-scale-declared-per-field-type.md b/.changeset/19579-absent-scale-declared-per-field-type.md deleted file mode 100644 index f622ed54588..00000000000 --- a/.changeset/19579-absent-scale-declared-per-field-type.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): the protocol declares what an ABSENT `scale` means per field type — `percent` ⇒ 0 (#19579) - -**Clause-②: yes (widening)** — two new exported symbols on the `@objectstack/spec/data` index (`resolveFieldScale`, `FieldScaleMeta`), so a published public surface grows purely additively. Graded `minor` for that act, per the repo's level rule. ⛔ Nothing narrows: no key is added, removed or retyped on any `z.object`, no `.default()` is introduced, and a field's parse output is byte-identical to before — see "Why a resolver" below for why that last point is deliberate rather than incidental. - -`FieldSchema.scale` is optional with **no declared meaning for its absence**, so every face that renders a decimal width invented one. Measured on the pinned sibling checkout: the read-only percent cell, the grid summary footer, the detail summary chip and the dashboard metric widget each resolved an absent `scale` to `0`, while the percent EDIT widget resolved it to `2`. One stored `0.25` therefore read **`25%`** on one face and **`25.00%`** on another — two magnitudes for one record, out of a single empty declaration, and a difference users report as a data bug rather than a formatting one. - -Maintainer ruling (director seat, summon 25, batch 194 item 1, letter A′, 「同意」), quoted rather than paraphrased: - -> `@objectstack/spec` declares the default decimal places for an **absent** `scale` per field type, and consumers read it from the protocol — ⛔ no `?? N` in any consumer. **percent ⇒ 0** in this card. - -**What lands.** `resolveFieldScale(field)` in `data/field-scale.ts` answers the effective decimal width: the field's declared `scale` when it has a well-formed one, the platform's declared value for an absent `scale` on that type otherwise. `percent` is the one type with a declared value, and it is `0`. `FieldSchema.scale`'s `.describe()` now states the rule in words an author can read and names the resolver as the single source, so the generated field reference page carries it too. - -**Nothing to migrate.** The key keeps its type, its optionality and its bounds; an authored `scale` round-trips unchanged; a field that declares none parses to output that still omits it. Adopting the resolver is what removes a consumer's private fallback, and the consumer half of that is a separate landing in the sibling repo. - -**Why a resolver, and not a Zod default — measured, ⛔ not assumed.** `FieldSchema` is a flat `strictObject`, so a key-level `.default(0)` cannot see `type` and would land on every numeric type at once: that is the plain letter the ruling refused by name, because an undeclared currency would fall from `$25.00` to `$25`. A type-conditional materialization in the schema's `.overwrite()` tail — the instrument `unique` and `deleteBehavior` use, which CAN see `type` — is wrong for a second reason, outside presentation entirely: `packages/objectql`'s record validator arms its write-time `max_scale` REFUSAL only when `def.scale !== undefined`. Materializing a `0` would start refusing writes the platform accepts today, on every percent field whose author declared nothing — a stored-data change bought for a display ruling, and one no author could read off their own metadata. The absent value therefore stays absent on the parsed field and is resolved at the moment of display; a pin asserts a bare `percent` field parses to output carrying no `scale` key. - -**`number` and `currency` are deliberately NOT declared here.** The ruling scoped them to a consumer census, and the census came back inconsistent for both, so under its own instruction each takes its own card with the readings instead of a guessed default. `number`'s faces disagree by design — the cell renderer resolves absence to "no fixed width" while the summary footer and the metric widget resolve it to `0` — and the display-grouping policy keys on the very distinction a default would erase: a DECLARED `scale: 0` marks a discrete integer (a year, a fiscal period, an ordinal) and renders ungrouped, while an absent `scale` means "decimals unknown" and keeps its separators, so declaring `number ⇒ 0` would print `2026` where the platform shows `2,026`. `currency`'s money faces do not read this key at all: they resolve fraction digits from the currency's own ISO 4217 minor-unit count, with a different surface's `precision` as the authored override. diff --git a/.changeset/19580-retire-connector-connection-timeout-ms.md b/.changeset/19580-retire-connector-connection-timeout-ms.md deleted file mode 100644 index da6a3bf441e..00000000000 --- a/.changeset/19580-retire-connector-connection-timeout-ms.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/connector-rest': patch -'@objectstack/connector-openapi': patch -'@objectstack/connector-mcp': patch -'@objectstack/connector-slack': patch -'@objectstack/service-automation': patch ---- - -feat(spec)!: retire `connector.connectionTimeoutMs` — declared, bounded, defaulted, served back, and never applied as a deadline - -**BREAKING** — `connector.connectionTimeoutMs` is removed. ADR-0049 -enforce-or-remove; maintainer ruling 2026-09-22, letter A. It is the narrower -**second** decision this key was owed: the earlier ruling that made its nine -liveness siblings live (`retryConfig.*`, `requestTimeoutMs`) left this one dead -on a stated reason rather than by oversight, and `packages/spec/liveness/connector.json` -has been asking for this decision since. - -The key was bounded (`min(1000).max(300000)`), defaulted (`30000`), -`.describe()`d, authorable on both carriers and served back by -`/meta/connector`. Every signal an authoring surface can give said it worked. - -### FROM → TO - -| removed | what to write instead | -| --- | --- | -| `connector.connectionTimeoutMs` (on `Connector` and on `DeclarativeConnectorEntry`, so `stack.connectors[]` and `PUT /meta/connector/:name`) | `requestTimeoutMs` — the deadline the platform keeps, applied as `resilientFetch`'s per-attempt timeout. For a connect-only bound, configure it at a connector provider or upstream gateway on a transport that can separate the phases. | -| `ConnectorProviderContext.connectionTimeoutMs` (handed to every `ConnectorProviderFactory` — added after `@objectstack/spec@17.4.0` and never in a release, see below) | `ctx.requestTimeoutMs`, or the factory's own `providerConfig` where the provider owns the vocabulary. | -| The `ZodObject` combinators on `ConnectorSchema` and `DeclarativeConnectorEntrySchema` — `.extend()`, `.omit()`, `.pick()`, `.partial()`, `.merge()`, `.strict()`, `.keyof()`, `.safeExtend()` | Both exports are now `z.preprocess` **pipes** (the residue stage below), so those methods no longer exist on them. **Build on the object and re-wrap:** `acceptRetiredDefaultResidue(, { connectionTimeoutMs: 30000 })`, the `EffectiveObjectPermissionSchema` route. ⚠️ `.superRefine()` still *exists* on a pipe but returns a schema with no read-through `shape`, so refine before wrapping, not after. Parsing, `z.input` / `z.infer`, and the read-through `.shape` are unchanged. | - -**The one-line fix: delete the key.** `os migrate meta --from 17` lists the -mechanical edits for existing sources; apply them by hand. - -The three interface members withdrawn with it were **never in a release**: -`ConnectorProviderContext.connectionTimeoutMs`, -`RestConnectorOptions.connectionTimeoutMs` and -`OpenApiConnectorConfig.connectionTimeoutMs` all entered with `b929e0a662`, -after the `@objectstack/*@17.4.0` tag, and leave in this same release. A factory -or caller built against a released version never saw them; only code written -against an unreleased `main` in between can read them, and it stops. - -⚠️ Runtime behaviour is **unchanged for every shipped provider**, because none -ever applied the value: a connector that authored `connectionTimeoutMs: 1000` -made exactly the same calls, with exactly the same deadlines, as one that did -not. What does change is observable and intended: the def served by -`GET /connectors` no longer echoes a connect deadline nobody keeps. - -### ⭐ This is NOT the zero-mention retirement shape - -Measured with `git grep -n connectionTimeoutMs SHA -- . ':!packages/spec'` at -`e07843b5a6`, the tree this retirement landed on: **thirteen** non-test source -occurrences over seven files in five -packages — **six reads** (`openapi-connector.ts:242`, `openapi-provider.ts:193`, -`rest-connector.ts:134`, `rest-provider.ts:64`, `plugin.ts:307`, -`plugin.ts:1589`), **four type declarations**, and **three** surviving hardcoded -`30000` writes. Reading the retirement as "nothing referenced it" loses the -finding. Measured across all six reads, every one is a **pass-through**: the -value's only termini were the def `GET /connectors` echoes and the fingerprint -that decides whether to re-materialize. `connectorFetchOptions()` — the one -mapping from authored policy onto the platform's outbound `fetch` — was handed -`{ retryConfig, requestTimeoutMs }` only. Carrying a number is not honouring it, -and ADR-0049 forbids the parsed-unmarked-unenforced state whether the inert -value travels or sits still. - -Nor was the `实现` arm available. A connector's outbound call is a WHATWG -`fetch`, whose only cancellation surface is ONE `AbortSignal` covering the whole -operation; nothing in that interface observes the connection phase. Bounding -"time until the response arrives" with this key would kill a slow-but-connected -upstream the author meant to allow with a large `requestTimeoutMs` — breaking -the very promise the key makes. (undici's `connectTimeout` needs a custom -dispatcher: Node-only, and a new subsystem underneath every connector, which the -ruling that made the siblings live forbids.) - -### The retirement kit - -- The **authorable key** is a `retiredKey()` tombstone on `ConnectorSchema`, - registered as `integration/Connector:connectionTimeoutMs` and - `integration/DeclarativeConnectorEntry:connectionTimeoutMs` in - `RETIRED_KEYS_BY_MAJOR[18]`. The schema is not `.strict()`, so a bare deletion - would strip an authored key in silence (ADR-0104): the tombstone is audible in - both channels — `tsc` (input type `never`) and the parse, which raises the - prescription itself. `DeclarativeConnectorEntrySchema` carries it too — both - published carriers wrap the same private `ConnectorBaseSchema` — so - `stack.connectors[]` and the `/meta/connector` door refuse it too: every value - but the retired default `30000`, which the residue stage below strips first. -- **A D2 conversion, `connector-connection-timeout-ms-removed`** — one strip per - `connectors[]` entry, a pure lossless delete. ⭐ The ruling left whether one was - owed to be **measured** ("a D2 conversion only if a stored connector row can - carry the key"). It can, and both legs were measured before the tombstone - landed: `getMetadataTypeSchema('connector')` — what `PUT /meta/connector/:name` - validates against — parsed a body carrying the key and its output **retained** - the authored value, so the number reached `sys_metadata`; and - `applyConversionsToStoredItem('connector', …)` is live for this type. Rows - written on 17.x therefore replay clean. -- **A D3 semantic entry, - `connector-provider-context-connection-timeout-ms-retired`**, for the withdrawn - `ConnectorProviderContext` member (never in a release, above). A provider - factory is code: there is no authored source and no `sys_metadata` row for a - conversion to rewrite, so the removal reaches a factory author who read it — - possible only against an unreleased `main` — as a `tsc` error and as that - entry. -- **No def leaves.** The key was a bare `z.number()`, never a `ConfigSchema` - shape, so `RETIRED_DEFS_BY_MAJOR[18]` gains nothing — and `api-surface/` and - `json-schema.manifest/` are byte-identical, which is the correct reading for a - key-only tombstone rather than a missed regeneration. -- `authorable-surface/integration.json` gains two `[RETIRED]` rows; - `authorable-defaults/integration.json` loses the two `= 30000` rows. -- The liveness row **stays** `dead` with a `REMOVED` note, because `retiredKey()` - keeps the key in the walked shape. Its previous note claimed "every occurrence - outside `packages/spec` is a WRITE". That reading was **correct at the SHA the - card cited and dated** (`0870fb5418` — exactly five non-spec source hits, all - five `connectionTimeoutMs: 30000,`) and was superseded by `b929e0a662`, the PR - the card itself flagged as pending. It is **stale, not false**, and the row now - carries both readings with their trees rather than one undated claim. -- **An `acceptRetiredDefaultResidue` stage** (#12840), `{ connectionTimeoutMs: 30000 }` - on both carriers. The key was `.optional().default(30000)`, so a 17.x parse - materialized it into **every** connector — measured on both sides of the - retirement: the released - `@objectstack/spec@17.4.0` emits `connectionTimeoutMs: 30000` for an entry that - authored only `name`/`label`/`type`, and the tombstone **without the stage** - refuses that exact object at `connectionTimeoutMs`. With the stage, as it - ships, that object is **accepted and the key stripped** before the tombstone - reads it — on `ConnectorSchema`, `DeclarativeConnectorEntrySchema`, the - `/meta/connector` schema and `stack.connectors[]` alike. - The D2 does **not** discharge the obligation, and the precedent shows it: - `ObjectPermission:allowPurge` carries a D2 **and** the residue stage, for its - own reason (a released toolchain materialized its default into every built - artifact's entries). The reason *here* is a different one — this schema has a - second door: `AutomationEngine.registerConnector` parses `ConnectorSchema` for - a def a plugin or provider factory builds **in code**, where no conversion - ever runs, and in 17.4.0 all four shipped connector packages put that `30000` - straight into the def literal. So the emitted `30000` is accepted-and-stripped, - while every other value (`15000`, `1000`, the string `"30000"`) keeps the - tombstone's refusal — at `connectionTimeoutMs`, or at - `connectors.0.connectionTimeoutMs` inside a stack — and nothing is un-retired: - `z.input` stays `never` and the `[RETIRED]` row stays. -- **No deprecation window** (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」), - and no staged retirement. - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` -is published, so this is breaking for consumers no download, dependent or source -telemetry was consulted for. The pinned sibling checkout **was** measured: zero -occurrences of the name at objectui `87af769e`, against a lit control on the same -command and scope, so no sibling fix or pin bump rides with this. - -`Clause-②: yes (narrowing)` — a published authorable key is removed on two -carriers, so the accept set a consumer writes against narrows. Nothing is -widened and nothing is renamed. Contract-review tier. - - diff --git a/.changeset/19581-zod-formatter-proto-path-bump.md b/.changeset/19581-zod-formatter-proto-path-bump.md deleted file mode 100644 index 6ae342dfe28..00000000000 --- a/.changeset/19581-zod-formatter-proto-path-bump.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -"@objectstack/cli": patch -"@objectstack/core": patch -"@objectstack/driver-turso": patch -"@objectstack/mcp": patch -"@objectstack/metadata": patch -"@objectstack/metadata-core": patch -"@objectstack/metadata-protocol": patch -"@objectstack/objectql": patch -"@objectstack/rest": patch -"@objectstack/runtime": patch -"@objectstack/spec": patch ---- - -**The declared `zod` floor moves from `^4.4.3` to `^4.6.1`**, because on zod below 4.6.1 the three standard error formatters — `z.treeifyError()`, `error.format()` and `error.flatten()` — cannot render a refusal these packages actually emit (#19581). - -Clause-②: no - -**What breaks below the new floor.** All three formatters walked an issue's `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming a member of `Object.prototype` was answered by the prototype and no node was ever created. Two different failures follow: - -| path shape | what happened on `^4.4.3` | -|:---|:---| -| terminal element (`['assignments','__proto__']`, `['x','toString']`) | the inherited member is adopted as the node, then `node._errors.push(...)` runs on it — `TypeError: Cannot read properties of undefined (reading 'push')` | -| non-terminal element (`['__proto__', …]`) | the walk continues **into** `Object.prototype` and writes the next segment onto it — the message is silently dropped from the returned tree and the process gains a global prototype key | - -**Why it reached this platform's consumers.** `@objectstack/spec` refuses a `__proto__` key on its open-key authoring surfaces, and that refusal's issue path is `['assignments','__proto__']` — precisely the terminal shape. Anything that formatted one of these refusals for display crashed on it, and the crash was in the formatter, not in the guard. The guards themselves are unchanged and still necessary: 4.6.1 still drops a `__proto__` key from `z.record()` and `.catchall()` output, which is what they exist to refuse. - -**What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. - -`@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. - -## The second half the floor move needs: an unknown key refuses TERMINALLY again - -From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no -longer aborts the shape that raised it. Two things follow, and both were -measured on this package with the same bodies on 4.4.3 and 4.6.1: - -1. **A closed shape's own refinements now run after the refusal**, adding a - second complaint that contradicts the first. -2. **A union containing that shape loses its envelope.** zod's - `handleUnionResults` returns a single non-aborted member's issues - *unwrapped* instead of raising `invalid_union`, so the union's message - becomes whichever branch zod judged closest. - -At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong -branch's prescription. Writing `type: 'page'` on a ViewItem answered: - -``` -Unrecognized key(s) on this view container: `viewKind`, `config`. - • `viewKind` belongs to a single VIEW, not to the container. Wrap it: … -``` - -— naming neither `page` nor its removal. It now answers, as it did before: - -``` -config.type: 'page' was removed from the list-view `type` enum in -@objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … -``` - -**What an upgrading consumer must do.** Nothing. No key or value changed -status: everything this package accepted before it accepts now, and everything -it refused it still refuses. What changed is which of several competing -complaints an author reads, and that a refusal behind a union is again -reported as `invalid_union` with its branches, which is what `z.treeifyError()` -and this package's own `formatZodError` expand. - -⚠️ A closed shape declared with a bare `z.object(…).strict()` or -`z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT -get this and will still collapse its union. Build closed authoring shapes with -`strictObject`, or re-declare an existing one through `closedObject`. diff --git a/.changeset/19592-item-key-discriminator-stale-quote.md b/.changeset/19592-item-key-discriminator-stale-quote.md deleted file mode 100644 index b4e7b1c2322..00000000000 --- a/.changeset/19592-item-key-discriminator-stale-quote.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/metadata-core': patch ---- - -docs(metadata-core): the `item-key-discriminators` module docblock quoted a spec sentence that no longer exists and that the contract denies ("best match") (#19592) - -Clause-②: no — no accept set moves, no published payload key changes, no -export is added or removed. The corrected prose ships as TSDoc in -`@objectstack/metadata-core`'s `dist/index.d.ts` and `dist/index.d.cts` (the -package publishes `dist`), which is why this is a changeset rather than -`skip-changeset`. - -The module docblock of `packages/metadata-core/src/item-key-discriminators.ts` -put a sentence inside quotation marks and attributed it to -`EmailTemplateDefinitionSchema` in `packages/spec/src/system/email-template.zod.ts`: -that the service "picks the best match for the recipient's locale". That -sentence occurs nowhere in `packages/spec/src` today, and it states the opposite -of the contract: `SendTemplateInput.template` in -`packages/spec/src/contracts/email-service.ts` says there is no "best match" and -no language-subtag folding. - -The docblock now cites the spec by file and symbol instead of quoting it. It -says the `locale` key is the second half of the bundle key, that resolution is -exact, and that `SendTemplateInput.locale` holds the ladder: the named tag matched -exactly, then the literal `en-US`, then, only for a call that named no locale and -only when the bundle has no `en-US` row, the bundle's lowest locale tag. The one -quotation left in the docblock ("is resolved by `(name, locale)`", from the -schema's header) still exists verbatim in the spec. - -No behaviour changes: the edit is prose. `ITEM_KEY_DISCRIMINATORS`, -`readDiscriminatorValue`, `itemDiscriminator` and the `en-US` canonical are -untouched. diff --git a/.changeset/19620-translation-item-settings-platform-only.md b/.changeset/19620-translation-item-settings-platform-only.md deleted file mode 100644 index 4c3126eed2f..00000000000 --- a/.changeset/19620-translation-item-settings-platform-only.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/core': minor ---- - -**BREAKING for runtime-authored `translation` items** — the registered `translation` metadata type no longer declares `settings`: platform settings copy is platform-only at BOTH application doors (#19620) - -Clause-②: no - -`TranslationItemSchema` — one `translation` metadata item, authored with -`defineTranslation`, in Studio, or through the metadata API — now takes the same -ten groups as a per-app bundle entry (`TranslationData`). `settings`, and its -singular `setting`, are refused by name with the platform-only prescription, -exactly as the per-app bundle has refused them since #15178. The file door and -the item door are two authoring surfaces for one app metadata type, so they -accept one shape. - -### Migration — FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `defineTranslation({ locale: 'zh-CN', settings: { mail: { title: '邮件投递' } } })` | delete the `settings` group — there is no application-side replacement key | -| a `translation` item saved through the metadata API or Studio carrying `settings` | delete the `settings` group; the save answers `422 INVALID_METADATA` until you do | -| `const t: TranslationItem = { locale: 'en', settings: … }` | move the copy to the PLATFORM bundle (`PlatformTranslationData`), or delete it | - -**The one-line fix: delete the `settings` group from the item.** Settings copy is -not application-authorable — `settings` is keyed by `SettingsManifest.namespace` -and only platform code declares a manifest. `settingsCommon` is **not** affected: -the Settings UI shell strings (the source badges, under -`settingsCommon.sourceLabels`) stay on both application faces. -Run `os migrate meta --from 17` to list the mechanical edits for existing -sources; apply them by hand. - -### Rows already stored are converted, not refused - -A `translation` row saved before this change keeps loading. The runtime -translation sync (`@objectstack/core`'s `authored-translation-sync`) reads -`sys_metadata` itself and used to merge the RAW stored payload; it now replays -the ADR-0087 conversion chain over each row before merging it, the same policy -as every other stored-metadata read seam. `translation-per-app-settings-removed` -has learned the item shape, so a stored row's `settings` is dropped there, the -rest of the item (`objects`, `apps`, …) still loads, and the server logs one -warning per row naming the row, the group and the conversion. Run -`os migrate meta --stored --apply` to persist the canonical rows. - -### What changes on screen, which is not nothing - -On the item door the group was STRONGER than on the bundle door. A published -item is loaded into the runtime-authored layer, which both i18n adapters read -**over** the shipped bundles — so an item's `settings` overrode the platform's -own Settings copy for its locale, rather than only filling gaps. After -upgrading, re-read the Settings screens in each locale such an item covered: -where it overrode a platform string, **the platform's string renders again**; -where it filled a gap the platform bundle leaves, the **manifest's own literal -renders, which is English**. If a platform string is wrong or missing for your -locale, correct it in the platform bundle (`@objectstack/service-settings`'s -`settingsBuiltinTranslations`). - -No deprecation window: the item door refuses the key by name from this major. - -### Unchanged - -The platform face — `PlatformTranslationDataSchema`, `settingsBuiltinTranslations`, -and `GET /api/v1/i18n/translations/:locale`, whose served document is the merged -tree — still declares `settings`. The liveness ledger's `translation.settings` -row is deleted because the key left the ITEM's shape; the platform capability it -evidenced is untouched. - -Ruling batch #210 item 2 letter B (2026-09-22) — maintainer 「210 同意」. - - diff --git a/.changeset/19629-currency-scale-retired.md b/.changeset/19629-currency-scale-retired.md deleted file mode 100644 index a1e1ecbc43d..00000000000 --- a/.changeset/19629-currency-scale-retired.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor ---- - -fix(spec,objectql)!: `scale` is retired from the `currency` field type — refused at parse, and no longer enforced on currency writes (#19629) - -Clause-②: no (narrowing) - -**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. A `currency` field that declares `scale` — any value, `scale: 0` included — no longer parses. The hand-migration prescription is registered under protocol major 18 as `field-currency-scale-refused`. - -A currency's decimal places are the currency's, not a setting. On a currency field the key was three-faced. The metadata-admin field designer offered it as stored metadata; the amount's cell never read it, because a currency amount's fraction digits come from its currency's own ISO 4217 minor unit; and the record validator's `max_scale` branch still refused writes carrying more decimals. An author who set `scale: 3` bought a narrower write contract and no visible change. The maintainer's rulings retire the key from the type rather than aligning the money faces to it. - -**`@objectstack/spec`** — `FieldSchema` refuses `scale` on `type: 'currency'` with a located issue at `scale`. Its remedy: delete the key; the currency's ISO 4217 minor unit decides how the amount displays, and the field's write allowance stays unconstrained. The remedy names no other key to carry the value. No alias and no grace window. `scale` on `number`, `percent`, `rating`, `slider` and `formula` is untouched, and the key's describe now names that set. Studio's object editor no longer offers `scale` on a currency field: the fields grid of the `objectForm` this package registers in `METADATA_FORM_REGISTRY` now shows it only for `number` and `percent`. - -**`@objectstack/objectql`** — the record validator's `max_scale` branch no longer reads `scale` for `currency`, so the type leaves the enforced set. A field definition that reaches the validator without passing `FieldSchema` (stored before this release, or built by hand at runtime) therefore narrows nothing either. `min`, `max` and the finite-number check still apply to `currency`, and `number` / `percent` / `rating` / `slider` still refuse over-scale writes exactly as before. A currency write with more decimals than a former `scale` is now ACCEPTED: the write allowance stays unconstrained, the contract every currency field without `scale` already had. Enforcing a currency width on writes instead was offered to the maintainer and not taken. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `Field.currency({ label: 'Amount', scale: 2 })` | `Field.currency({ label: 'Amount' })` | -| `{ type: 'currency', scale: 2, currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }` | `{ type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }` | - -The one-line fix: delete `scale` from every `currency` field. Nothing replaces it, so ⛔ do not re-declare the value under any other key. The currency's ISO 4217 minor unit decides how the amount displays. - -What an upgrade changes beyond the refusal: - -- **Writes.** A currency value with more decimals than the deleted `scale` is accepted where it used to answer `VALIDATION_FAILED` with field code `max_scale`. -- **Two console faces.** At the console pin measured when this change was written, the grid summary footer and the dashboard metric widget read a currency column's `scale ?? 0`. This change lands only after the console derives both faces from the currency, the way the cell does, and after this repository's console pin has moved past that console change. So in the console bundled with this release, deleting `scale` changes neither face. - -## Who is affected, measured - -AST sweep on `origin/main` `1f89ba0d70`: 15 `Field.currency` declarations in `examples/` (app-crm 4, app-showcase 11) and 13 documentation code examples carried `scale`, every one `scale: 2`. All were deleted in this change. No platform object, seed or JSON fixture in the tree declares it. Seven test fixtures pinned the old shape and were re-judged. One of them, a flow oracle that needed a live `scale` gate, moved its field from `currency` to `number`. - - diff --git a/.changeset/19630-view-binding-rows-gantt-timeline-map.md b/.changeset/19630-view-binding-rows-gantt-timeline-map.md deleted file mode 100644 index efbd7f461e3..00000000000 --- a/.changeset/19630-view-binding-rows-gantt-timeline-map.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`view/layout-without-binding` — the `gantt`, `timeline` and `map` warnings no longer tell an author the renderer falls back to literal default field names, because objectui deleted those floors; each now names the refusal screen the renderer shows instead, and the keys that clear it (#19630). - -Re-derived at the objectui pin this repo builds against (`87af769e9`), not at objectui's head, both halves of each path a list view takes: - -- **`gantt`** — `ListView.tsx`'s `case 'gantt'` restates only declared bindings (objectui#7070 deleted the `start_date` / `end_date` floors, objectui#7499 the `progress` / `dependencies` ones); `ObjectGantt`'s `getGanttConfig` returns `null` without both dates and the component renders "Gantt configuration required". The body now names `gantt.startDateField`, `gantt.endDateField` and `gantt.titleField`, the three keys `GanttConfigSchema` requires. -- **`timeline`** — the `startDateField || 'created_at'` floor is gone (objectui#7070 step three) and `ObjectTimeline` renders "Timeline date axis required"; the `titleField || 'name'` default still stands, and the body says so. It names `timeline.startDateField` and `timeline.titleField`, the two keys `TimelineConfigSchema` requires. -- **`map`** — `locationField || 'location'` is gone on both faces (objectui#8169): `ObjectMap` no longer guesses coordinate field names and its `hasCoordinateBinding` gate renders "Map configuration required", for an absent `map` block and for a declared block that names neither coordinate form alike. Both `map` messages — the absent-block body and the block-present one, which had quoted `locationField || 'location'` as the renderer's read — now describe that refusal and name `map.locationField` or the `map.latitudeField` + `map.longitudeField` pair. - -`kanban` and `tree` were re-read at the same pin and still floor (or infer) a binding, so they keep the generic body. The `VIEW_BINDING_BLOCKS` docblock rows carry asserting pin citations, so the next `.objectui-sha` bump reds on them instead of leaving them to go stale. - -No severity moves and no finding appears or disappears: every route stays `warning`, consistent with #16577's ruling B for the `calendar` route (both doors loud — `os validate` and a named refusal at render), which these rows now measure too without extending or reopening it. No schema, export or accept set moved: this corrects prose and three warning strings. `Clause-②: no` diff --git a/.changeset/19672-seed-dataset-stamped-duplicate.md b/.changeset/19672-seed-dataset-stamped-duplicate.md deleted file mode 100644 index 0e19f1085e4..00000000000 --- a/.changeset/19672-seed-dataset-stamped-duplicate.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -A seed dataset declared **once** on an **additive** multi-package artifact (one that carries its collections both flattened at the top level and under `packages[]`) is now registered once per boot. Before this, it reached the shared `seed-datasets` registry twice, so a `mode: 'insert'` dataset wrote its rows twice on every boot and again on every per-organization replay. - -The cause was the identity check that stops the two halves of an additive artifact from being read twice. A dataset has no `name`, so it is matched on its whole value. Boot registration stamps the ADR-0010 envelope (`_packageId`, `_provenance`) onto the package body's copy in place, and `AppPlugin` reads its collections after that. In a compiled `objectstack.json` the two halves are separate objects, so the stamped copy and the unstamped top-level copy no longer compared equal. The value comparison now leaves out the registration envelope. The key set comes from `MetadataProtectionFields` and is not listed by hand, so every nameless collection item is matched on what its author wrote. - -Artifacts in this shape are still produced and loaded: - -- Every multi-package artifact built before the emitter stopped writing the flattened copy has this shape. `os dev` without `--compile`, `os start` and `--artifact` / `OS_ARTIFACT_PATH` boot it as is after an upgrade. -- The current `composeStacks(…, { manifest: 'preserve' })` still emits this shape when the package bodies do not reproduce the flattened copy. Two examples are a standalone action bound to a sibling package's object, and an input with no `manifest`. - -Nothing an author writes changes. An artifact whose datasets are all `upsert` ends up with the same rows as before; each dataset is simply no longer applied twice. diff --git a/.changeset/19677-returntype-option-parity.md b/.changeset/19677-returntype-option-parity.md deleted file mode 100644 index 134da9197ee..00000000000 --- a/.changeset/19677-returntype-option-parity.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The object designer's quick-add grid no longer offers two formula `returnType` members that `FieldSchema` refuses (#19677). - -`FieldSchema.returnType` declares four members — `number`, `text`, `boolean`, `date`. The `fields` repeater in `object.form.ts` declared an inline `options` list of **six**, adding `datetime` and `currency`. An author who added a formula field from the object designer and picked Datetime or Currency wrote a value the parse rejects: the select is populated from that inline list, nothing reconciled it against the enum, and the refusal arrived later from the save door naming a key the author never typed. - -The control is narrowed to the four declared members. This is a pull-back to a spelling that already existed in this package twice, not a decision about what a formula may return: - -- **The enum is unchanged** — `FieldSchema.returnType` accepted exactly these four before this change and accepts exactly these four after it. No authorable value is removed, because neither `datetime` nor `currency` was ever accepted; what is removed is an offer with nothing behind it. -- **The sibling control already spelled it correctly.** The field designer's own `returnType` select in `field.form.ts` carries the same explicit four-member list. The object designer was the lone divergent carrier; three now agree, counting the published reference doc. -- **The producer side agrees with the enum too.** Authoring stamps `returnType` from the inferred CEL type, and that inference is typed `number | text | boolean | date | unknown`, so there is no path by which the platform stamps `datetime` or `currency`. - -Whether any stored field carries `returnType: 'datetime'` or `'currency'` today is not measured here and is unaffected either way: the parse that refuses those values is the one that already ran. - -The regression test derives its expected set from `FieldSchema` at runtime rather than hard-coding four strings — a test pinned to literals rots exactly the way this defect did — and judges the offer at value level: every offered value must survive a full parse on a formula field. diff --git a/.changeset/19678-form-option-enum-derive-remedy.md b/.changeset/19678-form-option-enum-derive-remedy.md deleted file mode 100644 index ef4b7531fe5..00000000000 --- a/.changeset/19678-form-option-enum-derive-remedy.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -A metadata form's option-value refusal now says what to do: `defineForm`'s module-load refusal of an inline option `value` that fails the system-identifier grammar names the derive path, and the form field's `options` describe states the rule it belongs to (#19678, #19907). - -Clause-②: no - -A form option `value` is a lowercase system identifier — `FormSelectOptionSchema` reuses `SelectOptionSchema.value` by reference — so an enum member carrying a hyphen or a capital (`object.managedBy`'s `system-data`, `action.openIn`'s `new-tab`, `action.execution`'s `perRecord`) cannot be written as an inline option at all. That bound stays. An enum-typed metadata-form row may still carry an inline `options` list, to give its members human labels or to offer a deliberate subset. A row whose members cannot be spelled as option values omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. - -- **The refusal names the remedy.** `defineForm` still throws a `ZodError` at module load with the same issues and codes (`invalid_format` for the pattern, `too_small` for the two-character floor). The grammar message on an inline option's `value` is kept, and now carries the derive path after it, for a row whose members cannot be spelled as option values. Only schema-bound forms built by `defineForm` get this sentence. The grammar message where it is declared (`SystemIdentifierSchema`) is unchanged, because it also bounds object-field options and three object-storage names, where omitting `options` is not the answer. -- **The describe states the rule** on `FormFieldSchema.options`: an inline list is allowed on an enum-typed row, and the derive path is named for a row whose members cannot be spelled. That text is served in the JSON Schema and on the generated reference page. -- ⛔ **No accept-set change.** Every value refused before is still refused, and every value accepted before is still accepted. No key, export or schema shape moves. diff --git a/.changeset/19679-format-describe-vocabulary.md b/.changeset/19679-format-describe-vocabulary.md deleted file mode 100644 index f86b919fc53..00000000000 --- a/.changeset/19679-format-describe-vocabulary.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -**Fix:** `FieldSchema.format`'s description said only `Format string (e.g. email, phone)`. It offered two example words without saying which field type they apply to or what reads them, and it shipped in the JSON Schema, in `dist`, in the published `src/**/*.zod.ts` and in `content/docs/references/data/field.mdx`. Followed onto an `autonumber` field, it produced `email1` as a business identifier. The value parsed, it was stored, and nothing reported it. - -`Clause-②: no`: the key is still `z.string().optional()`. Nothing is split, narrowed, retired or gated by type. No accept set moves in either direction, and no consumer is touched. Only the sentence changes. - -**What the description now says, reader by reader.** Each point was measured, not recalled, and each is stated as what a reader does rather than as a claim that nothing else reads the key. - -- On an `autonumber` field the key is the record-number **pattern**, the shorthand that predates `autonumberFormat`. `resolveAutonumberFormat` takes the canonical key first, then this one, then the declared default `{0000}`. The ObjectQL engine's `applyAutonumbers` and `driver-sql` both mint through it, and the build-time autonumber lint in `@objectstack/lint` reads the same pattern. Measured against this build: `format: 'INV-{0000}'` gives `INV-0001`; `format: 'email'` gives `email1`, because a value with no `{...}` token is literal text with the bare counter appended; `{ autonumberFormat: 'A-{000}', format: 'email' }` gives `A-001`. -- On any other field type the server does not act on the key. It picks no column type from it, coerces no value by it and runs no check from it. The write-time record validator's built-in email, url and phone checks key on the field `type`. -- The Studio UI reads the key as a display hint, using words and defaults that its renderers own. The description names two examples. The `date` and `datetime` cells read a display style. On a plain-text field, the shared cell-renderer resolver reads a small word set that promotes the cell to a richer renderer; at the pinned objectui, `{ type: 'text', format: 'phone' }` renders a `tel:` link. The words themselves are deliberately not copied into the spec. They belong to those renderers, and a copy here would go stale without anything going red. -- The spec declares no vocabulary for the key and checks nothing except that it is a string, so any string parses on any field type. - -To constrain a **value**, the description points to the field `type` or to a `format` validation rule (`{ type: 'format', field, format: 'email' }`), whose own `format` key is the closed set `email | url | phone | json`. - -The wider question is deliberately left alone here. One `z.string()` key is read differently by different readers, and nothing checks that they agree. Whether any of those readings should become a declared vocabulary is a contract-shape decision. diff --git a/.changeset/19704-client-no-draft-jsdoc.md b/.changeset/19704-client-no-draft-jsdoc.md deleted file mode 100644 index 3c78403b299..00000000000 --- a/.changeset/19704-client-no-draft-jsdoc.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/client': patch ---- - -`publishItem`'s JSDoc, which ships into `dist/index.d.ts`, is corrected to the refusal spelling the runtime has emitted since PR #19683 (#16245): 404 `NO_DRAFT` on the `code` axis, instead of the retired bracketed lowercase opener `[no_draft]` that PR removed from the message. No behaviour change — the SDK method, its request and its return type are untouched; only the doc comment's stale prose is corrected (#19704). diff --git a/.changeset/19709-authoring-gate-bracketed-opener.md b/.changeset/19709-authoring-gate-bracketed-opener.md deleted file mode 100644 index fa30da7d909..00000000000 --- a/.changeset/19709-authoring-gate-bracketed-opener.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -The runtime authoring gate's `422 INVALID_METADATA` refusal no longer opens its message with a bracketed `[invalid_metadata]` tag restating the `code` the same throw declares. `error` carries the human sentence, `code` carries the machine token, and the token is no longer duplicated onto the prose axis. - -Clause-②: no - -This is the third producer of the family the protocol and the metadata repository already retired. The gate refuses an `active` publish whose body fails an author-time rule, and its message opened with `[invalid_metadata]` in front of its own `code = 'INVALID_METADATA'` / `status = 422`. `withoutDeclaredCodePrefix` strips a leading restatement only when the message opens with the declared code followed by a colon, and a lowercase bracketed tag matches neither the casing nor the separator. So it was never stripped, and it reached every caller in `error.message`. - -## FROM → TO - -| before | now | -| --- | --- | -| `error: "[invalid_metadata] flow/leave_approval failed author-time validation: 1 issue — flows[0].nodes[1].config.approvers[0].value [approval-expression-invalid]"` | `error: "flow/leave_approval failed author-time validation: 1 issue — flows[0].nodes[1].config.approvers[0].value [approval-expression-invalid]"` | - -**Every accept/reject verdict is unchanged.** The same bodies are refused under the same conditions, with the same `code`, `status`, `issues` and `rulesRun`. A reader matching `error.message` for `invalid_metadata` should read `error.code` (`INVALID_METADATA`) instead. A reader already using `code` needs no change. - -- **The `[rule]` locators stay.** Each one names the rule behind a finding, for example `[approval-expression-invalid]`, and no other field on the message carries that fact. Only the opener that restated `code` is gone. -- **The batch publish response is unaffected on its machine axis.** `publishPackageDrafts` already puts `code: 'INVALID_METADATA'` and the structured `issues` on the causal `failed[]` row beside this message. -- **Pinned as an absence.** The package's bracketed-opener pin now scans this producer too. A re-introduced tag, or a new refusal copied from a neighbour, fails it. diff --git a/.changeset/19722-scaffold-object-factory.md b/.changeset/19722-scaffold-object-factory.md deleted file mode 100644 index e56892e7aee..00000000000 --- a/.changeset/19722-scaffold-object-factory.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os init` (the `app` and `plugin` templates) and `os generate object` now declare the object they scaffold with `ObjectSchema.create({ … })` — the one authorised shape for a `*.object.ts` — instead of a `Data.ServiceObject`-annotated object literal (#19722). - -The factory parses the declaration against `ObjectSchema` when the file is evaluated, so a mistake surfaces in the file where it was written; the typed literal deferred every check to a build the author might never run. `create-objectstack`'s starter, the data-modeling docs ("Every object definition follows this pattern") and every object file in this repository already used the factory — the two CLI doors were the outliers, and they now write the same shape as each other and as everything else. - -- **What a new scaffold contains**: `import { ObjectSchema } from '@objectstack/spec/data';` (a value import — the factory runs), `const myAppItem = ObjectSchema.create({ … });`, and the unchanged `export default myAppItem;`. The barrel lines both commands write (`export { default as … }`) are unchanged, as are the object's fields, its `sharingModel` and the comment explaining it. -- **Projects you already scaffolded keep working.** Nothing reads the old file differently at runtime, and nothing here renames or rewrites a file you have. -- **Converting an existing file is one mechanical rewrite** — wrap the literal in `ObjectSchema.create( … )`, drop the annotation, and import the factory: - - ```ts - // before - import * as Data from '@objectstack/spec/data'; - const myAppItem: Data.ServiceObject = { name: 'my_app_item', /* … */ }; - export default myAppItem; - - // after - import { ObjectSchema } from '@objectstack/spec/data'; - const myAppItem = ObjectSchema.create({ name: 'my_app_item', /* … */ }); - export default myAppItem; - ``` - - If the converted file now throws when it loads, the factory has found something the literal was carrying unchecked — an unknown top-level key, for example — and the message names it. diff --git a/.changeset/19724-typescript-serializer-readme-shape.md b/.changeset/19724-typescript-serializer-readme-shape.md deleted file mode 100644 index 6b4e777548f..00000000000 --- a/.changeset/19724-typescript-serializer-readme-shape.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/metadata": patch ---- - -`README.md` — the `TypeScriptSerializer` line now says what the serializer emits and what it is for, instead of claiming it exists "for `ObjectSchema.create()`, `defineView()`, etc." (#19724). - -The serializer has never written a factory call. It writes a JSON document wrapped in a module — `export const metadata = { …JSON… };` then `export default metadata;`, with the `typescript` format adding an `import type { ServiceObject }` and annotating the constant with it — and it reads back only a JSON body (double-quoted keys and strings, no comments, no trailing commas), so an authored `ObjectSchema.create({ … })` file with ordinary unquoted keys is refused with `Failed to parse object literal as JSON`. It is the file format `FilesystemLoader` uses for the `typescript` / `javascript` formats: `MetadataManager.save('object', 'account', data)` routed to the filesystem loader writes `{rootDir}/object/account.ts`, never a `*.object.ts`. - -- **No behaviour moves.** The emitter, the parser and every published export are byte-identical; only the README text shipped in this package's `files[]` changes. -- ⚠️ **Not an authoring shape.** Authored metadata — a `*.object.ts` written `ObjectSchema.create({ … })`, a view written `defineView({ … })` — is not produced by, and in its usual TypeScript spelling not readable by, this serializer; do not point it at authored source files. diff --git a/.changeset/19727-field-rule-predicate-fault-refuses-submit.md b/.changeset/19727-field-rule-predicate-fault-refuses-submit.md deleted file mode 100644 index dc90080fff3..00000000000 --- a/.changeset/19727-field-rule-predicate-fault-refuses-submit.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/lint': patch ---- - -fix(objectql)!: a field-level `requiredWhen` / `readonlyWhen` predicate that cannot be evaluated now REFUSES the write, naming the field and the rule, instead of letting it through (ADR-0137 D2) - -Clause-②: no (narrowing) - -**BREAKING**: shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA). The banner and the ADR-0087 -disposition below carry the breaking change, not the level. - -**Writes that used to save now fail.** ADR-0137 D2 says: "At submit time, a -field-rule predicate that cannot be evaluated refuses the write and names the -field and the rule. Nothing is persisted." The server now enforces that on the -two arms that let such a write through: - -- **`requiredWhen`**: a predicate that faults used to be logged - (`requiredWhen for '' failed to evaluate — skipped`), and the record - saved with the field empty. It now refuses the insert or update. This covers - every fault, including a `parent`-scoped rule whose master-detail header - could not be resolved for the write. -- **`readonlyWhen`**: a predicate that faults used to be logged - (`failed to evaluate — change allowed through`), and the field the author - declared frozen was written. It now refuses the update. On a bulk update, a - fault in any matched row refuses the whole write, and the refusal names that - row. One case is unchanged: a predicate that faults because the header it - reads as `parent` could not be resolved still holds the lock, as before. - -The refusal is the same `ValidationError` a broken validation rule has thrown -since #4649: `VALIDATION_FAILED`, served as `400`. Its entry for the field -carries `code: 'rule_violation'` and -`constraint: { rule: 'requiredWhen' | 'readonlyWhen', reason: 'unevaluable', fault }`, -with `missingKey` or `hint: 'null-comparison'` when the fault is one of those. -The message names the field and the rule. It is refused before anything is -written, on insert, single-id update and bulk update alike. The operator also -gets a `warn` line saying the write was rejected. - -The refusal applies to the whole submit. A `requiredWhen` whose predicate -faults refuses the write even when the write supplies the field, because the -rule has no verdict to judge that value against. - -**What starts refusing.** A stored predicate that faults on the writes it -judges: - -- a key the object does not declare, usually a typo (`record.statsu`); -- an ordering comparison or arithmetic over a `null` (`record.amount > 100` - where `amount` is empty). Guard it with `!= null`. `has(x)` is true for a - declared field holding null, so it does not guard this; -- a column read through a lookup (`record.account.tier`). The field level never - reads the related record, so the reference holds a bare id there. The refusal - says so, and names the reference and its target object; -- an envelope with no evaluable `source`: blank, or `ast`-only. - -Nothing in this repository's own metadata is affected. A census of every -`requiredWhen` / `readonlyWhen` under `packages/`, `examples/` and `apps/` -found none that faults on a write it judges. How many stored predicates in a -deployment fault is unknown, and ADR-0137 names that as the point: the loud -state is what finds them. - -**Fix.** Read the refusal. It names the field, the rule, and the key or -overload that faulted. Then correct the predicate: fix the key's spelling, -guard the null operand with `!= null`, or move a check that reads through a -lookup into a `validations[]` `script` rule, whose condition does read one hop -through a reference. - -Unchanged: a predicate that evaluates is judged exactly as before, in both -directions. So is the ADR-0113 legacy-row rule for an evaluated `requiredWhen`. -Option-level `visibleWhen` is not a field-rule predicate, so D2 does not reach -it, and it stays fail-open. The render side is not touched here (ADR-0137 D3 -keeps its directions for display). - -`@objectstack/lint`: the build-time messages for a field `requiredWhen` no -longer say the server "skips" a faulting predicate. The unbound-root message, -the `parent`-without-a-master message and the null-guard message now say the -server refuses the write. - - diff --git a/.changeset/19748-package-registry-docblock-cycle-edge.md b/.changeset/19748-package-registry-docblock-cycle-edge.md deleted file mode 100644 index a6bc484b58d..00000000000 --- a/.changeset/19748-package-registry-docblock-cycle-edge.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The doc comment on `InstallPackageRequestSchema`'s `enableOnInstall` key in `kernel/package-registry.zod.ts` no longer says `ManifestSchema` is declared in that file (it is declared in `kernel/manifest.zod.ts`), and its import-cycle reason now rests on the import that makes the cycle: `api/package-api.zod.ts`, which declares `PackageInstallRequestSchema`, imports `InstalledPackageSchema` from `kernel/package-registry.zod.ts` (#19748). Doc comment only; the directive against spelling `PackageInstallRequestSchema.shape.enableOnInstall` there is unchanged. diff --git a/.changeset/19751-view-filter-rule-absent-value-refused.md b/.changeset/19751-view-filter-rule-absent-value-refused.md deleted file mode 100644 index cf68bc4cfbc..00000000000 --- a/.changeset/19751-view-filter-rule-absent-value-refused.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec): a stored view filter rule with no value on a value-taking operator is now refused at save instead of failing every query (#19751) - -**BREAKING** — an accept-set narrowing on a published authoring surface, pulling `ViewFilterRuleSchema` back to what its own `value` description already declares: every operator outside `in` / `not_in` / `between` and the four unary operators takes a scalar, and only the unary operators ignore the key. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings: during the launch window a breaking change ships as `minor`, so the level alone does not signal the break — this banner and the ADR-0087 disposition below carry it. The hand-migration prescription is registered under protocol major 18 as `view-filter-rule-absent-value-refused`. - -## What changes - -A filter rule that omits `value` (or carries `value: undefined`) on `equals`, `not_equals`, `contains`, `not_contains`, `icontains`, `starts_with`, `ends_with`, `greater_than`, `less_than`, `greater_than_or_equal`, `less_than_or_equal`, `before` or `after` used to parse green and then fail at query time: the rule lowers to `[field, operator]`, and the query path refuses that with `400 INVALID_FILTER` ("Filter comparand at … is undefined") — which failed the whole view, not just that rule. It is now refused when the view is saved, at the rule's `value` path, on every carrier of `ViewFilterRuleSchema` (`ListView.filter`, a tab filter, `Page.filterBy`, a related-list filter, a lookup picker filter): - -```text -Filter comparand for operator "icontains" on field "name" is undefined. The rule carries no value, … -``` - -This reverses a carve-out the #19514 entry records: its statements that an **omitted** value still parses (`value` is optional) and that an **absent** `icontains` comparand is left unjudged on a view rule no longer hold for any operator that takes a value — such a rule is now refused once, with the message above. - -## What stays accepted - -- The unary operators `is_empty` / `is_not_empty` / `is_null` / `is_not_null`, with or without a value. -- `in` / `not_in` / `between` refused an absent value before this change and still do, with their own wording. -- `value: null` on a scalar operator is a value (the null predicate), not an absent one, and still parses. - -## Migration - -For each refused rule, decide what it meant: - -```ts -// FROM — no value on an operator that takes one -{ field: 'status', operator: 'equals' } - -// TO — a comparison: write the value -{ field: 'status', operator: 'equals', value: 'open' } - -// TO — a test for "no value": use an operator that takes none -{ field: 'status', operator: 'is_empty' } -``` - -A rule that was an unfinished row is deleted. The console's filter builder never saved this shape (it drops a half-filled row before saving), so the rules to look for are hand-authored or written by another tool. - -Clause-②: no (narrowing) — no key is added, removed or renamed and no exported symbol moves; the accept set of `ViewFilterRule.value` narrows back to what its published description declares. - - diff --git a/.changeset/19757-dispatch-cases-array-where-id-retired.md b/.changeset/19757-dispatch-cases-array-where-id-retired.md deleted file mode 100644 index b944cd2a7e4..00000000000 --- a/.changeset/19757-dispatch-cases-array-where-id-retired.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/metadata-core": patch ---- - -`ENGINE_DELETE_DISPATCH_CASES` and `ENGINE_UPDATE_DISPATCH_CASES` retire their three ARRAY `where.id` rows (#19757) - -The engine-double conformance tables no longer carry these three rows: - -- delete's `array id, no multi` -- update's `array id, no multi` -- update's `a SCALAR data.id beside an ARRAY where.id` - -Each row puts `where: { id: ['a', 'b'] }` in the equality slot. Since this release's `@objectstack/spec` change, the shared comparand-shape face refuses an array in that slot with `INVALID_FILTER` / 400. The face runs at the engine's lowering seam, which every verb crosses before the dispatch runs, so the real engine never reaches the dispatch predicate with such an input. A row claiming a dispatch verdict for it would pin a branch the engine cannot reach. It was measured red against the real engine: `ObjectQL.delete` / `ObjectQL.update` refused the input with the face's words, not the dispatch's. - -The predicates themselves are unchanged. `resolveEngineDeleteDispatch` / `resolveEngineUpdateDispatch` and the `assert*` helpers still answer an array `where.id` with `reject`, and `scalarDeleteId` / `scalarUpdateId` still treat an array as not-an-id. A test double bound to them therefore still refuses such a call, with the dispatch's sentence. No double runs the shared filter face, for this shape or for any other face refusal. The `$in` rows keep the "a non-scalar `where.id` is not an id" coverage, including the #11230 refusal beside a scalar payload id. - -If you run these tables against your own engine double, it has three fewer cases to answer. Nothing else changes. diff --git a/.changeset/19757-equality-slot-array-refused.md b/.changeset/19757-equality-slot-array-refused.md deleted file mode 100644 index efd175fece0..00000000000 --- a/.changeset/19757-equality-slot-array-refused.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: the shared comparand-shape face refuses an ARRAY in the equality slot — `{ field: [...] }` and `{ field: { $eq: [...] } }` — for every driver at once (#19757) - -**BREAKING** — an accept-set narrowing at the runtime filter doors, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Ruled on #19757 (record 5793368540, letter 乙, 「217 同意」): an array in the implicit-equality slot is refused at the shared face, for every driver at once — no alias, no grace window. The hand-migration prescription is registered under protocol major 18 as `filter-equality-array-comparand-refused`. - -## What changes - -`parseFilterAST` lowers `['tags', 'equals', ['a']]` — and the same triple on `=`, `==` and `eq` — to the implicit form `{ tags: ['a'] }`. That shape, and its explicit spelling `{ tags: { $eq: ['a'] } }`, now get `INVALID_FILTER` / 400 from the shared comparand-shape face (`assertListComparandShapes` in `@objectstack/spec/data`). That face runs inside `parseFilterAST` and at the engine's lowering seam on both engine doors, so the refusal lands before any driver runs, at any depth under `$and` / `$or` / `$not`. The empty array is refused too. The message names the field, the path, and the two operators a list in that slot was standing in for: `{"$in": […]}` for "one of these values" (authoring spelling `in`), and `{"$contains": "…"}` for "the stored list holds a value" on a multi-value field (authoring spelling `contains`), with an `$or` of those for any-of. - -How each backend answered the lowered `{ tags: ['a'] }` before this change. Each was run for this change beside a scalar and an `$in` control: - -| backend | before | how it was measured | -|:--|:--|:--| -| `driver-sql` (SQLite) | **refused**, 400, at the top level. Nested under `$and` / `$or` / `$not` it answered **500 `DATABASE_ERROR`**: SQLite could not bind the list. | `SqlDriver.find` on better-sqlite3 | -| `driver-memory` | **refused**, 400, at every depth | `InMemoryDriver.find` | -| `@objectstack/formula` | **no row**, including a row storing exactly `['a']` | `matchesFilterCondition` | -| `driver-mongodb` | **answered**. `translateFilter` emits the array unchanged. MongoDB equality on an array operand selects a stored array **equal to** `['a']` **or holding** `['a']` as an element. | `translateFilter`, then mingo 7.2.4 as the named proxy for the server. Over `['a']`, `'a'`, `['a','b']`, `['b','a']`, `[['a'],'x']`, `[['a']]`, `'b'` and `[]`, it selected `['a']`, `[['a'],'x']` and `[['a']]`. | -| `service-analytics` filter normalizer | **answered as membership**. The FilterArray form `[['stage', '=', ['won', 'lost']]]` charted as `stage IN ('won', 'lost')`. | `normalizeAnalyticsFilterTree` | - -⚠️ NOT MEASURED: a live `mongod`, MySQL, PostgreSQL, and a live Turso server. `driver-turso` and `driver-sqlite-wasm` are built on `driver-sql` and were not run separately. - -After this change, every row above that goes through a platform door gets the 400. That covers `parseFilterAST`, the engine's lowering seam on both doors (every engine verb's `where` passes through it; measured on `find` and `count`), and the analytics normalizer's FilterArray form. The drivers themselves are untouched, so a caller that hands a raw `FilterCondition` straight to a driver, without `parseFilterAST`, still gets that driver's own answer. - -## What does NOT change - -- **`$ne` carrying an array is not judged.** The ruling names implicit and explicit equality. `$ne` measured the same split (refused by `driver-sql` and `driver-memory`, answered by `driver-mongodb`) and is left to its own ruling. -- The other scalar operators carrying an array (`$gt`, `$contains`, `$like`, …) are not judged here either. -- The list operators keep their arrays: `$in`, `$nin` and `$between`, including `$in: []` / `$nin: []`. -- Every scalar equality comparand is untouched. That includes `null`: `{ field: null }` and `{ field: { $eq: null } }` are the has-no-value predicate. -- A `{ $field }` reference on an equality spelling still lowers to `$eq` and passes. -- A field spec with no `$` key (`{ author: { tags: ['a'] } }`) is still not descended into. -- This change does not touch the schema doors. A separate change in this release does: `FilterConditionSchema` and `FieldOperatorsSchema.$eq` now refuse the same shape when a document is saved, with this refusal's sentence (the location is carried by the issue's path instead). Its changeset and the ADR-0087 entry `filter-equality-array-comparand-refused-at-save` describe it. -- `ViewFilterRule` already refused an array on every scalar view operator at authoring time. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `{ tags: ['a', 'b'] }` / `[['tags', 'equals', ['a', 'b']]]`, meaning "one of these values" | `{ tags: { $in: ['a', 'b'] } }` / `[['tags', 'in', ['a', 'b']]]` | -| `{ tags: ['a'] }`, meaning "the stored list holds `a`" on a multi-value field | `{ tags: { $contains: 'a' } }` / `[['tags', 'contains', 'a']]` | -| `{ tags: ['a', 'b'] }`, meaning "the stored list holds `a` or `b`" | `{ $or: [{ tags: { $contains: 'a' } }, { tags: { $contains: 'b' } }] }` | -| `{ tags: ['a'] }`, meaning one value | `{ tags: 'a' }` | -| `{ tags: { $eq: [...] } }` | any of the rows above | - -On `driver-mongodb`, check what the query is supposed to return, and do not assume the old rows were right. The old answer was MongoDB array equality, and neither `$in` nor `$contains` gives the same rows. A dashboard or dataset filter written as the FilterArray sugar with an array on equality used to chart as membership. It is now refused, and `$in` is the spelling that charts the same rows. - -## Who is affected, measured - -Nothing in this repository's examples, seeds, docs or published skills authors the shape. The repo was grepped for the FilterArray triple on `=` / `==` / `equals` / `eq` carrying an array, for `$eq` carrying an array, and for filter / where objects whose field value is an array. The hits are tests and the engine-double conformance tables. The full suites of `@objectstack/spec`, `objectql`, `driver-memory`, `driver-sql`, `driver-mongodb`, `driver-turso`, `driver-sqlite-wasm`, `formula`, `service-analytics`, `metadata-protocol`, `metadata-core`, `plugin-sharing` and `lint` were run, and four things went red. Each was re-judged, not rewritten by rote: - -- The comparand-shape suite pinned `{ tags: ['a','b'] }` and `$eq: ['a','b']` as shapes the face passes through. Both rows are inverted, and the shapes now live in the arm's refusal section. -- The field-reference lowering suite pinned `['stage', '=', ['a','b']]` lowering to the implicit form. What that row proved still holds, because an array is not promoted to `$eq`. The row now asserts the refusal, which names the implicit slot and not `$eq`. -- Two probe helpers passed a two-element array through every AST spelling to find its `$` operator. One is in this package's comparand-shape suite, the other in `driver-memory`'s vocabulary suite. Each assumed the array could never trip the face. The equality spellings now refuse it, so each helper reads that refusal as `undefined`, which is the answer the helper always gave those spellings. -- `@objectstack/metadata-core`'s engine-double dispatch tables carried three ARRAY `where.id` rows. The real engine now refuses that input at the face before its dispatch runs, so the rows are retired. Their own changeset explains why. - -`FILTER_COMPARAND_TYPE_CASES` gains three `door-refusal` rows (implicit, `$eq`, and nested under `$or`). Every driver suite that consumes the table runs them through `parseFilterAST`. - -Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. The runtime accept set narrows: one comparand shape in one slot, which the face now refuses the way `driver-sql` and `driver-memory` already did. - - diff --git a/.changeset/19772-autonumber-lint-empty-format.md b/.changeset/19772-autonumber-lint-empty-format.md deleted file mode 100644 index db5140ad9c5..00000000000 --- a/.changeset/19772-autonumber-lint-empty-format.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`os validate` and `os build` now check an `autonumber` field's `format` when its `autonumberFormat` is an empty string (#19772). - -An empty `autonumberFormat` counts as not set: the platform numbers records with the field's `format` instead, and with `{0000}` when neither is set. The build-time check stopped at the empty `autonumberFormat` and looked no further, so a `format` naming a field the object does not have — `{ type: 'autonumber', autonumberFormat: '', format: '{nope}{000}' }` — passed `os validate` and `os build`, and then every record create failed with `Cannot generate autonumber … referenced field(s) [nope] are empty on the record`. - -The check now reads the format the platform numbers records with. That field now fails the build with the same `autonumber-references-unknown-field` error it gets when `format` is written alone, and publishing the object at runtime is refused with the same error. The optional-field, self-reference and unrecognised-token checks follow the same format. A field that sets a non-empty `autonumberFormat`, sets only `format`, or sets neither is checked exactly as before. diff --git a/.changeset/19775-delegated-admin-anchor-organization-scope.md b/.changeset/19775-delegated-admin-anchor-organization-scope.md deleted file mode 100644 index ee0beaa21f7..00000000000 --- a/.changeset/19775-delegated-admin-anchor-organization-scope.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -The delegated-administration gate resolves a scope's business-unit anchor **inside the caller's own organization**. In a single-database multi-org posture (ADR-0105 D1 `group` / `isolated`) a unit name shared by two organizations no longer crosses the boundary in either direction (#19775). - -`sys_business_unit.name` carries no uniqueness — the object's only unique index is `(code, organization_id)` — yet the gate looked the anchor up by name alone under a bare `{ isSystem: true }` context, which carries no tenant. The engine threads a tenant to the driver only when `execCtx.tenantId` is defined and `SqlDriver.applyTenantScope` returns early without one, so nothing scoped that read: a `limit: 1` lookup answered whichever id the driver ordered first, and which organization won was an id ordering. Measured on a real engine over a real SQL driver, with two organizations each holding a unit called `sales`, both directions were wrong at once — the delegate **lost its own subtree** (denied inside its own unit) while the gate **approved** a delegated write anchored in the other organization, and `describeDelegableScope` handed that organization's unit ids back to the caller. - -- **What changed**: the anchor read, the descendant walk and the two catalog reads behind `describeDelegableScope` now carry the caller's organization — `organizationId ?? tenantId`, the same spelling the permission-set load already resolves a caller's authority with — and the candidates that come back are reduced to the caller's own rows. Both arms are load-bearing and each was measured to be: the driver's compatibility arm deliberately also returns organization-less rows, and a driver with no tenant scoping at all returns every organization's. -- **Fail closed**: an anchor that resolves to no unit of the caller's own organization now approves nothing, exactly as a misconfigured scope already did. Under a walled posture this also refuses an **organization-less** business unit, which that posture already treats as invalid state; a delegation anchored on one stops resolving and must be re-anchored on a unit the organization owns. -- **`group` posture**: the anchor resolves in the caller's **active** organization, not their whole membership set — the narrower of the two, and the one the caller's permission sets (and therefore the `adminScope` itself) were already loaded in. -- **Unchanged where there is no boundary to cross**: a caller carrying no organization (the `single` posture) keeps the by-name answer it had. - -No exported symbol and no payload key was added: `describeDelegableScope` and `scopesCoverUser` take the caller's context as a new optional argument, and omitting it resolves exactly as before. diff --git a/.changeset/19778-preset-entry-carriers.md b/.changeset/19778-preset-entry-carriers.md deleted file mode 100644 index 8f4a207dddb..00000000000 --- a/.changeset/19778-preset-entry-carriers.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`packages/spec/src/migrations/registry.ts`: the shipped ADR-0087 semantic entry `filter-preset-ordering-comparand-refused` listed a page filter and a component filter among the `FilterConditionSchema` carriers, and said the schema door and the `@objectstack/lint` `filter-preset-comparand` rule refuse a bare date-range preset there at publish. Both are `ViewFilterRuleSchema` rule arrays, and a rule array carries no preset check, so an upgrader who swept stored pages with a schema parse found nothing and concluded the sweep was clean. The entry's `surface`, `reason` and `acceptanceCriteria` now put each carrier under the door that actually refuses it. - -Clause-②: no - -No behaviour moves. No schema, accept set or lint rule is touched, and no export is added, removed or retyped. Every line this change edits in `registry.ts` is a string literal inside that one step-18 entry, which the exported `MIGRATIONS_BY_MAJOR` carries, so what moves in `dist` is prose. - -- **The groups the entry now draws.** They list the carriers measured, not a closed partition; the entry's grep sentence is the catch-all. Each was measured against the built `dist` with a preset comparand (`last_30_days`, and `today` as a `between` endpoint), and an ISO-date dark control reads green in every cell. - 1. Slots typed `FilterConditionSchema`: `DashboardWidgetSchema.filter`, `GlobalFilterOptionsFromSchema.filter`, `DatasetSchema.filter`, `DatasetMeasureSchema.filter`, `ReportSchema.runtimeFilter`, `JoinedReportBlockSchema.runtimeFilter`, `FieldSchema.relatedListFilter` and `FieldSchema.summaryOperations.filter`. A parse of the declaring schema refuses each one at the comparand's own path, and the lint rule reports each one as well. - 2. Filters under a key the lint walks whose declared type carries no preset check. These are `ViewFilterRuleSchema` rule arrays (a view's `filter`, a page element's `dataSource.filter`, a page component's `filter` prop) and a Mongo-shape record typed as a loose record rather than `FilterConditionSchema` (a flow `get_record` / `update_record` / `delete_record` node's `config.filter`). These parse green, and the lint rule alone refuses them. -- **Two more false sentences are narrowed.** - - The `replacement` called the dashboard date-filter positions "the only place any layer ever resolved" a preset name. An analytics query's `timeDimensions[].dateRange` accepts and resolves the names too. - - The `reason` said equality and membership "are NOT judged". That holds for the schema door only. The lint rule refuses a preset in an equality or membership position on a field it can resolve to a declared `date` or `datetime`, while `this_quarter` on a `select` field stays green. Where the filter binds to no object, such as a widget whose `dataset` names no dataset, that arm does not fire. -- **Reach.** Counted over `dist/index.js`, `dist/index.mjs`, `dist/browser/index.js` and `dist/browser/index.mjs`: - - The removed carrier list `page filter, component filter, rollup filter` and each of the four other removed claims read 4 before and 0 after. - - The unchanged dark control `compared false against every row: HTTP 200` reads 4 on both sides. diff --git a/.changeset/19784-concat-fields-refusal.md b/.changeset/19784-concat-fields-refusal.md deleted file mode 100644 index aa9c765bd37..00000000000 --- a/.changeset/19784-concat-fields-refusal.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `composeStacks` refuses a stack whose value for a concatenated collection (`permissions`, `data`, `views`, …) is not an array, with the ADR-0112 envelope - -**BREAKING** — `composeStacks`, a public root export, now refuses a class of input it used to compose with that stack's content silently missing. - -Step 3 of `composeStacks` concatenates every collection key the composer declares `concat` (`permissions`, `data`, `apps`, `views`, `flows`, `agents`, `packages`, … — every `'concat'` row of `COMPOSE_KEY_DISPOSITIONS`). It kept only the array values and announced the rest with a one-time `console.warn`. The strict `defineStack` parse already rejects a non-array value for any of these keys, so the reachable population is an input that bypassed it — a hand-built stack object, or `defineStack(config, { strict: false })`. Measured before this change, per key, composing a well-formed stack with one whose value for the key is a map: - -| the second stack's value | before | after | -| :--- | :--- | :--- | -| a map, a number, a string, `null`, `false`, a `Set` | composed; the composed collection lacks every entry of that stack (e.g. its permission-set grants, its seed rows), one `console.warn` | refused, `STACK_SCHEMA_INVALID`, `status: 422` | -| the same, under `manifest: 'preserve'` | composed; the top-level collection lacks the entries, while that stack's package body still carries the malformed value — the artifact disagrees with itself | refused, `STACK_SCHEMA_INVALID`, `status: 422` | - -A composed artifact is complete or it is refused, so no non-array value is skipped. An absent key (`undefined`) is not malformed and composes as before. The refusal is the one `composeStacks` already raises for a non-array `objects`: the code the strict parse raises for the same authored mistake, the zod issue on `issues` (`path` rooted at the key, `expected: 'array'`), and a message naming the stack by manifest id and position and the key. For a key `defineStack` accepts in the map form, the message says so. A non-object entry inside an array is still concatenated as-is — the entry is carried, not lost. - -The one-line fix: author the key as an array, or pass the stack through strict `defineStack` (which normalizes the map form and rejects every other shape where it is written). - -No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`, and the error class stays module-local. - - - -Clause-②: no (narrowing) diff --git a/.changeset/19785-merge-actions-shape-refusal.md b/.changeset/19785-merge-actions-shape-refusal.md deleted file mode 100644 index 62432ad9ae1..00000000000 --- a/.changeset/19785-merge-actions-shape-refusal.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `defineStack(config, { strict: false })` refuses a non-array `objects` with the ADR-0112 envelope - -**BREAKING** — `defineStack`, a public root export, now refuses under `strict: false` a class of input it used to crash on or hand on unusable: a non-array `objects`, or an `objects` array holding an entry that is not an object. - -The non-strict door skips the parse and hands the normalized input to the action merge that ends every `defineStack` call. That merge read `objects` with no shape guard. Measured before this change: - -| `objects` under `strict: false` | before | after | -| :--- | :--- | :--- | -| a number or a string (`5`, `'abc'`) | bare `TypeError: config.objects.map is not a function`, `code` and `status` both `undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422` | -| `null`, `''`, `0`, `false` | returned untouched, refused one call later by `composeStacks` | refused, `STACK_SCHEMA_INVALID`, `status: 422` | -| an array holding `null` (`[null, obj]`) | bare `TypeError` reading `actions` off `null` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['objects', index]` | -| an array holding another non-object (`[obj, 7]`, `[obj, 'x']`) | returned with the entry in place, a success whose objects are not all objects | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['objects', index]` | - -`strict: false` skips validation — cross-references and schema detail — and never promised to accept a shape the merge cannot read. The refusal carries the code the strict parse raises for the same authored mistake, with the zod issue on `issues` — `path: ['objects']`, `expected: 'array'` for the collection, `path: ['objects', index]`, `expected: 'object'` for each non-object entry — the same line `composeStacks` draws for a non-array `objects`. Every row narrows: nothing that used to be refused is accepted now. An absent `objects` (`undefined`) is not malformed and behaves as before; the map form (`{ name: { … } }`) is still normalized to an array first and accepted. - -Fix: author `objects` as an array of object definitions or in the map form, or drop `strict: false` to have every schema check run. - -No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`. - - - -Clause-②: no (narrowing) diff --git a/.changeset/19790-nav-doc-audience-prune.md b/.changeset/19790-nav-doc-audience-prune.md deleted file mode 100644 index b57b41337af..00000000000 --- a/.changeset/19790-nav-doc-audience-prune.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -fix(rest): `GET /meta/app` leaves out a `type: 'doc'` navigation entry the caller may not read (#19790) - -`DocNavItemSchema` declares that a `doc` entry the member may not read is not -rendered, and that a `book` entry is not rendered for a member with no readable -page in it. Until now only a renderer could honour that. The server's app-nav -filter pruned on `requiredPermissions`, `requiresService` and object servability, -so every member of the app got the entry. That included its label and the gated -book or doc name, however the book was gated. - -The filter now applies the docs audience (ADR-0046 §6.7) on both the list route -(`GET /meta/app`) and the by-name route (`GET /meta/app/:name`), in the top-level -navigation, inside `children` and inside `areas[].navigation`: - -- **`doc`**: the entry is dropped when the doc's effective audience does not - admit the caller. This is the answer `GET /meta/doc/:name` gives. -- **`book`**: the entry is dropped when the book's own audience does not admit - the caller, or when none of its pages is readable. A book's pages are the docs - its groups claim. The *Uncategorized* group that the book tree adds does not - count. -- **`book` + `doc`**: the entry is dropped when either of those checks fails. -- An app emptied by the prune is still served, as it is today when - `requiredPermissions` empties one. An emptied `group` or area collapses, as - with every other gate. - -The verdicts come from the same resolution that `/meta/doc`, `/meta/doc/:name`, -`/meta/book` and `/meta/book/:name/tree` now share. What those reads return is -unchanged. - -**Fails closed.** If the book or doc list read throws while `/meta/app` is being -answered, every `doc` entry is left out of that one response and a warning is -logged. The rest of the navigation is still served. If the caller's -permission-set holdings cannot be resolved, set-gated entries are dropped, as -set-gated content already is. - -**Cost.** An app list with no `doc` entry performs no extra read. Otherwise each -request adds one `book` list read, one permission-set resolution when some book -is set-gated, and one `doc` list read when a set-gated book exists or an entry -names only a book. Each of these happens once per request, however many apps are -listed. Nothing is cached across requests. diff --git a/.changeset/19791-filter-walk-rule-array-carriers.md b/.changeset/19791-filter-walk-rule-array-carriers.md deleted file mode 100644 index 7a410167723..00000000000 --- a/.changeset/19791-filter-walk-rule-array-carriers.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`filter-preset-comparand` now judges a list page's `interfaceConfig.filterBy` and a lookup field's `lookupFilters`, consumed filter carriers the shared filter walk never entered (#19791). - -Both carriers are rule arrays (`{ field, operator, value }`) whose values reach the engine's `where` verbatim, and neither schema carries a preset check. So `{ field: 'close_date', operator: 'gt', value: 'last_30_days' }` in either one parsed green and linted green, then the engine refused it at query time (`INVALID_FILTER` / 400). The same rule on a component `dataSource.filter` or a view `filter` was already refused. `filterBy` and `lookupFilters` join `FILTER_KEYS`, so `os lint`, `os validate` and the runtime publish gate (for `page` and `object` writes) now refuse it where it is written. Each finding carries its path (`pages[0].interfaceConfig.filterBy[0].value`, `objects[2].fields.account.lookupFilters[0].value`). - -- **Which object a condition addresses.** The field-typed arm, which refuses a preset under equality or membership on a `date` / `datetime` field, binds `filterBy` to `interfaceConfig.source`. Without a `source` it falls back to the page's `object`. It binds `lookupFilters` to the field's `reference` and never to the object that owns the field, because the picker queries the referenced object. A `relatedListFilter` on the same field still binds to the owner. -- **`filter-token-unknown` reaches the same two carriers.** An unresolvable placeholder such as `{current_user}` in `filterBy` or `lookupFilters` is now reported, as it already is in a view's `filter`. `{current_user_id}` and the date macros stay clean. -- **What you do:** in a `filterBy` or `lookupFilters` rule, replace a preset name with the `{date-macro}` window the message names (`{ operator: 'gte', value: '{30_days_ago}' }`) or with an ISO date. diff --git a/.changeset/19791-preset-entry-filterby-lookupfilters.md b/.changeset/19791-preset-entry-filterby-lookupfilters.md deleted file mode 100644 index 00e24952074..00000000000 --- a/.changeset/19791-preset-entry-filterby-lookupfilters.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The shipped ADR-0087 semantic entry `filter-preset-ordering-comparand-refused` drops, from its `surface` and its `reason`, the text that said a page's `interfaceConfig.filterBy` and a lookup field's `lookupFilters` are refused by neither door at publish, and drops, from its `acceptanceCriteria`, the by-hand search it prescribed for those two keys. The `@objectstack/lint` `filter-preset-comparand` rule now walks both keys, so `os lint`, `os validate` and the runtime publish gate (on `page` and `object` writes) refuse `{ field: 'close_date', operator: 'gt', value: 'last_30_days' }` in either one, while the schema parse still accepts it. - -Clause-②: no diff --git a/.changeset/19796-map-form-non-plain-object.md b/.changeset/19796-map-form-non-plain-object.md deleted file mode 100644 index c3259324dd6..00000000000 --- a/.changeset/19796-map-form-non-plain-object.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): strict `defineStack` refuses a `Set`, `Map` or other non-plain object for a map-form collection key instead of accepting it as an empty collection - -**BREAKING** — `defineStack` (strict, the default) now refuses a class of input it used to accept with every authored entry silently missing. - -`normalizeMetadataCollection` turns the map form of a collection (`permissions: { rep: { … } }`) into an array before the schema parse. It read any `typeof 'object'` value as that map form, so a `Set`, a `Map` or a `Date` went through `Object.entries`, which yields `[]` for them. The parse then saw a valid empty array: `defineStack({ manifest, permissions: new Set([{ name: 'rep', … }]) })` was accepted with `permissions: []` — the author's grants gone, no error, no warning. This reached every map-form key (every entry of `MAP_SUPPORTED_FIELDS`: `objects`, `apps`, `permissions`, `flows`, `agents`, …). - -| the key's value | before | after | -| :--- | :--- | :--- | -| a `Set`, a `Map`, a `Date` | accepted, the collection is `[]` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, zod issue at the key (`expected: 'array'`) | -| a class instance | read as a map of its own fields | refused the same way | -| an object literal, `Object.create(null)`, a plain object from another realm | normalized (key → `name`) | unchanged | -| an array | passed through | unchanged | - -Only a plain object is the map form; every other value reaches the parse unchanged and is refused there, at the key where it was written. `normalizeMetadataCollection`, `normalizeStackInput` and `normalizePluginMetadata` (public `@objectstack/spec` exports) now return such a value unchanged instead of `[]`. - -The one-line fix: author the key as an array (`[...set]`, `[...map.values()]`) or as a plain-object map (`Object.fromEntries(map)`). - -No code is added to the ADR-0112 ledger and no export changes: the refusal is the strict parse's existing `STACK_SCHEMA_INVALID`. - - - -Clause-②: no (narrowing) diff --git a/.changeset/19799-merge-actions-actions-shape.md b/.changeset/19799-merge-actions-actions-shape.md deleted file mode 100644 index 05e7ae9f938..00000000000 --- a/.changeset/19799-merge-actions-actions-shape.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `defineStack(config, { strict: false })` refuses a malformed `actions` — top-level or an object's own — with the ADR-0112 envelope - -**BREAKING** — `defineStack`, a public root export, now refuses under `strict: false` a class of input it used to crash on or hand on unusable: a non-array `actions`, or an `actions` array holding an entry that is not an object, at the top level or on an object. - -The non-strict door skips the parse and hands the normalized input to the action merge that ends every `defineStack` call. That merge stable-sorts every `actions` array by `order` and read each one with no shape guard. Measured before this change: - -| `actions` under `strict: false` | before | after | -| :--- | :--- | :--- | -| top-level, a number or a string (`5`, `'abc'`) | bare `TypeError: actions.some is not a function`, `code` and `status` both `undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['actions']` | -| top-level, an array holding `null` (`[null]`) | bare `TypeError` reading `order` of `null` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['actions', index]` | -| top-level, an array holding another non-object (`[5]`) | returned with the entry in place | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['actions', index]` | -| an object's own, a non-array (`5`, `'abc'`, `{}`) | bare `TypeError: actions.some is not a function` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['objects', i, 'actions']` | -| an object's own, an array holding a non-object (`[null]`, `[7]`) | bare `TypeError` reading `order` of `null`, or returned with the entry in place | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['objects', i, 'actions', index]` | - -A falsy non-array (`null`, `false`) at either site, which the merge used to hand on untouched, is refused the same way. `strict: false` skips validation — cross-references and schema detail — and never promised to accept a shape the merge cannot read. Each refusal carries the code the strict parse raises for the same authored mistake, with the zod issues on `issues` at the strict parse's own paths, all findings in one refusal; the top-level line is the one `composeStacks` already draws for a non-array `actions`. The same merge ends `composeStacks`, so a hand-built input stack whose `actions` carries a non-object entry is now refused there with the same code instead of being carried into the artifact. Every row narrows: nothing that used to be refused is accepted now. An absent `actions` (`undefined`) is not malformed and behaves as before, and the top-level map form is still normalized to an array first. - -Fix: author every `actions` as an array of action definitions (the top-level one may also use the map form), or drop `strict: false` to have every schema check run. - -No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`. - - - -Clause-②: no (narrowing) diff --git a/.changeset/19808-lookup-reference-tenant-scope.md b/.changeset/19808-lookup-reference-tenant-scope.md deleted file mode 100644 index 69813638d86..00000000000 --- a/.changeset/19808-lookup-reference-tenant-scope.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a lookup can no longer point at a record in another organization - -When a user in one organization saved a `lookup` (or any other reference field) whose id named a record that exists only in a **different** organization, the write was accepted and the cross-organization link was stored. An id that exists nowhere was refused. So a caller could tell "this id belongs to another organization" apart from "this id does not exist", without being able to read that record. - -The reference check now looks only where the caller's organization can see. A record in another organization is treated exactly like a record that does not exist: the write is refused with the existing `VALIDATION_FAILED` error, and the field error code is `reference_not_found`. This applies on create, on update by id and on bulk update. The two cases now give the same response. - -What does not change: - -- References inside the caller's own organization resolve as before. -- References to platform-global objects (`tenancy: { enabled: false }`) and to federated (`external`) objects still resolve from any organization. The engine already sends no tenant to the driver for those objects. -- The check still ignores row-level security. A user can still link to a record they are not allowed to read, as long as it is in their organization (or in their membership set under the `group` tenancy posture). Whether they may create that link at all is still decided by the permission layer. -- System-context writes (seed replay, package install, provisioning) are still not checked. -- The dangling-reference audit (`inspectDanglingReferences`) still checks existence across all organizations. - -One case to check if your deployment uses it: an object made global only by the deployment's `platformGlobalObjects` setting (not by its own `tenancy: { enabled: false }`) is still scoped by organization when the database is read. So a reference to a record of that object that another organization created is now refused. This matches what the caller already gets when reading that object directly. Records with no organization still resolve. diff --git a/.changeset/19810-preview-unevaluable-filter-operator.md b/.changeset/19810-preview-unevaluable-filter-operator.md deleted file mode 100644 index 94a3d42373d..00000000000 --- a/.changeset/19810-preview-unevaluable-filter-operator.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -The draft-data preview **refuses** a `where` operator it cannot evaluate instead of answering it for every row, so a drafted chart no longer silently ignores a filter and then changes at publish (#19810). - -`preview-evaluator.ts` evaluates a pending seed draft's rows in memory — the ADR-0037 P3 Live Canvas path — and its operator switch carried ten cases (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin`, `$contains`) and then `default: return true; // unknown operator — permissive (preview, reads only)`. Every other declared operator therefore matched EVERY row: `$icontains`, `$notContains`, `$startsWith`, `$endsWith`, `$null`, `$exists`, the staged `$like` / `$ilike`, and any typo. A drafted chart with `name $icontains 'acme'` charted the whole dataset and looked exactly like a working chart; the published chart, which runs the real filter doors, applied the filter. - -- **Fail-closed, and VISIBLE.** The operator is refused in the ADR-0112 `INVALID_FILTER` / 400 envelope this package's `where` door already speaks, through `filter-normalizer`'s exported `invalidFilterError`. No new error code and no new exported symbol. Refused rather than excluded from the result: an excluded row makes the preview merely *different* from publish — zero rows where publish draws numbers — which is the silent shape `lowerPreviewDateRange` abolished on this same evaluator; only a refusal reaches the author who can fix it. It is the call `uncompilableFieldOperatorError` states for the analytics cube face, and the posture `service-analytics` already takes for `$like` / `$ilike`. -- **The vocabulary and the evaluator are now ONE table**, the shape `memory-analytics`' `MONGO_TO_CUBE_OPERATOR` took for this same defect class: adding a row is the only way to widen what this face accepts, and forgetting to add one is a loud refusal rather than a wrong number. -- **The gate does not depend on the data.** It walks `where` before any row is read, so a seed draft holding zero rows — the state a draft is authored in — refuses too instead of answering an empty chart. -- ⚠️ **What it costs**: a drafted chart whose filter uses one of those operators now returns `400 INVALID_FILTER` in preview where it previously rendered a number. That number was computed over rows the filter excludes, and it changed at publish. Growing the preview's arms is deliberately separate work — the `FILTER_OPERATORS` docblock's ruling that a name must not land ahead of its evaluators reads the same in this direction, so an arm joins the table in the PR that measures it against the shared conformance kits. -- **The ten evaluated arms are byte-for-byte unchanged**, pinned in both directions (a matching row still matches, a non-matching row still does not). diff --git a/.changeset/19814-view-form-pagination-all-kinds.md b/.changeset/19814-view-form-pagination-all-kinds.md deleted file mode 100644 index b5434a0cc71..00000000000 --- a/.changeset/19814-view-form-pagination-all-kinds.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/platform-objects": patch ---- - -The Studio view form (`viewForm`, served by `METADATA_FORM_REGISTRY.view`) now offers `pagination` for every view type, not only grids. - -`pagination.pageSize` is the row bound every view type carries. The form used to place `pagination` inside the grid-only `Table options` section (shown when `type` is `grid` or unset), so an author editing any other view type could not see or set it without editing the metadata by hand. It now has its own collapsed `Pagination` section with no visibility condition. `Table options` keeps `resizable`, `compactToolbar`, `rowHeight` and `selection`, still for grids only. - -No schema changed: every view type already accepted `pagination`. `@objectstack/platform-objects` ships the new section's label and description in its metadata-form translation bundles (en, zh-CN, ja-JP, es-ES). diff --git a/.changeset/19816-compose-action-collision-shape.md b/.changeset/19816-compose-action-collision-shape.md deleted file mode 100644 index ecfa891f8fb..00000000000 --- a/.changeset/19816-compose-action-collision-shape.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): `composeStacks` refuses a malformed `actions` with the ADR-0112 envelope instead of crashing in its action-key collision pass - -`composeStacks` checks the composed stacks for cross-stack action-key collisions before it binds each standalone action to its object. That check read every input's top-level `actions` entries, and every composed object's own `actions`, with no shape guard. On a hand-built input stack it therefore crashed before the bound-action merge could refuse the same input. `defineStack` already refuses these shapes at its own door, with or without `strict: false`, so a stack it built never reached this crash. Measured before this change: - -| malformed input stack | before | after | -| :--- | :--- | :--- | -| top-level `actions: [null]` or `[undefined]` | bare `TypeError` reading `objectName`, `code` and `status` both `undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['actions', index]` | -| an object's `actions: 5` (or `'abc'`, `{}`, `true`) | bare `TypeError: (obj.actions ?? []).entries is not a function` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['objects', i, 'actions']` | -| an object's `actions: [null]` or `[undefined]` | bare `TypeError` reading `name` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['objects', i, 'actions', index]` | -| two stacks that each carry a non-object top-level entry (`['x']`) | refused as `STACK_COMPOSE_ACTION_KEY_COLLISION` on the key `global:undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry | - -The collision check now skips anything that declares no action key: a non-object entry, and an object's `actions` that is not an array. It does not word a refusal of its own. Every such input still reaches the bound-action merge, whose existing guard gives the one refusal for this condition. The index in each issue path is the entry's index in the composed artifact. Nothing that used to be accepted is refused now, and nothing that used to be refused is accepted. Well-formed stacks compose exactly as before, and a real cross-stack collision is still refused with `STACK_COMPOSE_ACTION_KEY_COLLISION`, including one that sits beside a skipped entry. - -Fix: author every `actions` as an array of action definitions, or run each stack through strict `defineStack` to have the shape refused where it is written. - -No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`. - -Clause-②: no diff --git a/.changeset/19819-delegated-admin-position-organization-scope.md b/.changeset/19819-delegated-admin-position-organization-scope.md deleted file mode 100644 index df5916343f1..00000000000 --- a/.changeset/19819-delegated-admin-position-organization-scope.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -The delegated-administration gate resolves a position name **inside the caller's own organization** when it decides whether that position may be self-delegated and which permission sets it distributes. In a single-database multi-org posture (ADR-0105 D1 `group` / `isolated`) a position name shared by two organizations no longer lets one organization's row answer for the other. - -`sys_position` is a per-organization catalog and its `name` carries no installation-wide uniqueness, yet the two position reads behind those decisions looked the row up by name alone, `limit: 1`, under a bare `{ isSystem: true }` context carrying no tenant — so whichever id the driver ordered first answered. Measured on a real engine over a real SQL driver, with the other organization's ids sorting first: a holder could **self-delegate a position their own organization never marked delegatable**, because the other organization's same-named row was; and a delegated administrator could **assign a position whose own bindings hand out a permission set outside their allowlist**, because the other organization's bindings were the ones checked — while positions their own organization bound correctly were refused. - -- **What changed**: both reads now carry the caller's organization (`organizationId ?? tenantId`, the spelling the business-unit anchor read already uses) and keep only the caller's own row out of what comes back. The self-delegation check, the delegated-assignment allowlist and containment checks, and the `assignablePositions` list of `describeDelegableScope` all read the caller's own position. -- **Fail closed**: a position name with no row in the caller's organization is not delegatable and distributes no permission sets — never another organization's row. Under a walled posture an **organization-less** `sys_position` row no longer answers either question, as that posture already treats such a row as invalid state. -- **Unchanged where there is no boundary to cross**: a caller carrying no organization (the `single` posture) keeps the by-name answer it had. - -No exported symbol, payload key, error code or refusal message was added or changed. diff --git a/.changeset/19822-report-top-level-aliases.md b/.changeset/19822-report-top-level-aliases.md deleted file mode 100644 index 6388555f0c9..00000000000 --- a/.changeset/19822-report-top-level-aliases.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -A top-level report now corrects the same ten misspelled keys a joined-report block already corrects. Before this change, `measures:` on a plain report was rejected with no suggestion, while the same key on a block was told to use `values`. - -Clause-②: no - -`ReportSchema`'s alias table says it is kept parallel to `JoinedReportBlockSchema`'s, but ten of the block's entries were missing from it. The rejection now names the target key, and every target is a key the report declares: - -| authored key on a report | before | now | -| :--- | :--- | :--- | -| `measures`, `metrics` | no suggestion | ``Did you mean `measures` → `values`?`` | -| `dimensions`, `groupBy`, `groupings` | no suggestion | ``Did you mean `dimensions` → `rows`?`` | -| `sort`, `sortBy` | no suggestion | ``Did you mean `sort` → `order`?`` | -| `orderBy` | `order`, found by edit distance | `order`, from the alias table | -| `objectName`, `object` | no suggestion | ``Did you mean `objectName` → `dataset`?`` | - -**Every accept/reject verdict is unchanged.** The same reports are refused, with the same `unrecognized_keys` issue. Only the prescription in that issue's message is new. Nothing authorable is added, removed or renamed. diff --git a/.changeset/19823-turso-remote-deferred-ddl-refusal.md b/.changeset/19823-turso-remote-deferred-ddl-refusal.md deleted file mode 100644 index d6c729e1b59..00000000000 --- a/.changeset/19823-turso-remote-deferred-ddl-refusal.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/driver-turso': minor ---- - -fix(driver-turso): a REMOTE `TursoDriver` refuses to arm deferred schema DDL instead of accepting it and running the DDL anyway (#19823) - -Clause-②: no (narrowing) - -**BREAKING for callers that arm DDL deferral on a remote Turso datasource** — `TursoDriver.setDeferredDdl(true)` in `remote` transport mode (a `libsql://`, `https://`, `http://`, `wss://` or `ws://` URL with no `syncUrl`) now throws a `NOT_IMPLEMENTED` / `501` error, where it used to be accepted and then ignored. The five `os migrate` commands that arm it — `plan`, `apply`, `duplicates`, `account-issuer` and `multi-value-columns` — therefore exit non-zero against a remote Turso database, where they used to exit 0 after changing it. Disarming (`setDeferredDdl(false)`) is accepted, and the `local` and `replica` modes defer exactly as before. - -What the refusal replaces, measured on the transport's SQLite-backed test double: arming was accepted, but none of the remote schema doors reads the flag. The engine's boot sync (`syncSchemasBatch`) ran `CREATE TABLE` and `ALTER TABLE … ADD COLUMN` through `RemoteTransport`; the `syncSchema` / `initObjects` doors ran the same DDL plus the canonical temporal backfill, rewriting stored `datetime` / `time` values in place; and `previewDeferredSchemaWork()` and `flushDeferredSchemaDdl()` both answered `[]`. So `os migrate plan` changed the database and then reported no pending work, and `os migrate apply` asked for confirmation after the schema work had already run. - -- **Refused at the setter.** Every deferring caller passes through `setDeferredDdl`, and it runs before any schema work: a refused arm sends nothing to the database and leaves the driver un-armed. -- **The driver's message is what the operator reads.** The CLI prints it verbatim. It names the `remote` transport mode, says why the promise cannot be kept, and says what to do instead. -- **No new error code.** `NOT_IMPLEMENTED` / `501` is a standard code, the envelope this transport already uses for its remote transaction and auto-number refusals. -- **Ordinary boots are unchanged.** A boot that does not arm the deferral (`os serve`, `os start`, `os dev`) syncs a remote schema exactly as before. - -**If you are refused:** to preview schema work, run the command against a local SQLite copy of the database (a `file:` URL); the local and embedded-replica faces defer DDL. To perform the additive schema work, let an ordinary boot against the remote datasource (`os serve` / `os start`) run it directly. - - diff --git a/.changeset/19834-per-kernel-scheduled-work-policy.md b/.changeset/19834-per-kernel-scheduled-work-policy.md deleted file mode 100644 index 07db023e47d..00000000000 --- a/.changeset/19834-per-kernel-scheduled-work-policy.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/service-automation': minor -'@objectstack/trigger-schedule': minor ---- - -A kernel can now carry its own scheduled-work policy. `AutomationEngineOptions`, `AutomationServicePluginOptions` (forwarded to the engine), `ScheduleTriggerPlugin` and `TimeRelativeTriggerPlugin` accept an optional `scheduledWorkPolicy`: a `ScheduledWorkPolicy` value, or a resolver called at each bind. When it is present, the engine's bind gate and each trigger's own gate read it instead of the deployment resolver. When it is absent, they call the zero-argument `resolveScheduledWorkPolicy()` exactly as before. - -It exists for a host that runs several kernels of different plans in one process. `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` is one reading for the whole process, so such a host could not turn scheduled work off for one kernel and leave it on for the kernel beside it: - -```ts -const policy = { enabled: false, posture: 'single', requiresActingOrganization: false, runOwnership: 'unscoped' } as const; -kernel.use(new AutomationServicePlugin({ scheduledWorkPolicy: policy })); -kernel.use(new ScheduleTriggerPlugin({ scheduledWorkPolicy: policy })); -kernel.use(new TimeRelativeTriggerPlugin({ scheduledWorkPolicy: policy })); -``` - -Give all three the same policy. The engine gates first, and each trigger keeps its own gate for hosts that drive it without the engine. If a trigger reads a different answer from its engine, the engine's audit reports that trigger's refusal as a binding failure. A hand-built value must keep the resolver's invariant, `requiresActingOrganization === (enabled && runOwnership === 'declared')`. - -A time-triggered flow that the per-kernel policy leaves unarmed is reported the same way as one the deployment leaves unarmed. `getTriggerBindingAudit()` and the `getFlowRuntimeStates()` row both give `SCHEDULED_WORK_DISABLED_REASON`, never a binding failure and never "add `requires: ['triggers']`". `ScheduleTrigger` and `TimeRelativeTrigger` also accept the same option in a new trailing constructor argument. The types `ScheduleTriggerPluginOptions`, `TimeRelativeTriggerPluginOptions`, `ScheduledWorkTriggerOptions` and `ScheduledWorkPolicySource` are exported from `@objectstack/trigger-schedule`. - -Nothing changes for a host that passes no policy. The deployment default keeps its meaning and its spelling, and `objectstack serve` is unchanged. This change only adds options, so there is nothing to migrate. - -Clause-②: no diff --git a/.changeset/19835-preview-empty-field-constraint.md b/.changeset/19835-preview-empty-field-constraint.md deleted file mode 100644 index 4625441c8ac..00000000000 --- a/.changeset/19835-preview-empty-field-constraint.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -The draft-data preview **refuses** a field constraint with zero operators (`{ name: {} }`) instead of answering it with every row, so a drafted chart no longer shows rows for a filter publish refuses outright (#19835). - -`preview-evaluator.ts`'s `matchesWhere` iterated a field constraint's entries; an empty object has none, so the loop never ran and the row fell through to a MATCH. `matchesWhere({ name: 'Globex' }, { name: {} })` answered `true`. Every data driver refuses this shape (`driver-memory`, `driver-mongodb`, and `driver-sql` at the top level and inside `$and`/`$or`/`$not`), and so does this package's own `where` door, so the preview and publish gave opposite answers to the same filter. - -- **Refused in the ADR-0112 `INVALID_FILTER` / 400 envelope**, through the same `invalidFilterError` the preview already uses for an operator it cannot evaluate. No new error code and no new exported symbol. The message follows the drivers' wording: it names the constraint and its position (`where.$or[1].amount`), and gives the two legal repairs (name an operator, or write a direct comparand). -- **Not answered as "matches zero rows" either.** `{ status: {} }` does not mean "no rows". Read literally it means "rows whose status is anything", and the shape is almost always an authoring accident: a filter builder that recorded a field but never its operator. Only a refusal names the constraint to repair. -- **Nesting cannot route around it.** The check walks the whole `where` before any row is read, `$and` / `$or` / `$not` arms included. So a constraint in an `$or` arm that a matching row would short-circuit past still refuses, and so does a seed draft holding zero rows. -- ⚠️ **What it costs**: a drafted chart whose filter carries `{ field: {} }` now returns `400 INVALID_FILTER` in preview, where before it rendered a number computed over every row. Fix: name the operator the constraint was meant to carry, e.g. `{ status: { $eq: 'open' } }` or `{ status: 'open' }`. -- **Unchanged**: constraints that name an operator, implicit-equality comparands, and an empty *node* (`where: {}` or `$and: [{}]`, which is the identity and not a field constraint). diff --git a/.changeset/19837-parent-binding-tenant-scope.md b/.changeset/19837-parent-binding-tenant-scope.md deleted file mode 100644 index 81e1d1490cb..00000000000 --- a/.changeset/19837-parent-binding-tenant-scope.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): `parent.*` validation predicates no longer read another organization's header, and, outside the `group` posture, the dangling-reference audit reports cross-organization references - -**Master-detail `parent.*` predicates.** A detail object's `requiredWhen` and `readonlyWhen` can read the master-detail header as `parent` (for example `requiredWhen: "parent.status == 'locked'"`). The engine read that header without the caller's organization, so it found the header in any organization. A user in one organization who put another organization's header id on a detail record got an answer that depended on that header's fields: on create, a `locked` header answered "`note` is required" while an `open` one answered `reference_not_found`; on update, a `readonlyWhen` field was dropped or kept, and a strict write was refused or not. That leaked one bit of another organization's record per write. - -The header is now read only where the caller's organization can see, like the reference check. A header outside the caller's tenant scope (another organization; under the `group` posture, an organization outside the caller's membership set) is treated exactly like a header that does not exist: `parent` is left unbound. On create and on repoint, the write gets the same `VALIDATION_FAILED` / `reference_not_found` answer whatever the header's state. A `readonlyWhen` that needs `parent` stays locked, as it already did for a header that cannot be read. A `requiredWhen` that needs `parent` is skipped, as it already was in that case. - -What does not change: - -- Headers in the caller's own organization bind as before, so their `requiredWhen` and `readonlyWhen` rules apply as before. -- Headers of platform-global masters (`tenancy: { enabled: false }`), federated masters, and headers with no organization still bind from any organization. -- The header read still ignores row-level security. -- System-context writes with no organization (seed replay, provisioning) still read the header from any organization. - -One case to check: a detail record that already points at a header in an organization the editor cannot see (another organization; under the `group` posture, one outside the editor's membership set), written before this fix or by a system-context write, now edits as if its header were missing. Its `parent`-scoped `readonlyWhen` fields stay locked, and its `parent`-scoped `requiredWhen` rules are not enforced. Outside the `group` posture, the dangling-reference audit below now reports such records, so you can find and fix them. Under `group` it does not; see the audit paragraph below. - -**Dangling-reference audit.** `inspectDanglingReferences` (the read-only audit that runs with the lifecycle sweep) checked each stored reference across all organizations. A reference to a record in another organization therefore looked fine, even though the write path now refuses it. Outside the `group` tenancy posture, the audit now checks each record's references in that record's own organization. A cross-organization reference is reported in `dangling`, and records with no organization are still checked across all organizations. Under the `group` posture the audit still checks across all organizations, as before. There, a member of several organizations may legitimately link records across them, and the stored record does not say which organizations its writer could see. So under `group`, a reference into another organization is reported only when the target does not exist anywhere. Custom `DanglingReferenceAuditPort` implementations get the record's organization, or `null`, as a new third argument to `probe`. Implementations that ignore it keep working. diff --git a/.changeset/19844-turso-remote-boot-read-coercion.md b/.changeset/19844-turso-remote-boot-read-coercion.md deleted file mode 100644 index c212a4a0b80..00000000000 --- a/.changeset/19844-turso-remote-boot-read-coercion.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/driver-turso": patch ---- - -A remote Turso deployment now reads, writes and filters the objects it synced at boot by their declared field types. "Remote" means a `libsql://`, `https://`, `http://`, `wss://` or `ws://` URL with no `syncUrl`, or an explicit `mode: 'remote'` (#19844). - -The engine's boot schema sync (`ObjectQLPlugin`) reaches this driver through `syncSchemasBatch`, because the driver declares `supports.batchSchemaSync`. On the remote transport that method ran the DDL and stopped. It skipped the field-type registration that its sibling doors, `syncSchema` and `initObjects`, run afterwards. For every object a remote app synced at boot, that meant: - -- **Reads came back as stored.** A declared `boolean` read back as `1`/`0`, and a `json` field as its stored text. A `datetime`, `time` or `date` field read back exactly as stored, for example as an offset-bearing string or epoch text rather than the canonical `…Z` form. The `created_at` / `updated_at` audit columns were a partial exception: a zone-naive cell shaped `YYYY-MM-DD HH:MM:SS` or `YYYY-MM-DDTHH:MM:SS`, optionally with a fractional second, was read as UTC and read back canonical (the column default writes the first shape); any other cell read back as stored, including one ending in `Z` or in an offset such as `+08:00` (so a canonical cell stays canonical), an epoch number or its text, and anything that does not parse as a date. This held for records read through the driver and through the engine's `find` / `findOne`, including the rows an `afterFind` hook receives. A CEL expression or an in-memory `$ne: true` filter evaluated over such a record, such as `field != true`, was therefore true even for a stored `true`. Write-side hook contexts did see `true`/`false`, because the engine converts declared booleans there: the `afterInsert` / `afterUpdate` results and the `previous` record on update and delete hooks. -- **Writes were not converted either.** A `datetime` or `time` value in any spelling other than the canonical one (with an offset, a zone-naive wall clock, an epoch number) was stored as sent. A `Date` given to a `datetime` was the exception: it was stored in canonical form. A `date` given as a `Date` or a full timestamp was stored as a full timestamp. An object or array in a `json` field was stored as it would have been anyway. A scalar `json` value (a string, number or boolean) was stored without its JSON encoding. -- **Filters compared text as spelled.** A filter on a `datetime` or `time` field compared the stored text with the comparand exactly as the caller wrote it, converting neither side. Rows whose cell or comparand used another spelling of the same value were missed or matched wrongly. For example, a bare-day upper bound `$lte: '2025-07-28'` left out that day's rows stored as ISO text. -- **Paging was not deterministic.** A paged read with no `orderBy` got no `id` tie-breaker, so walking the pages could serve one row twice and skip another. The driver logged `Paged read of '…' is NOT deterministic`. - -`syncSchemasBatch` now finishes the way the other two doors do. It registers each object's field types, keyed by the `object` name it was given, and then runs the canonical temporal backfill once for the whole batch. A DDL failure still rejects before anything is registered. Reads, writes and filters on those objects now convert exactly as they do through `syncSchema` and `initObjects`. - -What happens on disk at the first boot after upgrading: the one write this change adds is that backfill, which the `syncSchema` and `initObjects` doors already ran. It rewrites `datetime` and `time` cells stored in a non-canonical spelling, including any this door wrote unconverted, into the canonical spelling of the same value. It leaves alone a cell it cannot safely read as a time. Nothing else on disk is touched, so two kinds of cells this door wrote unconverted stay as they are: - -- A `date` stored as a full timestamp reads back as its calendar day, but an equality filter on that day does not match it. -- A scalar `json` value stored without its encoding reads back as whatever its text parses to. A stored `true` reads back as `1`, and a numeric-looking string reads back as a number. - -Local and embedded-replica deployments are unaffected. diff --git a/.changeset/19845-turso-remote-drift-detection-refusal.md b/.changeset/19845-turso-remote-drift-detection-refusal.md deleted file mode 100644 index 0346af36b88..00000000000 --- a/.changeset/19845-turso-remote-drift-detection-refusal.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/driver-turso': minor ---- - -fix(driver-turso): a REMOTE `TursoDriver` refuses to detect schema drift instead of answering that there is none (#19845) - -Clause-②: no (narrowing) - -**BREAKING for callers that read schema drift from a remote Turso datasource** — `TursoDriver.detectManagedDrift()` in `remote` transport mode (a `libsql://`, `https://`, `http://`, `wss://` or `ws://` URL with no `syncUrl`) now throws a `NOT_IMPLEMENTED` / `501` error, with or without an explicit object list, where it used to answer `[]`. The `local` and `replica` modes detect drift exactly as before. - -What the refusal replaces, measured on the transport's SQLite-backed test double: the inherited detector reads the physical schema through Knex, and a remote driver's Knex connection is a placeholder in-memory database holding none of the datasource's tables. A synced table carrying an extra physical column the declaration omits therefore read `unmapped_column` / `drop_column` on the local face and `[]` on the remote one. The artifact-pinned boot gate of `os serve` (`OS_ARTIFACT_URL`), which refuses a boot on destructive drift, read that `[]` as "never drifted" and let every remote-Turso boot through. - -- **The boot gate now says it could not check.** It already treats a failed drift detection as "the check did not run": it prints a warning carrying the driver's message and the boot continues. A remote-Turso boot is therefore not refused by this change; it is told the schema was not checked, where before it was told nothing. -- **No other caller in this repository reaches it.** The `os migrate` commands that read drift (`plan`, `apply`, `multi-value-columns`) arm deferred schema DDL first, which the remote face already refuses. -- **No new error code.** `NOT_IMPLEMENTED` / `501` is a standard code, the envelope this transport already uses for its remote transaction, auto-number and deferred-DDL refusals. - -**If you are refused:** to check a remote Turso database for drift, run `os migrate plan` against a local SQLite copy of it (a `file:` URL), where the physical schema is introspected. - - diff --git a/.changeset/19846-automation-caller-param-keys.md b/.changeset/19846-automation-caller-param-keys.md deleted file mode 100644 index 8f13942429e..00000000000 --- a/.changeset/19846-automation-caller-param-keys.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/runtime': minor -'@objectstack/service-automation': patch ---- - -`AutomationContext` declares `callerParamKeys?: string[]` — the flow doors say which `params` keys the caller supplied, and a `screen` node reads that instead of inferring it (#19846). - -Clause-②: yes - -A screen whose fields the run's caller already supplied continues without pausing (#15787). Deciding "did the caller supply this field?" from the params bag was an inference: the bag a flow receives also holds the subject record's columns and the launched row's id, which the door seeds itself. Two constructions still skipped a screen that should have paused — both a non-default `recordIdField` with a `recordIdParam` naming a key the record lacks, on an object-less action whose record has a `recordId` column, or on an object-bound action whose record shadows `recordId` and the `Id` alias. Maintainer ruling on #15705, verbatim: 「15705同意」. - -**What the key says.** The keys of the caller's own `params`, recorded before the door seeds anything. An empty array means the caller supplied nothing; an absent key means the producer does not say. - -**Who fills it.** The action door (`dispatchFlowAction`: `POST /api/v1/actions/...` and MCP `run_action`) and the trigger door (`buildAutomationContext`: `POST /api/v1/automation/:name/trigger`, the legacy `POST /api/v1/automation/trigger/:name`, and a declarative `type: 'flow'` endpoint). Record-change, time-relative and webhook triggers, and code calling `execute` directly, leave it absent; the schedule trigger, whose run has no caller, states an empty list (#19900). `subflow` and `map` nodes drop the parent's list from the child run's context, because it describes the parent's bag. - -**What the screen does with it.** When the key is present, a field is caller-supplied when its name is in the list and `params` holds a value for it; the inference is not consulted. A present value that is not an array names nothing, so the screen pauses. When the key is absent, the inference from #15787 applies unchanged. - -**Accepted cost, precisely:** both doors leave out of that list the keys they use to carry the launched row's id — `recordId`, the camelCase `Id` alias, and on the action door the action's own `recordIdParam` — even when the caller's bag names them, because a client that mirrors the row id into `params.recordId` is addressing the row, not answering the screen. On a run started through either door, a field named like one of those keys is therefore not caller-supplied: a required such field is collected interactively, and an optional one does not count as answering the screen. - -**What moves for a headless caller, through either door:** - -- the two constructions above pause instead of skipping; -- a field whose value equals a column of the subject record, or equals the row id, now counts as supplied when the caller named it — the inference could not tell those from the seeds and paused; -- a field named like the action's `recordIdParam` no longer counts as supplied when the caller sent that key with a value other than the row id — the inference counted it; the door now treats that key as the row-id channel. - -Nothing moves for an implementation of `IAutomationService`: the key is optional, and a context without it keeps its prior meaning. diff --git a/.changeset/19852-typescript-serializer-per-type-annotation.md b/.changeset/19852-typescript-serializer-per-type-annotation.md deleted file mode 100644 index 830b96e6fc9..00000000000 --- a/.changeset/19852-typescript-serializer-per-type-annotation.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/metadata": patch ---- - -`TypeScriptSerializer` no longer annotates every `typescript`-format file `ServiceObject` (#19852). A saved view, or any other item that is not an object, used to be written as `export const metadata: ServiceObject = { … }`: a false annotation, which `tsc` refused with TS2353 (for a view, `'"type"' does not exist in type …`). - -If you type-check the `.ts` files that `MetadataManager.save()` / `FilesystemLoader.save()` write: - -- An `object` file written by the built-in serializer the package wires in is byte-identical: `import type { ServiceObject } from '@objectstack/spec/data'` and `export const metadata: ServiceObject = …`. -- Every other metadata type whose spec type is exactly the `z.input` type of its schema is now annotated with that type instead: `Flow` (`@objectstack/spec/automation`) for a `flow`, `Page` (`@objectstack/spec/ui`) for a `page`, `PermissionSet` (`@objectstack/spec/security`) for a `permission`, and so on: 28 annotated metadata types, `object` included. `tsc` now checks such a file against its own type instead of `ServiceObject`. -- `view`, `book`, `external_catalog` and any other metadata type (a plugin's own, for example) are written with no annotation and no import: `export const metadata = { … };`. `ViewMetadata` is `unknown` and `Book` is narrower than `BookSchema`, so neither would be a true annotation. -- A serializer you wire into `FilesystemLoader` by hand is called as it always was: a custom one, or a subclass that overrides `serialize()`, writes what its `serialize()` writes, and a `TypeScriptSerializer` taken from the package's other entry point (`.` versus `./node`) writes no annotation. -- If you call `TypeScriptSerializer.serialize()` yourself, it now writes no annotation for any item, an object included. It used to write `ServiceObject` whatever the item was, and it cannot know the item's metadata type, so that annotation could be false. The loader picks the annotation through a package-internal function. The public API is unchanged: `SerializeOptions` and every declaration the package exports are as before. - -Reading is unchanged: `deserialize` still reads the first JSON block, so a file written before this fix, `ServiceObject` annotation and all, still reads back. The `javascript` format is unchanged. diff --git a/.changeset/19853-readonlywhen-stored-parent.md b/.changeset/19853-readonlywhen-stored-parent.md deleted file mode 100644 index 9aaeecbfd62..00000000000 --- a/.changeset/19853-readonlywhen-stored-parent.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a parent-scoped `readonlyWhen` lock is judged against the invoice a line STAYS under, not the one the update names (#19853) - -**What a caller could do before.** On a master-detail child whose parent field -is read-only, a caller with edit rights could change a field locked by a -`parent`-scoped `readonlyWhen` by naming a different, unlocked parent in the -same update. With `amount: { readonlyWhen: "parent.status == 'paid'" }` and a -`readonly: true` `invoice` field, `update(line, { amount: 999, invoice: -'open_invoice' })` on a line of a PAID invoice committed `amount = 999`: the -lock was judged against the open invoice the payload named, then the read-only -`invoice` was stripped, so the line stayed under the paid invoice with its -frozen amount rewritten. The same happened when the parent field carried a -`readonlyWhen` lock of its own that kept the line where it was, and on bulk -(`multi: true`) updates for every matched row. - -**What happens now.** The engine settles whether the update really moves the -line BEFORE it judges any `parent`-scoped lock, and judges every lock against -the parent the row is stored under afterwards. In the example above `amount` is -dropped as locked, exactly as `update(line, { amount: 999 })` on its own always -was. A legitimate move — the parent field writable, or an `isSystem` / -`preserveAudit` write the read-only strip exempts — is still judged against the -parent it moves to, unchanged. - -**What else you may see move, all in the same direction (the parent the row is -stored under decides):** - -- Naming a LOCKED parent beside a read-only parent field no longer locks a line - that stays under an open one — its field now commits. -- `requiredWhen` reads the same parent binding, so a `parent`-scoped requirement - is also judged against the parent the row stays under: clearing a required - field on a paid line by naming an open parent is now refused - (`VALIDATION_FAILED`), and naming a paid parent beside a read-only parent - field no longer refuses an open line. -- `onFieldsDropped` now reports the locked field (`readonly_when`) beside the - parent field (`readonly`), and a `strictReadonlyWrites` refusal names both. -- When the parent field carries its own `readonlyWhen`, that lock is still - judged against the parent the update names, as before. diff --git a/.changeset/19856-joined-report-container-selection-refused.md b/.changeset/19856-joined-report-container-selection-refused.md deleted file mode 100644 index 605578d346d..00000000000 --- a/.changeset/19856-joined-report-container-selection-refused.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): a `joined` report refuses a top-level `dataset` / `rows` / `columns` / `values`, pointing each onto `blocks[]` (#19856) - -Clause-②: no (narrowing) - -**BREAKING** — shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by -this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, -never by the level). - -A `joined` report selects nothing itself: each block binds its own `dataset` and -selects its own `rows` / `columns` / `values`, and the renderer's joined branch -reads `blocks` and returns before it reads any top-level selection key. -`ReportSchema` already refused a container-level `order` for exactly that -reason, but the four selection keys beside it parsed green and were then dropped -without a word. Each is now refused at its own path: - -``` -FROM ReportSchema.safeParse({ name: 'overview', label: 'Overview', type: 'joined', - blocks: [/* … */], dataset: 'tasks', values: ['task_count'] }) - -> { success: true } // both keys silently ignored at render - -TO -> { success: false, issues: [ - { code: 'custom', path: ['dataset'], - message: 'a `joined` report selects per block — move `dataset` onto `blocks[]`, or delete it; on the container it selects nothing.' }, - { code: 'custom', path: ['values'], - message: 'a `joined` report selects per block — move `values` onto `blocks[]`, or delete it; on the container it selects nothing.' } ] } -``` - -**Fix.** Move the key onto the `blocks[]` entries that need it, or delete it. -Either way the report renders exactly as before, because the container value was -never read. - -**What does not change.** A present `dataset` and a NON-EMPTY list are refused; -an empty `rows` / `columns` / `values` list selects nothing and still parses (the -same threshold as the container `order` refusal, whose message is unchanged). A -joined report's container `runtimeFilter` and `drilldown` — the two container keys -the joined branch does read — parse as before, and every non-joined report is -untouched. An aliased spelling (`measures` → `values`, `dataSet` → `dataset`, …) -is still renamed first, and the renamed key on a joined container then meets this -refusal. - - diff --git a/.changeset/19867-decision-config-mode.md b/.changeset/19867-decision-config-mode.md deleted file mode 100644 index f8a1d3706ac..00000000000 --- a/.changeset/19867-decision-config-mode.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`DecisionConfigSchema` declares an optional `mode: 'exclusive' | 'inclusive'` — the contract half of the #15429 ruling. The author of a `decision` node that routes on its out-edges can now declare whether it takes only the first out-edge whose condition holds or every one of them — with taking every one as the value that must be written down, the way BPMN separates the exclusive gateway from the inclusive one and n8n's Switch keeps "send to all matching outputs" behind an off-by-default toggle (#19867). - -Clause-②: yes (widening) — one new OPTIONAL key on a published, strict node-config schema, so the set of accepted configs grows. Nothing previously accepted is refused, no key is renamed or retired, and the parsed output of an existing config is unchanged (the key has no `.default()`). - -- **`'exclusive'`** — only the first out-edge whose condition holds, in the order the edges are declared; this is what an omitted `mode` means. **`'inclusive'`** — every out-edge whose condition holds. Any other value is refused at `mode` with a prescription naming both members' meanings. -- **⚠️ Declared ahead of its enforcement, on purpose.** The ruling's split order lands this key first, then the engine semantics together with the `os migrate meta` conversion in one change, then the docs. Until that second step ships, nothing reads `mode`: an edge-branched decision still takes EVERY out-edge whose condition holds, whatever `mode` says. The key's own description says so, and the conversion that writes `mode: 'inclusive'` onto every decision relying on today's behaviour ships in the same change as the new traversal, so no flow changes behaviour silently. -- **A `conditions` list is unaffected**: it is ordered first-match on its own, and `mode` speaks about the out-edges. -- **Where it binds today**: `decision` config stays export-only (nothing parses it at run time), so the closed pair is enforced by `tsc`, by the published JSON Schema and by a direct parse — the same doors `conditions` has. diff --git a/.changeset/19868-turso-remote-date-json-residue.md b/.changeset/19868-turso-remote-date-json-residue.md deleted file mode 100644 index db134aeb6ec..00000000000 --- a/.changeset/19868-turso-remote-date-json-residue.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/driver-turso": patch ---- - -A remote Turso deployment now converges, at its first boot after upgrading, the `date` and `json` cells that the engine's boot schema sync wrote without converting them before #19844, where the cell's original value can be read exactly from its stored text. "Remote" means a `libsql://`, `https://`, `http://`, `wss://` or `ws://` URL with no `syncUrl`, or an explicit `mode: 'remote'` (#19868). - -What a first boot now rewrites: - -- **A `date` stored as a full timestamp.** A `Date`, or a string such as `2025-07-28T10:00:00Z`, `2025-07-28T01:00:00+08:00` or `2025-07-28 10:00:00`, was stored as written, and a `Date` as its ISO text. Such a cell is rewritten to the calendar day the driver reads it as since the #19844 read fix, which is the day the write path stores today for the same value: the leading `YYYY-MM-DD` of the text, after surrounding whitespace is trimmed. A `Date` gives its UTC day. That is not necessarily the day a caller meant when it built the `Date` at local midnight east of UTC, and no stored cell records which day that was. The rewrite changes no read, because the #19844 read fix already returns that day. An equality filter on the day (`{ day: '2025-07-28' }`), or a bare-day bound such as `$lte: '2025-07-28'`, now matches these rows. The time of day in the stored text is dropped. Since the #19844 read fix, which ships in the same release, no read of a `date` field returns it. Before that fix, an object synced at boot read such a cell back exactly as stored, time of day included. -- **A `json` string stored bare whose text does not parse as JSON** (for example `hello`, or an empty string). It is rewritten as its JSON string (`"hello"`), which is what the write path stores for it today. It reads back as the same string as before. - -What it deliberately leaves as stored, because the original value cannot be told from the stored bytes: - -- **Any `json` cell whose text parses.** This includes a stored `true`, which reads back as `1`. The column is TEXT, and a boolean `true` became the text `1`. A string `'1'` left the same text, and `1` is also what the write path stores for the number `1` today. A string `'42'` reads back as the number `42`, and its text `42` is also what the write path stores for the number `42`. A string `'true'` reads back as `true`, and its text is what the write path stores for the boolean `true`. These cells keep reading as the #19844 read fix reads them. Before that fix, an object synced at boot read them back as their stored text. Correct the affected records by writing them again through the API. -- **JSON nested deeper than SQLite's JSON depth limit**, which SQLite reports as invalid although it parses. It reads back as the structure it is. -- **Single-value `image` / `file` / `avatar` / `video` / `audio` columns.** Whether their ids are stored quoted or bare depends on the deployment. Both forms read back the same. - -The pass runs after every remote schema sync. On an already converged database it costs one read round-trip, and it writes nothing. It works in batches. A large table that does not finish in one boot continues at the next schema sync. A failure is logged at `warn` and never stops a boot. Local and embedded-replica deployments are unaffected. Their schema sync registers the field types before any write, so their write path converts these values, and this pass does not run there. diff --git a/.changeset/19870-manifest-id-digit-led-segment.md b/.changeset/19870-manifest-id-digit-led-segment.md deleted file mode 100644 index 3b18c2dc7ed..00000000000 --- a/.changeset/19870-manifest-id-digit-led-segment.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): a package-id segment may open with a digit, and the refusal states the rule the pattern enforces (#19870) - -Clause-②: yes (widening) — the accept set of `ManifestSchema.id` and `PackageSchema.manifestId` (one shared constant, `MANIFEST_ID_PATTERN`) grows by the ids that carry a digit-led segment. Nothing previously admitted is refused, no key is added, removed or renamed, and no export moves. - -**The rule, as the pattern now enforces it:** two or more lowercase dot-separated segments of letters, digits and inner hyphens. A segment may open with a letter or a digit — never with a hyphen — and underscores are not admitted. `com.163.crm`, `com.example.2app` and `local.2024-app` are package ids now; `com.example.-app`, `com.example.my_app` and `Com.Example.App` stay refused. - -**Why the leading-letter clause went.** The package id is a registry key (`manifest_id`), a runtime package-map key and a grant-source key — never a table name, a JS identifier, a filesystem path or a hostname — and a DNS label may itself open with a digit. The clause had no downstream reason, so the refusal sentence could not state one: an author who followed the sentence could write `com.example.2app`, satisfy every clause it listed, and still be refused. - -**The refusal sentence** is now `… Expected reverse-domain notation ('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments of letters, digits and inner hyphens; a segment may not open with a hyphen; underscores are not admitted.` It was `… — lowercase dot-separated segments; hyphens allowed inside a segment, underscores are not.` The doors that surface it verbatim (`POST /api/v1/packages`, the protocol install and duplicate primitives, `POST /api/v1/marketplace/install-local`, `os package publish`) follow it with no change of their own. A digit-led bare word now gets the prefixed repair (`2fa` → `Did you mean 'com.example.2fa'?`), where it used to get none. - -**What moves with it.** `os package publish` derives `local.` from `manifest.name` or the artifact filename when no id is declared; a slug is lowercase letters, digits and inner hyphens, so a derived id now always parses — a manifest named `2024 App` publishes as `local.2024-app` instead of being refused. A declared `manifest.id` is still used or refused, never replaced by a derived one. - -**What does not move.** `manifest.namespace` — the physical object-name prefix (`crm` → `crm_account`) — keeps its own leading-letter rule, for the SQL-identifier reason. A namespace derived from a package id still strips a leading digit run from its last segment (`com.163.crm` → `crm`; `com.example.123` derives none, and the author declares `namespace`). - -**If you relied on the refusal:** nothing you stored changes. A consumer that assumed a package id opens each segment with a letter should stop assuming it; parse it through `PackageSchema.shape.manifestId` or `ManifestSchema.shape.id` rather than a copy of the pattern. diff --git a/.changeset/19872-typescript-serializer-sortkeys.md b/.changeset/19872-typescript-serializer-sortkeys.md deleted file mode 100644 index a18e68ddbaa..00000000000 --- a/.changeset/19872-typescript-serializer-sortkeys.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/metadata": patch ---- - -`sortKeys` — a declared `MetadataSaveOptions` field, already honoured by the `json` and `yaml` -formats — is now honoured by the `typescript` / `javascript` formats too (#19872). - -`TypeScriptSerializer` wraps a JSON body in `export const metadata = { … };`. Both its public -`serialize()` and the package-internal `serializeTypeScriptForMetadataType()` (the one -`FilesystemLoader.save()` calls for the built-in `typescript` serializer, `save()`'s default -format) share one `renderModule()` code path, and neither read `options.sortKeys` — so a caller -saving metadata to the filesystem with `sortKeys: true` got unsorted keys in the default format, -silently. `javascript` shares the same `TypeScriptSerializer` class and the same path, so it was -affected too and is fixed the same way. - -The body is now sorted with the exact recursive (deep) key sort `JSONSerializer` already applies -— extracted into one shared, package-internal helper (`sort-object-keys.ts`, not published from -any `@objectstack/metadata` `exports` entry) so there is one sort implementation, not two. -`sortKeys` absent or `false` is unchanged: byte-identical output to before this fix, for every -format, including through `FilesystemLoader.save()`. - -No public API changes — `SerializeOptions` already declared `sortKeys`; this closes the gap -between the declaration and the `typescript`/`javascript` formats' enforcement of it. diff --git a/.changeset/19874-run-lifecycle-deny-remedy.md b/.changeset/19874-run-lifecycle-deny-remedy.md deleted file mode 100644 index 3ca239b5f13..00000000000 --- a/.changeset/19874-run-lifecycle-deny-remedy.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): the run-lifecycle refusal names only a remedy the deployment's tenancy posture honours (#19874) - -`POST /api/v1/automation/:name/runs/:runId/cancel` and -`…/restore-suspension` admit only a caller with platform-operator standing -(the `PLATFORM_ADMIN` rung). When they refuse, the message says how to get that -standing. It used to say "the unscoped `admin_full_access` grant" on every -deployment. Under the walled postures (`group` / `isolated`) that grant no -longer confers platform-admin standing, so an operator who followed the advice -was still refused, with no sign of why. - -The sentence now depends on the requested tenancy posture, read the same way -the platform-admin derivation reads it: - -- **`group` / `isolated`**: standing comes only from the deployment's declared - platform administrators, meaning an account whose verified email address is - listed in `OS_PLATFORM_OWNER_EMAIL`. -- **`single`**: standing comes from an unscoped `admin_full_access` grant or - from `OS_PLATFORM_OWNER_EMAIL`. Both work on that posture today. -- **A posture that cannot be read**: the message names only - `OS_PLATFORM_OWNER_EMAIL`, which every posture honours. - -Nothing else changes. The same callers are admitted and refused, and a refusal -is still `403 PERMISSION_DENIED`. The last sentence still points a refused -caller at `POST /automation/:name/runs/:runId/resume`. diff --git a/.changeset/19885-nested-bare-comparand.md b/.changeset/19885-nested-bare-comparand.md deleted file mode 100644 index 70fd21fc85b..00000000000 --- a/.changeset/19885-nested-bare-comparand.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -A bare comparand nested under `$and` / `$or` / `$not` now gets the same compilation and the same refusal it gets at top level - -A filter with no operator anywhere compiles through one loop; any other filter (a -combinator, or a single sibling key carrying an operator) compiles through -`applyFilterCondition`. That second path handled a bare `{ field: value }` leaf in -two ways the first one did not: - -- **An array in the equality slot was not refused.** `{ tags: ['a'] }` at top level - answers `INVALID_FILTER` / 400, but under `$and` / `$or` / `$not`, or beside an - operator-carrying sibling (`{ tags: ['a'], name: { $ne: 'x' } }`), it was bound as - it stood. SQLite refused the bind, so the caller got a 500 `DATABASE_ERROR` for a - filter it can fix. Postgres bound the array as its array-literal text (`{"a"}`) - and silently returned the wrong rows. Every such leaf now gets the top-level - refusal: the same code, status, message and server-side diagnostic. -- **A `Date` comparand was dropped, and a binary one was refused or dropped.** That - path treated any non-array object as an operator map. A `Date` has no entries, so - `{ $and: [{ closed_at: someDate }] }` emitted no predicate for the leaf and - answered every row on SQLite and Postgres, while `{ closed_at: someDate }` - answered the matching rows. A non-empty binary comparand (`Buffer` / `Uint8Array`) - had its byte indices read as operator names, so it was refused with - `INVALID_FILTER` / 400 `Unsupported filter operator "0"`, although the same leaf - at top level compiles. An empty one was dropped like the `Date`. Only a plain - object is now read as an operator map (the same test the filter-validating walk - already uses), so all of these compile as the equality they are at top level. - The `$not` NULL guard now reads a comparand the same way, so a binary comparand - under `$not` returns the rows whose column is NULL, as every other negated - equality does; an empty one used to get no guard at all. - -Valid filters are unchanged. A scalar leaf in any of these positions compiles to -byte-identical SQL, and the new suite pins that SQL against the output captured -before the fix. Neither shape is stopped before the driver today: `parseFilterAST` -and the engine's object-form comparand walk both pass the array leaf through, and -the engine's walk also keeps a `Date` leaf a `Date`. Both refuse a binary -comparand in every position, so the binary half reaches direct driver callers only. diff --git a/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md b/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md deleted file mode 100644 index 52caa957aaa..00000000000 --- a/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/formula": minor -"@objectstack/driver-mongodb": minor -"@objectstack/plugin-security": minor -"@objectstack/plugin-sharing": minor -"@objectstack/lint": minor -"@objectstack/spec": patch ---- - -A row-level or sharing-rule predicate comparing a field against a list with `!=` / `==` is refused at the CEL lowering instead of lowering to a filter that widens on driver-mongodb, and driver-mongodb refuses `$ne` with an array comparand (#19886). - -**BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/driver-mongodb`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-list-comparand-refused`. - -Clause-②: no (narrowing) - -**Security fix for RLS reads on MongoDB and RLS write checks.** A policy written `record.status != ['closed', 'archived']` (or `!(record.status == [...])`, or `!=` against a `current_user` membership set) lowered to `{ status: { $ne: [...] } }` (or `$not` around a bare-array equality). The RLS `using` clause is composed into the query after the engine's comparand-shape check, and driver-mongodb passed the shape to the server, where it selects every scalar row: the read returned the rows the policy was written to hide. A `check` written `!=` against a membership set admitted every write. - -- `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose comparand is a list (`unsupported`): a list literal, or a `current_user` variable that resolves to an array. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports the literal; a resolved array is refused per request. -- `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403). A CEL-authored `check` gets this 403; the `INVALID_FILTER` / 400 of `matchesFilterCondition` remains for a filter passed to it directly. -- `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. -- `@objectstack/lint`: the list-literal form is reported (`rls-predicate-unenforceable`, `sharing-rule-unlowerable-condition`). The RLS reference pass probes each kernel-resolved `current_user` key with its runtime type. -- `@objectstack/driver-mongodb`: `translateFilter` refuses `$ne` with an array comparand at any depth, with `INVALID_FILTER` / 400, as driver-sql and driver-memory already do. -- `@objectstack/spec`: the migration registry carries the entry. - -**What to change.** "One of these values" is `record.status in ['open', 'pending']`; "none of these values" is `!(record.status in ['closed', 'archived'])`. In a raw filter, use `$in` / `$nin`. `in`, scalar `==` / `!=`, `null` and field-to-field comparisons are unchanged. - - diff --git a/.changeset/19886-formula-array-comparand-refused.md b/.changeset/19886-formula-array-comparand-refused.md deleted file mode 100644 index b59b508b3e4..00000000000 --- a/.changeset/19886-formula-array-comparand-refused.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/formula": minor -"@objectstack/plugin-security": minor -"@objectstack/spec": patch ---- - -`matchesFilterCondition` refuses an array comparand under `$ne` and in the equality position (`{ field: [...] }`, `$eq: [...]`) with `INVALID_FILTER` / 400, before any record is judged (#19886). - -**BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula` and `@objectstack/plugin-security` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `rls-predicate-array-comparand-refused`. - -Clause-②: no (narrowing) - -**Security fix for row-level write checks.** This evaluator is what `@objectstack/plugin-security` runs against the post-image of an insert or update to enforce a row-level `check`. It compared strictly, and no stored value ever equals an array, so: - -- a `check` written `record.status != ['closed', 'archived']`, or `!=` against a `current_user` membership array, lowered to `{ status: { $ne: [...] } }` and matched **every** post-image; -- a `check` written `!(record.status == ['closed', 'archived'])` lowered to `{ $not: { status: [...] } }` and did the same. - -Every write such a policy was written to refuse was admitted and stored. The positive `record.status == ['open', 'pending']` already refused every write (403). - -The message withholds the field, the operator and the value, because the filter is usually an access policy the caller did not write, and the comparand may be a resolved membership set. - -**What to change.** A `check` or `using` predicate that means "one of these values" or "none of these values" is spelled with `in`: `record.status in ['open', 'pending']`, or `!(record.status in ['closed', 'archived'])`. Those, scalar `!=` / `==`, `null`, `Date` comparands and `{ $field }` references evaluate exactly as before. - - diff --git a/.changeset/19886-ne-array-comparand-refused-at-face-and-slot.md b/.changeset/19886-ne-array-comparand-refused-at-face-and-slot.md deleted file mode 100644 index 896f10d80bb..00000000000 --- a/.changeset/19886-ne-array-comparand-refused-at-face-and-slot.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: an ARRAY under `$ne` is refused at the shared comparand-shape face and at `FieldOperatorsSchema.$ne`, with one remedy text naming `$nin` (#19886) - -**BREAKING** — an accept-set narrowing at the runtime filter doors and at one published operator slot, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Ruled on #19886 (record 5805254639, ruling A, class 1): "The shared comparand-shape face refuses an array under `$ne` for every driver, and `FieldOperatorsSchema.$ne` refuses it at parse — one remedy text, naming the declared list-negation operator by its spec spelling". No alias, no grace window. The hand-migration prescription is registered under protocol major 18 as `filter-ne-array-comparand-refused`. - -## What changes - -`$ne` already declared its comparand as "a literal, or a { $field } reference to another column of the same table". An array is neither. Two doors now say so. - -**The shared comparand-shape face** (`assertListComparandShapes` in `@objectstack/spec/data`) refuses `{ field: { $ne: [...] } }` with `INVALID_FILTER` / 400. It refuses it at any depth under `$and` / `$or` / `$not`, and it refuses the empty array too. `parseFilterAST` lowers `['tags', 'ne', ['a']]` to that shape, and so do `!=`, `<>`, `neq`, `not_equals` and `notequals`. The face runs inside `parseFilterAST` and at the engine's lowering seam, so the refusal lands before any driver runs. The message is: - -```text -Operator "$ne" on field "tags" requires a single comparable value, but received an array (["a"]) at where.tags.$ne. For "none of these values" use {"$nin": […]} (authoring: nin, not_in, notin). The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set. -``` - -Its first sentence is `driver-memory`'s own wording for this condition. It names one remedy: `$nin`, the list-negation operator `FieldOperatorsSchema` declares ("Not in list"), with its authoring spellings. - -**The operator slot** `FieldOperatorsSchema.$ne` refuses an array on parse. So do its documentation copy `EqualityOperatorSchema.$ne` and the `NormalizedFilter` AST that validates against it. They print the same sentence without ` on field "…"` and ` at `, because a slot cannot see either. The issue's own `path` carries the location instead (`$ne`, `$and.0.stage.$ne`). - -How each door answered `{ tags: { $ne: ['a'] } }` on `origin/main` `9e7824a4`, just before this change: - -| door | before | after | -|:--|:--|:--| -| the shared face, `parseFilterAST`, the engine's lowering seam | **passed** it to the driver, at every depth | `INVALID_FILTER` / 400, before any driver runs | -| `FieldOperatorsSchema`, `EqualityOperatorSchema`, the `NormalizedFilter` AST | **parsed it green** | refused on parse at `$ne` | -| the analytics `where` door (`normalizeAnalyticsFilterTree`) | **compiled it**: `{ stage: { $ne: ['won', 'lost'] } }` became `stage` not-set OR `stage` not-equals `['won', 'lost']`, and both analytics strategies render a not-equals member from its first value, so `'lost'` was dropped and its rows were counted | `INVALID_FILTER` / 400 with the face's sentence | -| `driver-sql`, `driver-memory` | refused, 400, in their own words | unchanged; the face now answers first | -| `driver-mongodb`, the formula evaluator | refused, since earlier stages of this card, in their own words | unchanged; the face now answers first | - -The analytics row's compiled tree was measured with the face's `$ne` arm removed, which is `main`'s face. Its rendering from the first value was read at source (`values[0]` in the native SQL strategy, `v0` in the ObjectQL strategy) and was not executed. A live `mongod`, MySQL, PostgreSQL and a live Turso server were NOT measured. - -So on the SQL family and `driver-memory` the verdict does not move (400 before and after). What moves is the text and the moment: the refusal now arrives at the face with the `$nin` remedy, before any driver runs. On the analytics `where` door the verdict does move: a query that used to answer with a silently widened row set is now refused. - -## What does NOT change - -- **A stored filter carrier still saves the shape.** `FilterConditionSchema`, which every stored filter parses through (a dataset filter and measure filter, a dashboard widget filter, a report `runtimeFilter`, a rollup filter and the rest), does not parse a field's operator map through `FieldOperatorsSchema`. Its own walk judges `$eq` and does not judge `$ne`. The ruling names the face and the operator slot, not that walk. So `DatasetSchema` with `filter: { stage: { $ne: ['won', 'lost'] } }` still parses green, and every query that uses it is refused at the face. -- `$ne: null` is the has-a-value predicate and is untouched. Every scalar, a `Date` and a `{ $field }` reference are untouched too. `['amount', '!=', { $field: 'budget' }]` still lowers to `{ amount: { $ne: { $field: 'budget' } } }` and passes. -- The list operators keep their arrays: `$in`, `$nin` and `$between`, including `$in: []` and `$nin: []`. -- A field spec with no `$` key (`{ author: { tags: { $ne: ['a'] } } }`) is still not descended into. -- The ordering operators carrying an array, a nested array inside `$in`, and a `{ $field }` referent to a multi-valued field are not judged here. They are the same class and are scoped separately on the card. -- The drivers are untouched. A caller that hands a raw `FilterCondition` straight to a driver, without `parseFilterAST`, still gets that driver's own refusal. -- `ViewFilterRule` already refused an array on `not_equals` at authoring time. -- The published JSON Schema cannot state the check. `z.toJSONSchema()` has no projection for it, so `data/FieldOperators`, `data/EqualityOperator` and `data/NormalizedFilter` still read `{}` at `$ne`. The three sites are declared in `dropped-refinements.baseline.json`. -- No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `{ stage: { $ne: ['won', 'lost'] } }`, meaning "none of these values" | `{ stage: { $nin: ['won', 'lost'] } }` | -| `[['stage', 'not_equals', ['won', 'lost']]]` (or `ne`, `!=`, `<>`, `neq`, `notequals`) | `[['stage', 'not_in', ['won', 'lost']]]` (or `nin`, `notin`) | -| `{ stage: { $ne: ['won'] } }`, meaning one value | `{ stage: { $ne: 'won' } }` | - -On `driver-mongodb`, check what the query is supposed to return and do not assume the old rows were right. Before this card, MongoDB read `$ne` against an array as "not equal to that array and not holding it as an element", and `$nin` does not reproduce that. On the analytics `where` door, the old answer dropped every member after the first, so a chart built on it counted rows its filter named. - -## Who is affected, measured - -Nothing shipped authors the shape. Each count below was read against the named tree, with a control: - -- **This repository**, `origin/main` `9e7824a4`. `$ne` followed by an array literal in `packages/**`, `examples/**`, `apps/**`, `scripts/**`, `content/**` and `skills/**` has 20 hits. All of them are tests, refusal code, comments or migration prose. The control, `$in` followed by an array literal in `packages/**` and `examples/**`, has 901 hits. The FilterArray triple on `ne`, `!=`, `<>`, `neq`, `not_equals` or `notequals` carrying an array has 0 hits. A CEL `!=` against a list literal in `packages/platform-objects/**`, `examples/**` and `packages/qa/**` (tests excluded) has 0 hits. `$ne` fed by a variable in non-test source has 11 sites, and none of them builds a list. Each passes a list's first member, a stored-form value, a named constant, a `{ $field }` reference, or a caller's comparand passed through: the better-auth adapter's `ne`, and the CEL lowering, which refuses a list under `!=` before it emits. -- **objectui** at the `.objectui-sha` pin `f8a9d0fb05`: 2 hits, both in its own refusal test. The dataset filter builder gives `notEquals` a scalar arity, and the rollup summary editor gives only `in` / `notIn` a list. The control, `$in` with an array, has 46 hits. -- **cloud** `main` `48d7066`: 0 hits. The control has 33 hits. - -Deployed stacks were NOT measured. Every query that carries the shape is refused with `INVALID_FILTER` / 400 naming the field, the path and `$nin`, so a test suite that exercises the query finds each one. A clean re-save of a stored carrier does not find them, because the carrier schema still accepts the shape. Exercise stored filters, or grep them. - -Clause-②: no (narrowing) — nothing is widened. One comparand shape in one slot, which `$ne`'s published description already excluded, is refused at the face and at the operator slot. - - diff --git a/.changeset/19886-one-value-comparand-refused.md b/.changeset/19886-one-value-comparand-refused.md deleted file mode 100644 index 61350490338..00000000000 --- a/.changeset/19886-one-value-comparand-refused.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/formula": minor -"@objectstack/plugin-security": minor -"@objectstack/plugin-sharing": minor -"@objectstack/lint": minor -"@objectstack/objectql": minor -"@objectstack/spec": patch ---- - -A row-level or sharing-rule predicate whose comparison is handed something other than one value is refused at the CEL lowering or at the write-check evaluator, instead of admitting writes and reads it was written to refuse (#19886). - -**BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing`, `@objectstack/lint` and `@objectstack/objectql` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`. - -Clause-②: no (narrowing) - -**Security fix for RLS write checks and reads.** Each shape below was measured through the real plugin-security on driver-sql and driver-memory: - -- `!(record.status in [['closed', 'archived']])` (a list nested in an `in` list) admitted and stored every write the `check` was written to refuse, and a `using` read returned every row on driver-memory. -- `current_user.org_user_ids != 'x'` and `current_user.org_user_ids > 'a'` (a membership set on a comparison with no field) folded to "no restriction": every write admitted, every row read, on every driver. -- `record.status > ['m']` compared the list as the string `'m'` on the write check, while the analytics read scope bound the whole list as one SQL parameter. `record.reviewer_id > current_user` compared the whole caller object as a string and admitted and stored every write; in this release the RLS compiler's comparand faces (#20212) already drop that policy, and this change refuses it at the lowering for every caller of the compiler. -- `record.status != record.tags`, its negation `!(record.status == record.tags)`, and the mirror `record.tags != record.status`, with `tags` a `json` field or a `multiple` lookup, admitted and stored every write. - -What changes: - -- `@objectstack/formula`: `compileCelToFilter` refuses, with `unsupported`, a list comparand under every comparison (the ordering operators now included, and on the constant-fold branch, whichever side), the `current_user` root or a key resolving to an object under an ordering operator, and an `in` list whose member is itself a list. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports each literal form; a resolved value is refused per request. `matchesFilterCondition` refuses, with `INVALID_FILTER` / 400, an array under `$gt` / `$gte` / `$lt` / `$lte`, an array member of `$in` / `$nin`, and a `{ $field }` comparison (`$eq`, `$ne` or an ordering operator) whose column holds a list or an object on the record being judged, on either side. The message withholds the field, the operator and the value. -- `@objectstack/plugin-security`: the RLS compiler drops a policy the compiler refuses and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, and `getReadFilter` hands the analytics read scope the deny scope). A `check` comparing a field with a list-holding column is refused 400 and stores nothing. -- `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. -- `@objectstack/lint`: the literal forms are reported as `rls-predicate-unenforceable`, and an ordering comparison against a membership set through the reference pass. -- `@objectstack/objectql`: a `having` comparison against a `{ $field }` column whose aggregated row holds a list is refused 400 where the row carries the list itself (driver-memory); driver-sql rows carry the stored JSON text and compare as before. -- `@objectstack/spec`: the migration registry carries the entry. - -The stage 2a changeset's sentence that `{ $field }` references evaluate as before no longer holds for a column holding a list or an object: that comparison is now refused. - -**What to change.** "One of these values" is `record.status in ['open', 'pending']`, and "none of these values" is `!(record.status in ['closed', 'archived'])`, with the list flat. An ordering takes one bound (`record.status > 'm'`); a range is two comparisons joined by `&&`. Compare against one key of the caller (`record.reviewer_id > current_user.id`). A field compared with a `json` or `multiple` field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook. In a raw filter, use `$in` / `$nin` with flat lists and one bound per ordering operator. - -Not changed: a field compared with a `json` or `multiple` field still lowers and is not reported at authoring time, because the lowering sees the predicate's text and not the object's field types; driver-memory still answers a `{ $field }` comparison on a read without evaluating the reference. - - diff --git a/.changeset/19886-rls-list-holding-field-comparison-authoring.md b/.changeset/19886-rls-list-holding-field-comparison-authoring.md deleted file mode 100644 index 37995425236..00000000000 --- a/.changeset/19886-rls-list-holding-field-comparison-authoring.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -A row-level-security predicate that compares a field with a `json` or `multiple` field is refused when it is authored, at `os validate` / `os build` / `os lint` and at the metadata save door, instead of only when it runs (#19886). - -**BREAKING** — an accept-set narrowing, shipped by `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is already registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`, which names this class. - -Clause-②: no (narrowing) - -`record.status != record.tags`, with `tags` a `json` field or a `multiple` lookup, lowers to a legal filter shape, because the CEL lowering sees the predicate's text and not the object's field types, and the engine's filter admission does not judge a `{ $field }` reference against the referenced column's type either. Measured before this change: 400 cells (`==`, `!=`, `!(==)`, `>`, `<=`; a `json`, `address`, `multiselect`, `multiple` lookup and `multiple` user field; both operand orders; `using` on `select` / `all` / `update` / `delete` / `insert` and `check` on `insert` / `update` / `all`) were all accepted by the real `os validate` and by the save door. The runtime refused every one of them, measured through the real plugin-security on driver-sql: a read the `using` scopes answered `INVALID_FILTER` / 400, a by-id update or delete it scopes `PERMISSION_DENIED` / 403, and every insert or by-id update judged by the `check` (or by a `using` standing in as the check) `INVALID_FILTER` / 400, with nothing stored. - -What changes: - -- `@objectstack/lint`: `validateRlsPredicateEnforceability` reports `rls-predicate-unenforceable` for every lowered field-to-field comparison (`==`, `!=`, `>`, `>=`, `<`, `<=`, on either side, under `!` too) in which either column is DECLARED to hold a list or an object. The declaration is read from the stack's own objects through the spec's value-shape classes, the same two driver-sql refuses such a comparison by: a structured JSON type (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`), or a multi-valued field (`multiselect`, `checkboxes`, `tags`, or `select` / `radio` / `lookup` / `user` / `file` / `image` with `multiple: true`). It judges `using` and `check` on every operation. The finding names each comparison and the declaration behind it, and states the clause's run-time consequence. A clause it refuses is not also handed to the engine's filter judge, so one defect earns one finding. -- Both doors run this rule already, so both refuse: `os validate` / `os build` / `os lint` fail, and a publish through the metadata save door answers `422 INVALID_METADATA` with the same sentence in `issues[]`. `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns the save-door refusal into a logged warning. - -Not changed: a field compared with a single-valued field (`record.status != record.owner`, `record.amount > record.budget`), a `json` or `multiple` field compared with a literal or tested against `null`, and any column the stack does not declare (an object from another package, an external object with no field map), which the rule does not judge. The runtime refusals of stages 2d and 2e stay as the backstop. The stage 2d changeset's sentence that a field compared with a `json` or `multiple` field "is not reported at authoring time" no longer holds: it is now reported at both doors. - -No shipped predicate moves: 0 of the 187 `using` / `check` / `condition` strings in this repository's packages and examples, and 0 of the 3 in the cloud repository, compare a field with a `json` or `multiple` field, and the real `os validate` over `app-crm`, `app-multi-package`, `app-showcase` and `app-todo` reports no `rls-predicate-*` finding. - -**What to change.** A field compared with a `json` or `multiple` field has no row-filter form: compare with a single-valued column, or with a literal or a `current_user` value ("one of these values" is `record.status in ['open', 'pending']`, or `record.owner in current_user.org_user_ids`), or move the condition into a validation rule or a hook. - - diff --git a/.changeset/19886-sharing-rule-list-holding-field-comparison-authoring.md b/.changeset/19886-sharing-rule-list-holding-field-comparison-authoring.md deleted file mode 100644 index 5ee212ef059..00000000000 --- a/.changeset/19886-sharing-rule-list-holding-field-comparison-authoring.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -A sharing rule whose `condition` compares a field with a `json` or `multiple` field is refused when it is authored, at `os validate` / `os build` / `os lint`, instead of being seeded and then granting nothing (#19886). - -**BREAKING** — an accept-set narrowing, shipped by `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is already registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`, whose surface names `sharingRules[].condition` and this class. - -Clause-②: no (narrowing) - -`record.status != record.tags`, with `tags` a `json` field or a `multiple` lookup, lowers to a legal filter shape, because the CEL lowering sees the condition's text and not the object's field types, so the seeder seeds the rule. Measured before this change: 72 conditions (`==`, `!=`, `!(==)`, `>`, `>=`, `<`, `<=`; a `json`, `address`, `multiselect`, `multiple` lookup and `multiple` user field; both operand orders; plus a list-against-list and a compound spelling) were all accepted by the real `os validate`. The runtime refused every one of them, measured through the real plugin-sharing on driver-sql and driver-sqlite-wasm: the rule was seeded into `sys_sharing_rule`, every criteria query it ran answered `INVALID_FILTER` / 400, `SharingRuleService` read that as matching no record, and no `sys_record_share` grant was written, at boot or on a later insert or update. The recipient read nothing. The only signal was one WARN line per rule in the server log. - -What changes: - -- `@objectstack/lint`: `validateSharingRuleEnforceability` reports `sharing-rule-unlowerable-condition` for a condition that lowers but compares two fields (`==`, `!=`, `>`, `>=`, `<`, `<=`, on either side, under `!` too) where either column is DECLARED to hold a list or an object. It uses the same classification as the row-level-security rule's arm for this class (`listHoldingComparisons`, now exported from `validate-rls-predicate-enforceability.ts`), which reads the spec's value-shape classes, the same two driver-sql refuses such a comparison by: a structured JSON type (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`), or a multi-valued field (`multiselect`, `checkboxes`, `tags`, or `select` / `radio` / `lookup` / `user` / `file` / `image` with `multiple: true`). The finding names each comparison and the declaration behind it, and states the run-time consequence. It keeps the unlowerable id because the fix is the same rewrite of the condition, and the literal spelling of the same class (`record.status == ['a', 'b']`) is already reported under that id. -- Inactive rules are judged too, as the rule already does for every condition: the seeder seeds them regardless of `active`. - -Not changed: a field compared with a single-valued field (`record.status != record.owner_name`, `record.amount > record.budget`), a `json` or `multiple` field compared with a literal or tested against `null`, and any column the stack does not declare (an anchor object from another package, an object with no field map, an undeclared name), which the rule does not judge. No row-level-security verdict changes. The rule still runs only at the CLI doors; the metadata save door for a `sharing_rule` does not run it, as before. - -No shipped condition moves: the 3 declared sharing-rule conditions in this repository's packages and examples compare a field with a literal, the cloud repository declares none, and the real `os validate` over `app-crm`, `app-multi-package`, `app-showcase` and `app-todo` reports no new `sharing-rule-*` finding. - -**What to change.** A field compared with a `json` or `multiple` field has no row-filter form: compare with a single-valued column, or with a literal ("one of these values" is `record.status in ['open', 'pending']`), or keep the value the rule keys on in a single-valued field and compare with that. - - diff --git a/.changeset/19886-stored-list-ordering-refused.md b/.changeset/19886-stored-list-ordering-refused.md deleted file mode 100644 index a06285eb232..00000000000 --- a/.changeset/19886-stored-list-ordering-refused.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/formula": minor -"@objectstack/plugin-security": minor -"@objectstack/spec": patch ---- - -A row-level write check that orders a field against a bound (`>`, `>=`, `<`, `<=`) is refused with `INVALID_FILTER` / 400 when that field holds a list or an object on the record being written, instead of comparing the list's string form and admitting the write (#19886). - -**BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula` and `@objectstack/plugin-security` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `rls-predicate-stored-list-ordering-refused`. - -Clause-②: no (narrowing) - -**Security fix for RLS write checks.** Measured through the real plugin-security on driver-sql and driver-memory: `record.tags > 'a'`, with `tags` a `json` field holding `['m']`, compared `'m' > 'a'` and admitted and stored the write. `record.meta < 'a'` with `meta` holding `{ a: 1 }` compared `'[object Object]' < 'a'` and did the same, and so did a `multiple` lookup. `using` stands in as the check for a policy that declares no `check`, so the same predicate in `using` was enforced the same way on writes. No shipped row-level or sharing-rule predicate orders a field at all. - -What changes: - -- `@objectstack/formula`: `matchesFilterCondition` refuses `$gt` / `$gte` / `$lt` / `$lte` and `$between` on a field whose value on the record is a list or a plain object, whatever the comparand, with the same `INVALID_FILTER` / 400 and the same message as the stage 2d refusals. The refusal is per record: a record whose `json` field holds one scalar is compared as before. `null` and `Date` values are unchanged, and so is every equality against a stored list (`$eq`, `$ne`, implicit equality, `$in`, `$nin`). `$between` is not produced by the CEL lowering, so it reaches this only through a filter passed to `matchesFilterCondition` directly. -- `@objectstack/plugin-security`: a check insert or by-id update whose post-image holds a list or an object in an ordered field is refused 400 and stores nothing. That includes a by-id update that edits another field of a row whose stored `json` column holds a list, because the post-image merges the stored row. -- `@objectstack/spec`: the migration registry carries the entry. The stage 2a entry `rls-predicate-array-comparand-refused` now ends "Scalar != and ==, null, Date comparands, and { $field } references between single-valued columns evaluate exactly as before", which is true since stage 2d. - -Three moves, named: - -1. **The write check now matches driver-sql's read.** driver-sql refuses every ordering comparison, and `$between`, on a column it stores as JSON text, by declared type (400, #7398). The in-process check now refuses the same predicate on the same row (400). -2. **driver-memory's read parts from the write check.** driver-memory, a test driver, compares a stored list element by element on a read and keeps returning those rows (`record.tags > 'a'` reads a row holding `['m']`), while the check now refuses writing it. This is declared on #15104, as for stage 2d's `{ $field }` half. -3. **A list written into a scalar field under an ordering check now answers 400.** `status: ['m']` into a `text` field under `record.status > 'a'`, or `amount: [500]` into a `number` field under `record.amount > 10`, was admitted, and driver-sql stored it as the text `'["m"]'` / `'[500]'`. It is now refused before anything is stored. - -**The explain answer.** `security/explain` evaluates the business RLS predicate in-process on the fetched record, so it now answers `INVALID_FILTER` / 400 where the record holds a list or an object under an ordering predicate (this stage). It already answered 400 for a `{ $field }` comparison against a list-holding column (stage 2d). For both, per operation: - -| explain operation | driver | the enforced operation answers | same as explain's 400? | -|---|---|---|---| -| `read` | driver-sql | 400 `INVALID_FILTER` (the driver's refusal) | yes | -| `update` | driver-memory | 400 `INVALID_FILTER` (the post-image check) | yes | -| `update` | driver-sql | 403 `PERMISSION_DENIED`: the pre-image gate fails closed on the driver's 400 | no — both deny | -| `read` | driver-memory | the rows its element-wise read admits | no — the test driver's read | - -Explain itself is unchanged. - -**What to change.** Order a single-valued column (`record.priority > 2`), or test membership in the list with `in` (`record.status in ['open', 'pending']`). A `json` or `multiple` field has no ordering. - - diff --git a/.changeset/19887-readonlywhen-stored-record.md b/.changeset/19887-readonlywhen-stored-record.md deleted file mode 100644 index fae29d67603..00000000000 --- a/.changeset/19887-readonlywhen-stored-record.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a record-scoped `readonlyWhen` lock is judged against the values the update STORES, not a read-only value the caller forged (#19887) - -**What a caller could do before.** A caller with edit rights could change a -field locked by a `record`-scoped `readonlyWhen` by putting a forged value for -a statically `readonly` field in the same update. With `amount: { readonlyWhen: -"record.status == 'closed'" }` and a `readonly: true` `status`, -`update(ticket, { status: 'open', amount: 999 })` on a CLOSED ticket committed -`amount = 999`: the lock was judged against the forged `status: 'open'`, then -the read-only `status` was stripped, so the ticket stayed closed with its frozen -amount rewritten. The same happened through a read-only master-detail field -read as `record.invoice`, on bulk (`multi: true`) updates for every matched -row, and for a parent field's own `readonlyWhen` lock that reads a read-only -field. - -**What happens now.** The lock reads the update as it will be stored: a value -the read-only strip removes is replaced by the row's stored value before any -`readonlyWhen` predicate is evaluated. In the example above `amount` is dropped -as locked, exactly as `update(ticket, { amount: 999 })` on its own always was. -Where the read-only strip keeps the value (an `isSystem` caller, a -`preserveAudit` write of a preservable field, a value a `beforeUpdate` hook -wrote), the value is stored, and the lock reads it as before. - -**What else you may see move, all in the same direction (the stored values -decide):** - -- Forging a LOCKING value for a read-only field (`status: 'closed'` on an open - ticket) no longer locks the other fields — they now commit. -- `onFieldsDropped` now reports the locked field (`readonly_when`) before the - read-only one (`readonly`), and a `strictReadonlyWrites` refusal names both; - it was a refusal before and still is. -- A field that is both `readonly: true` and `readonlyWhen`-locked may now be - reported as `readonly_when` instead of `readonly` when its own predicate reads - a forged value; it is dropped either way. -- `requiredWhen` and validation rules run on the stripped update, so they now - see the amount the row keeps: a requirement only the forged-through amount - raised no longer refuses the write, and clearing a field the kept amount - requires is now refused (`VALIDATION_FAILED`). diff --git a/.changeset/19888-analytics-implicit-array.md b/.changeset/19888-analytics-implicit-array.md deleted file mode 100644 index ea75a785994..00000000000 --- a/.changeset/19888-analytics-implicit-array.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(service-analytics)!: the analytics `where` door refuses a list in the equality slot instead of reading it as `IN` (#19888) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what the analytics faces of `@objectstack/service-analytics` accept. A caller `where`, a dataset `filter` or a measure `filter` that carries a list in the equality slot, `{ field: [...] }` (the empty list included) or `{ field: { $eq: [...] } }`, at any depth under `$and` / `$or` / `$not` or inside a nested relation, compiled before this change. It is now refused with `INVALID_FILTER` / 400. It ships as `minor` under the launch-window convention for accept-set narrowings. The remedy is `$in`: - -| you wrote | write instead | -|:--|:--| -| `{ stage: ['won', 'lost'] }` or `{ stage: { $eq: ['won', 'lost'] } }`, meaning "one of these values" | `{ stage: { $in: ['won', 'lost'] } }` | -| `{ stage: ['won'] }`, meaning one value | `{ stage: 'won' }` | -| `{ stage: [] }` | `{ stage: { $in: [] } }`, which matches no row, as the bare list did | - -`$in` charts the rows the implicit list charted before. The refusal also names `{ "$contains": "…" }` for "the stored list holds a value" on a multi-value field. - -Ruling 乙 of #19757 refuses a list in the equality slot at the shared comparand-shape face in `@objectstack/spec`, for every driver at once. The analytics `where` door met that face only for the `FilterArray` spelling (`['stage', '=', ['won', 'lost']]`), which was already refused. The object spelling was compiled by the analytics filter normalizer, which read one condition four ways: - -- `{ stage: ['won', 'lost'] }` compiled to `stage IN (...)`. On the ObjectQL path the engine received `{ stage: { $in: [...] } }`, so the engine's own shared-face check never saw the list. -- `{ stage: { $eq: ['won', 'lost'] } }` compiled to `stage = 'won'`, and `'lost'` was dropped without a word. -- `{ stage: { $eq: [] } }` compiled to no predicate at all, so the chart was drawn over every row. -- `{ stage: [] }` compiled to the FALSE constant. - -Each list is now handed to the shared face's equality arm before any node is built. Both spellings of one condition therefore get the same refusal, with the same wording, path and `$in` prescription. The draft-data preview runs the same gate, so a drafted chart refuses what the published chart refuses. Before, it compared each row against the list's string form. - -Who is affected: nothing in this repository's examples, seeds or docs authors the shape (measured over `examples/**`, `packages/**` and the fenced code in the docs). Stored datasets, dashboard widget filters, report runtime filters and measure filters in a deployment were NOT measured. In this release the authoring schema refuses the shape too, when such a document is saved (a separate change in `@objectstack/spec`, ADR-0087 entry `filter-equality-array-comparand-refused-at-save`). One position is judged here and not by the shared authoring schema: a list inside a nested relation, which this door flattens to a dotted member. A dataset `filter` or measure `filter` carrying one is refused when it is saved, in the same words (a separate change in `@objectstack/spec`, ADR-0087 entry `dataset-filter-nested-relation-equality-array-refused-at-save`). Any other `where` that reaches this door with one, such as a caller `where` or a dataset selection's `runtimeFilter`, is refused only when it is charted. The refusal names the field and the path. `$ne` with a list is not part of the ruling and is not judged here. The list operators (`$in`, `$nin`, `$between`) keep their lists, and every scalar, `null` included, compiles as before. diff --git a/.changeset/19889-filter-schema-door-array-equality.md b/.changeset/19889-filter-schema-door-array-equality.md deleted file mode 100644 index fe9c0979a87..00000000000 --- a/.changeset/19889-filter-schema-door-array-equality.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: a filter carrying an array in the equality slot is refused when it is saved, in the query face's own words (#19889) - -**BREAKING** — an accept-set narrowing of published authoring schemas, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Ruled on #19889 (record 5805248669, letter A). The hand-migration prescription is registered under protocol major 18 as `filter-equality-array-comparand-refused-at-save`. - -## What changes - -`FilterConditionSchema` now refuses an ARRAY in the equality slot at parse: the implicit form `{ field: [...] }` and the explicit form `{ field: { $eq: [...] } }`, the empty array included, at any depth under `$and` / `$or` / `$not`. `FieldOperatorsSchema.$eq` refuses an array comparand too, and so do its documentation copy `EqualityOperatorSchema.$eq` and the `NormalizedFilter` AST that validates against it. - -The comparand-shape face (`assertListComparandShapes`) refuses both shapes on every query, from the equality-slot change earlier in this release. Before this change the schema accepted them, so a dataset or dashboard filter carrying one published clean and then failed every query that used it. Measured on `origin/main` `a0920b42dc`: `DatasetSchema.safeParse` with `filter: { stage: ['won', 'lost'] }` answered `success: true`, and so did a measure `filter` of `{ stage: { $eq: ['won', 'lost'] } }`. - -The schema door prints the face's sentence: the field, the received list, and the two operators a list in that slot stood in for. Both doors import it from one builder. The face adds `at where.`. The schema door leaves that out, because the issue's `path` already says where it is (`filter.stage`, `measures.0.filter.stage.$eq`). - -Every schema that carries a `FilterCondition` refuses it on parse. That covers the dataset `filter` and measure `filter`, the dashboard widget `filter` and options-source `filter`, the report and joined-report-block `runtimeFilter`, the field `relatedListFilter` and rollup `summaryOperations.filter`, the solution-blueprint summary `filter`, the analytics query `where`, the dataset selection `runtimeFilter`, the query `where` and `having`, the data-engine aggregate call's `having`, the aggregation `filter`, and the query-filter `where`. So `defineStack`, `os validate` and a save through the metadata protocol (`422 INVALID_METADATA`) refuse such a document at the filter's path. - -Two request doors parse these carriers, and they now answer before the analytics compiler does. The REST dataset selection (its `runtimeFilter`) and the analytics query body (its `where`) answer `VALIDATION_FAILED` / 400 with the sentence at the field. Before, the compiler answered `INVALID_FILTER` / 400. - -## What does NOT change - -- **Nothing stored is rewritten, and nothing is dropped.** The parse fails and strips nothing. The read path does not re-validate stored rows, so a stored document keeps loading, and its next save is refused. Such a filter has failed every query since the equality-slot change, so the refusal is a repair. -- **The reach is the face's, and no wider.** A field spec with no `$` key, such as the nested-relation condition `{ account: { region: ['a'] } }`, is not judged by `FilterConditionSchema`, because the face does not judge it either. The analytics `where` door does refuse that shape, because it flattens the relation to a dotted member. So the two carriers that door charts, the dataset `filter` and the measure `filter`, refuse it on save as well, in the same words. That is a separate change in this release, ADR-0087 entry `dataset-filter-nested-relation-equality-array-refused-at-save`. -- **The data-engine calls' `where` option still parses.** Its type is a union whose first arm is an open record. The face refuses the shape when the call runs. -- **`$ne` carrying an array is not judged.** -- The list operators keep their arrays, `$in: []` and `$nin: []` included. Every scalar, `null`, a `Date` and a `{ $field }` reference pass as before. -- **The published JSON Schema cannot state the check.** `z.toJSONSchema()` has no projection for it, so `data/FieldOperators`, `data/EqualityOperator` and `data/NormalizedFilter` still read `{}` at `$eq`. The three sites are declared in `dropped-refinements.baseline.json`. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `{ stage: ['won', 'lost'] }`, meaning "one of these values" | `{ stage: { $in: ['won', 'lost'] } }` | -| `{ tags: ['a'] }`, meaning "the stored list holds `a`" on a multi-value field | `{ tags: { $contains: 'a' } }` | -| `{ tags: ['a', 'b'] }`, meaning "the stored list holds `a` or `b`" | `{ $or: [{ tags: { $contains: 'a' } }, { tags: { $contains: 'b' } }] }` | -| `{ stage: ['won'] }`, meaning one value | `{ stage: 'won' }` | -| `{ stage: { $eq: [...] } }` | any of the rows above | - -## Who is affected, measured - -Nothing shipped in this repository carries the shape. A brace-matched scan of every filter-carrier literal (`filter`, `where`, `runtimeFilter`, `having`, `relatedListFilter`) in `packages/**`, `examples/**`, `apps/**`, `content/docs/**` and `skills/**` read 4709 literals across 7785 files and found 20 field entries whose value opens an array. Sixteen are test fixtures, and four are not filter carriers (a realtime subscription filter and a plugin-permission filter). A grep for `$eq` followed by an array found 24 lines: prose, MongoDB aggregation expressions, and one door-refusal conformance row. Deployed datasets, dashboards and reports were NOT measured. Validating each stack, or re-saving each document, finds every instance the surface above lists. - -Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. One comparand shape in one slot, which every query door already refused, is now refused on save as well. - - diff --git a/.changeset/19893-turso-remote-url-replica-refusal.md b/.changeset/19893-turso-remote-url-replica-refusal.md deleted file mode 100644 index d23826de3f3..00000000000 --- a/.changeset/19893-turso-remote-url-replica-refusal.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/driver-turso': minor ---- - -fix(driver-turso)!: a local or replica `TursoDriver` on a remote url, or a replica off a local file, is refused at construction - -Clause-②: no (narrowing) - -A remote `url` beside `syncUrl` was classified as an embedded replica, and the local SQLite engine that every replica read and write goes through was handed `:memory:`. Writes succeeded and read back, then vanished on restart, and none of them reached the remote. `@libsql/client` builds no embedded replica for a remote url: it routes `libsql://` / `https://` / `http://` to its HTTP client and `wss://` / `ws://` to its WebSocket client, neither of which reads `syncUrl`. A forced `mode: 'replica'` or `mode: 'local'` beside a remote url was handed the same `:memory:` engine, and so was a replica on `:memory:`. Measured before the change, with `create`, `find`, then a fresh driver on the same config: - -``` -libsql:// + syncUrl (sync.onConnect: false) -> 1 row back, 0 rows after restart -libsql:// + mode: 'replica' or mode: 'local' -> 1 row back, 0 rows after restart -:memory: + syncUrl + a supplied client -> 1 row back, 0 rows after restart -file: + syncUrl (unchanged) -> 1 row back, 1 row after restart -``` - -With the driver building its own client and the default `sync.onConnect`, two of these did fail at `connect()`, but on a libsql error that did not say why: `libsql://` + `syncUrl` with `SYNC_NOT_SUPPORTED`, and `:memory:` + `syncUrl` with `URL_INVALID`. - -**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses configurations it accepted before**, at `new TursoDriver()`, ahead of the Knex base and of any client, with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`. A remote url here means one of the schemes `TursoDriver.detectMode` classifies as remote: `libsql://`, `https://`, `http://`, `wss://`, `ws://`. The #19976 entry in this same version matches them in any letter case, so an uppercase `LIBSQL://` is a remote url too. Refused: - -- a remote url beside `syncUrl`; -- a remote url under a forced `mode: 'replica'` or `mode: 'local'`; -- a replica on a url `@libsql/client` reads as in-memory (`:memory:`, or `file::memory:` with or without a query string), beside `syncUrl` or under a forced `mode: 'replica'`. `@libsql/client` refuses such an embedded replica itself. For `:memory:` and a bare `file::memory:` the local engine was a private in-memory database. With a query string it was a file literally named after the url's path (for example `:memory:?cache=shared`) in the working directory, which no sync reaches; -- under a forced `mode: 'replica'`, a `url` that is none of `:memory:`, a `file:` url or a remote url, such as a bare path or an unsupported scheme. The #19976 entry in this same version refuses such a url in every local or replica mode, and matches the `file:` scheme in any letter case: an uppercase `FILE:` url naming a file is a `file:` url and is not refused, and the replica runs on that file (`FILE::memory:` is refused as in-memory, like `file::memory:`). - -The remote-url refusal names the scheme it met. Neither refusal echoes the url, which may carry a token. Both loaders (`@objectstack/runtime`'s host factory and the datasource factory) reach this refusal through the same constructor, so a datasource declaring one of these configurations now fails by name when its loader builds the driver. - -**What stays accepted**, pinned by preservation tests: a `file:` url with `syncUrl` (the embedded replica), a `file:` or `:memory:` local database, a remote url on its own or with `mode: 'remote'`. `TursoDriver.detectMode()` still classifies a remote url beside `syncUrl` as `'replica'`: the refusal sits in the constructor, not in a re-classification. - -**Not refused by this change:** a url with no `mode` that is none of a lowercase `file:` url, `:memory:` or a lowercase remote scheme, such as an uppercase `LIBSQL://`, an uppercase `FILE:` url or a bare path like `./data/app.db`, auto-detected `'local'` with or without `syncUrl`, and the local engine was handed `:memory:`. Under a forced `mode: 'local'` the same url got the same `:memory:` engine. The #19976 entry in this same version removes that fall-through: it matches every scheme in any letter case, so an uppercase remote url is a remote url and an uppercase `FILE:` url is a `file:` url, and it refuses every other url in a local or replica mode. No configuration runs on an in-memory database it did not name. - -**What an affected author does.** Each refusal names its ways out. For a remote url in a local or replica mode: - -- to use the remote database, drop `syncUrl` (and `sync`) and any forced `mode`; the remote url alone sends every read and write to it; -- for an embedded replica, point `url` at a local file and keep the remote in `syncUrl`: `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io'`. - -For a replica off a local file, point `url` at a local `file:` path beside `syncUrl`. A throwaway in-memory database instead drops `syncUrl` (and `sync`) and any forced `mode: 'replica'`, and keeps `url: ':memory:'`. - -Blast radius, measured on this tree: no example, template, published skill, hand-written doc or factory default declares a remote url beside `syncUrl`, and the host boot path (`OS_DATABASE_URL`) passes no `syncUrl`. Outside this package's own tests, the in-repo configurations carrying the pair are test fixtures that never construct the real driver: loader fixtures that exercise the config builder or a capturing constructor, stored-row redaction fixtures and a schema-parse fixture. Whether any out-of-repo deployment declares it is NOT measured and is not claimed to be zero. - - diff --git a/.changeset/19894-turso-remote-media-column-move-refusal.md b/.changeset/19894-turso-remote-media-column-move-refusal.md deleted file mode 100644 index e6aeb91e2ce..00000000000 --- a/.changeset/19894-turso-remote-media-column-move-refusal.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/driver-turso': minor -'@objectstack/cli': minor ---- - -fix(driver-turso): a REMOTE `TursoDriver` refuses to plan the ADR-0104 media column move instead of answering that there is nothing to move, and `os migrate files-to-references` reports that refusal as a column step it could not judge (#19894) - -Clause-②: yes (narrowing) - -**BREAKING for callers that plan the media column move on a remote Turso datasource** — `TursoDriver.planMediaColumnMove()` in `remote` transport mode (for example a `libsql://` URL) now throws a `NOT_IMPLEMENTED` / `501` error, where it used to answer `{ plans: [], refusals: [] }`. The `local` (`:memory:` and `file:`) and `replica` modes plan exactly as before, and the local modes plan exactly what `SqlDriver` plans for the same declaration. - -What the refusal replaces, measured on the transport's SQLite-backed test double: a table with a `file` and an `image` field, synced through each of the three remote schema doors (`syncSchemasBatch`, `syncSchema`, `initObjects`), held its two TEXT media columns on the remote database, and the remote face answered an empty scan on every door. The inherited planner walks the objects `SqlDriver`'s own schema sync registers, which no remote schema door reaches, and probes each table through the placeholder in-memory Knex connection a remote driver is built with. The local and embedded-replica faces planned two `unquote` moves for the same declaration. `os migrate files-to-references` printed that empty scan as "Column step: nothing to move — this datastore declares no single-value media column". - -- **The command reports the refusal instead of failing on it.** `os migrate files-to-references` calls the planner only after the backfill and its self-check have passed, and an `--apply` run has recorded the deployment flag by then. Measured on the command's own test doubles before this change, a planner that throws ended the run with the error alone (`--json`: `{"error": …, "code": "NOT_IMPLEMENTED"}`) and exit 1, with no backfill report, no verify report and no word about the flag it had recorded. The column step now catches a `NOT_IMPLEMENTED` refusal by its code and reports it as a skip: the text face prints `Column step: NOT JUDGED` with the driver's message, and `--json` gains `columnMoveRefused` — `{ error, code }` when the driver refused, `null` otherwise — beside `columnMove: null` and `columnsMovedAt: null`. The backfill, verify and flag reports are emitted as on any other run, nothing is stamped, and the exit code is the self-check's, as it already was for the command's other column-step skips. -- **Any other throw from the planner still fails the command**, through the same error report and exit 1 as before. -- **A genuinely empty scan still reads "nothing to move".** -- **No new error code.** `NOT_IMPLEMENTED` / `501` is a standard code, the envelope this transport already uses for its remote transaction, auto-number, deferred-DDL and drift-detection refusals. - -**If you are refused:** the backfill, its self-check and, on `--apply`, the deployment flag are unaffected. A remote Turso datasource keeps its single-value media columns on the JSON encoding: measured on the same double, the remote face writes a file id as a JSON string and reads it back as the id, and it does not read the record of a completed column move. - - diff --git a/.changeset/19897-sys-user-role-help-stale-translations.md b/.changeset/19897-sys-user-role-help-stale-translations.md deleted file mode 100644 index 2123d932ab3..00000000000 --- a/.changeset/19897-sys-user-role-help-stale-translations.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -The `zh-CN`, `ja-JP` and `es-ES` help text for `sys_user.role` told a Setup administrator to press the "Set Platform Role" action retired earlier — the `en` text had already moved on (renamed to describe `OS_PLATFORM_OWNER_EMAIL` and the `single`-posture `admin_full_access` grant) but the three translations were never updated to match. Retranslated the leaf into each locale as a faithful rendering of the current `en` text, with code spans (`OS_PLATFORM_OWNER_EMAIL`, `single`, `admin_full_access`, `sys_user_permission_set`) kept verbatim. - -No source-hash entry was added for this leaf — measured to be architecturally unreachable for a genuinely translated (non-literal-copy) leaf under the `objects`/`metadataForms` provenance mechanism (`source-hash.ts`, ruling #12069 Option A): `collectFilledFromHashes` records a hash only when the translated value is currently a byte copy of the source, and a real translation satisfies neither `value === currentSource` nor `previous[path] === hash(value)`, so it stays legacy-trusted by the ruling's own stated design. Verified empirically: mutating the source description and re-running `pnpm i18n:extract`, `pnpm check:i18n` and `pnpm check:i18n-stale-fill` reports nothing for this leaf either before or after this fix — this class of leaf has no gate-visible staleness detection today, which is the pre-existing status quo for every hand-translated leaf in the `objects` bundle, not a regression this PR introduces. diff --git a/.changeset/19900-schedule-caller-param-keys.md b/.changeset/19900-schedule-caller-param-keys.md deleted file mode 100644 index 5361d7a9227..00000000000 --- a/.changeset/19900-schedule-caller-param-keys.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/trigger-schedule': patch -'@objectstack/spec': patch ---- - -A scheduled run no longer skips a screen whose field is named `schedule`, `jobId` or `flowName` (#19900). - -The schedule trigger starts each run with three seeds of its own in `params` — `jobId`, `flowName` and `schedule` — and there is no caller behind the run. It stated nothing about that, so a `screen` node's headless verdict inferred who supplied each field from `params`, read those three seeds as the caller's answers, and continued past a screen whose field shared one of the names: the run completed with the trigger's value (for `schedule`, the cron descriptor) as the answer. - -The trigger now sets `AutomationContext.callerParamKeys: []` — "the caller supplied nothing" — which the verdict reads instead of inferring. A screen in a scheduled flow pauses, whatever its fields are named. The three seeds stay in `params`; flows that read them are unaffected. - -`@objectstack/spec`: the `callerParamKeys` TSDoc now names the schedule trigger as a producer that states the empty list, and no longer lists it among the producers that leave the key absent. No type changes. - -This supersedes one sentence of the `callerParamKeys` entry (#19846): the schedule trigger no longer leaves the key absent. Record-change, time-relative and webhook triggers still do. diff --git a/.changeset/19911-readonlywhen-interdependent-locks.md b/.changeset/19911-readonlywhen-interdependent-locks.md deleted file mode 100644 index 12099571638..00000000000 --- a/.changeset/19911-readonlywhen-interdependent-locks.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a value one `readonlyWhen` lock drops can no longer unlock another `readonlyWhen` lock (#19911) - -**What a caller could do before.** When one field's `readonlyWhen` read a field -that carries its own `readonlyWhen`, a caller with edit rights could change a -locked field by sending a new value for the other one in the same update. With -`status: { readonlyWhen: "previous.status == 'closed'" }` and `amount: { -readonlyWhen: "record.status == 'closed'" }`, `update(c1, { status: 'open', -amount: 999 })` on a CLOSED row committed `amount = 999`: `status` was dropped -by its own lock, but `amount` was judged against the dropped `'open'`, so the -row stayed closed with its frozen amount rewritten. The same happened on bulk -(`multi: true`) updates for every matched row, and for a master-detail field's -own `readonlyWhen` lock that reads such a field — the row moved to another -header although its lock held on the row it kept. `isSystem` callers were -affected too (a `readonlyWhen` lock binds them). - -**What happens now.** A value one `readonlyWhen` lock drops can no longer -unlock another: no field is written while its `readonlyWhen` is TRUE on the row -the update stores. A lock is judged after the locks whose fields it reads, with -each value they drop put back to the row's stored one. Locks that read each -other in a cycle are judged together, again with each dropped value put back, -until no further field locks. Then the fields held only by a value that was -later put back are released, and every lock in the cycle is judged again -against what that release stores, round after round, until the dropped fields -are exactly the ones locked on the row the update stores; after one round more -than the cycle has fields, the first, larger set of drops stands for the -cycle's fields instead. In the example -above `amount` is dropped as locked, exactly as `update(c1, { amount: 999 })` -on its own always was. Values a `beforeUpdate` hook wrote are still stored and -read as before. - -**What else you may see move:** - -- The reverse: a value that WOULD lock another field no longer locks it when - its own lock drops it. With `status` frozen by `previous.frozen == true`, - `update(r, { status: 'closed', amount: 999 })` now stores the amount (the row - stays open, so its amount is unlocked); before, the amount was dropped too. -- `onFieldsDropped` reports every dropped field in the one `readonly_when` - event, and a `strictReadonlyWrites` refusal names the fields the update would - have dropped; it was a refusal before and still is. -- `requiredWhen` and validation rules run on the stripped update, so they see - the amount the row keeps: a requirement only the let-through amount raised - no longer refuses the write, and clearing a field the kept amount requires is - now refused (`VALIDATION_FAILED`). -- A field whose own lock is FALSE on the stored row can still be dropped, but - only when its lock is in a cycle of locks that read each other's fields (a - `parent`-scoped lock counts as reading the master-detail field, which picks - the header, and a lock that reads `record` other than as `record.` - counts as reading every field). A field whose lock is in no such cycle is - dropped exactly when its lock is TRUE on the row the update stores (on a - bulk update, on at least one matched row). In a cycle, - no set of drops may agree with the stored row: with `a` locked by `record.b - == 'x'` and `b` by `record.a == 'old_a'`, `update(r, { a: 'new_a', b: 'x' - })` on a row `{ a: 'old_a', b: 'y' }` drops both, although `a` is unlocked - on the row it stores. A cycle can also have more than one set that agrees: - with `a` locked by `record.b == 'new_b'` and `b` by `record.a == 'new_a'`, - `update(r, { a: 'new_a', b: 'new_b' })` on a row holding neither new value - would agree with the row by dropping either one, and it drops both. Some - other updates with two such sets store one of them. Where a cycle's drops do - not settle, the first, larger set stands for that cycle's fields: a lock the - update cannot settle is not waived. -- A master-detail repoint that the field's own `record`-scoped lock used to - hold can now land. Its lock is judged together with the other locks on the - header the update names, so when the value that lock reads is itself locked - under that header, the value is dropped, the repoint lands, and the edit is - dropped under the header the row lands on. With `invoice: { readonlyWhen: - "record.amount == 'big'" }` and `amount: { readonlyWhen: "parent.status == - 'paid'" }`, `update(line, { invoice: 'inv_a', amount: 'big' })` on a line - under an open invoice, naming a paid one, used to keep the line where it was - and store `amount: 'big'` (`onFieldsDropped` reported `invoice`); it now - moves the line onto the paid invoice and keeps its old amount - (`onFieldsDropped` reports `amount`), by id and on bulk updates. A - `strictReadonlyWrites` refusal of that write now names `amount` instead of - `invoice`. Both outcomes agree with the locks on the row they store. -- A master-detail field whose own lock reads `record`, on an object where - another field in the update has a `parent`-scoped lock, now reads the named - header before deciding whether the row moves: one more header read when it - does not. diff --git a/.changeset/19912-json-backfill-depth-limit.md b/.changeset/19912-json-backfill-depth-limit.md deleted file mode 100644 index 650dc9dce6e..00000000000 --- a/.changeset/19912-json-backfill-depth-limit.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/driver-sql": minor -"@objectstack/driver-turso": patch ---- - -fix(driver-sql): the local SQLite `Field.json` storage backfill no longer turns a deeply nested array or object into a string on the next schema sync (#19912) - -The backfill that converges legacy json cells on their JSON-encoded form (it came with the change that made the SQLite write path JSON-encode every json value; the issue number that change cites no longer resolves, and its live record is the `SqlDriver.backfillCanonicalJsonEncoding` doc block and `sql-driver-12380-json-roundtrip.test.ts`) ran one `UPDATE … set col = json_quote(col)` over every TEXT cell SQLite's `json_valid()` rejects. `json_valid()` answers 0 for JSON nested more than 1000 levels deep (SQLite's JSON depth limit in every build this repository bundles), while the driver reads such a cell with `JSON.parse` without trouble. So a deep array written correctly through the driver was quoted into a JSON string by the next `syncSchema` / `initObjects`, and read back as a string from then on — silently, with no error. - -SQL now only pre-selects the candidate cells, a page at a time. The driver's own codec decides each one: a cell `JSON.parse` reads is left exactly as stored; a cell it cannot read is a legacy plain string and is rewritten to `JSON.stringify` of that string, byte-for-byte what the old statement wrote for it wherever the engine reads the stored bytes back verbatim. A cell the engine does not read back verbatim is left as stored and keeps reading as it did, where the old statement rewrote it: text holding invalid UTF-8, measured on better-sqlite3 and sql.js. `SqlDriver` on better-sqlite3, `TursoDriver` in local mode (which runs on better-sqlite3 too) and `SqliteWasmDriver` (sql.js) were each measured to read such a cell back with U+FFFD in place of the invalid bytes, so the text the rewrite would be decided from is not the stored text, and the cell is left alone. A legacy text with a leading U+FEFF or an embedded NUL is read back verbatim on all three faces, and is rewritten like any other plain string. Each rewrite is a compare-and-set on the text it was decided from, so a value written concurrently is never overwritten, and a re-run over a converged table still writes nothing. This covers every local SQLite face that inherits the backfill: `SqlDriver` on every client it treats as SQLite (`better-sqlite3`, `sqlite3` and its alias `sqlite`), `SqliteWasmDriver`, and `TursoDriver` in local mode. - -The decision rule is exported from `@objectstack/driver-sql` as `recoverUnencodedJsonText(stored)`, and `@objectstack/driver-turso`'s remote codec-residue backfill now imports it instead of carrying its own copy, so the local and remote backfills apply one rule. That is a new public export on `@objectstack/driver-sql`'s root entry, and the reason this package takes `minor`: the function returns `null` for text `JSON.parse` accepts and `JSON.stringify(stored)` for text it rejects, and it is exported so that `SqlDriver.backfillCanonicalJsonEncoding` and the remote backfill's `recoverResidueCell` decide each cell by one shared rule rather than by two copies that could drift apart. The remote backfill's behaviour is unchanged. diff --git a/.changeset/19920-exported-types-family-close.md b/.changeset/19920-exported-types-family-close.md deleted file mode 100644 index a584e186601..00000000000 --- a/.changeset/19920-exported-types-family-close.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `ApiError.code` and a flattened list overlay's legacy `options` bag carry the shapes their doors accept (#19920) - -Clause-②: no (narrowing) - -**BREAKING for TypeScript code that annotates with `ApiError`, with any response type built on `BaseResponseSchema` (`BaseResponse`, `BatchUpdateResponse`, `SessionResponse`, the metadata, package, storage, analytics and automation response types, and the rest), with `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`, or with the input type of a schema returned by `makeApiErrorSchema`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no value and no export changes, and no export is added. - -Two places in the published types were wider than the doors that judge the same bodies, so values those doors refuse type-checked: - -- `ApiError.code` (the INPUT type of `ApiErrorSchema`): FROM `unknown` TO `ErrorCode`, the vocabulary the schema parses against (`StandardErrorCode` and the registered ledger codes). `ErrorCode` was cast to `z.ZodType` with its output type only, and `z.ZodType`'s input type defaults to `unknown`, so `{ code: 42, message: 'x' }` compiled as an `ApiError` while the schema refuses it at `code`. The same `code` narrows in the `error` of every response envelope built on `BaseResponseSchema`, and in each `ApiError` row of a batch result. `makeApiErrorSchema(codes)` had the same cast for a caller-supplied vocabulary: its schema's input `code` is now the standard catalogue plus `codes`, where it was `unknown`. The parsed types (`ApiErrorParsed`, the `…Parsed` response types) do not move: their `code` was already typed. -- A flattened list overlay's legacy `options` bag: FROM a string-keyed record of `unknown` TO one optional entry per list kind that has a block (`calendar`, `chart`, `gallery`, `gantt`, `kanban`, `map`, `timeline`, `tree`), each entry that kind's own block with every key optional. This holds on the list overlay member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. `options: { foo: 1, kanban: 42 }` type-checked as all four while that member refuses both keys. - -**If your code stops compiling.** A value you annotated with one of these names is not the shape the door accepts: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. An error `code` is a member of `ErrorCode` (or, for a `makeApiErrorSchema` schema, of the standard catalogue plus the codes you supplied); a producer whose own code is outside the vocabulary reports it on `declaredCode`, not `code`. An `options` bag carries only the per-kind blocks listed above, each judged key by key like the top-level block of the same kind; `grid` has no block, and its settings are top-level keys of the view. - -The declared types of `ErrorCode` and of `makeApiErrorSchema`'s `code` narrow with them, so `z.input` of each is typed where it was `unknown`. The types are the schemas' declared shapes, not their verdicts: refinements are not types, so each schema remains the only judge. - - diff --git a/.changeset/19920-exported-types-not-unknown.md b/.changeset/19920-exported-types-not-unknown.md deleted file mode 100644 index e67716ee4d5..00000000000 --- a/.changeset/19920-exported-types-not-unknown.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `InlineAction`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed` name the shapes their TSDoc promises instead of being `unknown` (#19920) - -Clause-②: no (narrowing) - -**BREAKING for TypeScript code that annotates with `InlineAction`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema, no parse and no export changes, and neither does the declared type of any schema. - -Four published type aliases were derived from a schema whose own static type erases to `unknown`, so any value type-checked against them. Each is now derived from the member schema the parse actually runs: - -- `InlineAction`: FROM `z.input` (`unknown`, because the schema is a `z.preprocess` whose input is the preprocess function's `unknown` parameter) TO `z.input<(typeof InlineActionSchema)['out']>`, the input type of the picked action object. -- `ViewMetadataParsed`: FROM `z.infer` (`unknown`, because the union's members are cast to `z.ZodTypeAny` where it is built) TO the union of the OUTPUT types of `VIEW_METADATA_MEMBERS`, the same record `ViewMetadata` reads its input types from. `diagnoseViewMetadata` keeps returning the schema's own parse output as `data`; only that value's static type changes. -- `AssembledViewArtifact` / `AssembledViewArtifactParsed`: FROM `z.input` / `z.infer` of `AssembledViewArtifactSchema` (`unknown`, the same cast) TO the input / output union of the three non-container `VIEW_METADATA_MEMBERS`, the members that schema's union is mapped from. A container body is now a compile error here, as it always was at the schema. - -**If your code stops compiling.** A value you annotated with one of these names is not the shape the name describes: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. For `InlineAction`, the legacy `type: 'navigation'` and `to` spellings are refused by the type while `InlineActionSchema` still folds them onto `url` / `target`: write `type: 'url'` and `target`. - -The types are the members' declared shapes, not the schemas' verdicts. Each schema still accepts some bodies its type refuses (the preprocess folds and strips) and still refuses some bodies its type admits (refinements are not types), so the schema remains the only judge. - -`JoinedReportBlock` is not changed by this change. It stops resolving to `unknown` in its own entry (#19920). - - diff --git a/.changeset/19920-exported-types-remainder.md b/.changeset/19920-exported-types-remainder.md deleted file mode 100644 index f610973b9d6..00000000000 --- a/.changeset/19920-exported-types-remainder.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `JoinedReportBlock`, a ViewItem's `config`, a flattened overlay's `viewKind` and a flattened list overlay's `type` / `columns` carry the shapes their doors accept (#19920) - -Clause-②: yes (narrowing) - -**BREAKING for TypeScript code that annotates with `JoinedReportBlock`, `Report`, `ReportParsed`, `ViewItem`, `ViewItemWire`, `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`, or that passes an unchecked value to `defineReport` / `defineViewItem`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no value and no existing export changes. Three parsed-state type names are added (below); nothing is removed or renamed. - -Four places in the published types were wider than the doors that judge the same bodies, so values those doors refuse type-checked: - -- `JoinedReportBlock`: FROM `unknown` TO the input shape of `JoinedReportBlockSchema`. The schema was annotated `z.ZodTypeAny`, which erased its shape; it now carries its inferred type. The same erasure made every `blocks[]` element of `Report` / `ReportParsed` (and so of `defineReport`'s parameter) `unknown`; each is now a block. -- A ViewItem's `config`: FROM `unknown` TO the arm's own config type, a `ListView` config on the `list` arm and a `FormView` config on the `form` arm. This holds on `ViewItem`, `ViewItemWire`, `defineViewItem`'s parameter and return, and the `viewItem` member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. The arm builder took `config` as `z.ZodTypeAny`; it is now a generic parameter. -- A flattened overlay member's `viewKind`: FROM `'list' | 'form'` on both members TO `'list'` on the list overlay and `'form'` on the form overlay, the one value each member accepts. A list-shaped body naming `viewKind: 'form'` used to type-check, through the list overlay member, as `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. -- A flattened list overlay's `type` and `columns`: FROM `unknown` TO the list view's own types, both optional: `type` one of the list view types, `columns` a field list. This holds on the list overlay member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. The member read both keys off the list view shape through a cast that erased them, so `{ object, viewKind: 'list', columns: 42 }` type-checked as all four while that member refuses it. - -**If your code stops compiling.** A value you annotated with one of these names, or passed to `defineReport` / `defineViewItem`, is not the shape the door accepts: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. A ViewItem's `config` must match its `viewKind`: a `ListView` config under `viewKind: 'list'`, a `FormView` config under `viewKind: 'form'`. A flattened list overlay's `columns` is a field list and its `type` one of the list view types. - -The declared types of `JoinedReportBlockSchema`, `ViewItemSchema` and `ViewItemWireSchema` narrow with them, so `z.input` / `z.infer` of each is typed where it was `unknown` (or carried an `unknown` `config`). Typed, each schema's input and output now differ by its defaults, so three ADR-0122 parsed-state aliases are added beside the bare names: `JoinedReportBlockParsed`, `ViewItemParsed` and `ViewItemWireParsed`. Nothing is removed or renamed. - -One default is applied by the parse and is absent from `ViewMetadataParsed` / `AssembledViewArtifactParsed`, and their TSDoc now says so: the flattened list overlay member re-applies `type: 'grid'` in an `.overwrite()`, so every body it parses carries `type`, while its output type leaves `type` optional. - -The types are the members' declared shapes, not the schemas' verdicts: refinements are not types, so each schema remains the only judge. - - diff --git a/.changeset/19925-cli-non-array-packages-refusal.md b/.changeset/19925-cli-non-array-packages-refusal.md deleted file mode 100644 index bab947220c3..00000000000 --- a/.changeset/19925-cli-non-array-packages-refusal.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -fix(cli): `os info` and `os lint` refuse a stack whose `packages` is present but not an array, instead of reading it as no packages (#19925) - -Clause-②: no (narrowing) - -**BREAKING for `os info` and `os lint` on a hand-written stack.** A config whose -`packages` is present but is not an array (`{}`, `0`, `'x'`) is now refused with -`INVALID_ARTIFACT_PACKAGES` (ADR-0112, `status: 422`) and exit code 1. These -commands used to read it as a stack with no packages. `os info` exited 0 and -reported every package-owned collection as empty. `os lint` exited 0 with -`passed: true`. Only two spellings reach these commands with such a value: a -config exported as a plain object, and `defineStack(…, { strict: false })`. -The default `defineStack` parse, `os validate` and `os build` already refused -it. - -The accept set only shrinks back to what the declaration has always said. -`ObjectStackDefinitionSchema` declares `packages` as an array of package -entries. Ruling A on #15293 settled that a present non-array value is -malformed, not absent. The runtime, `@objectstack/core` and the plugin readers -already refused it. The CLI's stack-collection reader and its three -package-docs readers still answered "no packages". They now judge the value -through one helper, which hands a non-array to `resolveArtifactPackageOrder`. -So the refusal's code, status and sentence are core's own, and the CLI keeps no -second copy of the rule. - -**What is not affected.** An absent `packages` reads exactly as before, and so -does a `packages` array. A malformed entry inside an array is still refused as -`INVALID_ARTIFACT_PACKAGE_ENTRY`. `os serve` and `os dev` refused this stack -before the change, because the runtime's manifest service raises the same -refusal at boot, and they still do. - -**`packages: null` follows core's resolver.** Ruling `5805260775` on #19926 -makes `null` malformed at every reader, and the resolver's `null` refusal is -landing separately (#19926, PR #20228). The CLI readers do not answer `null` -themselves; they hand it to `resolveArtifactPackageOrder` and return what it -answers. Today that is its absent answer, so `null` still reads as no -packages. Once the resolver refuses `null`, these commands refuse it too, with -no change to the CLI. - -**If you are refused.** Omit `packages` for a single-package stack, or give it -an array of `{ manifest: … }` entries. The refusal says the same. - - diff --git a/.changeset/19926-packages-null-refused.md b/.changeset/19926-packages-null-refused.md deleted file mode 100644 index e5198808560..00000000000 --- a/.changeset/19926-packages-null-refused.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/runtime': minor -'@objectstack/plugin-dev': minor -'@objectstack/plugin-security': minor ---- - -fix(core,runtime,plugin-dev,plugin-security): a release artifact whose `packages` is `null` is refused as malformed, never read as absent (#19926) - -Clause-②: no (narrowing) - - - -**BREAKING** — an accept-set narrowing on a value the schema already refuses, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the level). - -`ObjectStackDefinitionSchema.packages` is `z.array(ArtifactPackageSchema).optional()`, and `.optional()` admits `undefined`, not `null`. The schema refused `packages: null` (`invalid_type`), and `composeStacks` refused it with two or more inputs (`STACK_SCHEMA_INVALID`, `status: 422`). The runtime readers below read it as absent instead: a single-package artifact whose own top level is the one package body. Those readers now follow the declaration. An absent `packages` is `undefined` and nothing else; `null` is one of the present, non-array values the rule beside `AssembledPackageBodySchema` calls malformed, like `{}`, `0` or `'x'`, and it is refused with the same envelope: `INVALID_ARTIFACT_PACKAGES`, `status: 422`. No error code is added. - -- **`@objectstack/core`**: `resolveArtifactPackageOrder` refuses `packages: null` where it returned `[artifact]`. The refusal message names the value `null`, not `object`. The resolver's callers that hand it the whole artifact raise the refusal: the kernel `manifest` service's `register()` (`ObjectQLPlugin`) and `@objectstack/verify`'s collection reader for a collection the stack's top level does not carry. -- **`@objectstack/runtime`**: `resolveArtifactCollections` drops `null` from its absent branch, so `AppPlugin`, `createStandaloneStack`, `loadArtifactBundle`'s runtime-module merge and `resolveProjectDatabaseUrl` answer a `packages: null` artifact exactly as they already answer `packages: {}`. `carriedPackageIds`, and `resolveArtifactGrantBinding` for an artifact whose `grantedPermissions` is a record, read the package list through the core resolver and raise its refusal too. -- **`@objectstack/plugin-dev`**: the i18n detector's private absent guard moves in lockstep with the resolver's absent branch, so `devI18nPluginOptions` reaches the resolver and raises its refusal when the `i18n` config (on the stack or its `manifest`), a non-empty `manifest.translations` and a non-empty top-level `translations` do not answer first. `DevPlugin` keeps its posture: it reports the metadata defect on its `error` line and boots on the in-memory i18n fallback. -- **`@objectstack/plugin-security`**: `appSecurityPluginOptions` has no guard of its own and raises the resolver's refusal for `packages: null`. -- **What does not change**: the schema; an absent `packages` (no key, or an explicit `undefined`), which still returns the caller's own object by identity; a well-formed `packages[]`; and `composeStacks` with a single input, which still returns that input by identity. - -No in-repo producer writes `packages: null`, and `os build` and `os validate` refuse it at the schema before any reader runs. For a single-package artifact, leave the `packages` key out. diff --git a/.changeset/19927-readonlywhen-exact-drop-set.md b/.changeset/19927-readonlywhen-exact-drop-set.md deleted file mode 100644 index ccfce14c40d..00000000000 --- a/.changeset/19927-readonlywhen-exact-drop-set.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a chain of `readonlyWhen` locks no longer ignores an edit whose own lock is FALSE on the row the update stores (#19927) - -**What happened before.** When `readonlyWhen` locks read each other in a chain, -an update could ignore an edit whose own lock was FALSE on the row it stored. -With `c: { readonlyWhen: "previous.c == 'L'" }`, `x: { readonlyWhen: "record.c -== 'open'" }` and `y: { readonlyWhen: "record.x == 'xv'" }`, `update(r, { c: -'open', x: 'xv', y: 'yv' })` on a row with `c: 'L'` ignored all three fields. -The row keeps `c: 'L'`, so `x`'s lock is FALSE there, yet its edit was lost -with no refusal, and a `strictReadonlyWrites` refusal named `x` as read-only. - -**What happens now.** That update stores `x: 'xv'` and ignores `c` and `y`: -`c` is locked, and `y`'s lock reads the `x` the row now holds. This holds by -id and on bulk (`multi: true`) updates, and for `isSystem` callers. - -**What else you may see move:** - -- `onFieldsDropped` reports only the fields the update ignored (`['c', 'y']` - above), and a `strictReadonlyWrites` refusal names only those. Whether a - write is refused under that option does not change. -- An edit that used to be stored can now be ignored. When a field that used - to be ignored now lands, a lock that reads it can be TRUE on the row the - update stores, and that lock's field is then ignored instead of written. - With `p` locked by `previous.p == 'L'`, `m` by `record.p == 'L'`, `j` by - `record.m == 'new'` and `k` by `record.p == 'L' && record.j == 'new'`, - `update(r, { p: 'new', m: 'new', j: 'new', k: 'new' })` on a row `{ p: 'L', - m: 'old', j: 'old', k: 'old' }` used to ignore `j` and store `k: 'new'`; it - now stores `j: 'new'` and ignores `k`, whose lock reads that `j`. A - `strictReadonlyWrites` refusal of that update now names `k` instead of `j`. -- A master-detail repoint that such a chain used to hold can now land. With - `c` locked by `previous.c == 'L'`, the master-detail `invoice` by `record.c == - 'open'`, `y` by `record.invoice == 'h_open'` and `amt` by `parent.status == - 'paid'`, an update setting all four on a line with `c: 'L'` under a paid - invoice used to keep the line there, store `y` and ignore `amt`. It now moves - the line to `h_open`, stores `amt` (unlocked under the open invoice) and - ignores `y`, by id and on bulk updates. A `strictReadonlyWrites` refusal of - that update now names `c` and `y` instead of `c`, `invoice` and `amt`. diff --git a/.changeset/19929-readonlywhen-cycle-isolation.md b/.changeset/19929-readonlywhen-cycle-isolation.md deleted file mode 100644 index 63fb762a12d..00000000000 --- a/.changeset/19929-readonlywhen-cycle-isolation.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a `readonlyWhen` cycle no longer makes an update ignore edits to fields outside it (#19929) - -**What happened before.** When an update wrote fields whose `readonlyWhen` -locks read each other in a cycle whose drops did not settle, it gave up -settling the drops for the whole update and kept the first, larger set of -drops for every field. It could then ignore an edit to a field in no cycle -whose own lock was FALSE on the row it stored. With `c: { readonlyWhen: -"previous.c == 'L'" }`, `x: { readonlyWhen: "record.c == 'open'" }`, `y: { -readonlyWhen: "record.x == 'xv'" }`, `a: { readonlyWhen: "record.b == 'x'" }` -and `b: { readonlyWhen: "record.a == 'old_a'" }`, `update(r, { c: 'open', x: -'xv', y: 'yv', a: 'new_a', b: 'x' })` on a row `{ c: 'L', a: 'old_a', b: 'y' }` -ignored all five fields. The row keeps `c: 'L'`, so `x`'s lock is FALSE there, -yet its edit was lost, and a `strictReadonlyWrites` refusal named `x`. The same -happened beside a cycle that two sets of drops agree with, such as `a: { -readonlyWhen: "record.b == 'new_b'" }` and `b: { readonlyWhen: "record.a == -'new_a'" }` written with `a: 'new_a', b: 'new_b'` on a row holding neither new -value. - -**What happens now.** A lock is judged after the locks whose fields it reads, -locks that read each other in a cycle are judged together, and the fallback to -the first, larger set of drops applies only to the fields of the cycle whose -drops do not settle. The update above stores `x: 'xv'` and ignores `c`, `y`, -`a` and `b`. A field whose lock is in no such cycle is ignored exactly when its -lock is TRUE on the row the update stores (on a bulk update, on at least one -matched row); a `parent`-scoped lock counts as reading the master-detail field, -which picks the header. This holds by id and on bulk (`multi: true`) updates, -and for `isSystem` callers. It holds for a master-detail field's own lock too: -with `c` locked by `previous.c == 'L'`, the master-detail `invoice` by -`record.c == 'open'`, `y` by `record.invoice == 'h_open'` and `amt` by -`parent.status == 'paid'`, an update setting all four and `a`/`b` above, on a -line with `c: 'L'` under a paid invoice, used to keep the line there, store `y` -and ignore the other five fields; it now moves the line to `h_open`, stores -`amt`, and ignores `c`, `y`, `a` and `b`. - -**What else you may see move:** - -- A lock that reads a cycle's field is judged against the value the cycle's - drops leave on the row. With `z: { readonlyWhen: "record.a == 'new_a'" }` - beside the cycle `a`/`b` above, `update(r, { a: 'new_a', b: 'x', z: 'zv' })` - used to ignore `z` too; it now stores `z: 'zv'`, because the row keeps `a: - 'old_a'`. A lock reading `record.a == 'old_a'` there is still ignored. -- A cycle that more than one set of drops agrees with can now reach a - different one of those sets than before, or keep its first, larger set of - drops where it used to reach one. Which it reaches now depends only on the - cycle's own locks and the fields they read, never on the other locks in the - update. -- A lock whose predicate reads `record` other than as `record.` (for - example `record['b']` or `size(record)`) counts as reading every field the - update writes. -- `onFieldsDropped` reports only the fields the update ignored, and a - `strictReadonlyWrites` refusal names only those. Whether a write is refused - under that option does not change. diff --git a/.changeset/19930-eager-first-import-cycle.md b/.changeset/19930-eager-first-import-cycle.md deleted file mode 100644 index 27539c5185f..00000000000 --- a/.changeset/19930-eager-first-import-cycle.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the `OS_EAGER_SCHEMAS=1` rollback no longer crashes the `@objectstack/spec/api` and `/data` entries at import - -`OS_EAGER_SCHEMAS=1` is the documented emergency rollback of the lazy-schema -memory optimization. With it set, a process whose first spec import was -`@objectstack/spec/api` or `@objectstack/spec/data` threw at import: the bundles -failed with `Cannot read properties of undefined (reading 'optional')`, and the -source with `Cannot access 'FilterConditionSchema' before initialization`. The -default lazy path was not affected. - -The cause was an import cycle. `shared/suggestions.zod` held a value import of -`FieldType` from `data/field.zod`, only to feed `suggestFieldType`, and -`shared/strict-object` (which nearly every closed schema imports) imports -`suggestions.zod`. So `data/filter.zod` pulled `field.zod` in before it had -finished loading, and `field.zod`'s eagerly built `FieldSchema` read -`FilterConditionSchema` too early. - -`suggestFieldType` now lives in its own module, and `suggestions.zod` imports no -schema module. **Nothing an author or a consumer writes changes:** -`suggestFieldType` is still exported from `@objectstack/spec` and -`@objectstack/spec/shared` with the same signature and the same answers, and no -schema accepts or rejects anything differently. Every published entry now -imports cleanly as the first spec import under the flag, and a test pins that -for each subpath in the `exports` map. diff --git a/.changeset/19938-fields-value-slot-cel-envelope.md b/.changeset/19938-fields-value-slot-cel-envelope.md deleted file mode 100644 index d9c538951fd..00000000000 --- a/.changeset/19938-fields-value-slot-cel-envelope.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-automation": minor -"@objectstack/lint": minor ---- - -`create_record` / `update_record` field values accept the CEL value envelope, declared and evaluated together. - -A value in a `create_record` or `update_record` node's `fields` map may now be a CEL value envelope, `{ dialect: 'cel', source: '…' }`, with the same shape and dialect rules the `assignment` node's `assignments` map already has. The envelope is evaluated by the expression engine that flow conditions use, so the whole CEL stdlib is reachable from a field value, and the result is written with its type kept: - -```ts -fields: { - subject: 'Quote for {account.name}', // `{token}` template — unchanged - total: { dialect: 'cel', source: 'round(amount * 100.0) / 100.0' }, // CEL, evaluated to the value written -} -``` - -Clause-②: yes (widening) — a published authoring slot's accept set grows (a valid envelope in `fields.*` is newly evaluated), and the one newly refused shape is the edge the `assignments` map accepted when it gained the envelope: a malformed one. - -**What newly passes.** A valid CEL value envelope as a top-level `fields` value, on both nodes. Before this release the executor wrote such an object into the record verbatim: a text or JSON column stored `{"dialect":"cel","source":"…"}` and the run reported success, and a number column was refused by the data engine. - -**What newly refuses.** A top-level `fields` value that is a plain object with a string `dialect` key and is NOT a valid CEL value envelope. That covers a missing, empty or whitespace-only `source`, an `ast` with no `source`, a `template` or `cron` dialect, and a `source` that does not parse as CEL. Every door refuses it, located at `config.fields.`: `AutomationEngine.registerFlow` refuses the flow, `objectstack validate` reports an `expression-invalid` error, the runtime publish gate answers `422 INVALID_METADATA`, and the node's execute-time contract parse refuses it. Such an object used to be written as data. - -**The rule for nested and literal values.** Only the top-level value of each field is judged. An object nested inside a JSON value or an array is data, whatever keys it carries, and strings inside it still interpolate. A plain string is always a `{token}` template with its existing meaning, and every other literal is written as before. A JSON column whose intended literal value is itself an object with a string `dialect` key is now read as an envelope. To write such an object as data, bind it to a flow variable and write `'{thatVariable}'` (a sole token keeps its type). Measured: no flow in this repository or in HotCRM writes an envelope-shaped object into `fields`. - -**The refusal sentence is slot-neutral.** A refused field value used to be told it was "an assignment value". The sentence every value-slot refusal leads with is now `VALUE_ENVELOPE_REFUSAL`: "A value carrying a `dialect` key is read as an expression envelope, and this one is not a valid CEL value envelope." The published `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` is kept and is the same string, so code that matches on the constant keeps matching. Code that matched the old literal text ("An assignment value carrying…") does not. - -**New in `@objectstack/spec/automation`** (5 exports, 0 removed): - -- `VALUE_ENVELOPE_REFUSAL`, the slot-neutral refusal sentence. -- `FlowValueSlotSchema` / `FlowValueSlot` / `FlowValueSlotParsed`, the value contract every value slot shares (`AssignmentValueSchema` is the same rule under the assignment map's description). -- `resolveFlowNodeValueSlots(nodeType, config)`, which returns every authored value in the ledger's value slots, strings included. -- The expression ledger `FLOW_NODE_EXPRESSION_PATHS` has two new rows, `create_record.fields.*` and `update_record.fields.*` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries both CRUD contracts. - -**Author-time hint (`@objectstack/lint`).** `objectstack validate` warns when a value slot holds a `{…}` template expression, meaning arithmetic or a call to `round` / `floor` / `ceil` / `abs` / `min` / `max`, and points it at the envelope. The warning never fails a build, and the template form keeps working unchanged. Plain references, the `NOW()` / `TODAY()` macros and `$User` paths are not hinted. CEL's `now()` / `today()` are timestamps rather than the strings those macros write, and the flow's CEL scope binds no user. - -**Corrected guidance: `/ 100.0`, not `/ 100`.** The template dialect's `round()` arity refusal used to call `round(x * 100) / 100` the CEL authoring pattern. In CEL that expression truncates: `round()` returns an int, and int / int is integer division, so `x = 1234.5678` gives `1234` instead of `1234.57`. The refusal now prescribes `round(x * 100) / 100.0`, which is correct in both dialects. In the template dialect `/ 100` and `/ 100.0` give the same value. diff --git a/.changeset/19949-mongodb-field-reference-refused.md b/.changeset/19949-mongodb-field-reference-refused.md deleted file mode 100644 index 6bc86f985bb..00000000000 --- a/.changeset/19949-mongodb-field-reference-refused.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/driver-mongodb": minor ---- - -fix(driver-mongodb)!: `translateFilter` refuses a `{ $field }` cross-field reference instead of sending it to MongoDB as a literal value (#19949) - -Clause-②: no (narrowing) - -**BREAKING**: an accept-set narrowing, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. **A filter that answered before is now refused**: any filter carrying a `{ $field }` reference gets `INVALID_FILTER` / 400 from `translateFilter`, and so from every driver door that reads a `where` (`find`, `findOne`, `count`, `updateMany`, `deleteMany`, `aggregate`, `explain`). - -**Security fix for RLS reads on MongoDB.** `compileCelToFilter` lowers a field-to-field comparison such as `record.s != record.t` to `{ s: { $ne: { $field: 't' } } }`. This driver has no lowering for a column-to-column comparison, and `translateFilter` sent the reference to the server as a literal sub-document. The RLS `using` clause is composed into the read after the engine's comparand checks, so nothing stopped it on the way. Measured over the rows `{ s: 'a', t: 'a' }` and `{ s: 'a', t: 'b' }`, with mingo 7.2.4 as the proxy for MongoDB's query semantics: - -- `$ne` against the reference selected both rows, including the one where `s` equals `t`, and `$eq` selected none; -- `$nin: [ref]`, `$notContains: ref`, `$ne` against a reference carrying `addDays`, and `$eq` under `$not` also selected both rows; -- through `ObjectQL`, `SecurityPlugin` with a `rowLevelSecurity` policy `using: 's != t'`, and `MongoDBDriver` over a mingo-backed collection, `find` returned both rows, `count` returned `2`, and `findOne` returned the `s == t` row. The policy's read restriction was lost. - -A live `mongod` was not measured, because this fleet cannot fetch the binary. - -**What changes.** The driver's filter walk refuses a `{ $field }` reference in every comparand position, at any depth under `$and` / `$or` / `$not`: - -- the whole comparand of any operator: the orderings, `$eq` / `$ne`, the string operators, `$null`, `$exists`; -- a member of a list: `$in` / `$nin` members, either `$between` endpoint, an array given to `$ne` or `$eq`; -- the implicit-equality position: the bare `{ field: { $field: … } }` form, or a list holding a reference; -- a reference carrying `addDays`, and a malformed reference whose `$field` is not a string. - -The refusal uses the same envelope as the driver's other filter refusals. Its message names the unsupported feature (field-to-field comparison) and withholds the fields, the operator and the position, because the filter may be an access policy the caller did not write. Through the engine, an RLS read carrying such a policy is now refused with that 400, and the server is never asked. Before, it returned the unfiltered rows. A policy written `s == t`, which used to return no rows, is refused the same way. - -**What does not change.** - -- Every literal comparand translates to the same document as before, including literal `$in` / `$nin` / `$between` lists and `$not` around a literal. -- `$ne` with an array of literal values keeps its own refusal. An array with no reference in the equality slot still passes through `translateFilter`: the shared comparand-shape face owns that slot. -- Field-to-field comparison is not implemented on this driver. It is refused, not lowered to a MongoDB `$expr`. `driver-sql` and the in-memory evaluator are not touched. - -**What an affected author does.** On a MongoDB datasource, a row-level policy or filter can compare a field only against a literal value or a `current_user` value, not against another field of the same record. A policy that needs a field-to-field comparison cannot be enforced by this driver. Before this change it returned every row. - - diff --git a/.changeset/19950-rls-check-multi-row-writes.md b/.changeset/19950-rls-check-multi-row-writes.md deleted file mode 100644 index 4f3fdb5bbfe..00000000000 --- a/.changeset/19950-rls-check-multi-row-writes.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/objectql": minor ---- - -fix(plugin-security, objectql)!: a row-level `check` now holds for every row a multi-row write stores — an array insert and a predicate (`multi: true`) update (#19950, #19964) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows the set of writes the write gate accepts. A multi-row write that is admitted today can be refused after this change. It ships as `minor` under the launch-window convention, as the using-defaulted check did (#19942). - -A row-level security `check` (declared on the policy, or defaulted from its `using`) is the write-side half of the policy: a row the check refuses is never stored. The write gate enforced it for a single-row insert and a by-id update, but not for the two multi-row write shapes. An **array insert** (`insert(object, [rows])`, which the create-many data route calls) installed no check, so its rows were stored unjudged. A **predicate update** (`update(object, changes, { where, multi: true })`) never judged its new rows. The gate assumed a `using`-scoped `where` covered the write, but a policy that declares only `check` scopes nothing, and a scoped `where` says nothing about the new row in any case. - -Both shapes are now judged row by row. An array insert judges each row on the image the `beforeInsert` chain produced. A predicate update judges each row the write selects on its new image: the matched row merged with the final payload. The engine (`@objectstack/objectql`) supplies those rows through the seam the insert check already uses (`OperationContext.postHookWriteImageCheck`). It runs the judgement on the predicate path over the rows its composed query selects, reusing the matched-row read that path already makes. - -**Writes that are now refused.** Each refusal is the existing row-level CHECK denial, `403 PERMISSION_DENIED`, and nothing is stored. One failing row refuses the whole write. There is no transition switch. - -- **A predicate update under a policy that declares `check`**, when any matched row's new image fails that check, including when the policy has no `using` at all. -- **A predicate update that moves a matched row out of a policy's `using`**, when no applicable policy declares `check`. The `using` is the defaulted check; a by-id update already gives this answer. -- **An array insert** when any row fails the check. This includes every configuration that already refused each single insert, such as a `using` or `check` that does not compile. -- **A predicate update on a host that installs the judgement and never runs it**, for example a custom write executor in place of the engine. It is refused as an insert already is, with an `error` log saying the check was not evaluated. - -**Remedy.** To let a write store a row outside a policy's scope, declare a `check` on that policy that admits it; otherwise fix the data the write carries. - -**What does not change.** - -- A single-row insert is judged exactly as before. A by-id update is not changed by this entry; its judgement on the row it stores is its own entry (#19989). -- A predicate update still touches only the rows its scoped `where` selects. The check refuses a write; it never changes which rows are selected. -- A predicate update or array insert whose rows all pass is admitted as before. -- A system-context write is not gated. diff --git a/.changeset/19951-rls-lint-per-request-refusals.md b/.changeset/19951-rls-lint-per-request-refusals.md deleted file mode 100644 index 759b8ca9b3e..00000000000 --- a/.changeset/19951-rls-lint-per-request-refusals.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -`rls-predicate-unenforceable` now reports the RLS predicates the runtime refuses on every request, which the shape check cannot see (#19951). - -`validateRlsPredicateEnforceability` judged a predicate's shape with every `current_user` value replaced by a placeholder, so a predicate whose refusal depends on a value passed `os validate` cleanly and enforced nothing. Its reference pass now reports two such classes as `error`, so **a previously green `os validate` can fail** on a stack that declares one of these predicates. Measured over this repository, 0 of the 126 authored `using` / `check` predicates (77 of them `current_user` predicates) change verdict. - -**A `current_user` value of the wrong type for its position.** The kernel resolves `positions`, `org_user_ids` and `accessible_org_ids` as lists and `id`, `organization_id` and `email` as one value on every request, and `current_user` alone is the whole caller context. The compiler refuses a mismatch on every request, and the RLS compiler drops the policy: reads return no rows and `check` writes are refused 403. - -- FROM silent TO `rls-predicate-unenforceable`: `record.f != current_user.org_user_ids` (any list key). Write `!(record.f in current_user.org_user_ids)`. -- FROM silent TO `rls-predicate-unenforceable`: `record.f == current_user.positions` and `!(record.f == current_user.positions)`. Write `record.f in current_user.positions`, keeping any enclosing `!(...)`. -- FROM silent TO `rls-predicate-unenforceable`: `record.f in current_user.id` (any one-value key). Write `record.f == current_user.id`. -- FROM silent TO `rls-predicate-unenforceable`: `record.f in current_user`, `record.f.startsWith(current_user)`, `.endsWith(current_user)` and `.contains(current_user)`. Name the key that holds the value, for example `record.f in current_user.org_user_ids` or `record.f.startsWith(current_user.email)`. -- FROM silent TO `rls-predicate-unenforceable`: `record.f.startsWith(current_user.org_user_ids)` (a list handed to a string method). Write `record.f in current_user.org_user_ids`, or pass a one-value key. - -**A `null` list member or `null` ordering bound.** The platform refuses both in every filter it is sent. The RLS compiler runs that check on its own filter too (#20212) and drops the policy on every request: reads return no rows and `check` writes are refused 403. - -- FROM silent TO `rls-predicate-unenforceable`: `record.f in ['a', null]` and `!(record.f in ['a', null])`. Write `(record.f in ['a'] || record.f == null)`, or drop the `null` member. -- FROM silent TO `rls-predicate-unenforceable`: `record.f in [null]`. Write `record.f == null`. -- FROM silent TO `rls-predicate-unenforceable`: `record.f > null`, `>= null`, `< null` and `<= null`. Write `record.f != null` or `record.f == null`, or compare against a real bound. - -Each finding's hint carries the rewrite with the predicate's own field and key. - -**Unchanged.** A comparison whose answer depends on which caller asks, such as `current_user.email == 'ops@acme.com'`, stays silent: it grants everything to the caller it names and is refused for everyone else, so no probe value can stand for it. The list literal (`record.f != ['a', 'b']`) and the bare root under `==` / `!=` (`record.f != current_user`) were already reported. `field == current_user.id`, `field in current_user.org_user_ids` and `field == null` stay clean. diff --git a/.changeset/19953-rls-check-default-composition-text.md b/.changeset/19953-rls-check-default-composition-text.md deleted file mode 100644 index 982c5e6cee6..00000000000 --- a/.changeset/19953-rls-check-default-composition-text.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/lint": patch ---- - -fix(spec, lint): the RLS `check` → `using` default is stated per operation across the applicable policies, as the runtime applies it, not per policy (#19953) - -Clause-②: no - -Text only. No schema shape, accepted value or runtime behaviour changes. - -`RowLevelSecurityPolicySchema.check` said the clause "defaults to USING clause if not specified", which reads as a rule for each policy on its own. The write gate decides the default once per write operation, across every applicable policy: - -- When any applicable policy for the operation declares `check`, only the declared checks decide, OR-combined. A policy with only a `using` beside them adds nothing to the check. -- Only when none declares `check` does each applicable policy's `using` stand in as its check, OR-combined. - -A policy is applicable when it is not `enabled: false`, its `object` is the written object or `'*'`, its `operation` is the write's own or `'all'`, and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated. - -When this text change was written, the check ran on the new row of a single-record insert and of a by-id update only, and the texts said so. The same release extends it: every row of an array insert and of a `multi: true` update is judged (#19964, #19950), and a by-id update is also judged on the row its `beforeUpdate` hooks leave (#19989). The texts now state that every row an insert or an update writes is judged (#19967). - -- **`@objectstack/spec`**: the `check` describe and TSDoc state this composition and that scope. The `rowLevelSecurity[].priority` refusal no longer gives "applicable policies OR-combine (most permissive wins)" as its reason, which is not true of the write check, and the file overview limits "OR-combine" to reads. The generated reference pages (`references/security/rls`, `references/security/permission`) are regenerated from the describe. -- **`@objectstack/lint`**: the `rls-predicate-*` findings on a `using` now also say what the dropped `using` does to an insert. On an `insert` or `all` policy, when no applicable policy for the insert declares a `check`, that `using` is also the single-record insert check. If nothing else in that set compiles, every single-record insert the policy governs is refused with `PermissionDeniedError`. The findings on a `check` now say the refusal is a blanket one only when no other applicable policy declares a `check` that compiles. diff --git a/.changeset/19955-view-repeater-row-i18n.md b/.changeset/19955-view-repeater-row-i18n.md deleted file mode 100644 index 9877a5ef4aa..00000000000 --- a/.changeset/19955-view-repeater-row-i18n.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/platform-objects': patch ---- - -Studio's view property panel names the columns of its Columns, Sort and Tabs tables in the author's language, not only in English - -Clause-②: no - -`view.form.ts` now enumerates the row properties of the `columns`, `sort` and `tabs` repeaters, each with a `label` equal to its item schema's own `.meta({ title })`, so `os i18n extract` emits a `metadataForms.view.fields` key for each row property. The `en`, `zh-CN`, `ja-JP` and `es-ES` platform catalogs carry those keys. The translated catalogs reuse the word they already use for the same concept where they have one (`Label` → 显示名称 / 表示名 / Etiqueta, `Direction` → 排序方向 / 並び方向 / Dirección). - -The row children declare no `type`, so each row input's widget is still derived from the schema. The view schema itself is unchanged. diff --git a/.changeset/19959-cel-variable-root-comparand-refused.md b/.changeset/19959-cel-variable-root-comparand-refused.md deleted file mode 100644 index 2f35b3f8f71..00000000000 --- a/.changeset/19959-cel-variable-root-comparand-refused.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/formula": minor -"@objectstack/plugin-security": minor -"@objectstack/plugin-sharing": minor -"@objectstack/lint": minor -"@objectstack/spec": patch ---- - -A row-level or sharing-rule predicate comparing with `!=` / `==` against the bare `current_user` root is refused at the CEL lowering instead of lowering against the whole caller context object (#19959). - -**BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-variable-root-comparand-refused`. - -Clause-②: no (narrowing) - -**Security fix for RLS write checks.** A policy written `record.owner_id != current_user` (or `== current_user`, or `!(record.owner_id == current_user)`) named the variable root alone, which resolved to the whole caller context, and lowered to `{ owner_id: { $ne: } }` (or the bare object, or `$not` around it). A strict compare never equals an object, so a `check` so written admitted and stored every insert and by-id update it was written to refuse, a USING-only such policy admitted every insert, and explain reported the read as narrowed with the caller's membership sets echoed in its `readFilter`. A constant comparison such as `current_user != 'guest'` folded to no restriction. - -- `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose operand is the bare variable root (`unsupported`), in both of its modes, so the authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports it before any request. A variable that resolves to an object is refused per request; a `Date` still passes. -- `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, explain answers `denies`). -- `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is still skipped at bootstrap, now with reason `unsupported` instead of `unresolved-variable`. -- `@objectstack/lint`: the shape is reported as `rls-predicate-unenforceable` on either RLS clause, where it was silent, and as `sharing-rule-unlowerable-condition` on a sharing condition, where it was `sharing-rule-runtime-variable-condition`. -- `@objectstack/spec`: the migration registry carries the entry. - -**What to change.** Compare against the key the predicate means: `record.owner_id != current_user` becomes `record.owner_id != current_user.id` (or `current_user.organization_id`, `current_user.email`); a membership test is `record.owner_id in current_user.org_user_ids`. Scalar keys, `in`, `null`, literals and field-to-field comparisons are unchanged. - - diff --git a/.changeset/19961-decision-branch-expression-absent-refused.md b/.changeset/19961-decision-branch-expression-absent-refused.md deleted file mode 100644 index 979bb2e788a..00000000000 --- a/.changeset/19961-decision-branch-expression-absent-refused.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': minor ---- - -fix(spec)!: a `decision` branch with no `expression` — the key absent, or `null` — is refused at authoring (#19961) - -Clause-②: no (narrowing) - - - -**BREAKING** — an accept-set narrowing on one authored flow-node slot, shipped as -`minor` under the launch-window convention (`check-changeset-no-major` refuses -`major` until GA; breaking-ness is carried by this banner and the ADR-0087 -disposition above, not by the level). - -**What changed.** `DecisionConditionSchema` declares a branch `{ label, expression }` -with `expression` a required `z.string()`. Nothing enforced that: a decision node's -`config` is an open record no schema is parsed against, and the expression ledger's -resolver skipped an absent value as "not authored". So `conditions: [{ label: 'y' }]` -passed `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate`, -and the run then failed at that branch — the executor evaluates every branch it -reaches, and a branch with no `expression` is a condition with no `source`, which -`evaluateCondition` refuses. The ledger now marks the slot `required` (reconciled -against the schema's own `required` list), and the branch is refused at all three -doors through the walk and the function that already refuse a blank one — by -`FlowSchema.parse` with a `custom` issue anchored at the slot (for example -`nodes.1.config.conditions.0.expression`), by `registerFlow` and `objectstack validate` -through that same parse, and by `validateStackExpressions` for a stack handed to it -directly — with one message, led by the published `PREDICATE_SLOT_STRING_REFUSAL` -sentence. `expression: null` is refused the same way, and so is a branch that wrote -its predicate under `condition` (the edge's spelling), which has no `expression` -either. The Studio flow designer writes the refused shape when a branch row's -expression cell is left empty. Where such a branch already sits, the whole flow is -refused: registered from the metadata -registry or `sys_metadata` at boot, it is skipped with a `failed to register flow` -warn naming it while the flows beside it register; a `defineStack({ flows })` source -throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused -whole at load. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `conditions: [{ label: 'high' }]` on a `decision` node | the predicate you meant — `{ label: 'high', expression: 'record.amount > 10000' }` | -| `conditions: [{ label: 'high', condition: 'record.amount > 10000' }]` | the same predicate under `expression` | -| `conditions: [{ label: 'high', expression: null }]` | the predicate you meant, or `expression: 'false'` to keep the branch and never take it | - -**One-line fix:** write the predicate under `expression`. `expression: 'false'` keeps -the branch and its label and never takes it — a change of behaviour, not a preserved -one: a run that reached the branch used to FAIL there, and now routes on to the next -branch or the declared fallback. ⚠️ Do not drop a decision's only branch: the node -then routes by its out-edges alone, and the out-edge that branch labelled is no -longer held back. - -**Unchanged.** A branch carrying a non-blank predicate parses, registers and -validates as before; a blank one keeps its refusal and its own prescription -(`flow-predicate-slot-blank-string-refused`); a `decision` with no `conditions`, or -an empty list, still routes by its out-edges; an absent screen field `visibleWhen` -is still legal (that slot is not required); and `PREDICATE_SLOT_STRING_REFUSAL` -keeps its name and its text. diff --git a/.changeset/19963-explain-write-verdict-inputs.md b/.changeset/19963-explain-write-verdict-inputs.md deleted file mode 100644 index 758e34f3d9b..00000000000 --- a/.changeset/19963-explain-write-verdict-inputs.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security): `security/explain` computes a record's `update` / `delete` verdict from the by-id write path's own inputs, so `record.visible` matches what the by-id PATCH / DELETE does (#19963) - -Clause-②: no - -`POST /api/v1/security/explain` with `{ object, operation: 'update' | 'delete', recordId }` answered `decision.record.visible: false` (`decidedBy: 'rls'` or `'sharing'`) on rows that the by-id `PATCH` / `DELETE /api/v1/data/{object}/{id}` then admitted for the same caller. It happened on every object whose OWD is private (set explicitly, or left unset). A console that gates Edit on `record.visible` hid Edit and inline edit from users who were allowed to edit. Two inputs differed from the write path: - -- **The platform ownership floor.** The by-id write gate drops `owner_only_writes` / `owner_only_deletes` (`created_by == current_user.id`) when the sharing service answers `allow` for the row. Explain kept the floor, so it excluded every row the caller did not create. That covers a row shared to them with `edit` access, a row they own but did not create, and a row an `org`-depth writer may edit. -- **The write depth.** The write path hands the sharing service's per-record gate (`canEdit` / `canDelete`) the caller's effective write depth. Explain asked the same gate without it, so a caller with `org` or unit write depth was judged owner-only. - -Explain now asks the same floor decision the write gate asks. It also passes the same write depth to the per-record gate. - -Unchanged: - -- Enforcement: the by-id write gate admits and refuses exactly what it did before. Its floor decision moved into one method that both paths call. -- Reads (`operation: 'read'`) and object-level explanations (no `recordId`). -- A caller acting on behalf of another user (`onBehalfOf`): its record-level write explanation uses the same inputs as before. -- Objects whose OWD is `public_read_write` already matched and still do. diff --git a/.changeset/19965-rls-check-on-select-or-delete-refused.md b/.changeset/19965-rls-check-on-select-or-delete-refused.md deleted file mode 100644 index 31be583e94e..00000000000 --- a/.changeset/19965-rls-check-on-select-or-delete-refused.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING for authored metadata** — a row-level security policy (`RowLevelSecurityPolicySchema`, authored as `rowLevelSecurity[]` on a permission set) whose `operation` is `select` or `delete` may no longer declare a `check`. It is refused at parse, at the `check` path, with a message that names the operation and says what to write instead (#19965). - -Clause-②: no - -## What changed, and why - -`check` judges the post-image of a write: the new row of an insert, the changed row of an update. A `select` or `delete` writes no row, and the runtime's write gate only ever collects the policies whose `operation` is the write's own or `all`. So a `check` on a `select` or `delete` policy was accepted, stored and **never evaluated**. It did not guard any write, and beside a USING-only sibling it did not replace that sibling's `using` default the way a `check` on an `insert`, `update` or `all` policy does. An author who wrote a `check` on a `delete` policy believed deletes were guarded by it (ADR-0049: declared is not enforced). - -``` -FROM RowLevelSecurityPolicySchema.safeParse({ - name: 'no_archived', object: 'account', operation: 'delete', - using: 'owner_id == current_user.id', check: "status != 'archived'" }) - -> { success: true } // the check never ran - -TO -> { success: false, - issues: [{ code: 'custom', path: ['check'], - message: '`check` is never evaluated on a `delete` policy: it validates the new - row an insert or an update writes, and a delete writes none. Remove - `check` from this policy. …' }] } -``` - -Through a permission set the issue lands at `rowLevelSecurity[N].check`; through `defineStack` it is part of the `STACK_SCHEMA_INVALID` refusal (422). - -## Migration — FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `operation: 'select'` with a `check` meant to limit which rows can be read | that predicate as the policy's `using` (AND it into an existing `using` with `&&`), and remove `check` | -| `operation: 'delete'` with a `check` meant to limit which rows can be deleted | that predicate as the policy's `using` (AND it into an existing `using` with `&&`), and remove `check` | -| `operation: 'select'` or `'delete'` with a `check` meant to validate written rows | the `check` on a policy whose `operation` is `insert`, `update` or `all` | - -**The one-line fix: remove `check` from every `select` / `delete` policy, and write its predicate as that policy's `using` or on an `insert` / `update` / `all` policy, depending on what it was meant to guard.** This cannot be converted automatically: dropping the key would discard the predicate, and moving it would change which rows the policy admits. So it ships as an ADR-0087 D3 structured TODO with **no D2 conversion**. - - - -## Stored permission sets - -Stored rows are not rewritten. A permission set already stored with a `check` on a `select` or `delete` policy is refused the next time it is parsed through `@objectstack/spec`, for example on its next save. Removing the `check` changes nothing at runtime, because it never ran. Moving its predicate into `using` or onto a write policy does change behaviour, so re-check the policy set afterwards. - -## What does NOT change - -- A `check` on an `insert`, `update` or `all` policy parses and is enforced exactly as before. -- A `select` or `delete` policy with `using` only parses exactly as before. -- A blank `check` (empty or whitespace only) declares nothing, and the runtime reads it as absent. It is not refused by this rule. -- No export is added, removed or renamed. diff --git a/.changeset/19967-rls-using-check-texts.md b/.changeset/19967-rls-using-check-texts.md deleted file mode 100644 index eae1a908a3e..00000000000 --- a/.changeset/19967-rls-using-check-texts.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the RLS `using` / `check` texts say what the write gate enforces today — every row an insert or an update writes is checked, a check-only `update` policy is legal, and an `insert` policy's `using` is its check when none is declared (#19967) - -Clause-②: no - -Text only. No schema shape, accepted value or runtime behaviour changes. - -- **`RowLevelSecurityPolicySchema.check`**: the describe and TSDoc said the check ran on the new row of a single-record insert or a by-id update, and that an array insert and a `multi: true` update were not post-image checked. Every insert and update shape is now judged: each row of an insert, an array insert included, as its `beforeInsert` hooks leave it, and each row an update changes, by id or `multi: true`, as the prior row merged with the final payload after its `beforeUpdate` hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the change set as sent. -- **`RowLevelSecurityPolicySchema.using`**: the describe called it a filter for SELECT/UPDATE/DELETE and "optional for INSERT-only policies", and the TSDoc said UPDATE requires it. It now says, per operation, which rows it admits, that it stands in as the check on an insert or update when no applicable policy declares `check` (on an `insert` policy that is its only effect), that a `select` or `delete` policy needs it, and that an `insert`, `update` or `all` policy may declare `check` alone. -- **The "at least one of `using` or `check`" refusal**: its head is unchanged. It no longer says an UPDATE policy must provide `using` or that an INSERT policy must provide `check`. It names what each operation takes. -- **OR-combination**: the schema overview, the `priority` tombstone notes and the `os migrate meta --from 16` prose for the `priority` removal no longer say applicable policies OR-combine with "most permissive wins" without qualification. That holds on reads. On a write, the check is chosen per operation across the applicable policies first, and the chosen predicates then OR-combine. -- **Default deny**: the overview now limits "default deny" to the policies that apply. A caller to whom no policy applies is not restricted by the policies; the tenant wall still applies. -- The generated reference pages (`references/security/rls`, `references/security/permission`) and `docs/protocol-upgrade-guide.md` are regenerated from these sources. diff --git a/.changeset/19969-sdui-containment-declared-children.md b/.changeset/19969-sdui-containment-declared-children.md deleted file mode 100644 index 38908932d73..00000000000 --- a/.changeset/19969-sdui-containment-declared-children.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/sdui-parser': patch ---- - -fix(sdui-parser): `not-a-container` is decided by the declared `children` input, not `isContainer`. This matches the renderer's copy of the parser (#19969) - -The save gate's `validateTree` now warns `not-a-container` on a child list only when the component's manifest entry declares no input named `children`. It never reads `isContainer`, which now means layout containment only, and it has no fallback to it. This is a port of objectui#9910 (objectui `5ea623ea`), which is already inside the pinned console build. Saving and rendering now reach the same verdict on every page again. - -Against the served `sdui.manifest.json`, five of its 59 components change: - -- `badge`, `alert`, `button` declare a `children` slot and are not flagged `isContainer`. A child list under them no longer draws a false `not-a-container` warning. -- `page:tabs`, `page:accordion` are flagged `isContainer` but declare no `children` input. They render `items[].children`, never `schema.children`, so a child list under them now draws the warning the flag used to silence. - -Clause-②: no - -`not-a-container` stays a `warning`, so the default save gate refuses no page it accepted before and accepts no page it refused. Only the two modes that treat warnings as errors see the five-component change: `os validate --strict` and `os lint --strict`. Both run `validateJsxPages`. The package's public entry adds no export. diff --git a/.changeset/19974-having-comparand-shape-face.md b/.changeset/19974-having-comparand-shape-face.md deleted file mode 100644 index 9a40646a3b2..00000000000 --- a/.changeset/19974-having-comparand-shape-face.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: `engine.aggregate({ having })` walks through the shared comparand-shape face, so `having: { total: [5] }` is refused exactly as the same shape in `where` is (#19974) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what `having` accepts on `engine.aggregate` (and on the REST aggregate query that forwards it there). A `having` carrying one of the shapes below used to answer; it is now refused with `INVALID_FILTER` / 400, before any driver is asked for a row. The refusal is the shared face's own message, byte for byte the refusal the same shape gets in `where`, with the path rooted at `having` instead of `where`. It ships as `minor` under the launch-window convention for accept-set narrowings. - -The 2026-09-23 ruling on #19757 refuses an array in the equality slot at the shared comparand-shape face (`assertListComparandShapes` in `@objectstack/spec/data`) "for every driver at once". The face already ran on `where` and on each `aggregations[i].filter`. `having` never reaches a driver: the engine evaluates it itself after aggregation, on both the native `driver.aggregate()` path and the in-memory fallback. That evaluator answered every shape the face refuses. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sqlite-wasm`, over three groups with totals 500, 1250 and 20: - -| you wrote in `having` | what it did before | write instead | -|:--|:--|:--| -| `{ total: [500] }` or `{ total: { $eq: [500] } }`, at any depth under `$and` / `$or` / `$not` | kept the 500 group, because JS `500 == [500]` is true; under `$not` it kept the complement, the 1250 and 20 groups | `{ total: 500 }`, or `{ total: { $in: [500, 1250] } }` for "one of these" | -| `{ total: [] }` | kept no group | drop the condition, or write the value you meant | -| `{ customer_id: { $in: 'c1' } }` / `{ customer_id: { $nin: 'c1' } }` | `$in` kept no group; `$nin` kept every group | `{ customer_id: 'c1' }` / `{ customer_id: { $ne: 'c1' } }`, or wrap the value in a list | -| `{ customer_id: { $in: ['c1', null] } }` (or `$nin`) | the null member was compared as a value | `{ $or: [{ customer_id: { $in: ['c1'] } }, { customer_id: { $null: true } }] }` | -| `{ total: { $gt: null } }` (or `$gte` / `$lt` / `$lte`) | `$gt` / `$gte` kept every group; `$lt` / `$lte` kept none | `{ total: { $eq: null } }` for "has no value", `{ total: { $ne: null } }` for "has a value" | -| `{ total: { $between: 500 } }` or `{ total: { $between: [500] } }` | the scalar kept every group; the one-bound list kept the groups at or above it | `{ total: { $between: [min, max] } }` | -| `{ total: { $between: [null, 1000] } }`, `['', 1000]` or `[undefined, 1000]` | the blank bound compared as a value | `{ total: { $lte: 1000 } }` for a one-sided range, or the bound you meant | -| `{ total: { $between: [{ $field: 'order_count' }, 1000] } }` | the reference compared as a value | literal bounds. ⚠️ The refusal's own text suggests a two-bound `{ $field }` comparison, which `having` does not evaluate. In an operator slot the reference is compared as a value: under `$eq`, `$gt`, `$gte`, `$lt` or `$lte` it keeps no group, and under `$ne` it keeps every group. In the implicit slot (`{ total: { $field: 'order_count' } }`) it is refused as an unsupported operator (`INVALID_FILTER` / 400), though only when a grouped row carries that column: an empty grouped set evaluates nothing and comes back empty. That gap is not changed here | - -The gate is ONE call in `engine.aggregate`, ahead of both `having` evaluations, so the two paths cannot disagree, and the verdict belongs to the filter rather than to the data: an empty grouped set refuses the same `having` a populated one does. Whatever arm the shared face gains later, `having` gains with it. - -Who is affected: `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. Every `having` in this repository's docs and published skills is a scalar comparison (`{ order_count: { $gt: 5 } }` and the like), and none authors a refused shape. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. - -Not changed: scalars, `null` in the equality slot (the has-no-value predicate), `$in` / `$nin` lists including the empty list, a two-bound `$between`, and scalar ordering bounds all answer exactly as before, on both paths. `$ne` with a list is not judged by the face yet, so `having` still answers it. Neither the comparand-TYPE door nor the unknown-field and declared-type gates that `where` also passes are run on `having` by this change, which adds the comparand-shape face only (the comparand-TYPE door, #20099, and the temporal-comparand door, #20263, reach `having` in the same release). diff --git a/.changeset/19975-read-scope-eq-array.md b/.changeset/19975-read-scope-eq-array.md deleted file mode 100644 index 533cfd51e7f..00000000000 --- a/.changeset/19975-read-scope-eq-array.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(service-analytics)!: the read-scope compiler refuses a list under `$eq` instead of binding it (#19975) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what `compileScopedFilterToSql`, exported from `@objectstack/service-analytics`, accepts. A read scope carrying `{ field: { $eq: [...] } }` compiled before this change and is refused after it. It ships as `minor` under the launch-window convention for accept-set narrowings. The remedy is `{ field: { $in: [...] } }` for "one of these values". - -`compileScopedFilterToSql` compiles a row-level read scope into the SQL the analytics NativeSQL path and the `/analytics/sql` echo run. It already refused a list in the implicit equality slot (`{ field: [...] }`). The explicit spelling, `{ field: { $eq: [...] } }`, was compiled to an equality with the whole list bound as one parameter, so what the scope selected depended on how the executing database read a list, not on what the scope said. - -It is now refused, at any depth under `$and` / `$or` / `$not`, with the envelope every other refusal of this compiler carries: `READ_SCOPE_COMPILE_FAILED` / 500, with the message kept for the server log. This applies ruling 乙 of #19757, which the shared comparand-shape face in `@objectstack/spec` already enforces, to a compiler that face never sees. `$ne` with a list is not part of that ruling and is not judged here. - -No policy authored as metadata produces this shape. The refusal therefore reaches only a host-supplied `getReadScope` or a direct caller of `compileScopedFilterToSql`. A scalar, `null` or a `Date` under `$eq` compiles exactly as before, and a `{ $field }` reference there keeps its existing answer. diff --git a/.changeset/19976-turso-unrecognised-url-refusal.md b/.changeset/19976-turso-unrecognised-url-refusal.md deleted file mode 100644 index 31a4814fd23..00000000000 --- a/.changeset/19976-turso-unrecognised-url-refusal.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/driver-turso': minor ---- - -fix(driver-turso)!: a url scheme matches in any letter case, and a url the driver cannot open is refused instead of running on a private in-memory database - -Clause-②: no (narrowing) - -`TursoDriver.detectMode` matched `file:` and the five remote schemes case-sensitively, and answered `'local'` for any other url with no `mode`. The local engine can open only a `file:` path or `:memory:`, so for everything else it was handed `:memory:`: writes succeeded and read back, then vanished on restart. `@libsql/client` reads a scheme in any letter case (`@libsql/core@0.17.4` routes on `uri.scheme.toLowerCase()`), so an uppercase `LIBSQL://` url that the client routes to the remote database ran on that local `:memory:` engine instead. `@objectstack/cli` and `@objectstack/runtime` select this driver for an `OS_DATABASE_URL` matching `libsql://` in any letter case and hand it the url as written, so an uppercase `OS_DATABASE_URL` reached that engine too. Measured before the change, with `initObjects`, `create`, `find`, then a fresh driver on the same config: - -``` -LIBSQL://… (no mode) -> local, 1 row back, 0 rows after restart -FILE: (no mode) -> local, 1 row back, 0 rows after restart, file never created -.//app.db (no mode) -> local, 1 row back, 0 rows after restart, file never created -/app.db (no mode) -> local, 1 row back, 0 rows after restart, file never created -.//app.db + mode: 'local' -> local, 1 row back, 0 rows after restart, file never created -file: (unchanged) -> local, 1 row back, 1 row after restart -``` - -**The scheme now matches in any letter case**, as it does in `@libsql/client`, in `detectMode` and in every constructor check. `LIBSQL://`, `HTTPS://`, `Http://`, `WSS://` or `Ws://` with no `mode` is remote. `FILE:` is a local file, and an embedded replica beside `syncUrl`. The url itself is passed to the client as written. - -**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses configurations it accepted before**, at `new TursoDriver()`, ahead of the Knex base and of any client, with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`. Refused: - -- in a local or replica mode, a `url` that is none of `:memory:`, a `file:` url or a remote url (`libsql://`, `https://`, `http://`, `wss://`, `ws://`, in any letter case). That is a bare path (`./data/app.db`, `data/app.db`, `/var/lib/app.db`, `C:\data\app.db`), an unsupported scheme (`sqlite:`, `memory://`), `:MEMORY:`, a remote scheme with no `//`, a url behind leading whitespace, and an empty url. Newly refused with no `mode` (with or without `syncUrl`) and under a forced `mode: 'local'`. Under a forced `mode: 'replica'` it was already refused, and the refusal now names the `file:` spelling for the replica. `@libsql/client@0.17.4` refuses each of these urls itself, as `URL_INVALID` or `URL_SCHEME_NOT_SUPPORTED`; -- an uppercase remote url beside `syncUrl` (no `mode`), or under a forced `mode: 'local'`. Both constructed on the private `:memory:` engine before, and both now meet the refusal their lowercase spelling already met. Under a forced `mode: 'replica'` it was already refused, now with that same remote-url refusal; -- an uppercase `WSS://` or `WS://` url with a non-zero `timeout`, no `mode` and no `syncUrl`. It is now detected as remote, so the existing refusal of a `timeout` on the WebSocket transport reaches it; -- `FILE::memory:` beside `syncUrl` (no `mode`), refused as an in-memory replica exactly like `file::memory:`. Under a forced `mode: 'replica'` it was already refused. - -The unrecognised-url refusal names the `file:` spelling (`url: 'file:./data/app.db'`, or `url: 'file:./data/replica.db'` beside `syncUrl`) and never echoes the url, which may carry a token. `TursoDriver.detectMode()` now answers `'replica'` for such a url beside `syncUrl` (it answered `'local'`), which is what the declaration asks for. The refusal sits in the constructor, not in a re-classification. - -**Newly accepted:** an uppercase or mixed-case `FILE:` url naming a file, under a forced `mode: 'replica'`, with or without `syncUrl`. The #19893 change refused it, because under a forced `mode: 'replica'` it refused every url that did not start with a lowercase `file:`. With this change it is a `file:` url: the replica runs on that file, and its rows survive a restart (pinned). It is the one configuration the #19893 change refused that this change accepts. - -**What stays accepted**, pinned by preservation tests: a lowercase `file:` url, alone or with `syncUrl`; `:memory:` as a local database; a lowercase remote url on its own or with `mode: 'remote'`. A forced `mode: 'remote'` runs no local engine, so this change does not judge its url: a bare path there still constructs, and `@libsql/client` refuses it at `connect()` as `URL_INVALID`. - -**What an affected author does.** A local database file needs the `file:` prefix: `url: 'file:./data/app.db'`. For a throwaway in-memory database, `url: ':memory:'`. An uppercase remote url with no `mode` and no `syncUrl` now reaches the remote database and needs no change. Beside `syncUrl` or under a forced local or replica mode it is refused with the same ways out as the lowercase spelling. - -The #19893 entry in this same version (the constructor refusal of a remote url in a local or replica mode) describes this fall-through as not refused by that change, and names this entry as the one that removes it. - -Blast radius, measured on this tree: no example, template, hand-written doc, published skill or factory default, and no test fixture outside this package's own tests, spells a turso url with an uppercase scheme or as a bare path. Neither host url sniffer selects this driver for a bare path (both select it only for `libsql://` or an `http(s)://` url naming a `.turso.` host); only an explicit `OS_DATABASE_DRIVER=turso` or a datasource declaring `driver: 'turso'` hands it one. Whether any out-of-repo deployment declares such a url is NOT measured and is not claimed to be zero. - - diff --git a/.changeset/19977-turso-config-transport-refusals.md b/.changeset/19977-turso-config-transport-refusals.md deleted file mode 100644 index c43da549b85..00000000000 --- a/.changeset/19977-turso-config-transport-refusals.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/driver-turso': minor ---- - -fix(spec)!: a turso datasource config the driver refuses, or ignores a key of, is refused where it is written - -Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed and no exported symbol moves. Combinations of `url`, `syncUrl`, `mode` and `timeoutMs` that the turso driver refuses when it is built, and one it builds and then ignores, are now refused at parse. - -`TursoConfigSchema` (the `turso` / `libsql` `datasource.config` contract in `@objectstack/spec`, and the published mirror in `@objectstack/driver-turso`) parsed each key on its own. So it accepted configurations that `new TursoDriver()` refuses with `VALIDATION_ERROR` / 400: a datasource published clean and then failed at boot, or at a test connection. Measured on `main` before the change, both schemas accepting every row: - -``` -libsql:// (any scheme, any case) + syncUrl -> constructor refuses -libsql:// + mode: 'replica' or mode: 'local' -> constructor refuses -./data/app.db (a bare path), sqlite:, :MEMORY: -> constructor refuses -:memory: or file::memory: + syncUrl -> constructor refuses -wss:// or ws:// + timeoutMs -> constructor refuses -libsql:// + mode: 'remote' + syncUrl (+ sync) -> constructs; syncUrl ignored -``` - -On that last row the remote client is built without `syncUrl`, no sync interval starts, the driver's sync call rejects `SYNC_NOT_SUPPORTED`, and the driver still reports sync as enabled. - -**BREAKING** accept-set narrowing on a published schema, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). Refused now, each as one `custom` issue on the key it names: - -- **on `url`**, in a local or replica mode (a forced `mode: 'local'` / `'replica'`, or `syncUrl`, or a url that is not remote): a remote url (`libsql://`, `https://`, `http://`, `wss://`, `ws://`, in any letter case); a url that is none of a `file:` url, `:memory:` or a remote url, such as a bare path, another scheme, `:MEMORY:`, a remote scheme with no `//` or a blank url; and a replica on an in-memory url (`:memory:`, `file::memory:` in any case, with or without a query string); -- **on `timeoutMs`**: a window beside a `wss://` / `ws://` url in remote mode; -- **on `syncUrl`**: `syncUrl` under a forced `mode: 'remote'`. The constructor accepts this one, so it is refused at authoring only; -- **on `sync`**, in the `@objectstack/driver-turso` mirror only: `sync` with no `syncUrl`, in the words the spec contract has always used for it. - -The rules mirror the constructor's own: a scheme matches in any letter case, `:memory:` matches exactly, and the url is read trimmed, as both datasource loaders hand it to the driver. A forced `mode: 'remote'` keeps its url unjudged, as the constructor does. Nothing the constructor accepts is refused, the `syncUrl`-under-`mode: 'remote'` row aside. The mirror declares no `mode` key and strips an authored one, so it judges every config in the mode its url and `syncUrl` select. A test in `@objectstack/driver-turso` holds the constructor and both schemas to one case table of 54 rows, with messages compared byte for byte. - -The spec `url` describe named "a file path" among the accepted spellings, which is the one spelling the driver refuses. It now reads "a local file written as a file: URL (never a bare path)". - -### Migration: FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `url: 'libsql://my-db.turso.io', syncUrl: 'libsql://my-db.turso.io'` | a remote database: `url: 'libsql://my-db.turso.io'` alone. An embedded replica: `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io'` | -| `url: 'libsql://my-db.turso.io', mode: 'replica'` (or `'local'`) | drop `mode`, or set `mode: 'remote'` | -| `url: './data/app.db'` | `url: 'file:./data/app.db'` | -| `url: ':memory:', syncUrl: …` | a replica on a file: `url: 'file:./data/replica.db'` beside `syncUrl`. An in-memory database: drop `syncUrl` and `sync` | -| `url: 'wss://my-db.turso.io', timeoutMs: 30000` | `url: 'libsql://my-db.turso.io', timeoutMs: 30000`, or drop `timeoutMs` | -| `url: 'libsql://my-db.turso.io', mode: 'remote', syncUrl: …` | drop `syncUrl` and `sync` | - -Each refusal prints these ways out and names only a remote url's scheme, never the url, which may carry a token. Stored datasource rows are not re-parsed when they load, so a stored row keeps loading as before; creating, testing or editing its `config` through the datasource admin service, `defineStack` or `os validate` is refused at the key until it is rewritten. The constructor already refuses the first five rows at boot. - -Blast radius, measured on this tree: no example, template, published skill or hand-written doc authors a refused combination. Four test fixtures spelled one and are rewritten in this change, each named in the PR: one in `@objectstack/spec`, two in `@objectstack/driver-turso` (one of them pinned a placeholder url as accepted), and the stored-row redaction fixture in `@objectstack/service-datasource`, now an embedded replica on a `file:` url. Loader fixtures in `@objectstack/runtime`, `@objectstack/cli` and `@objectstack/service-datasource` that spell a remote url beside `syncUrl` exercise only the config builder or a capturing constructor. They never parse this schema or build the real driver, and are unchanged. Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero. - - diff --git a/.changeset/19978-sqlite-wasm-text-roundtrip.md b/.changeset/19978-sqlite-wasm-text-roundtrip.md deleted file mode 100644 index e9b12b95aac..00000000000 --- a/.changeset/19978-sqlite-wasm-text-roundtrip.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/driver-sqlite-wasm": patch ---- - -fix(driver-sqlite-wasm): a text value now round-trips byte-for-byte, as it does through `SqlDriver` on better-sqlite3 — an embedded U+0000 no longer cuts the stored value short, and a leading U+FEFF is no longer dropped when the value is read (#19978) - -Clause-②: no - -`SqliteWasmDriver` changed a text value without raising, at two points in sql.js (measured on sql.js 1.14.1, the version the lockfile installs, against better-sqlite3, which round-trips every value below): - -- **Write.** sql.js binds a string with a length of `-1`, so SQLite stores it only up to the first U+0000: `'a'` + U+0000 + `'b'` was stored as the single byte `61`, and `'ab'` + U+0000 as `6162`. -- **Read.** sql.js decodes a text cell up to the first NUL byte, through a decoder that drops a leading byte-order mark: a stored `610062` read back as `'a'`, and a stored text beginning with U+FEFF read back without it. A U+FEFF was always stored, because the write keeps it. - -What changes: - -- Every text cell the driver reads is decoded from its stored bytes, so a U+0000 anywhere in it and a U+FEFF at its start come back as stored. This includes values already on disk: a stored text that begins with U+FEFF now reads back with it. -- A string value holding U+0000 is bound as its UTF-8 bytes, and the parameter that receives it becomes `+CAST( AS TEXT)`, so SQLite stores the same TEXT value better-sqlite3 stores, and compares it the same way. Positional `?`, numbered `?NNN` and named parameters are all numbered as SQLite numbers them. A statement that binds no such string runs exactly as before. -- An equality filter on such a value now compares the whole value. Before, the comparand was cut at the same U+0000 as the stored value, so `{ v: 'a' }` also matched a row written as `'a'` + U+0000 + `'b'`. It no longer does. -- The local `Field.json` storage backfill (`SqlDriver.backfillCanonicalJsonEncoding`) now converges a legacy json text cell holding a leading U+FEFF or an embedded U+0000 on this driver too: measured on the next `initObjects`, a stored `EFBBBF78` becomes `22EFBBBF7822` and a stored `610062` becomes `22615C75303030306222`, the same bytes better-sqlite3 writes, where before this change both were left as stored because sql.js did not read them back verbatim. -- If the loaded sql.js has no `Statement.getBlob` (the one sql.js read that carries a byte length), reading a text cell throws instead of decoding through the lossy path. sql.js 1.14.1 has it in its Node, browser and debug builds. - -What does not change: a value already stored cut short stays cut short. The bytes after the U+0000 were never written, so nothing can restore them. diff --git a/.changeset/19986-explain-read-scope.md b/.changeset/19986-explain-read-scope.md deleted file mode 100644 index 31a5d8efe11..00000000000 --- a/.changeset/19986-explain-read-scope.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security): `security/explain` asks the sharing read filter with the caller's read depth, so a record's `read` verdict matches what the caller's `find` returns (#19986) - -Clause-②: no - -`POST /api/v1/security/explain` with `{ object, operation: 'read', recordId }` answered `decision.record.visible: false` (`decidedBy: 'sharing'`) on rows that the same caller's `find` returned. It happened on every object whose OWD is private (set explicitly, or left unset), for a caller whose read depth is wider than `own`. The find path hands the sharing service's read filter the caller's effective read depth. Explain asked the same filter without it, so a caller with `org` read depth was judged owner-only on every row it did not own, and a caller with unit read depth was judged owner-only on rows its unit owns. - -Explain now passes the same read depth, computed the way the find path computes it. A read depth already present on the explained context no longer decides the report. - -Unchanged: - -- Enforcement: `find` admits and refuses exactly what it did before. -- Writes (`update` / `delete`) and object-level explanations (no `recordId`). -- A caller acting on behalf of another user (`onBehalfOf`): its record-level read explanation uses the same inputs as before. -- Objects whose OWD is not private already matched and still do. diff --git a/.changeset/19987-resume-caller-gate.md b/.changeset/19987-resume-caller-gate.md deleted file mode 100644 index f91126bf326..00000000000 --- a/.changeset/19987-resume-caller-gate.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): `POST /automation/:name/runs/:runId/resume` checks WHO is resuming — the run's own starter, or the `sys_automation_run` read grant (#19987) - -Clause-②: no - -**The defect.** The resume route checked the body and then resumed. It never asked who the caller was. An authenticated user holding another user's run id could continue that user's paused run, and the run then went on under the identity STORED on the run: its data nodes ran as the user who started it, with the values the other user submitted. The screen read on the same pause, `GET /automation/:name/runs/:runId/screen`, already refused that caller. - -**The fix.** The route now asks the screen read's own question, through the same function: the caller must be the user who started the run (`getRun(runId).trigger.userId`), OR hold read access to `sys_automation_run` (the operator override), OR be a system context. Anyone else gets `403 PERMISSION_DENIED`, the screen read's code and status, and the run is not touched: nothing reaches the engine, so the pause stays parked for the person it is waiting on. The message states the screen read's requirement word for word under this route's own verb: "Resuming a paused run requires being the identity that triggered the run, or read access to 'sys_automation_run'." - -**What stays the same.** - -- The user who started a run resumes it exactly as before, with no grant needed, and still does while the permission subsystem is down. -- The suspended node's own gate (`resumeAuthority`) is unchanged and still answers behind this one. An `approval` pause is still refused on this route for every caller; approvers decide through the approvals API, which never uses this route. -- Body checks come first, as before, so every `400` for a malformed body is unchanged for every caller. -- For the starter, a `sys_automation_run` reader and a system context, every engine answer is unchanged, including the `404` for an unknown or finished run. - -**Who needs to act.** A caller that resumes runs it did NOT start (for example an integration that feeds a `wait` node's signal, or a support tool) now needs read access to `sys_automation_run`. Without it, it gets the `403` above. Such a caller also gets that `403`, not the old `404`, for a run id that does not resolve. The check fails closed there because a run that is still paused can read as "not found" while the run store is degraded. diff --git a/.changeset/19989-by-id-update-post-hook-check.md b/.changeset/19989-by-id-update-post-hook-check.md deleted file mode 100644 index 1bf3fce9725..00000000000 --- a/.changeset/19989-by-id-update-post-hook-check.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/objectql": minor ---- - -fix(plugin-security, objectql)!: a by-id update's row-level `check` now holds for the row it stores, after the `beforeUpdate` chain (#19989) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows the set of writes the write gate accepts. A by-id update that is admitted today can be refused after this change. It ships as `minor` under the launch-window convention, as the multi-row check did (#19950). - -A row-level security `check` (declared on the policy, or defaulted from its `using`) is the write-side half of the policy: a row the check refuses is never stored. An insert and a predicate update are judged on the row the driver stores. A by-id update was judged only on the change set as the caller sent it, merged with the stored row, before the `beforeUpdate` chain ran. A value a hook wrote into a checked field after that point was never judged, so the row it produced could be stored outside the policy. - -A by-id update is now also judged on the row it stores: the prior row merged with the final payload, after the `beforeUpdate` chain and both readonly strips, before the statement. The engine (`@objectstack/objectql`) runs that judgement through the seam the insert and predicate update already use (`OperationContext.postHookWriteImageCheck`). The existing judgement of the change set as sent stays, so this change only ever refuses more. - -**Writes that are now refused.** Each refusal is the existing row-level CHECK denial, `403 PERMISSION_DENIED`, and nothing is stored. There is no transition switch. - -- **A by-id update whose `beforeUpdate` chain writes a checked field to a value the check refuses**, including a value derived from a field the caller changed. -- **A by-id update on a host that installs the judgement and never runs it**, for example a custom write executor in place of the engine. It is refused as an insert and a predicate update already are, with an `error` log saying the check was not evaluated. -- **An update whose payload `id` addresses no row while `where.id` addresses one**, under a policy with a `check`. The engine writes the `where.id` row while the gate had judged the payload id. It used to be written and then refused; it is now refused before anything runs. - -**Remedy.** A hook that must store a value the caller's `check` refuses does so in a separate write under a system context, or the policy declares a `check` that admits it. Otherwise fix the data the write carries. For the last case, address the row with one id: `update(object, { id, ...fields })` or `update(object, fields, { where: { id } })`. - -**What does not change.** - -- A by-id update whose hooks leave the checked fields inside the check is admitted as before. -- A change set the check refuses as sent is refused as before, even when a hook would have replaced the refused value. -- Inserts and predicate updates are judged exactly as before. -- A system-context write is not gated. diff --git a/.changeset/19992-currency-config-precision-retired.md b/.changeset/19992-currency-config-precision-retired.md deleted file mode 100644 index 35588890cff..00000000000 --- a/.changeset/19992-currency-config-precision-retired.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/platform-objects': patch ---- - -**BREAKING** — retire `currencyConfig.precision`: a currency's decimal places are its currency's (#19992). - -`currencyConfig.precision` was declared, validated against ISO 4217, and baked to `2` -into parse output — and **no renderer or runtime ever read it**. objectui's -`CurrencyField` derives an amount's decimal places from the currency's ISO 4217 -minor unit (2 for USD, 0 for JPY, 3 for KWD) and never looked at the key, so an -author who wrote `precision: 4` saw the same two decimals as everyone else. Its -only reader was its own contradiction check. ADR-0049 enforce-or-remove; triage -direction REMOVE under ruling 乙 on #19910 — 「a currency's decimal places are the -currency's, not a setting」. - -Clause-②: no - -## FROM → TO - -| you wrote (17.4 and earlier) | write instead | -| --- | --- | -| `currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }` | `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }` | -| `currencyConfig.decimals` / `currencyConfig.scale` (always refused, with a suggestion to write `precision`) | nothing — delete the key; the refusal now says why instead of suggesting `precision` | -| a field whose amounts need a different number of decimals | a different currency: the width is the currency's minor unit and is declared nowhere | - -**The one-line fix:** delete `precision` from every `currencyConfig`. ⛔ Do not move -the number to the field-level `precision`: that key is the amount's TOTAL digit count -(a DECIMAL(18,2) amount declares `precision: 18`), not its decimal places, and it is -unchanged by this release. - -`os migrate meta --from 17` lists the mechanical edits for existing sources; apply -them by hand. - -## The retirement kit - -- **`CurrencyConfigSchema.precision`** — removed from the shape. The schema is a - `strictObject`, so the route is strict deletion plus a `guidance` entry: an - authored key is refused as `unrecognized_keys` at `currencyConfig`, and the message - carries the prescription (``currencyConfig.precision` was removed in - @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer or runtime ever - read it: …``). `tsc` refuses a literal in a typed position too — the key is off - `CurrencyConfig`'s input type. -- **The `decimals` / `scale` aliases** — gone with their target. Each is now answered - with the same reason (`` `currencyConfig.scale` is not a currency configuration key, - and nothing replaces it: … ``) and no rename suggestion. -- **The ISO 4217 contradiction check** (the `.superRefine`) and **the - default-materializing `.overwrite()`** — both existed only for this key and are - removed. `CurrencyConfigSchema.parse({})` now returns exactly - `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`; `CurrencyConfigParsed` no - longer declares `precision`. The internal helpers `currencyPrecisionContradiction` - and `currencyFractionDigits` (never exported from a public entry) are removed; the - CLDR table they read stays, because the `iso_4217_currency` value domain reads its - key set. -- **The field designer form** — the field-level `precision` row's help text read - "Decimal places (e.g., 2 for $10.50)", the one reading the contract refuses. It now - reads "Total digits", matching the key's describe and the object designer's row; - the zh-CN / ja-JP / es-ES translations follow (`@objectstack/platform-objects`). -- **Registry** — `RETIRED_KEYS_BY_MAJOR[18]` gains `data/CurrencyConfig:precision`; - the protocol-18 step gains the D2 conversion `currency-config-precision-removed` and - its D3 entry `currency-config-precision-retired`, which states the two judgments the - strip cannot make: a width declared where the old check never looked (a `dynamic` - field, or a code with no known ISO 4217 minor unit) never applied, and code of your - own that read the served key must derive the width from the field's currency. - -## What an operator with STORED metadata sees - -Nearly every stored currency field carries this key without anyone having written it: -the old `.overwrite()` baked `precision: 2` into parse output, so `sys_metadata` -object rows and built artifacts hold it. Nothing breaks at read: the conversion -`currency-config-precision-removed` is retired from the load path but replayed by the -stored-row and artifact seams, which strip the key from every field's -`currencyConfig` on objects and object extensions and serve the row canonical. The -strip is lossless — the key never had an effect — and the field-level `precision` is -never touched. `os migrate meta --stored --apply` rewrites the stored rows so the -per-row notice stops. - - diff --git a/.changeset/19992-field-precision-write-seam.md b/.changeset/19992-field-precision-write-seam.md deleted file mode 100644 index 6ef72453c17..00000000000 --- a/.changeset/19992-field-precision-write-seam.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/spec": minor ---- - -feat(objectql,spec)!: a numeric field's declared `precision` ("Total digits") is enforced on writes — a value that needs more digits is refused with field code `max_precision` (#19992) - -Clause-②: yes - -**BREAKING** — a narrowing of the write accept set on `@objectstack/objectql`, shipped as `minor` under the repo's launch-window convention (`check-changeset-no-major` refuses `major` until GA); the breaking-ness is carried by this banner and the ADR-0087 disposition, never by the level. Nothing an author writes changes spelling: `precision` keeps its key, its type and its legality. - -`FieldSchema.precision` was declared ("Total digits") and read by nothing. Every numeric column is the fixed exact decimal of `NUMERIC_COLUMN_REPRESENTATION`, the record validator had no branch for it, and the renderer reads the liveness ledger cited are gone, so `precision: 5` on a `number` stored `123456789` verbatim. The metadata designer writes the key (labelled Precision, beside Scale), so it was a setting an author could make and see nothing come of. It is now enforced at the one place a write is judged. - -**`@objectstack/objectql`** — the record validator refuses, after `min` / `max` and `max_scale`, a `number`, `currency`, `percent`, `rating` or `slider` value whose digit count exceeds a declared `precision`. It refuses with `400 VALIDATION_FAILED` and the field code `max_precision`, and it never rounds. The count is the SQL `DECIMAL(p, s)` one, taken on the stored value: - -- **With a `scale`**, digits are counted at the field's decimal places, so the integer part may carry `precision − scale` digits. `precision: 5, scale: 2` holds up to `999.99` and refuses `1234.5`, which is `1234.50`, six digits. -- **With no `scale`**, the value's own digits count. Leading zeros never count, and trailing zeros of the integer part always do: under `precision: 4`, `0.001` fits and `10000` does not. -- **On `currency`**, where `scale` is refused, an amount counts at its own decimals. The decimals themselves stay unconstrained, and only the total is bounded: `precision: 18` refuses a 19-digit amount. -- **On a fraction-stored `percent`** the count is taken two places further right (`scale + 2`, or 2 with no `scale`). The count is then the percentage-point value's digits as displayed: `precision: 4, scale: 2` holds 99.99% and refuses 100%. - -What an author with an oversize value sees: the write is refused, nothing is stored, and the field error names the declaration and the count. For example, `constraint: { precision: 5, scale: 2, actual: 6 }` renders as "Hourly rate must have at most 5 digits in total, counting 2 decimal places (got 6)" in four locales. The REST create, batch, update and import routes all answer it, and `validate` (the dry run) predicts it. Only NEW writes are judged: a stored value longer than a `precision` declared later is never re-read. Nothing changes in storage or DDL. - -The fix is one of three. Write a value that fits. Raise `precision` to the digits the field really holds. Or delete the key if the number was meant as decimal places: those are `scale`, and a currency's decimal places are its ISO 4217 minor unit. - -**`@objectstack/spec`** — `FieldErrorCode` (the ADR-0114 field-level catalog) gains `max_precision` beside `max_scale`. `BUILTIN_VALIDATION_MESSAGES` gains its two sentences, `max_precision` and `max_precision_scaled`, in `en` / `zh-CN` / `ja-JP` / `es-ES`. `FieldSchema.precision`'s describe now states the counting rule and where it is enforced. The `precision` row of the field liveness ledger is re-evidenced at the write seam. - -**Who is affected, measured** on `origin/main` `df3ba164`: no example app, template, platform object, seed or JSON fixture in the tree declares a field-level `precision`. Two test fixtures do (`precision: 5, scale: 0` on a 1–12 hours field), and every value they write fits. - - diff --git a/.changeset/19995-engine-door-scope-residue.md b/.changeset/19995-engine-door-scope-residue.md deleted file mode 100644 index 942f2d008dd..00000000000 --- a/.changeset/19995-engine-door-scope-residue.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -fix(service-analytics): the ObjectQL execute face refuses a read scope carrying a filter placeholder the engine cannot resolve in the withheld `READ_SCOPE_COMPILE_FAILED` / 500 envelope, not the engine's `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED` / 400 (#19995) - -Clause-②: no - -A row-level read scope carrying an unknown filter placeholder, or a known one the request has no value for, used to reach `engine.aggregate` composed with the caller's own filter. The engine's placeholder resolver then refused it with a 400. A 4xx's message is relayed to the caller, and this one named the policy's placeholder. - -The ObjectQL strategy now runs the engine's own placeholder resolver on the scope by itself, with the token context the engine builds, at both engine-bound merge sites: the base aggregate (direct and cross-object) and the referenced object's scope in the cross-object label lookup. It does this before composing the scope. A refusal there is `READ_SCOPE_COMPILE_FAILED` / 500. `POST /analytics/query` and `POST /analytics/dataset/query` withhold its message, and the full text goes to the operator's log. - -Unchanged: which scopes are served. A placeholder the engine resolves, such as `{current_user_id}` for a signed-in caller, is resolved the same way here, and the scope is served. The caller's own `where` keeps its `FILTER_TOKEN_UNKNOWN` / 400 with its message. diff --git a/.changeset/19995-judge-filter-read-scope.md b/.changeset/19995-judge-filter-read-scope.md deleted file mode 100644 index 04386eee4be..00000000000 --- a/.changeset/19995-judge-filter-read-scope.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -fix(service-analytics): the analytics ObjectQL face asks the engine's own filter admission about a row-level read scope before composing it, and refuses a scope the engine refuses with the policy withheld (#19995) - -Clause-②: no - -The ObjectQL execute face composes each object's read scope into the `where` it hands `engine.aggregate`. A scope the engine refuses through a door that reads the object's declared fields (a text operator over a field that never holds a string, a temporal comparand the field cannot read, a filter on a formula field, a dotted path through a lookup) came back as the engine's `INVALID_FILTER` or `INVALID_FIELD` / 400. Both analytics HTTP doors relay a 400's message, and that message named the policy's field, and for some classes its operator or comparand. A read-scope refusal is a server fault whose detail belongs in the server log only (the #5367 ruling), so these scopes now answer `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld, like the other refusals this package's read-scope compiler and guards raise. The `driver-sql` refusals that read the `'policy'` provenance mark are unchanged: they stay a withheld `INVALID_FILTER` / 400. - -**How.** The analytics face asks the engine's judge-only admission, `IObjectQLEngine.judgeFilter`, about the scope on its own before composing it. It asks at every engine-bound merge: the direct aggregate, both merges on the cross-object path, and the record-label lookup behind a lookup dimension. The engine runs the same admission it runs when it executes and stops before any driver, so a scope the engine serves is still served. The caller's own `where` is not judged here and keeps the engine's answer, including its 400 and message. - -**Also fixed.** The record-label lookup `AnalyticsServicePlugin` supplies for a lookup dimension composed the referenced object's scope with only the vacancy guard. When a dataset sorted by that dimension's labels, a scope the engine refused there came back as its 400, with the policy in the message. When a dataset only displays the labels, a failed lookup is caught and the raw ids render, as before. The lookup now runs the same checks as the other merges and answers the same withheld 500. - -**Wiring, and what a host without it keeps.** - -- `AnalyticsServicePlugin` wires the judge automatically when it bridges `executeAggregate` to the kernel's `data` engine itself. That is the default composition, so nothing changes in host code. -- A host that constructs `AnalyticsService` directly can pass the new optional `AnalyticsServiceConfig.judgeFilter`. It must be the judgement of the engine its `executeAggregate` runs on. -- A host with no judge (a custom `executeAggregate`, or a `data` engine without `judgeFilter`) keeps today's behaviour everywhere except the plugin's record-label lookup. The scope shapes this package judges itself are still refused with the policy withheld, and the rest reach the engine unjudged, as before. That lookup's comparand and placeholder checks are new for every host that uses it, with or without a judge. So on such a host a referenced-object scope that fails one of them now answers the withheld 500 at that lookup, where it used to reach the executor. The host logs one `warn` that no judge is wired, with the remedy. diff --git a/.changeset/19995-objectql-read-scope-envelope.md b/.changeset/19995-objectql-read-scope-envelope.md deleted file mode 100644 index fb4f355ed73..00000000000 --- a/.changeset/19995-objectql-read-scope-envelope.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -fix(service-analytics): the ObjectQL execute face refuses a read scope it cannot run in the withheld `READ_SCOPE_COMPILE_FAILED` / 500 envelope, not the engine's `INVALID_FILTER` / 400 (#19995) - -Clause-②: no - -A row-level read scope carrying a comparand the engine's shared comparand faces refuse — a list in the equality slot, a scalar under `$in` / `$nin`, a one-bound `$between`, a null list member, a plain-object or `undefined` comparand — used to reach `engine.aggregate` composed with the caller's own filter, and came back as the engine's `INVALID_FILTER` / 400. A 4xx's message is relayed to the caller, and this one named the policy's fields and comparands. The NativeSQL execute face and the `/analytics/sql` echo already refused the same scope as a server fault with the message withheld (the #5367 ruling), so one scope got two envelopes depending on which analytics face served it. - -The ObjectQL strategy now judges the scope on its own at both engine-bound merge sites (the base aggregate, direct and cross-object, and the referenced object's scope in the cross-object label lookup), with the same two shared functions the engine runs, before composing it. A refusal there is `READ_SCOPE_COMPILE_FAILED` / 500: `POST /analytics/query` and `POST /analytics/dataset/query` withhold its message, and the full text goes to the operator's log. - -Unchanged: which scopes are served. The judgement uses the engine's own functions, so a scope the engine serves is still served, including a `{ $field }` cross-field scope and an emptied `$in` beside an own-rows grant. The caller's own `where` still answers `INVALID_FILTER` / 400 with its message, whether the analytics door or the engine refuses it. diff --git a/.changeset/19999-sqlite-glob-nul-comparand.md b/.changeset/19999-sqlite-glob-nul-comparand.md deleted file mode 100644 index adbf084e69f..00000000000 --- a/.changeset/19999-sqlite-glob-nul-comparand.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/driver-sql": patch -"@objectstack/driver-sqlite-wasm": patch -"@objectstack/driver-turso": patch ---- - -fix(driver-sql, driver-turso): on SQLite, a `$contains` / `$notContains` / `$icontains` / `$startsWith` / `$endsWith` comparand holding U+0000 is compared whole, against the whole stored value, instead of being cut at the U+0000 by `GLOB` (#19999) - -Clause-②: no - -On the SQLite faces these five operators compile to `GLOB`, and SQLite's `glob()` reads both the pattern and the stored value only up to their first U+0000. Nothing raised, and the filter answered a different question. Measured on `SqlDriver` over better-sqlite3 (SQLite 3.53.4), on `SqliteWasmDriver` over sql.js (3.49.1), and on `TursoDriver`'s remote transport over a local libSQL engine (3.45.1). All three answered alike. Over the values `'a'` + U+0000 + `'b'`, `'ab'` + U+0000, U+0000 + `'z'`, `'plain'` and `''`: - -- `$contains: U+0000` and `$endsWith: U+0000` returned all five rows; -- `$contains: U+0000 + 'b'` returned all five rows, where the JavaScript answer is `'a'` + U+0000 + `'b'` only; -- `$startsWith: U+0000` returned `''` and U+0000 + `'z'`, where the JavaScript answer is U+0000 + `'z'` only. - -What changes: a comparand holding U+0000 now compiles to a length-aware comparison instead. `$contains`, `$notContains`, `$icontains` and `$startsWith` use `instr()`, and `$endsWith` compares the value's trailing bytes over BLOB. Such a filter now returns the rows `driver-memory` and `@objectstack/formula` return for it. The comparand is bound as written, so `*`, `?` and `[` in it are literal, as they were before. `$icontains` still folds ASCII letters only, and `$notContains` still returns a row whose value is NULL. - -- `@objectstack/driver-sql`: the SQLite arm of `SqlDriver`'s text-operator compiler. `SqliteWasmDriver` and `TursoDriver`'s local mode inherit it. -- `@objectstack/driver-sqlite-wasm`: none of its own code changes. It inherits the fix, and its exact-text bind reaches every parameter the new comparison binds. -- `@objectstack/driver-turso`: the remote transport's own emitter, changed the same way. - -What does not change: a comparand without U+0000 compiles to the same `GLOB` with the same bound pattern as before. The Postgres and MySQL arms are untouched. `GLOB` still reads a stored value only up to its first U+0000, so for a comparand without U+0000, `$contains`, `$notContains`, `$icontains` and `$endsWith` over a stored value that holds one still compare only the part before it. diff --git a/.changeset/20001-scim-row-scope.md b/.changeset/20001-scim-row-scope.md deleted file mode 100644 index 4542823d2d9..00000000000 --- a/.changeset/20001-scim-row-scope.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -fix(plugin-security)!: the SCIM projection tables are row-scoped in every shipped permission set — no principal below platform admin reads a SCIM row another organization provisioned (#20001) - -Clause-②: no (narrowing) - -**BREAKING** — shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by -this banner and the ADR-0087 disposition below, never by the level). - -The seven `@better-auth/scim` tables carry no tenant column, so the organization -wall is inert on them, and the read that the shipped permission sets grant on -every better-auth-managed object had no row policy behind it for any of the -seven. Any authenticated member could therefore read the users, groups and -memberships every organization's identity provider had provisioned. - -**What a principal can no longer read.** Every shipped set that grants that read -and carries row-level security — `member_default`, `viewer_readonly`, -`organization_admin` and its wall-less variant `organization_admin_no_bypass` — -now declares a row policy on each table: - -- `sys_scim_user`, `sys_scim_subject`, `sys_scim_projection_grant` and - `sys_scim_identity_tombstone`: only the rows whose `user_id` is the caller - (the `
_self` policies); -- `sys_scim_group`, `sys_scim_group_member` and `sys_scim_connection_binding`: - no row at all (the `
_none` policies). - -This binds organization admins as well: an org admin no longer reads their own -organization's SCIM users or groups, only the rows about themselves. An MCP -agent acting for a user is bounded by that user's sets and reads the same. No -organization-scoped read replaces the old one, because none of the seven tables -has a column naming the organization and a row policy cannot follow -`connection_id` to the connection's organization. `admin_full_access` is -unchanged and still reads every row. - -**Remedy.** A deployment that needs a principal below platform admin to read -these tables grants it in a permission set of its own, with a row-level-security -policy on each table that names the rows it may see. Do not reach for a policy -that admits every row: no organization wall bounds these tables, so such a -policy admits every organization's rows. - -Unchanged: SCIM provisioning itself (its reads and writes run through -better-auth's adapter under system context, which no row policy reaches), and -every other managed object. - - diff --git a/.changeset/20002-explain-sharing-fault.md b/.changeset/20002-explain-sharing-fault.md deleted file mode 100644 index 1378378be82..00000000000 --- a/.changeset/20002-explain-sharing-fault.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security): `security/explain` fails closed when a dependency it shares with enforcement throws, so a request that fails is no longer reported as allowed (#20002) - -Clause-②: no - -`POST /api/v1/security/explain` calls the same functions as the enforcement middleware. Enforcement does not catch a failure in them, so the request fails. The explain engine caught the same failure and turned it into a value that it then read as an answer. So when the sharing service's share store was unavailable, `{ object, operation: 'read', recordId }` answered `decision.record.visible: true` (`decidedBy: 'sharing'`, sharing layer `admitted`), and the caller's `find` for the same row threw. Four call sites had this problem: - -- **The sharing read filter** (the reported case). A failure became `null`, which the record matcher reads as "no filter". So an unshared row, a shared row, and the caller's own row were all reported visible. -- **The sharing service's per-record `update` / `delete` gate.** A failure became "no gate wired", so ownership, a `read` share or the OWD answered a write that the by-id `PATCH` / `DELETE` then failed on. -- **The layered row-level security composition.** A failure became "no tenant wall and no business RLS". The row was reported visible, while `allowed` was `false` because of the same failure. -- **An on-behalf-of delegator whose grants could not be read.** A failure became "no delegation", so the agent's own grants decided alone and `allowed` was `true`. The same `find` answered `503 SERVICE_UNAVAILABLE`. - -Each failure is now reported as it happened. The affected layer's `record.outcome` is `not_evaluated`, with no `rowFilter` and no `matchesRecord`, and its `detail` says the layer could not be evaluated. `record.visible` is `false`, and `decidedBy` names the layer that failed: `sharing`, or `rls` for the composition. For the delegator case, the `principal` and `object_crud` layers deny and `allowed` is `false`. The response has no new keys, and `not_evaluated` is an existing outcome value. - -Unchanged: - -- Enforcement admits and refuses exactly what it did before. -- A dependency that answers is reported exactly as before. That includes a read filter that answers "no restriction", such as an `org`-depth reader's `null`. -- Failures that already failed closed are unchanged: record fetch, share listing, and permission-set resolution for the principal or the delegator. diff --git a/.changeset/20003-record-id-filter-token.md b/.changeset/20003-record-id-filter-token.md deleted file mode 100644 index c5e789222cf..00000000000 --- a/.changeset/20003-record-id-filter-token.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor -"@objectstack/lint": minor ---- - -A record-scoped filter token, `{record_id}`: the id of the record a `type: 'record'` page is showing. It resolves where a record is in context, and is refused by name everywhere else (#20003). - -On a record page, `record:related_list` was the only component that could scope itself to the record in view. Every other data-bearing component takes a `FilterCondition`, and the only dynamic values a filter could hold named the signed-in viewer. So "open tasks" on a person's record page counted the whole organisation's tasks, under that person's name. `{ assignee: '{record_id}' }` now says "this record's". - -**Where it is accepted, and where it is refused:** - -- **Accepted:** a filter on a component of a `type: 'record'` page (`regions[].components[]`, `slots`, and any filter key inside them). `os lint` / `os validate` pass it there. A page with no `type` is a record page by `PageSchema`'s default. -- **Refused by `os lint` / `os validate`** (rule `filter-token-unknown`, `error`), with the reason "no record in context on this surface" rather than the unknown-token message: list views (top-level `views` and an object's list views and field filters), dashboard widgets and dashboard filters, reports, datasets, app navigation filters, and every page whose `type` is not `'record'` (`home`, `app`, `utility`, `list`, including a list page's `interfaceConfig.filterBy`). -- **Refused on every server path.** `resolveFilterTokens()` in `@objectstack/core` throws `UnresolvedFilterTokenError` (`FILTER_TOKEN_UNRESOLVED` / 400, `token: 'record_id'`) on the ObjectQL read path (`find`, `findOne`, `count`, `aggregate`), the write path (`update` / `delete`, by id or `multi`), the analytics query door and the dataset executor. That is the same envelope a session token gets when the request has no value for it. It happens whatever the request carries, because no server path knows which record a page is showing. The token never becomes `null` (a count "about nobody"), is never dropped (a count "about everybody"), and never reaches the driver. - -**What is in `@objectstack/spec/data`:** - -- `RECORD_CONTEXT_TOKENS` (`['record_id']`), `RecordContextToken` and `isRecordContextToken()`: a sibling of `CONTEXT_TOKENS`, not a member. `CONTEXT_TOKENS` resolves against the caller's session, and `{record_id}` resolves against the surface. So `isContextToken('record_id')`, `ContextTokenSchema` and `ContextTokenPlaceholderSchema` are unchanged and still reject it, and a client resolver that fills `CONTEXT_TOKENS` from the session does not pick it up. -- `classifyFilterToken('{record_id}')` returns the new kind `{ kind: 'record-context', token: 'record_id' }` instead of `unknown`. A consumer that switches exhaustively on `kind` gets a compile error until it handles the new kind. -- `isKnownFilterToken('record_id')` stays `false`. That predicate answers "can the server resolve it?", and its one consumer, the flow engine's filter hand-off, is a server position. A flow addresses its own record as `{record.id}`. -- Near misses are still refused, now with `{record_id}` suggested: `{recordId}` (the URL / flow-template placeholder), `{record.id}`, `{record-id}`, `{current_record_id}`. `CONTEXT_TOKEN_SUGGESTIONS`' value type widens to `ContextToken | RecordContextToken`. - -**Presentation scope, not access.** Like `{current_user_id}`, `{record_id}` narrows what a component shows. It decides nothing about which rows the caller may read; that is still RLS. - -**What you do:** on a record page, filter a component on the record in view with `{ : '{record_id}' }`. If `os validate` refuses it with "no record in context on this surface", the filter is on a surface with no record: move it onto a component of a `type: 'record'` page, or filter on a concrete id. Until the renderer you run resolves `{record_id}`, a record-page query that carries it is refused by the server with `FILTER_TOKEN_UNRESOLVED` rather than answered with a wrong number. diff --git a/.changeset/20006-cascade-fk-clear-refusal-text.md b/.changeset/20006-cascade-fk-clear-refusal-text.md deleted file mode 100644 index e30c69de677..00000000000 --- a/.changeset/20006-cascade-fk-clear-refusal-text.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): a delete refused because its reference cleanup trips a traversing validation rule now says so, naming the delete, the cleared reference and the repair (#20006) - -Clause-②: no - -Deleting a record clears each `set_null` reference to it (the default for an optional `lookup`) with an UPDATE of every record that references it. That cleanup resolves no related record for a validation rule, so a `script` / `cross_field` rule on the referencing object that reads through a reference (`record.account.status`) cannot be evaluated there. It refuses the cleanup, and the delete with it. That refusal is unchanged: same `VALIDATION_FAILED` error, same `rule_violation` field error, same `constraint` (`reason: 'unevaluable'`, the fault, the missing key), and the same set of deletes refused. - -What changes is the message. It used to be the generic one about the rule's own object: `The predicate reads 'status', which this object does not declare — fix the rule's condition, or declare the field.` Whoever deleted the record did not write that rule, and following the advice adds a bogus column to the wrong object. The message now names the blocked delete, the reference being cleared, the rule and its object, and the repairs: - -```text -Cannot delete crm_account (acc_1): the delete clears `account` on the crm_deal records that reference it, -and validation rule 'closed_account_frozen' on crm_deal could not be evaluated on that write — it reads -'status' through `account`, and a rule is given no related record while a delete clears references. -Guard the rule on `account` being set: make it the `then` of a `conditional` rule whose `when` is -`record.account != null`. Or change `deleteBehavior` on crm_deal.account: 'cascade' deletes those records -with the crm_account, 'restrict' refuses the delete while they exist. -``` - -- **The guard** is offered only to a rule that reads through the reference the cleanup empties. Such a rule already refuses every write that leaves that reference empty (`no single related record`), so the guard only lets those writes through. The guarded rule is still judged on every write where the reference is set. -- **A rule that reads only through another reference** is offered only `deleteBehavior`. A guard on the cleared reference would stop judging that rule on every record whose cleared reference is empty, on every insert and update. -- **On a multi-value reference** the cleanup removes the deleted record and keeps the other members, so the guard would still run the rule. There, only `deleteBehavior` is offered. -- **Unchanged:** a rule that reads a key where it is not held keeps today's text byte for byte, whichever key the fault reports. Those reads are `record.KEY`, `record.FIELD.KEY` through a field that is not a reference, `previous.KEY`, and `previous.FIELD.KEY` through any field, when the record, the previous row or the field's value lacks `KEY`. A declared column always reads, as `null` when empty, so it never counts. A rule that reads through no reference keeps today's text too, and so does every write that is not a delete's reference cleanup. diff --git a/.changeset/20007-optional-lookup-guard-prescription.md b/.changeset/20007-optional-lookup-guard-prescription.md deleted file mode 100644 index 8e7d1b662e6..00000000000 --- a/.changeset/20007-optional-lookup-guard-prescription.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/formula': patch -'@objectstack/objectql': patch ---- - -fix(formula,objectql): the two refusals a traversing validation rule on an optional lookup meets now name the repairs that work — a `conditional` wrapper or `required: true` (#20007) - -Clause-②: no - -An author who wants to refuse a write when an OPTIONAL lookup is set and its related record is secret writes `record.line != null && record.line.kind == 'secret'`. Two refusals then sent them in a circle: - -1. That expression reads `line` both through the relationship and as a plain value, which cannot be served, and is refused. The refusal said to "compare the id explicitly" and write `record.line.id` for the value comparison. -2. `record.line.id != null && record.line.kind == 'secret'` reads through `line` too, so an order with no line is refused before the rule is evaluated, as "no single related record". That refusal said to "guard the rule on the reference being set" and named no spelling for the guard. - -Which writes are refused is unchanged, and so are the error, the `rule_violation` field error and its `constraint` (`reason: 'unevaluable'` and the fault). `@objectstack/lint` passes the formula refusal through unchanged, so it shows the new text too. Only the prescriptions change. Both now name the two spellings measured to work for an optional reference, and the guard is worded exactly as in the delete-cleanup refusal: - -```text -… To compare the id, write `record.line.id` for the value comparison, and keep -`record.line.` for the traversal. `record.line.id` is not a null guard: it -reads through `line` too, and a rule that reads through an empty `line` rejects the write -instead of being skipped. If the plain value tests for empty, take that test out of this -expression. To skip the rule while `line` is empty, guard it on `line` being set: make it -the `then` of a `conditional` rule whose `when` is `record.line != null`. To refuse an -empty `line`, make `line` required (`required: true`). -``` - -```text -… A predicate resolves ONE hop through a single reference. To skip the rule while `line` -is empty, guard it on `line` being set: make it the `then` of a `conditional` rule whose -`when` is `record.line != null` — `record.line.id != null` inside the rule is no guard, as -it reads through `line` too. To refuse an empty `line`, make `line` required -(`required: true`). For a multi-value reference, test it with a macro (`exists`, `size`) -instead of reading through it. -``` - -The repair as an author writes it, measured end to end on insert and update. It accepts an order with no line or a public line, and refuses a secret line with the rule's own message: - -```ts -validations: [{ - name: 'no_secret_line_when_set', type: 'conditional', - message: 'Only checked while the order names a line.', - when: 'record.line != null', - then: { name: 'no_secret_line', type: 'script', message: 'An order may not carry a secret line.', - condition: "record.line.kind == 'secret'" }, -}] -``` - -With `required: true` on `line` instead, an order with no line is refused at the field (`required`), and the rule still judges one with a line. diff --git a/.changeset/20010-analytics-where-face-arms.md b/.changeset/20010-analytics-where-face-arms.md deleted file mode 100644 index c0b684a3e9a..00000000000 --- a/.changeset/20010-analytics-where-face-arms.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(service-analytics)!: the analytics `where` door runs every arm of the shared comparand-shape face on the object spelling, not only the equality arm (#20010) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what the analytics faces of `@objectstack/service-analytics` accept. A caller `where`, a dataset `filter` or a measure `filter` that carries one of the shapes below compiled before this change, at any depth under `$and` / `$or` / `$not` or inside a nested relation. It is now refused with `INVALID_FILTER` / 400, carrying the shared face's own message, path and prescription. It ships as `minor` under the launch-window convention for accept-set narrowings. - -| you wrote | what it did before | write instead | -|:--|:--|:--| -| `{ stage: { $in: ['won', null] } }`, meaning "one of these, or empty" | `stage IN ('won', NULL)`: the empty rows were never matched | `{ $or: [{ stage: { $in: ['won'] } }, { stage: { $null: true } }] }` | -| `{ stage: { $nin: ['won', null] } }`, meaning "has a value, and not one of these" | `stage IS NULL OR stage NOT IN ('won', NULL)`: only the empty rows | `{ $and: [{ stage: { $nin: ['won'] } }, { stage: { $null: false } }] }` | -| `{ amount: { $gt: null } }` (or `$gte` / `$lt` / `$lte`) | `amount > NULL`: no row, while for `$lt` / `$lte` the draft preview charted every non-empty row | `{ amount: { $eq: null } }` for "has no value", `{ amount: { $ne: null } }` for "has a value" | -| `{ amount: { $between: [null, 100] } }` | `amount >= NULL AND amount <= 100`: no row | `{ amount: { $lte: 100 } }` for a one-sided range; `$or` with `{ amount: { $null: true } }` to include the empty rows | -| `{ amount: { $between: ['', 100] } }` | `amount >= '' AND amount <= 100`: the blank compared as a value, and the ObjectQL engine path accepted it | the bound you meant, or `{ amount: { $lte: 100 } }` for a one-sided range | -| `{ stage: { $in: 'won' } }` or `{ stage: { $nin: 'won' } }` | laundered into a one-member list | `{ stage: 'won' }` / `{ stage: { $ne: 'won' } }`, or `{ stage: { $in: ['won'] } }` | - -The shared comparand-shape face in `@objectstack/spec` (`assertListComparandShapes`) is the one place that decides whether `$in` / `$nin` / `$between` received a list at all, for every driver. Three rulings put the null and blank positions on that same door: a null list member and a null `$between` endpoint (2026-08-31), a null ordering comparand (2026-09-01), and a blank `$between` endpoint (2026-09-20). The analytics `where` door met that face only for the `FilterArray` spelling (`['stage', 'in', ['won', null]]`), which was already refused. PR #20008 carried the face's equality arm to the object spelling. Every other arm now follows it: each field entry of the object-form `where` is handed to the face after the equality pass, so both spellings of one condition get the same refusal, byte for byte. This holds on the native SQL execute path, the `/analytics/sql` echo and the ObjectQL engine path. The draft-data preview runs the same gate, so a drafted chart refuses what the published chart refuses. Before, the preview charted rows for several of these shapes: `{ amount: { $lt: null } }` charted every non-empty row. - -Three shapes this door already refused now carry the face's wording instead of this package's own, the same wording the `FilterArray` spelling gets: a `$between` that is not a two-element list, a `$between` endpoint that is a `{ $field }` reference, and an `undefined` `$between` endpoint. Their verdict, code and status are unchanged. - -Who is affected: nothing in this repository's examples, seeds, docs or package sources authors any of the shapes. This was measured by a text scan over 4013 non-test files with positive controls. Stored datasets, dashboard widget filters and report runtime filters in a deployment were NOT measured. The authoring schema still admits the shapes, so such a document still publishes, and it is refused when it is charted. - -Not changed: `$ne` with a list is not judged by the face yet, so it compiles as before. `$in: []` / `$nin: []`, falsy list members (`0`, `''`, `false`), every non-null ordering comparand, a `{ $field }` in an ordering slot, and `null` in the equality slot (the has-no-value predicate) all compile as before. The comparand-TYPE face is not run on this door. An `undefined` comparand outside a `$between` endpoint keeps this package's own refusal. diff --git a/.changeset/20011-currency-field-precision-total-digits.md b/.changeset/20011-currency-field-precision-total-digits.md deleted file mode 100644 index 4187032db53..00000000000 --- a/.changeset/20011-currency-field-precision-total-digits.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec): a currency field's `precision` is its total digit count again — `precision: 18` on a fixed-USD field parses instead of being refused as a contradiction of the currency's two decimal places (#20011) - -Clause-②: yes (widening) — one refusal is removed from `FieldSchema`, so the accepted set grows. Nothing that parsed before is refused now, no key is renamed or retired, and the output of every previously accepted field is byte-identical. - -## What was wrong - -`FieldSchema` compared a currency field's field-level `precision` with the ISO 4217 fraction digits of its fixed currency, and refused any difference. The key is declared `Total digits (non-negative integer)`, so a DECIMAL(18,2) USD amount writes `precision: 18`. That field was refused with "currency USD has 2 fraction digits; `precision: 18` contradicts it", and the refusal told the author to write `precision: 2`, which is a total-digit count of 2. - -The check assumed the field-level key was the currency display width, because the Studio currency widget used to read it that way. That reading is gone. The ruled contract is that a currency amount's decimal places come from the currency and are not a field setting. No renderer reads the field-level `precision` as decimal places, and the SQL column does not read it at all. - -## What it does now - -- The field-level `precision` on a `currency` field is not compared with the currency. Any non-negative integer parses in every currency mode and is carried through unchanged. -- `currencyConfig.precision` is a different key and is unchanged. Under `currencyMode: 'fixed'` it must still agree with the currency's fraction digits, and a contradiction is still refused at `currencyConfig.precision` with the same message. -- `scale` on a `currency` field is still refused. - -Nothing to migrate. Metadata that parsed before parses the same way. A currency field that had to drop `precision` or set it to the currency's fraction digits to get through validation can now declare its real total digit count. diff --git a/.changeset/20013-tenant-wall-post-hook.md b/.changeset/20013-tenant-wall-post-hook.md deleted file mode 100644 index fe9914bbefd..00000000000 --- a/.changeset/20013-tenant-wall-post-hook.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -fix(plugin-security)!: the Layer 0 tenant write wall now holds for the row a write stores, after the `beforeInsert` / `beforeUpdate` chain (#20013) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows the set of writes the write gate accepts. Under a walled tenancy posture (`isolated` or `group`), a write whose `beforeInsert` or `beforeUpdate` hook sets `organization_id` to an organization outside the caller's organization scope is now refused. It is admitted today, and the row is stored in that organization. It ships as `minor` under the launch-window convention, as the row-level `check` changes did (#19950, #19989). - -The Layer 0 tenant wall (ADR-0095 D1, ADR-0105 D5) holds a write's `organization_id` to the same filter the read side uses, so a non-platform user can only place a row in an organization they hold. The wall judged the payload as the caller sent it, before the engine ran the hook chain. A value a hook wrote into `organization_id` after that point was never judged, whether the hook derived it from another field, a parent record or a lookup. - -The wall now also judges the row the engine is about to store, through the seam the row-level `check` already uses (`OperationContext.postHookWriteImageCheck`): an insert's rows once the `beforeInsert` chain has run (every row of an array insert), the one row of a by-id update, and every matched row of a predicate update, each merged with the final payload. It is installed whenever the wall applies to the write, with or without a business `check`. The existing judgement of the payload as sent stays, so this change only ever refuses more. - -**Writes that are now refused.** Each refusal is the wall's existing denial, `403 PERMISSION_DENIED` ("the insert/update would place '…' in another tenant"), and nothing is stored. There is no transition switch. - -- **An insert, a by-id update or a predicate update whose hook chain leaves `organization_id` outside the caller's organization scope**: another organization under `isolated`, one outside the membership set under `group`. For an on-behalf-of write the delegator's scope applies too (ADR-0090 D10). -- **A walled write on a host that installs the judgement and never runs it**, for example a custom write executor in place of the engine. It is refused after the write with an `error` log saying the tenant wall was not evaluated on the stored row, as a write with an uncalled row-level `check` already is. The platform's own permission-set data door (ADR-0094), which executes its writes without the engine and so runs no hook chain, is not affected. - -**Remedy.** A hook that must place a row in another organization does so in a separate write under a system context, which the wall does not gate. Otherwise fix the hook, or the data it derives the organization from, so the stored row stays in the caller's organization scope. - -**What does not change.** - -- A write whose hooks leave `organization_id` in the caller's scope, or leave it alone, is admitted as before. An update that does not touch the column keeps the row's current organization. -- An insert that leaves `organization_id` absent is not judged on it: the platform fills it with the caller's active organization, as before. -- A supplied out-of-scope `organization_id` is refused before anything runs, as before, even when a hook would replace it with an in-scope one. -- A system-context write, a platform administrator on an object whose posture lets them cross the wall, and every write under the `single` posture are not gated by the wall, as before. diff --git a/.changeset/20015-name-field-id.md b/.changeset/20015-name-field-id.md deleted file mode 100644 index c9e5124a944..00000000000 --- a/.changeset/20015-name-field-id.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch -"@objectstack/service-automation": patch -"@objectstack/service-messaging": patch ---- - -fix(plugin-approvals, service-automation, service-messaging): five system objects title their records with a text formula instead of the raw id (#20015) - -Clause-②: no - -ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation, and an explicit `nameField` takes precedence over the render-only `titleFormat`. Five system objects declared `nameField: 'id'` beside a composite `titleFormat`. A renderer that follows ADR-0079's order therefore showed the raw record id as the record page's title for: - -- `sys_approval_request`, whose `titleFormat` is `{process_name} · {record_id}`; -- `sys_approval_action`, whose `titleFormat` is `{action} · {step_name}`; -- `sys_approval_approver`, whose `titleFormat` is `{approver} · {request_id}`; -- `sys_automation_run`, whose `titleFormat` is `{flow_name} · {node_id}`; -- `sys_http_delivery`, whose `titleFormat` is `{label} → {url}`. - -Each object now declares `display_title`, a formula field with `returnType: 'text'` over the same columns, and points `nameField` and `displayNameField` at it. This is the migration the `titleFormat` schema text prescribes: "a composite to a formula field designated as nameField". The record title is now the text the `titleFormat` described. Where a source column is nullable (`step_name`, `node_id`, `label`), a row without it is titled by the other column alone. - -A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads and write responses now carry `display_title`. For these objects the server-side title accessor (`resolveRecordTitle`) now returns the formula's text instead of the raw id. - -`titleFormat` stays on all five objects, unchanged, for renderers that still read it first. `$search` resolution is unchanged: a formula field is never a search target, and neither was `id`. diff --git a/.changeset/20016-turso-readme-remote-refusals.md b/.changeset/20016-turso-readme-remote-refusals.md deleted file mode 100644 index 547b810d064..00000000000 --- a/.changeset/20016-turso-readme-remote-refusals.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/driver-turso": patch ---- - -`README.md` — the remote branch of the architecture tree no longer lists `beginTransaction`, `commit` and `rollback` as `RemoteTransport` operations. `RemoteTransport` has no transaction methods, and remote mode refuses all three with `NOT_IMPLEMENTED` / 501. A new "What remote mode refuses" section lists every `NOT_IMPLEMENTED` / 501 the remote face raises: transactions (including `options.transaction` passed to the remote data and schema methods), a record number for an empty `autonumber` field on `create`, `bulkCreate` and an `upsert` with no `id`, `_id` or `conflictKeys`, `setDeferredDdl(true)`, `detectManagedDrift()`, `planMediaColumnMove()`, and two `aggregate()` shapes that `engine.aggregate()` computes in memory instead (a `groupBy` entry with a `dateGranularity`, an `aggregations` entry with a non-empty `filter`). - -Also corrected: the remote-mode bullet no longer says every operation is delegated to `RemoteTransport`, the remote example no longer says every CRUD call works as in local mode, and the local branch no longer lists array-style filters, which the driver refuses. - -- **No behaviour moves.** The driver's source and every published export are byte-identical; only the README text shipped in this package's `files[]` changes. diff --git a/.changeset/20018-native-read-scope-faces.md b/.changeset/20018-native-read-scope-faces.md deleted file mode 100644 index 1953842cfdb..00000000000 --- a/.changeset/20018-native-read-scope-faces.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -'@objectstack/service-analytics': minor ---- - -fix(service-analytics)!: the NativeSQL execute face and the `/analytics/sql` echo refuse a read scope the shared comparand faces refuse, as the ObjectQL execute face already does (#20018) - -Clause-②: no (narrowing) - - - -**BREAKING** — an accept-set narrowing on the read-scope lowering, shipped as -`minor` under the launch-window convention (`check-changeset-no-major` refuses -`major` until GA; breaking-ness is carried by this banner and the ADR-0087 -disposition above, not by the level). - -**What changed.** `compileScopedFilterToSql` is the read-scope lowering behind the -NativeSQL execute face (`NativeSQLStrategy.applyReadScope`, base table and every -joined hop) and the `/analytics/sql` echo (`ObjectQLStrategy.generateSql`), and a -public export of this package. Once its own lowering succeeds, it now runs the two -shared comparand faces of `@objectstack/spec/data` on the scope. A scope they -refuse is refused as `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld -(the #5367 envelope), before any statement is built or executed. That is the -answer the ObjectQL execute face has given the same scope since #19995, so one read -scope now gets one verdict on every analytics face. - -**Which read scopes stop being served.** Each was lowered and executed before, and -each is refused by a standing ruling the shared faces carry. Measured on SQLite: - -| read-scope shape | what the native face and the echo served | -| --- | --- | -| a `null` member of `$in` | only the named non-null values; the NULL matched nothing | -| a `null` member of `$in` under `$not`, or of `$nin` | only the rows whose column is NULL, which the scope excludes | -| a `null` comparand under `$gt` / `$gte` / `$lt` / `$lte`, or a `null` `$between` bound | zero rows | -| a blank (`''`) `$between` bound | the rows inside the half-blank range | -| a bigint beyond ±2^53, or a binary comparand | zero rows | -| a plain-object or other non-plain-object comparand in a scalar position | the database refused the statement (`DATABASE_ERROR` / 500) | - -**Who is affected.** A host `getReadScope` provider, or a direct caller of -`compileScopedFilterToSql`, that produces one of these shapes. The ObjectQL -analytics face already refused all of them. No in-repo read-scope producer emits -them for a policy in this repository. An RLS `using` predicate can still be -written so that it lowers into the null shapes (a literal `null` inside an `in` -list, or an ordering comparison against `null`), but the RLS compiler drops such a -policy (#20212), so the analytics faces receive the deny sentinel and answer zero rows. - -**Fix.** State absence with the null predicate. "One of these values, or no -value" is `{ "$or": [{ "f": { "$in": ["a"] } }, { "f": { "$null": true } }] }`, -which in an RLS predicate is `f in ['a'] || f == null`. A one-sided range is `$gte` -or `$lte`. A comparand is a string, number, bigint within ±2^53, boolean, `null` or -`Date`. - -**Unchanged.** - -- Well-formed scopes compile to the same SQL and admit the same rows. That includes - the null predicates, an emptied `$in` beside an own-rows grant, and the spelling - above. -- A shape the lowering already refused keeps its own log sentence. -- The caller's own `where` never reaches this lowering, and it is untouched. diff --git a/.changeset/20020-refusal-doors-provenance.md b/.changeset/20020-refusal-doors-provenance.md deleted file mode 100644 index 5ffa7c36276..00000000000 --- a/.changeset/20020-refusal-doors-provenance.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/driver-sql': patch -'@objectstack/driver-sqlite-wasm': patch -'@objectstack/driver-turso': patch ---- - -fix(driver-sql, driver-turso): four filter-refusal doors stop naming a read scope's field and comparand unless the refused predicate is marked as the caller's own (#20020) - -Clause-②: no - -A read scope is the RLS, sharing or tenant predicate that `plugin-security` (ordinary reads) and `service-analytics` (the ObjectQL analytics face) AND into the caller's `where`. Both merges mark the scope `'policy'` and the caller's own predicate `'author'` (`markFilterSubtreeProvenance`, `@objectstack/spec/data`). When `SqlDriver` refused a scope at one of the four doors below, the `INVALID_FILTER` / 400 message named the scope's field, and for three of them its comparand too. It did not check the mark. Measured on both faces, through `POST /api/v1/analytics/query` and through an ObjectQL `find` under a merge shaped like `plugin-security`'s: - -- a column the table does not have. This is reachable from a real CEL rule on a field that is declared but has no column yet; -- a retired operator (`$regex`, `$options`) or an operator outside the vocabulary; -- `$and` / `$or` whose operand is not a list; -- a `$null` / `$exists` whose comparand is not a boolean. - -Each of these doors now reads the mark on the node it refused, the same way the cross-field and target-field refusals already did: - -- **`'policy'`, unmarked or ambiguous:** same `INVALID_FILTER` / 400. The message says which kind of refusal fired, but names no field, operator, comparand or filter path. Those go to the server log. For the unresolvable column, the message is the unnamed wording the driver already used when it could not parse the dialect's message. -- **`'author'`:** the full message, the same text the door answered before. - -To find the node, the unresolvable-column door looks up the column name the database reported. It discloses only when every node that names that column is marked `'author'`. A `$and` / `$or` with a primitive operand is judged by the node that carries the key. - -`SqliteWasmDriver` (`@objectstack/driver-sqlite-wasm`) and `TursoDriver` in local mode extend `SqlDriver`, so they inherit this change from it: the same four doors answer the same way there. - -**What an unmarked caller loses:** its own diagnostic from these four doors. Measured cases where the caller's own predicate reaches the driver unmarked: - -- no security plugin in the stack; -- a system-context call; -- an anonymous call; -- a `where` that holds a `{placeholder}` token, which the engine rewrites before the merge. - -That caller gets the withheld wording with the same code and status. A member's plain `where` under `plugin-security` is marked `'author'` and keeps the full text. - -The same three door classes on the Turso REMOTE transport (`RemoteTransport`) now read the mark too: the retired or unknown operator (including a non-operator key in an operator map), the non-list combinator, and the non-boolean `$null` / `$exists`. The operands go to its diagnostic sink. `TursoDriver`'s remote mode rebuilds every filter node before the transport sees it, so no mark reaches the transport there, and these refusals keep the withheld wording for every caller in that mode. The unresolvable WHERE column has no refusal on the remote face (the transport answers `[]`) and is not changed here. - -Not changed: which filters are refused, and the code and status of every refusal. The engine's declared-type, temporal-comparand and filter-token doors are not changed here. diff --git a/.changeset/20024-like-stored-nul.md b/.changeset/20024-like-stored-nul.md deleted file mode 100644 index 15866bf60da..00000000000 --- a/.changeset/20024-like-stored-nul.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/driver-sql": patch -"@objectstack/driver-sqlite-wasm": patch -"@objectstack/driver-turso": patch ---- - -fix(driver-sql, driver-turso): on SQLite, `$like` / `$ilike` read the whole stored value, instead of stopping at its first U+0000 (#20024) - -Clause-②: no - -On the SQLite faces `$like` and `$ilike` compiled to `GLOB`, and SQLite's `glob()` reads the stored value only up to its first U+0000. So a pattern without U+0000 answered a different question over a value holding one, and nothing raised. Measured on `SqlDriver` over better-sqlite3 (SQLite 3.53.4), on `SqliteWasmDriver` over sql.js (3.49.1), on `TursoDriver`'s local mode, and on its remote transport over a local libSQL engine (3.45.1). All four answered alike: - -- `$like: 'a'` returned a value stored as `'a'` + U+0000 + `'b'`, and `$like: ''` returned U+0000 + `'z'`; -- `$like: '%b'`, `$like: 'a_b'` and `$ilike: 'A_B'` did not return `'a'` + U+0000 + `'b'`; -- `$like: '_'` did not return a value that is a lone U+0000. - -Over 108 patterns and 22 `$not` / `$or` / `$and` compositions against 59 stored values, 359 of the 3380 cells over values holding U+0000 differed from `@objectstack/formula` on each face. - -What changes: a stored value holding U+0000 now has each U+0000 replaced by one stand-in character before `GLOB` reads it. The stand-in is never a literal character of the pattern, never an ASCII letter, and never U+0000. A U+0000 in the value can only be matched by `%` or `_`, and so can the stand-in, so the answer is the one the whole value gives. Such a filter now returns the rows `driver-memory` and `@objectstack/formula` return for it, under `$not`, `$or` and `$and` as well: 0 of those 3380 cells differ on any of the four faces. `$ilike` still folds ASCII letters only. - -- `@objectstack/driver-sql`: the SQLite arm of `SqlDriver`'s `$like` / `$ilike` compiler. `SqliteWasmDriver` and `TursoDriver`'s local mode inherit it. -- `@objectstack/driver-sqlite-wasm`: none of its own code changes. It inherits the fix. -- `@objectstack/driver-turso`: the remote transport's own emitter, changed the same way. - -What does not change: - -- A stored value without U+0000 gets the same answer as before: 0 of 16640 such cells moved on any face. -- A pattern that is a literal prefix followed only by `%` (`'ab%'`, `'%'`) compiles to the same `GLOB` with the same bound pattern as before. Cutting the value at its first U+0000 cannot change that answer. -- No index is lost. Under `EXPLAIN QUERY PLAN` over an indexed TEXT column on all three engines, each `$like` pattern measured that starts with a literal (`'ab%'`, `'ab_'`, `'ab%cd'`, `'abc'`, `'a%b%'`) keeps its covering-index search, and each one that starts with a wildcard still scans. A case-exact pattern with a literal prefix now leads with a `GLOB` on that prefix followed by `*`, which every matching value satisfies and which is what keeps that search. -- `_` still matches one character, as `GLOB`'s `?` does. A character outside the Basic Multilingual Plane is one character to `_` on SQLite and two to `@objectstack/formula`, which counts UTF-16 units. That difference is older than this change, and this change does not alter it. -- A pattern holding U+0000 is still refused (`INVALID_FILTER` / 400). -- The Postgres and MySQL arms are untouched. - -Cost, measured over 10,000 rows on the three engines: against a value without U+0000 the new compile adds one `instr()` per row, and those queries took 1.1 to 2.4 times as long as `GLOB` alone, at most 4.5 ms. A value holding U+0000 pays the rewrite: 10,000 rows each holding one to three U+0000 took up to 35 ms, against at most 3 ms for `GLOB`. diff --git a/.changeset/20024-sqlite-glob-stored-nul.md b/.changeset/20024-sqlite-glob-stored-nul.md deleted file mode 100644 index ca618b4cefa..00000000000 --- a/.changeset/20024-sqlite-glob-stored-nul.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/driver-sql": patch -"@objectstack/driver-sqlite-wasm": patch -"@objectstack/driver-turso": patch ---- - -fix(driver-sql, driver-turso): on SQLite, `$contains` / `$notContains` / `$icontains` / `$endsWith` read the whole stored value, instead of stopping at its first U+0000 (#20024) - -Clause-②: no - -On the SQLite faces these four operators compiled to `GLOB` for a comparand without U+0000, and SQLite's `glob()` reads the stored value only up to its first U+0000. Nothing raised, and the filter answered a different question. Measured on `SqlDriver` over better-sqlite3 (SQLite 3.53.4), on `SqliteWasmDriver` over sql.js (3.49.1), on `TursoDriver`'s local mode, and on its remote transport over a local libSQL engine (3.45.1). All four answered alike: - -- `$contains: 'b'` did not return a value stored as `'a'` + U+0000 + `'b'`; -- `$endsWith: 'a'` returned that value, and `$endsWith: 'b'` did not; -- `$notContains: 'b'` returned it; -- `$icontains: 'B'` did not return `'A'` + U+0000 + `'B'`. - -What changes: these four operators now compile to the length-aware comparisons a comparand holding U+0000 already used, for every comparand. `$contains`, `$notContains` and `$icontains` use `instr()`, and `$endsWith` compares the value's trailing bytes over BLOB. An empty `$endsWith` comparand uses `instr()` too, so it still matches every non-NULL value. Such a filter now returns the rows `driver-memory` and `@objectstack/formula` return for it, under `$not`, `$or` and `$and` as well. The comparand is bound as written, so `*`, `?` and `[` in it are literal, as they were before. `$icontains` still folds ASCII letters only, and `$notContains` still returns a row whose value is NULL. - -- `@objectstack/driver-sql`: the SQLite arm of `SqlDriver`'s text-operator compiler. `SqliteWasmDriver` and `TursoDriver`'s local mode inherit it. -- `@objectstack/driver-sqlite-wasm`: none of its own code changes. It inherits the fix. -- `@objectstack/driver-turso`: the remote transport's own emitter, changed the same way. - -What does not change: - -- `$startsWith` with a comparand without U+0000 compiles to the same `GLOB` with the same bound pattern as before. The stored value's cut cannot change its answer. -- No index is lost. The SQL `SqlDriver` compiles, run under `EXPLAIN QUERY PLAN` over an indexed TEXT column on all three engines, scanned the table for these four operators under `GLOB` and still does; `$startsWith` keeps its index search. -- The Postgres and MySQL arms are untouched. -- `$like` and `$ilike` are outside this entry. Two other entries in this release cover them: on SQLite they now read the whole stored value as well, and every driver that answers `$like` refuses a pattern holding U+0000 (`INVALID_FILTER` / 400). diff --git a/.changeset/20025-analytics-sqlite-text-nul.md b/.changeset/20025-analytics-sqlite-text-nul.md deleted file mode 100644 index 53627f671f8..00000000000 --- a/.changeset/20025-analytics-sqlite-text-nul.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -fix(service-analytics): on SQLite, the text operators in analytics filters and read scopes compare the whole stored value and the whole comparand, instead of stopping at their first U+0000 (#20025) - -Clause-②: no - -`service-analytics` compiles its own SQL for `$contains`, `$notContains`, `$startsWith`, `$endsWith` and `$icontains`, in three places: the read scope applied to an analytics query (`compileScopedFilterToSql`), the `where` that `NativeSQLStrategy` executes, and the `ObjectQLStrategy` statement the `/analytics/sql` caller runs. On a SQLite datasource all three compiled these operators to `GLOB`, and SQLite's `glob()` reads both the pattern and the stored value only up to their first U+0000. Nothing raised, and the filter answered a different question. Measured on better-sqlite3 (SQLite 3.53.4) and sql.js (3.49.1), every one of these faces alike: - -- a comparand holding U+0000 was cut at it, so `$contains` / `$endsWith` could match every row and `$notContains` none; -- a stored value holding U+0000 was read only up to it, so `$contains` / `$endsWith` / `$icontains` missed a match after it, `$endsWith` could match what came before it, and `$notContains` / `$not` returned a row whose value does contain the comparand. - -On a read scope the first kind widens what the scope admits and the second narrows or widens it. `driver-sql` and `driver-turso` already compile these operators this way (the #19999 and #20024 fixes); this package re-emits their construct table rather than importing it, and its copy had kept `GLOB`. - -What changes: on the `sqlite` dialect these operators now compile to `driver-sql`'s constructs, cell for cell. `$contains`, `$notContains` and `$icontains` use `instr()`; `$endsWith` compares the value's trailing bytes over BLOB, with an empty comparand using `instr()` so it still matches every non-NULL value; a `$startsWith` comparand holding U+0000 uses `instr(…) = 1`. Such a filter now returns the rows `@objectstack/formula` and `driver-sql` return for it, on all three faces, bare and under `$not`, with `''` and NULL values included. The comparand is bound as written, so `*`, `?` and `[` in it are literal, as they were. `$icontains` still folds ASCII letters only, and `$notContains` still returns a row whose value is NULL. - -What does not change: - -- `$startsWith` with a comparand without U+0000 compiles to the same `GLOB` with the same bound pattern as before; the stored value's cut cannot change its answer. It keeps its index search (`EXPLAIN QUERY PLAN` over an indexed TEXT column on both engines); the other operators scanned the table under `GLOB` and still do. -- The Postgres and MySQL arms, and every comparand refusal that runs before the text arm, are untouched. -- A host that answers no SQL dialect for a SQLite datasource still gets the dialect-neutral `LIKE`, which SQLite also reads only up to the first U+0000. -- `$like` and `$ilike` are still refused by these compilers, as before. diff --git a/.changeset/20026-conditional-validation-examples-cel.md b/.changeset/20026-conditional-validation-examples-cel.md deleted file mode 100644 index c685c442893..00000000000 --- a/.changeset/20026-conditional-validation-examples-cel.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Every example predicate in the `ConditionalValidationSchema` TSDoc docblock (all seven Use Cases, plus the ObjectStack side of the "Salesforce Pattern Comparison" block, above the schema in `packages/spec/src/data/validation.zod.ts`) is rewritten in the CEL the evaluator actually accepts, so an author or agent who copies a documented example gets a rule that evaluates instead of one that refuses every write it guards (#20026). - -Clause-②: no - -17 of the 19 predicates used `=` for comparison (or `AND`/`NOT`/`REGEX(...)` word-form operators) — a CEL parse error, since `@marcbachmann/cel-js` rejects a lone `=` as an unexpected character; CEL equality is `==`, and CEL has no `AND`/`OR`/`NOT` keyword form — and the other 2 (`order_total > 10000`, `approval_amount > 50000`) named a bare field with no `record.` root, an unknown-variable type error. `REGEX(tax_id, "…")` is rewritten to the evaluator's registered `matches(record.tax_id, "…")` stdlib function — the CEL form it accepts, not a literal transliteration — with the escape-free character class `[0-9]` rather than `\d`: a `\d` inside the CEL string literal parses fine as written in this TSDoc comment (comments are not JS-escape-processed), but a reader who pastes the same text into a real `.ts` string literal gets ONE level of JS unescaping the comment never applied, so `\\d` in the comment becomes `\d` at runtime and cel-js refuses it (`Invalid escape sequence: \d` — this was this round's own rework: a prior revision of this changeset claimed a passing result for that predicate without measuring it on the copied literal). The Salesforce-formula half of the comparison (`IF(ISPICKVAL(...), AND(...), FALSE)`) is deliberately untouched: it documents Salesforce's own syntax, not CEL. - -Measured through `ExpressionEngine.evaluate` (`@objectstack/formula`), taking each predicate as the RUNTIME STRING a reader gets by pasting the docblock's literal into real `.ts` source — the literal is extracted from the source file byte for byte and handed to Node's own parser to unescape, never hand-retyped — against a record that makes each rewritten predicate true and one that makes it false; both branches evaluate as expected for all 19 (plus one extra disjunct check on the regex branch) — transcript in the PR body. The strings ship in the published `dist/object.zod-*.d.ts`, confirmed before and after this change with a lit/dark control: every rewritten string is present exactly once and every original broken string is absent. - -| old (fails) | new (evaluates) | -|:--|:--| -| `account_type = "enterprise"` | `record.account_type == 'enterprise'` | -| `approval_status = null` | `record.approval_status == null` | -| `requires_shipping = true` | `record.requires_shipping == true` | -| `shipping_address = null OR shipping_address = ""` | `record.shipping_address == null \|\| record.shipping_address == ''` | -| `order_total > 10000` | `record.order_total > 10000` | -| `manager_approval_id = null` | `record.manager_approval_id == null` | -| `payment_method = null` | `record.payment_method == null` | -| `region = "EU"` | `record.region == 'EU'` | -| `gdpr_consent_given = false` | `record.gdpr_consent_given == false` | -| `tos_accepted = false` | `record.tos_accepted == false` | -| `country = "US"` | `record.country == 'US'` | -| `state = "CA"` | `record.state == 'CA'` | -| `tax_id = null OR NOT(REGEX(tax_id, "^\d{2}-\d{7}$"))` | `record.tax_id == null \|\| !matches(record.tax_id, "^[0-9]{2}-[0-9]{7}$")` | -| `is_taxable = true` | `record.is_taxable == true` | -| `tax_code = null OR tax_code = ""` | `record.tax_code == null \|\| record.tax_code == ''` | -| `user_role = "manager"` | `record.user_role == 'manager'` | -| `approval_amount > 50000` | `record.approval_amount > 50000` | -| `type = "enterprise"` (Salesforce comparison) | `record.type == 'enterprise'` | -| `amount > 100000 AND approval = null` (Salesforce comparison) | `record.amount > 100000 && record.approval == null` | - -`packages/spec/src/data/validation.test.ts`'s `ConditionalValidationSchema` fixtures move with the docblock (parse-only — `ValidationRuleSchema.parse(...).not.toThrow()`, no CEL evaluation, so no behaviour change): the six exact copies of Use Cases 1-3's original seven strings; `manager_approval = null` (the `order_value_validation` fixture whose message text and structure mirror Use Case 3's manager-approval example one field-name spelling apart), now `record.manager_approval == null`; and, under this round's rework, ten more fixtures whose surrounding test name and message text are verbatim copies of Use Cases 4, 5, 6 and 7's docblock text — `is_taxable = true` / `tax_code = null` (`tax_validation`, Use Case 6, the `tax_code` fixture now the full `record.tax_code == null || record.tax_code == ''`), `region = "EU"` / `gdpr_consent_given = false` / `tos_accepted = false` (`regional_validation`, Use Case 4), `user_role = "manager"` / `approval_amount > 50000` (`role_based_validation`, Use Case 7), and `country = "US"` / `state = "CA"` / `tax_id = null` (`nested_validation`, Use Case 5 — the `tax_id` fixture now the full `record.tax_id == null || !matches(record.tax_id, "^[0-9]{2}-[0-9]{7}$")`, escape-free for the same copy-paste reason as the docblock). 17 fixture strings moved in total. No schema, behaviour, or public export changes — TSDoc text only, in the same two files the strings already lived in. diff --git a/.changeset/20027-private-credential-objects.md b/.changeset/20027-private-credential-objects.md deleted file mode 100644 index 1acd0ba9171..00000000000 --- a/.changeset/20027-private-credential-objects.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -fix(plugin-security)!: no shipped permission set below platform admin reads a row of `sys_verification` or `sys_jwks` — the credential rows those objects declare private (#20027) - -Clause-②: no (narrowing) - -**BREAKING** — shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by -this banner and the ADR-0087 disposition below, never by the level). - -`sys_verification` (one-time verification and password-reset tokens) and -`sys_jwks` (JWT signing keys) declare `access: { default: 'private' }`, and the -shipped permission sets documented them as denied to every principal below -platform admin. The sets did not hold that: the read they grant on every -better-auth-managed object is an explicit per-object entry, which the `private` -posture does not govern, and neither object had a row policy behind it. - -**What a principal can no longer read.** Every shipped set that grants that read -and carries row-level security — `member_default`, `viewer_readonly`, -`organization_admin` and its wall-less variant `organization_admin_no_bypass` — -now declares a row policy that admits no row on each of the two objects -(`sys_verification_none`, `sys_jwks_none`). That covers the rows the objects -declare private, including a caller's own verification row. An MCP agent acting -for a user is bounded by that user's sets and reads the same. `admin_full_access` -is unchanged and still reads every row. - -**Remedy.** None is expected to be needed: no shipped product surface reads these -tables under a user context. A deployment that genuinely needs a principal below -platform admin to inspect them grants that in a permission set of its own, with -a row-level-security policy that names the rows it may see. - -Unchanged: better-auth's own verification, password-reset and token-signing flows -(they read and write through its adapter under system context, which no row -policy reaches), the owner-scoped reads of the other `private` identity objects -(`sys_device_code`, `sys_oauth_access_token`, `sys_oauth_refresh_token`), and -every other managed object. - - diff --git a/.changeset/20029-pin-bump-describe-correction.md b/.changeset/20029-pin-bump-describe-correction.md deleted file mode 100644 index 3aec0183ce0..00000000000 --- a/.changeset/20029-pin-bump-describe-correction.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -One published `describe` sentence that dates itself to the `.objectui-sha` pin is re-pointed to the pin this release builds against, objectui `f8a9d0fb0596`, after being re-read there (#20029). - -`Clause-②: no` - -- `FormField.span`: the `'auto'` clause says that at the pin this repo builds against, only textarea, markdown, html, richtext and repeater resolve to the full column count. It named `62597c588`. Re-read at `f8a9d0fb0596`, the claim still holds: `plugin-form`'s `autoLayout.ts` (`WIDE_FIELD_TYPES`, `resolveColSpan`) is byte-identical, and `form.tsx` changed only in its registration's input list (a `children` slot input), with `spanLadderFor` byte-identical, so `'full'` is still the whole row at every multi-column tier. Only the pin the sentence names moves. - -No key, default, enum member or export moves: the same authored metadata is accepted and refused as before, and `content/docs/references/ui/view.mdx` is regenerated from the sentence. diff --git a/.changeset/20034-automation-contract-api-v1-paths.md b/.changeset/20034-automation-contract-api-v1-paths.md deleted file mode 100644 index 148fb5beac0..00000000000 --- a/.changeset/20034-automation-contract-api-v1-paths.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`AutomationApiContracts` now names the paths the platform actually serves — `/api/v1/automation…` instead of `/api/automation…` (#20034). - -The dispatcher mounts the automation door at its `prefix` plus `/automation`, the prefix defaults to `/api/v1`, and `objectstack serve` passes none. So all nine declared paths answered `404 ENDPOINT_NOT_FOUND` on the default composition while the same requests under `/api/v1/automation` answered `200`, and the generated API reference printed the nine unserved paths as the endpoints. Every other `*ApiContracts` map in `@objectstack/spec/api` already carried `/api/v1`; this one was the only outlier. The runtime is unchanged — only the declaration moves. - -Clause-②: no - -**What moved on the published surface** - -| entry | from | to | -| --- | --- | --- | -| `listFlows` (`GET`), `createFlow` (`POST`) | `/api/automation` | `/api/v1/automation` | -| `getFlow` (`GET`), `updateFlow` (`PUT`), `deleteFlow` (`DELETE`) | `/api/automation/:name` | `/api/v1/automation/:name` | -| `triggerFlow` (`POST`) | `/api/automation/:name/trigger` | `/api/v1/automation/:name/trigger` | -| `toggleFlow` (`POST`) | `/api/automation/:name/toggle` | `/api/v1/automation/:name/toggle` | -| `listRuns` (`GET`) | `/api/automation/:name/runs` | `/api/v1/automation/:name/runs` | -| `getRun` (`GET`) | `/api/automation/:name/runs/:runId` | `/api/v1/automation/:name/runs/:runId` | - -The module's `Base path` and endpoint list move with them, and so does the text `ListRunsRequestSchema` raises for a retired `cursor`: it now names `GET /api/v1/automation/:name/runs`. - -**Who notices.** A caller that built request URLs from these constants was calling paths nothing served on the default composition; it now reaches the serving door with no code change. A caller that hard-coded one of the old strings should send the `/api/v1/automation…` form. The `path` type is unchanged (`string`), no accepted input narrows, and no method changes. - -A host that mounts the dispatcher under a different prefix — `@objectstack/hono`'s `createHonoApp`, whose `prefix` defaults to `/api`, is the in-repo example — serves every contract family under that prefix, so it replaces the leading `/api/v1` of any `*ApiContracts` path, now including these nine. The environment-scoped mount (`/api/v1/environments/:environmentId/automation…`, the only one served under `projectResolution: 'required'`) is not declared here, as it is not in any other contract map. - -**Kept from drifting again.** A new test in `@objectstack/runtime` boots the dispatcher plugin with its default prefix and requires every contract route to be one it mounts, and to be a row of the runtime route ledger under the `/api/v1` wire prefix that the live-mount parity gate probes. diff --git a/.changeset/20035-analytics-where-type-face.md b/.changeset/20035-analytics-where-type-face.md deleted file mode 100644 index b9e36863523..00000000000 --- a/.changeset/20035-analytics-where-type-face.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(service-analytics)!: the analytics `where` door runs the shared comparand-TYPE face on the object spelling, so a plain-object, binary, `Map`, class-instance, oversized-bigint or `undefined` comparand is refused like the `FilterArray` spelling and the engine refuse it (#20035) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what the analytics faces of `@objectstack/service-analytics` accept. A caller `where`, a dataset `filter` or a measure `filter` in the object spelling that carries one of the comparands below compiled before this change, at any depth under `$and` / `$or` / `$not` or inside a nested relation. It is now refused with `INVALID_FILTER` / 400, in the shared face's own message, path and prescription, before any SQL statement runs or any `engine.aggregate` call is made. It ships as `minor` under the launch-window convention for accept-set narrowings. - -| comparand in an object-form `where` | before (native SQL execute) | now, on every face | -|:--|:--|:--| -| a plain object under a scalar operator: `{ stage: { $ne: { a: 1 } } }`, `$gt`, `$eq`, the LIKE family | bound as the JSON text `'{"a":1}'`; `$ne` served EVERY row, the others matched nothing; the `/analytics/sql` echo answered `DATABASE_ERROR` / 500 | refused 400: "Filter comparand at where.stage.$ne is a plain object …" | -| a plain object as a `$between` endpoint or an `$in` / `$nin` member | the endpoint was compared as JSON text; the member was refused in this package's own sentence | refused 400, in the face's sentence | -| a `{ $field: 5 }` object (a non-string `$field`) under an ordering operator | bound as JSON text (it is not a field reference) | refused 400 as a plain object | -| a binary (`Uint8Array` / `Buffer`) under `$eq` / `$ne` or as an `$in` member | bound as the JSON text `'{"0":1,…}'`, never as a blob; `$ne` served every row | refused 400 | -| a binary, a `Map` or a class instance as the implicit comparand `{ stage: VALUE }` | read as a NESTED RELATION (`stage.0 = 1 AND …`, a 500 on the native path) or refused as a zero-operator wrapper | refused 400 as the value it is | -| a `bigint` beyond ±2^53 | bound as-is, answering no row | refused 400 | -| `undefined`, implicit or under any operator, including `$null` / `$exists` | refused 400 in this package's own sentence, except `{ $null: undefined }`, which compiled to IS NOT NULL; the draft preview answered no row | refused 400, in the face's sentence | - -The shared comparand-type face in `@objectstack/spec` (`normalizeFilterComparandTypes`) is the #7872 door: its accepted comparand types are `string | number | bigint | boolean | null | Date`, and it refuses everything else loudly at the compile face (maintainer ruling, 2026-08-12). `parseFilterAST` runs it on everything it returns and the ObjectQL engine's seam on every object-form `where`, so the `FilterArray` spelling of this door and the engine path already refused each row above. The object spelling now meets it too, after the comparand-shape face and before any node is built, the order `parseFilterAST` uses. Both spellings of one condition get the same refusal, byte for byte, on the native SQL execute path, the `/analytics/sql` echo, the ObjectQL engine path and the draft-data preview. - -A `bigint` within ±2^53 is not refused: the face narrows it to its number, and the condition every face lowers is the narrowed one. The rows do not change on the published faces. The draft-data preview used to order a bigint as text (`{ amt: { $gt: 2n } }` lost the row holding 10); it now evaluates the narrowed number and charts the published rows. - -Binary comparands are reconciled with the face rather than kept as a declared local extra of this package. Measured before this change, the `where` door never compared a binary as a blob on any face: the native path bound it as JSON text, the engine path and the `FilterArray` spelling refused it, and no producer can send one over JSON. This change does not touch the read-scope door, which refuses a binary comparand too since the read-scope lowering began running the same face (#20018), so a binary is now refused at both analytics doors. - -Refusals this door already gave in its own words now read in the face's words, the same words the `FilterArray` spelling gets: an `undefined` comparand, and a plain-object `$in` / `$nin` member or LIKE-family comparand. Their verdict, code and status are unchanged. The positions the face does not judge keep this package's sentences: an array or a `{ $field }` reference as a list member or a LIKE comparand, and an `undefined` inside an array comparand or under an operator outside the vocabulary. - -Who is affected: nothing in this repository's examples, seeds, docs or package sources authors any of the refused comparands. A text scan over 4013 non-test files found none, and it did find the shape in a code comment written for this change. Stored datasets, dashboard widget filters and report runtime filters in a deployment were NOT measured. Of the refused values, only the plain object can be stored as JSON. - -The refusal both analytics doors give for an unbindable `$in` / `$nin` / `$between` member no longer offers "(or a binary value)" as a repair, because neither door accepts a binary any more. It now names only the accepted set; its code, status and verdict are unchanged. - -Not changed: every string, number, boolean, `null` and `Date` comparand; a `{ $field }` reference in an ordering slot (served on the engine path); nested relations and dotted members; `$ne` with a list, which the shared face does not judge yet. diff --git a/.changeset/20039-compile-refusal-seam.md b/.changeset/20039-compile-refusal-seam.md deleted file mode 100644 index c714c30e8f3..00000000000 --- a/.changeset/20039-compile-refusal-seam.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/driver-sql': patch -'@objectstack/driver-sqlite-wasm': patch -'@objectstack/driver-turso': patch ---- - -fix(driver-sql, driver-turso): every filter-compile refusal stops naming a read scope's field or literal unless the refused predicate is marked as the caller's own (#20039) - -Clause-②: no - -A read scope is the RLS, sharing or tenant predicate that `plugin-security` (ordinary reads) and `service-analytics` (the ObjectQL analytics face) AND into the caller's `where`. Both merges mark the scope `'policy'` and the caller's own predicate `'author'` (`markFilterSubtreeProvenance`, `@objectstack/spec/data`). Nine more `SqlDriver` filter-compile refusals did not check the mark, so when one of them refused a scope, its `INVALID_FILTER` / 400 message named the scope's field, and for most of them its literal too. Measured through an ObjectQL `find` under a merge shaped like `plugin-security`'s, with the scope in the `'policy'` arm: - -- an empty or non-string `$icontains` comparand; -- a non-string `$like` / `$ilike` comparand; -- a `$like` / `$ilike` pattern ending in a lone backslash; -- an object or array comparand on `$contains`, `$notContains`, `$startsWith`, `$endsWith` or `$icontains`; -- an `$in` / `$nin` / `$between` member that cannot be bound; -- an `undefined` comparand, in any position; -- an element of `$and` / `$or`, or the operand of `$not`, that is not a filter condition object; -- a `$`-prefixed key in a node position that is not `$and`, `$or` or `$not`; -- a `where` that reaches the driver as an array (the message printed the whole array). - -Each of them now reads the mark on the node it was raised from, as the other compile refusals already did: - -- **`'policy'`, unmarked or ambiguous:** same `INVALID_FILTER` / 400. The message says which kind of refusal fired, but names no field, operator variant, comparand, list position or filter path. Those go to the server log. -- **`'author'`:** the full message, the same text the refusal answered before. - -With these nine, every refusal on `SqlDriver`'s filter-compile path goes through the same seam. - -`SqliteWasmDriver` (`@objectstack/driver-sqlite-wasm`) and `TursoDriver` in local mode extend `SqlDriver`, so they inherit this change from it: the same refusals answer the same way there. - -**What an unmarked caller loses:** its own diagnostic from these refusals. That is every caller whose predicate reaches the driver unmarked, for example with no security plugin in the stack, in a system-context or anonymous call, or with a `where` that holds a `{placeholder}` token (the engine rewrites it before the merge). That caller gets the withheld wording with the same code and status, and the full text is in the server log. A member's plain `where` under `plugin-security` is marked `'author'` and keeps the full text. - -The Turso REMOTE transport (`RemoteTransport`) compiles filters itself. Its copies of these refusals now read the mark the same way: the `$icontains`, `$like` / `$ilike`, lone-backslash, `undefined`, non-node element or operand, undeclared-key and non-object `where` refusals. So do its two other compile refusals that still named the field: an operator map with no operator in it, and a `$between` that reached the transport without being lowered. The operands go to its diagnostic sink. For six of these classes, the withheld sentence is the local one behind the `[RemoteTransport]` prefix. An object text comparand and an unbindable list member already answered there through its comparand refusal, which withholds. `TursoDriver`'s remote mode rebuilds every filter node before the transport sees it, so no mark reaches the transport there, and these refusals keep the withheld wording for every caller in that mode. - -Not changed: which filters are refused, and the code and status of every refusal. diff --git a/.changeset/20040-analytics-null-flag.md b/.changeset/20040-analytics-null-flag.md deleted file mode 100644 index 22b513ce7be..00000000000 --- a/.changeset/20040-analytics-null-flag.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(service-analytics)!: the analytics `where` door refuses a non-boolean `$null` / `$exists` flag, which it used to read as IS NOT NULL, the way every backend and the read-scope compiler already refuse it (#20040) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what the analytics faces of `@objectstack/service-analytics` accept. A `$null` or `$exists` flag whose value is not a boolean used to compile, in a caller `where`, a dataset `filter` or a measure `filter`, at any depth under `$and` / `$or` / `$not` or inside a nested relation. That covers a string such as `"false"`, a number, `null`, an array, a `Date` and a `{ $field }` reference. It is now refused with `INVALID_FILTER` / 400, in a message that names the operator, the field and the path, before any SQL statement runs or any `engine.aggregate` call is made. It ships as `minor` under the launch-window convention for accept-set narrowings. - -| flag in an object-form `where` | before | now | -|:--|:--|:--| -| `$null` or `$exists` with a string, a number, `null`, an array, a `Date` or a `{ $field }` reference | read as IS NOT NULL on the native SQL path, the `/analytics/sql` echo and the ObjectQL engine path (the engine received `{ "$ne": null }`); `POST /api/v1/analytics/query` and `/analytics/dataset/query` answered 200 | refused 400: `Operator "$null" on field "stage" requires a boolean comparand (true or false). Received …` | -| the same, under `$not` | the negation of IS NOT NULL: the rows with no value | refused 400 | -| the same, in the draft-data preview | refused 400 as an operator the preview does not evaluate | refused 400, in the published door's words | -| `true` or `false` | IS NULL or IS NOT NULL, per the contract | unchanged: the compiled tree, the SQL and the engine `where` are byte-identical | -| `undefined`, or a plain object | refused 400 by the shared comparand-type face | unchanged, in that face's words | - -`FieldOperatorsSchema` in `@objectstack/spec` declares both flags as booleans. The #5347 and #5369 rulings refuse a non-boolean one in every position and on every backend, because the backends read one in opposite directions: `driver-sql` compiled IS NULL for anything but `false`, and the JavaScript drivers compiled IS NOT NULL for anything but `true`. `driver-sql` refuses it, and so does this package's read-scope compiler. This door read every non-boolean as IS NOT NULL, so `"true"` and `"false"` asked for the same rows, and `{ "$null": "true" }` asked for the rows it excludes. - -The repair is to write the boolean the filter means. `"$null": true` matches rows with no value and `"$null": false` rows with one; `$exists` is the exact inverse. - -Over HTTP, both routes type the `where`, the `runtimeFilter` and the inline `dataset.filter` as `FilterConditionSchema`, whose field entries are open, so the body parse admitted the flag and the service served it. Both routes now answer 400 `INVALID_FILTER` from the service, with no statement run. - -A `where` that carries a non-boolean flag and also a defect this compiler finds only while lowering (a field constraint mixing `$` and non-`$` keys, an operator outside the vocabulary, a zero-operator constraint) is now answered with the flag refusal. The code and status are the same 400 `INVALID_FILTER`. A shape or type defect elsewhere in the same `where` is still answered first. - -Who is affected: nothing in this repository's examples, seeds, docs or package sources authors a non-boolean flag. A text scan over 4598 tracked non-test files found 22 matches: 21 are code comments, and one is an operator-name lookup table in `driver-memory`, not a filter. Stored datasets, dashboard widget filters and report runtime filters in a deployment were NOT measured. - -Not changed: a `true` or `false` flag; the `FilterArray` spelling, whose `is_null` and `is_not_null` take their boolean from the operator name; the read-scope door, which already refused a non-boolean flag in its own withheld envelope. diff --git a/.changeset/20041-like-nul-pattern-refused.md b/.changeset/20041-like-nul-pattern-refused.md deleted file mode 100644 index 3fd158c02b4..00000000000 --- a/.changeset/20041-like-nul-pattern-refused.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/driver-sql': minor -'@objectstack/driver-sqlite-wasm': minor -'@objectstack/driver-turso': minor -'@objectstack/driver-memory': minor ---- - -fix(spec, drivers)!: a `$like` / `$ilike` pattern holding U+0000 is refused by every driver that answers `$like`, instead of being cut at the NUL on SQLite - -Clause-②: yes (narrowing) - -On the SQLite faces `$like` / `$ilike` compile to `GLOB`, and SQLite reads a pattern only up to its first U+0000. A pattern holding U+0000 was cut there, so the filter answered a different question, and nothing raised. Measured through `find` over 13 stored values (12 non-NULL), against `@objectstack/formula` on the same rows: all 20 U+0000 cases of the probe (10 patterns, bare and under `$not`) differed on `SqlDriver` over better-sqlite3, on `SqliteWasmDriver`, on `TursoDriver`'s local mode, and on its remote mode over a stub and over a real `@libsql/client` engine, with identical answers on all five. For example: - -- `$like: '%'` + U+0000 returned all 12 non-NULL rows, where `formula` returns the two ending in U+0000; -- `$like: 'a'` + U+0000 + `'b'` also returned `'a'`; -- `$ilike: 'AB'` + U+0000 also returned `'AB'` and `'ab'`. - -`driver-memory` answered all 20 as `formula` does. SQLite has no NUL-safe pattern primitive to compile to instead: `LIKE` cuts the same way, `replace()` cannot target U+0000, and `instr()` has no wildcards. So the one contract is a refusal, the way a pattern ending in a lone unpaired backslash is refused. - -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A filter that answered before is now refused**: a `$like` or `$ilike` pattern holding U+0000 anywhere (at the start, in the middle, at the end, alone, or after a backslash) gets `INVALID_FILTER` / 400, on every door that already refused the lone trailing backslash: - -- `@objectstack/driver-sql`: on the filter walk, before a dialect is chosen, so SQLite, Postgres and MySQL all refuse it. `@objectstack/driver-sqlite-wasm` and `TursoDriver`'s local mode inherit it; `@objectstack/driver-sqlite-wasm`'s own code does not change. -- `@objectstack/driver-turso`: the remote transport's `$like` / `$ilike` arm, before anything is sent to the engine. -- `@objectstack/driver-memory`: the shape gate of the query path and of the reference matcher `match()`, and the QueryAST `comparison` spelling (`like` / `ilike`). -- `@objectstack/spec` exports the shared test, `hasNulInLikePattern`, beside `hasDanglingLikeEscape`, and the `$like` operator's description now names the refusal. - -On `driver-sql` and the Turso remote transport the refusal goes through the read-scope provenance seam, like every other filter-compile refusal there. On `driver-sql` (and so `driver-sqlite-wasm` and Turso's local mode), a caller whose predicate is marked `'author'` reads the operator, the field, the filter path and the pattern, with U+0000 written as `\u0000`. Any other caller gets only the class statement, and the rest goes to the server log. The remote transport withholds the same way, and through `TursoDriver` in remote mode no mark reaches it, so every caller gets the class statement there. On `driver-memory` every caller reads the full text, as for its dangling-escape refusal. - -A pattern that ends in a lone unpaired backslash AND holds U+0000 keeps the dangling-escape refusal it had before. - -**What stays accepted**, pinned per face: every `$like` / `$ilike` pattern without U+0000 answers exactly as before. - -**Not changed here:** - -- A pattern without U+0000 matched against a STORED value that holds U+0000 is not refused: it is well formed, and on the SQLite faces it reads the whole stored value, by its own entry in this release. -- `@objectstack/formula` still evaluates such a pattern. It refuses nothing, and answers `false` for a dangling escape rather than refusing it, so it is not one of these doors. -- `driver-mongodb`, objectql `having` and `service-analytics` refused every `$like` / `$ilike` before this change, and still do. - -**What an affected author does.** Remove the U+0000 from the pattern. No escape makes it portable: a backslash before it still leaves a U+0000 in the pattern. - -Blast radius, measured on this tree: no example or template writes a `$like` or `$ilike`, and the published `objectstack-query` skill and the hand-written docs that show one show no pattern holding U+0000. Whether any out-of-repo caller sends one is NOT measured and is not claimed to be zero. - - diff --git a/.changeset/20044-services-title-pointers.md b/.changeset/20044-services-title-pointers.md deleted file mode 100644 index 971e2f97747..00000000000 --- a/.changeset/20044-services-title-pointers.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch -"@objectstack/plugin-security": patch -"@objectstack/service-messaging": patch -"@objectstack/service-realtime": patch ---- - -fix(plugin-approvals, plugin-security, service-messaging, service-realtime): nine system objects that relied on `titleFormat` declare a title pointer, so their record title is no longer the raw id (#20044) - -Clause-②: no - -ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation, and an explicit `nameField` takes precedence over the render-only `titleFormat`. Nine system objects declared a `titleFormat` and no pointer. When such an object is registered, the registry's designate-only pass picks the first title-eligible field as `nameField`, and for these nine that field is `id`. A `/meta` read serves that pointer as if it had been declared, so a renderer that follows ADR-0079's order showed the raw record id as the record page's title. - -Eight of the titles are composites. Each of those objects now declares `display_title`, a formula field with `returnType: 'text'` over the same columns, and points `nameField` and `displayNameField` at it: - -- `sys_approval_delegation`: `{delegator_id} → {delegate_id}`; -- `sys_position_permission_set`: `{position_id} → {permission_set_id}`; -- `sys_user_permission_set`: `{user_id} → {permission_set_id}`; -- `sys_user_position`: `{user_id} → {position}`; -- `sys_notification_delivery`: `{channel} → {recipient_id}`; -- `sys_notification_preference`: `{user_id} · {topic} · {channel}`; -- `sys_notification_subscription`: `{principal} · {topic}`; -- `sys_presence`: `{user_id} ({status})`. - -`sys_notification_receipt`'s title is the single column `{state}`, so its `nameField` and `displayNameField` now name `state` directly. - -This is the migration the `titleFormat` schema text prescribes: "Migrate a single-field title to nameField, a composite to a formula field designated as nameField". The record title is now the text the `titleFormat` described. Every column these titles read is required, so the formulas carry no null guard. Each formula reads only its own row's columns, never a field of a looked-up record. - -A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads and write responses of the eight objects now carry `display_title`, and the server-side title accessor (`resolveRecordTitle`) returns the title text instead of the raw id. No row scope, permission set or API method changes. - -`titleFormat` stays on all nine objects, unchanged, for renderers that still read it first. The set of fields `$search` scans is unchanged: a formula field is never a search target, and neither was `id`. On `sys_notification_receipt`, `state` was already in the set and now leads it. No search-companion column is provisioned for any of the nine. - -The new `display_title` label and help text are in each package's English bundle. The zh-CN, ja-JP and es-ES bundles carry the generator's English fill for them, recorded in the source-hash companions. diff --git a/.changeset/20045-inline-grid-column-currency-scale-refused.md b/.changeset/20045-inline-grid-column-currency-scale-refused.md deleted file mode 100644 index 528891304cf..00000000000 --- a/.changeset/20045-inline-grid-column-currency-scale-refused.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: `scale` is refused on a `currency` inline grid column, and the column's `prefix` no longer promises a default symbol (#20045) - -Clause-②: no (narrowing) - -**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. An `inlineColumns` entry that declares `type: 'currency'` and `scale` — any value, `scale: 0` included, computed or not — no longer parses. The hand-migration prescription is registered under protocol major 18 as `inline-grid-column-currency-scale-refused`. - -A currency's decimal places are the currency's, not a setting. `scale` was already retired from the `currency` field type; the inline grid column, the strict mirror of the console grid's column, still offered per-column decimals on a currency column. It now follows the field. - -**`@objectstack/spec`** — `InlineGridColumnSchema` refuses `scale` on a column declaring `type: 'currency'`, with a located issue at the column's `scale`. The refusal opens with the currency field refusal's first sentence and carries its remedy: delete the key; the currency's ISO 4217 minor unit decides how the cell displays the amount and the width a computed amount is rounded to. The remedy names no other key to carry the value. No alias and no grace window. `scale` on a `number` column, and on a column that declares no `type`, is untouched, and the key's describe now names the currency refusal. The column's `prefix` describe no longer promises a `¥` default: it replaces the resolved currency's symbol, and when it is omitted the cell shows the symbol of the currency it resolves. `prefix` is still accepted on a currency column. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `inlineColumns: [{ name: 'amount', type: 'currency', computed: true, expr: 'quantity * unit_price', scale: 2 }]` | `inlineColumns: [{ name: 'amount', type: 'currency', computed: true, expr: 'quantity * unit_price' }]` | -| `inlineColumns: [{ name: 'unit_price', type: 'currency', prefix: '$', scale: 2 }]` | `inlineColumns: [{ name: 'unit_price', type: 'currency', prefix: '$' }]` | - -The one-line fix: delete `scale` from every inline grid column that declares `type: 'currency'`. Nothing replaces it, so ⛔ do not re-declare the value under any other key. A `number` column keeps its `scale`. - -## Who is affected, measured - -On `origin/main` `1c8b320a89`: one authored `inlineColumns` block in the tree (the showcase invoice, seven identity-only columns, none declaring `type` or `scale`). No platform object, skill, documentation example or JSON fixture declares an inline grid column. One test fixture carried `scale: 2` on a currency column and was re-judged. Deployed metadata was not measured. - - diff --git a/.changeset/20050-view-page-size-default-50.md b/.changeset/20050-view-page-size-default-50.md deleted file mode 100644 index 5d2a1ef488d..00000000000 --- a/.changeset/20050-view-page-size-default-50.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): a view that declares no page size now shows 50 per page — `PaginationConfigSchema.pageSize` defaults to 50 (was 25) - -The platform display page size is 50 (maintainer ruling on objectui#9853, -「9853 默认页大小改为50」). It is declared in one place, the view contract's -`PaginationConfigSchema.pageSize`, and the renderer reads that spec default -rather than keeping a number of its own — so the change lands in the protocol -first. - -**What changes for an author who omits the page size:** - -- A view whose `pagination` block does not set `pageSize` now parses to - `pageSize: 50` where it parsed to `25`: 50 rows per page on a paged view, and - a fetch ceiling of 50 on a view with no pager (kanban, gallery, timeline). -- A view with no `pagination` block at all parses with none, before and after; - its page size comes from the renderer, which takes this spec default (the - Console does so once objectui#9853 lands). - -**What does not change:** the accept set. `pageSize` is still a positive -integer; `0`, negatives and fractions are refused exactly as before, and every -page size an author wrote parses to the number they wrote. - -### Migration: FROM → TO - -| FROM | TO | -| :--- | :--- | -| a view that declares no page size and relied on 25 rows per page | write it: `pagination: { pageSize: 25 }` | -| a view that declares no page size and should follow the platform default | change nothing — it now shows 50 | -| reading `PaginationConfigParsed.pageSize` after parsing a `pagination` block without `pageSize` | it yields `50` where it yielded `25`; the type is unchanged | -| reading the published JSON Schema's `default` for `ui/PaginationConfig` `pageSize` | it is `50` | - -The move is declared in `DEFAULT_CHANGES_BY_MAJOR` -(`packages/spec/scripts/lib/default-changes.ts`, `ui/PaginationConfig:pageSize` -25 → 50) and carried on the upgrade path as the semantic entry -`view-pagination-page-size-default-50`. diff --git a/.changeset/20051-view-overlay-options-bag-judged.md b/.changeset/20051-view-overlay-options-bag-judged.md deleted file mode 100644 index 8157c36b6c0..00000000000 --- a/.changeset/20051-view-overlay-options-bag-judged.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec)!: a flattened list view overlay's legacy `options` bag is judged at the view write door, so an out-of-contract `options.KIND` key is refused by name exactly as the direct spelling is (#20051) - -**BREAKING** accept-set narrowing on the `view` write door (`PUT /api/v1/meta/view/:name`, the Studio and MCP save). It ships as `minor` under the repo's launch-window convention for breaking changes. This is the door half of ruling A on objectui#10380 (maintainer 「其他同意」). - -Clause-②: no - -## What was wrong - -The flattened list overlay member of `ViewMetadataSchema` re-opens its top level with `.strip()` so the console's round-trip keys survive. That strip also dropped a top-level `options` bag from the parse without looking inside it. `saveMetaItem` stores the request body, not the parse output, and objectui's interface page forwards a stored view's `options` into the list renderer, which merges `options.KIND` under the top-level `KIND` block. Measured on `origin/main` @ `8d1f7ab` through the real save: `timeline: { metaFields: [...] }` answered `422`, while the same key written as `options: { timeline: { metaFields: [...] } }` answered `200` and the row held it as sent. - -## What it does now - -- **The list overlay declares `options`.** Each `options.KIND` (`kanban`, `calendar`, `gantt`, `gallery`, `timeline`, `chart`, `map`, `tree`) is judged by that kind's own block schema: the same closed key set, the same per-key schemas and the same unknown-key message. The refusal names the key, with only the `options.` prefix added to the path. The kinds are derived from the list-view shape, not listed by hand. -- **Key by key.** The renderer reads the bag as a per-key underlay of the top-level block, so the block's required keys are not asked of it. `kanban: { groupByField, columns }` beside `options.kanban: { titleField }` stays legal, which is the population objectui pins. -- **The bag is closed.** A key that is not a kind (`options.foo`, `options.grid`) is refused by name at `options`. It is no longer dropped. -- **The form overlay pins `options` absent.** Without this, a column-less, type-less list body that the list overlay refused over its bag would be accepted by the form overlay and stored unjudged. The refusal says the bag belongs to a list view. - -The legacy `options.map` bag that objectui pins (`locationField`, `titleField`) is still accepted, and it round-trips. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `options: { kanban: { groupField: 'stage' } }` | `kanban: { groupByField: 'stage', columns: [...] }`, or `options: { kanban: { groupByField: 'stage' } }` | -| `options: { calendar: { dateField: 'kickoff' } }` | `calendar: { startDateField: 'kickoff' }` | -| `options: { timeline: { metaFields: ['region'] } }` | delete `metaFields`: the timeline block has no such key | -| `options: { chart: { xAxisField, yAxisFields } }` | the dataset-bound block, `chart: { dataset, values, dimensions }` | -| `options: { foo: 1 }` | delete `foo`: the bag carries per-kind blocks only | -| `options: {...}` on a form overlay (`viewKind: 'form'`) | delete `options`: a form view has no per-kind blocks | - -**The one-line fix:** read the refusal. It names the key and the block that refuses it; move the key to the top-level block's declared spelling, or delete it. - -## Stored-row census (ruling item 3) - -- **objectstack** @ `8d1f7ab` (examples, dogfood, fixtures, tests): zero view bodies carry a top-level `options` bag with a kind block. Authored views go through the strict authoring shape, which has always refused `options`. -- **objectui**, at the `.objectui-sha` pin `f8a9d0fb0` and at `main` `c3a26ccda`: 28 `options` bag literals, plus the finding's own probe body, judged against this change. 17 pass and 12 fail, and every failure is an out-of-contract key refused by name. None fails for a missing key. - - **Bodies that model a stored or authored view** (a console-merged `listViews` entry or a named view): 8, of which 5 pass, the pinned `options.map` path among them. The 3 that fail: `options.kanban.groupField` in `plugin-view` `ObjectView.tsx`'s docblock example (write `groupByField`), `options.calendar.dateField` in `ObjectView.calendarAliasRefused-8355.test.tsx` (write `startDateField`; that test already pins the alias as refused on objectui's side), and the finding's `options.timeline.metaFields` (delete it). - - **Renderer-level `ListView` props** (21), which never reach this door: 12 pass. The 9 that fail spell the legacy keys objectui's own refusal pins already retire (`groupField`, `groupBy`, `dateField`, `metaFields`, and the object-bound chart keys `xAxisField` / `yAxisFields` / `aggregation`). -- **Production `sys_metadata` rows: NOT MEASURED.** No deployment's store is reachable from the repository. A stored row that fails keeps being read and served exactly as stored. It is refused only on its next save, and the refusal names the key. - -## Not in this change - -Persisting the parse output instead of the request body (ruling item 2) is not in this change. This change leaves the save path's storage behaviour as it was: a body the door now accepts is stored as sent, so every stored `options` bag is one the door judged. - - diff --git a/.changeset/20054-turso-remote-no-native-driver.md b/.changeset/20054-turso-remote-no-native-driver.md deleted file mode 100644 index 98e2dd7d797..00000000000 --- a/.changeset/20054-turso-remote-no-native-driver.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/driver-turso": minor ---- - -fix(driver-turso)!: a REMOTE `TursoDriver` no longer needs `better-sqlite3` installed, and the two inherited calls that answered from its private in-memory database now reject (#20054) - -Clause-②: no (narrowing) - -`package.json` declares `better-sqlite3` an optional peer, and the README tells a remote-only deployment (Vercel, an Edge runtime) that it does not need it. The code did not keep that promise. With `better-sqlite3` absent, `new TursoDriver({ url: 'libsql://…' })` threw knex's `Knex: run $ npm install better-sqlite3 --save` error at construction, before any remote call. - -The cause: remote mode handed the `SqlDriver` base a `better-sqlite3` Knex config on `:memory:`, and knex loads a dialect's native driver whenever the config carries a `connection`. Remote mode now builds that Knex instance with no `connection`. It loads no native module, opens no pool and holds no private in-memory database. With `better-sqlite3` absent, a remote driver constructs, connects and runs CRUD through `@libsql/client`. - -**BREAKING** — two calls on a remote driver that resolved before now reject. This is an accept-set narrowing on a published driver, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). The calls are `SqlDriver` methods that remote mode does not override, and they now reject with knex's `Unable to acquire a connection` error: - -- `introspectSchema()` used to resolve `{ tables: {} }`, "no tables", whatever the remote database held; -- `reclaimSpace()` used to resolve. - -Both old answers came from the private in-memory database, not from the remote one. The per-method answer or refusal for these inherited calls is carried by #20055. - -**Error wording only, not the narrowing.** On a remote driver: - -- `findWithWindowFunctions()` still rejects. The error is now knex's `Unable to acquire a connection` instead of a missing-table error from the in-memory database. -- `analyzeQuery()` and `explain()` still resolve the compiled SQL with an `error` field. That field now carries knex's message instead of a missing-table error. -- `distinct()` answers the same `DATABASE_ERROR` / 500 as before. - -**Unchanged:** - -- **Local and embedded-replica modes.** Their `toKnexConfig` arms are untouched and still run on `better-sqlite3`, so a local driver (`:memory:` or a `file:` url) still fails at construction without it. -- **Every call remote mode sends to `RemoteTransport` behaves as before**, including raw SQL through `execute()`. None of them used the Knex instance. -- **The `NOT_IMPLEMENTED` / 501 refusals of `detectManagedDrift()` and `planMediaColumnMove()` on a remote driver** still refuse, with the same code and status. Their messages no longer say that remote mode's Knex connection is a placeholder in-memory database; they say that remote mode has no Knex connection. - - diff --git a/.changeset/20055-turso-remote-inherited-members.md b/.changeset/20055-turso-remote-inherited-members.md deleted file mode 100644 index 83fb861d2f0..00000000000 --- a/.changeset/20055-turso-remote-inherited-members.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/driver-turso": minor ---- - -fix(driver-turso)!: a REMOTE `TursoDriver` answers or refuses every public `SqlDriver` method it inherited, instead of failing on a Knex connection it does not have (#20055) - -Clause-②: yes (narrowing) - -`TursoDriver` extends `SqlDriver`. Before this change, 23 public `SqlDriver` members had no remote-mode arm, so a remote driver ran their Knex implementations. Remote mode builds Knex with no connection, so those calls failed with knex's `Unable to acquire a connection`, which reads as a network fault, or they answered from state no remote schema sync fills. Each one is now answered on the remote database or refused with `NOT_IMPLEMENTED` / 501, and the driver's source lists every public `SqlDriver` member with its remote answer. A method added to `SqlDriver` later fails this package's type check until its remote answer is decided. - -**BREAKING.** On a remote driver, six members that used to answer now refuse or answer differently. This narrows what a published driver accepts. It ships as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). - -- `explain()` and `analyzeQuery()` used to resolve with the SQL the local compiler would build, plus an `error` field in place of a plan. They now reject with `NOT_IMPLEMENTED` / 501. -- `applyMigrationEntries()` used to resolve. Every entry came back `skipped`, including a destructive entry that `allowDestructive` permitted. It now rejects with `NOT_IMPLEMENTED` / 501, with or without entries. -- `getKnex()` used to return a Knex instance on which every statement failed. It now throws `NOT_IMPLEMENTED` / 501. -- `supportsRotation` used to read `true`. It now reads `false`. The lifecycle service reads it, and for an object that declares a rotation storage policy it now takes its age-based reap instead of calling `rotateShards()`, which failed. -- `setFileColumnsMovedResolver()` used to return `true` ("taken"), and then never asked the resolver. It now returns `false`. Remote mode keeps writing media columns in the JSON encoding, as before. - -**Refused with a clearer error.** These calls already failed. They now reject with `NOT_IMPLEMENTED` / 501, and the message names the local or embedded-replica transport, or `execute()`, as the alternative: - -- `introspectSchema()`. The datasource connection test still answers `ok: false`, and its error now says introspection is not supported in remote mode; -- `findWithWindowFunctions()`; -- `rotateShards()`; -- `distinct()` called with `options.tenantId` on an object that has a tenant column. No remote read applies the tenant scope, so an answer would list every organization's values. - -**Now answered on the remote database.** These used to fail: - -- `distinct()` without a tenant scope runs a `SELECT DISTINCT` and answers what local mode answers for the same rows. The filter and the value presentation match, and an unknown column is refused with `INVALID_FIELD` / 400. -- `reclaimSpace()` sends the statement local mode issues, `PRAGMA incremental_vacuum`, to the remote database. The lifecycle service's sweep now counts the datasource as reclaimed instead of logging a warning. The statement returns pages only on a database whose `auto_vacuum` mode is `INCREMENTAL`. - -`RemoteTransport` gains one public method, `compileDistinct()`. It builds the statement behind a remote `distinct()`. - -**Unchanged.** Local and embedded-replica modes run the inherited Knex members as before. On a remote driver, `commitTransaction()` and `rollbackTransaction()` still reject through the `commit()` / `rollback()` refusal. The deferred-DDL readers still report nothing deferred, and `getSchemaSyncStats()` still answers `{ created: 0, existing: 0 }`, which the `IDataDriver` contract reads as "cannot say". The bookkeeping and dialect members also answer as before. - - diff --git a/.changeset/20059-identity-objects-title-formula.md b/.changeset/20059-identity-objects-title-formula.md deleted file mode 100644 index cf718454c3d..00000000000 --- a/.changeset/20059-identity-objects-title-formula.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): ten identity objects declare their record title instead of having `nameField: 'id'` stamped on them (#20059) - -Clause-②: no - -ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation, and an explicit `nameField` takes precedence over the render-only `titleFormat`. Ten identity objects declared a `titleFormat` and no title pointer. The registry's designate-only pass derived `id`, the first title-eligible field on each of them, and stamped `nameField: 'id'`, and a `/meta` read serves that stamp as if it were declared. A renderer that follows ADR-0079's order therefore showed the raw record id as the record page's title. - -Nine of them now declare `display_title`, a formula field with `returnType: 'text'` over the columns their `titleFormat` names, and point `nameField` and `displayNameField` at it: - -- `sys_account`: `{provider_id} - {account_id}`; -- `sys_business_unit_member`: `{user_id} in {business_unit_id}`; -- `sys_invitation`: `Invitation for {email}`; -- `sys_member`: `{user_id} ({role})`. A row without a role is titled by its user alone; -- `sys_scim_group_member`: `{scim_user_id} in {group_id}`; -- `sys_scim_projection_grant`: `{role} → {user_id}`; -- `sys_team_member`: `{user_id} in {team_id}`; -- `sys_two_factor`: `Two-factor for {user_id}`; -- `sys_verification`: `Verification for {identifier}`. - -This is the migration the `titleFormat` schema text prescribes: "a composite to a formula field designated as nameField". `sys_scim_subject` has a single-field title (`{user_id}`), so its `nameField` and `displayNameField` name `user_id` directly, as the same text prescribes for a single field. - -A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads now carry `display_title` on the nine objects. A formula is evaluated on the stored row, so where a `titleFormat` names a lookup (`user_id`, `team_id`, `business_unit_id`, `group_id`, `scim_user_id`), the formula's text carries the stored id of the related record, not its name. - -`titleFormat` stays on all ten objects, unchanged, for renderers that still read it first. `$search` resolution is unchanged: neither a formula field, a lookup nor `id` is ever a search target. diff --git a/.changeset/20061-rest-limit-parsing.md b/.changeset/20061-rest-limit-parsing.md deleted file mode 100644 index 5ce6226de2b..00000000000 --- a/.changeset/20061-rest-limit-parsing.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest): four published doors refuse a `?limit=` they cannot read with `400 VALIDATION_FAILED`, instead of substituting, clamping or dropping it and answering `200` (#20061, #20062) - -Clause-②: no (narrowing) - -**BREAKING**: shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA). The banner and the ADR-0087 -disposition below carry the breaking-ness, not the level. - -`GET /data/import/jobs`, `GET /data/:object/export`, `GET /meta/:type/:name/history` -and `GET /search` read `?limit=` with a bare `Number()`. That coercion does not -fail. It invents a value, and the door served it with a `200`: -- `?limit=0` on the import-job history answered the 50-row default against a - declaration of `min(1).max(200)`; -- `?limit=abc` on the export downloaded one row; -- on the metadata history it returned the whole change log; -- on search it removed the overall cap. - -Since `@objectstack/client` sends `limit` exactly as the caller wrote it, the door -is the only place such a value can be refused. - -Each door now reads the parameter against its own declaration: -- **`GET /data/import/jobs`** reads `limit` and `offset` through - `ListImportJobsRequestSchema` (`limit` `int().min(1).max(200)`, default 50; - `offset` `int().min(0)`, default 0). -- **`GET /meta/:type/:name/history`** reads `limit` through - `HistoryMetaItemRequestSchema.limit` (`z.number()`, so any finite number, as - before). -- **`GET /data/:object/export`** and **`GET /search`** declare no request schema. - There `limit` must be a whole number. Their range handling is unchanged: the - export floor of 1 and cap of 50000, and search's `[1, 100]` clamp. - -A value outside the declaration answers `400` with the data surface's existing -envelope, `{ error, code: 'VALIDATION_FAILED', fields }`. `fields[0].field` names -the parameter. `fields[0].code` is the ADR-0114 member for the failed constraint: -`invalid_type` for a value that is not a number (or not a whole one, where one is -required), `min_value` or `max_value` for one outside a declared bound. The -service is never called. - -What changes, per door (every row answered `200` before): - -| door | request | answered before | answers now | -|:--|:--|:--|:--| -| `GET /data/import/jobs` | `?limit=0`, `?limit=-3` | 50 rows / 1 row | `400`, `min_value` | -| `GET /data/import/jobs` | `?limit=201`, `?limit=500` | 200 rows | `400`, `max_value` | -| `GET /data/import/jobs` | `?limit=abc`, `?limit=1.5`, `?limit=Infinity` | 50 / 1.5 / 200 rows | `400`, `invalid_type` | -| `GET /data/import/jobs` | `?offset=-1`, `?offset=abc`, `?offset=1.5` | offset 0 / 0 / 1.5 | `400`, `min_value` / `invalid_type` | -| `GET /data/:object/export` | `?limit=abc`, `?limit=` (empty) | a one-row export | `400`, `invalid_type` | -| `GET /data/:object/export` | `?limit=1.5`, `?limit=Infinity` | limit 1.5 / capped to 50000 | `400`, `invalid_type` | -| `GET /meta/:type/:name/history` | `?limit=abc`, `?limit=Infinity` | the whole change log | `400`, `invalid_type` | -| `GET /meta/:type/:name/history` | `?limit=` (empty) | zero events | `400`, `invalid_type` | -| `GET /search` | `?limit=abc` | no overall cap | `400`, `invalid_type` | -| `GET /search` | `?limit=1.5`, `?limit=Infinity` | a cap of 1.5 / 100 | `400`, `invalid_type` | - -A blank value such as `?limit=%20` is refused on every door. - -**Unchanged:** -- An absent `limit` keeps each door's default: 50 jobs, a 10000-row export, the - full history, search's 20. -- An empty `?limit=` on the import-job history and on search still means absent, - as it always did there. -- Every conforming value reaches the service exactly as before, including the - ranges no card here takes a position on: export `?limit=0` still exports one - row, search `?limit=500` is still clamped to 100, and history still forwards - `0` or `1.5` because its declaration admits them. - -**Fix for a caller that now gets the `400`:** send `limit` as a whole number, within -the declared range where the door declares one (`1`–`200` for import jobs), or -omit it to get the door's default. - - diff --git a/.changeset/20068-analytics-icontains-empty.md b/.changeset/20068-analytics-icontains-empty.md deleted file mode 100644 index 956190f57a3..00000000000 --- a/.changeset/20068-analytics-icontains-empty.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(service-analytics)!: both analytics doors refuse the two `$icontains` comparands `FILTER_TEXT_CASES` declares refused, an empty one and a non-string one, each door in its own envelope (#20068) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what the analytics faces of `@objectstack/service-analytics` accept. An `$icontains` condition whose comparand is the empty string, or is not a string at all (a number, a boolean, `null`, a `Date`), used to compile on the analytics compilers. It is now refused before any SQL statement runs or any `engine.aggregate` call is made. It ships as `minor` under the launch-window convention for accept-set narrowings. - -`@objectstack/spec` declares both refusals as data: `FILTER_TEXT_CASES` carries a REJECTION row for an empty `$icontains` comparand and one for a non-string comparand, each `INVALID_FILTER` naming `$icontains`. It publishes the discrimination as `isRefusedTextComparand` and the reason as `textComparandRefusalReason`. The spec's parse door (`FilterConditionSchema`) and `driver-sql` already refused both. This package never asked, so one filter got two answers. Both analytics doors now call the published predicate and seat the published reason in their own envelope. - -| where the condition sits | before | now | -|:--|:--|:--| -| a caller's `where`, a dataset `filter` or a measure `filter`, either spelling, at any depth | `''` matched every row whose column has a value; a non-string was bound as its text and matched nothing. Native SQL, the `/analytics/sql` echo and `AnalyticsService.query` all served it | `INVALID_FILTER` / 400, with the spec's reason, naming `$icontains` | -| a row-level read scope, on the native SQL face and the echo | the same predicate: `''` admitted every row that has a value | `READ_SCOPE_COMPILE_FAILED` / 500, with the message withheld | -| a row-level read scope, on the ObjectQL face | the driver refused it as `INVALID_FILTER` / 400, and the message named the policy's field and comparand | `READ_SCOPE_COMPILE_FAILED` / 500, with the message withheld | - -The migration is the ledger entry named above: write a non-empty string, or drop the condition. An empty comparand was a predicate that constrained nothing, so the repair is to delete the condition. A number, boolean or `null` comparand is written as the string it was meant to match, or the operator was the wrong one. - -Over HTTP, `POST /api/v1/analytics/query`, `/analytics/sql` and `/analytics/dataset/query` already refused a caller-authored condition carrying either comparand, at their body parse (`400 VALIDATION_FAILED`). What this changes for an HTTP caller is the read scope. A row-level read scope supplied by the host (`getReadScope`) is refused in the withheld envelope on every analytics face. It is no longer served on the native SQL face, and it is no longer answered with a 4xx that carries policy content on the ObjectQL face. The CEL policy lowering never emits `$icontains`, and a filter placeholder never resolves to an empty string. - -Who is affected: nothing in this repository's examples, seeds, docs or package sources authors either comparand; every hit outside tests is a code comment. Stored datasets, dashboard filters and host-supplied read scopes in a deployment were NOT measured. A stored row saved before the parse-door refusal can still carry an empty comparand, and it is now refused at query time, where it used to answer every row that has a value. - -Not changed: a non-empty string comparand, whatever its case or content; an object or array comparand, still refused in its existing sentence; the non-text-column constant for an accepted comparand. `$contains`, `$notContains`, `$startsWith` and `$endsWith` keep their answer to an empty comparand: the table has no REJECTION row for them, and widening by analogy is the table's decision. diff --git a/.changeset/20071-standalone-hydration.md b/.changeset/20071-standalone-hydration.md deleted file mode 100644 index 61109ec0314..00000000000 --- a/.changeset/20071-standalone-hydration.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): a self-hosted restart reads the kernel's own `sys_metadata` back into the registry, so objects authored at runtime keep serving - -An object created and published at runtime on a self-hosted install (Studio, -`PUT /api/v1/meta/object/…`, `POST /api/v1/packages//publish-drafts`) -answered `404 OBJECT_NOT_FOUND` on the data API after the next restart, while -its `sys_metadata` row was still there and -`GET /api/v1/meta/object//published` still served it. Every `os dev` / `os serve` / `os start` boot was affected. - -**Cause.** `createStandaloneStack` stamps `environmentId: 'env_local'` on every -boot (or whatever `OS_ENVIRONMENT_ID` names), and `ObjectQLPlugin.start()` read -any environment id as "a per-project kernel whose metadata comes from an -artifact or a control-plane proxy", so it skipped reading `sys_metadata` at -boot. It logged `Project kernel — skipping sys_metadata hydration (metadata -sourced from artifact)`, which was false on this composition. This is the same -deduction that `runPlatformMigrations` was declared out of. - -**Fix: a declaration, not a deduction.** `createStandaloneStack` now declares -`hydrateMetadataFromDb: true` to `ObjectQLPlugin`, where it used to be deduced -from the environment-id stamp. The plugin option's own caution holds for every -boot this function builds, for two reasons: - -- the registry is per-instance: the function constructs a fresh - `ObjectQLPlugin`, which builds its own `ObjectQL` and `SchemaRegistry`; -- `sys_metadata` is on the kernel's own driver: it declares no datasource, so it - routes to the one `default` datasource the function composes, and every - database driver kind the function dispatches is a direct driver, never a - control-plane proxy. - -What an upgraded install sees at boot: - -- every env-wide `sys_metadata` row (`organization_id` NULL), any metadata type, - is registered again. Org-scoped rows are still served on demand and are not - read at boot (ADR-0005); -- a stored row that cannot register now says so at boot, on lines that were - never reached here before: `[Protocol] [metadata_field_type_refused] …` at - `error`, `[Protocol] Failed to hydrate /: …` and - `[Protocol] [metadata_spec_invalid] …` at `warn`. The same boot also reports - org-scoped rows of types that are not per-org overridable, on one aggregated - line. Each line names its remedy; -- the one-shot `os migrate *` / `os meta *` commands hydrate too, so a plan or - a scan covers runtime-authored objects the way the serving boot registers - them. The read itself writes nothing, and a deferred-DDL boot - (`os migrate plan`, `os migrate duplicates`) still defers the tables of what - it read. diff --git a/.changeset/20075-native-scope-placeholders.md b/.changeset/20075-native-scope-placeholders.md deleted file mode 100644 index fe1d3a1520a..00000000000 --- a/.changeset/20075-native-scope-placeholders.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/service-analytics': minor ---- - -fix(service-analytics)!: the NativeSQL execute face and the `/analytics/sql` echo resolve a read-scope filter placeholder with the caller's context, and refuse one they cannot resolve, as the ObjectQL execute face already does (#20075) - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on the read-scope lowering, shipped as `minor` under the launch-window convention. `minor` is also the level a new accepted option key takes. - -`compileScopedFilterToSql` is the read-scope lowering behind the NativeSQL execute face (`NativeSQLStrategy.applyReadScope`, the base table and every joined hop) and the `/analytics/sql` echo (`ObjectQLStrategy.generateSql`), and a public export of this package. It never resolved a filter placeholder, so both faces bound `{current_user_id}`, `{current_org_id}` or a date macro as its literal text. The ObjectQL execute face hands the same scope to the engine, which resolves it with the caller's context. One read scope, two row sets. - -It now takes an optional `context` (`ReadScopeCompileOptions.context`) and resolves the scope with `resolveFilterTokens(scope, filterTokenContextFrom(context))` from `@objectstack/core`, the resolver the engine calls, before lowering it. Both strategies pass the request's context. - -| a read scope carrying | before, on the NativeSQL face and the echo | now | -|:--|:--|:--| -| a placeholder the caller's context resolves | its literal text was bound: an equality matched no row, and a `$ne` exclusion admitted every row, the caller's own included | the resolved value is bound and the echo prints it; the same rows as the ObjectQL face | -| an unknown placeholder, or a context token the request has no value for | served, with the literal bound | `READ_SCOPE_COMPILE_FAILED` / 500, message withheld, as on the ObjectQL face | - -Without a context the answer is the engine's for a context-less operation: a date macro resolves against UTC now, and a context token is refused. A placeholder is never bound as its literal text. - -Who is affected: a host whose own `getReadScope` returns scopes carrying placeholders, and a direct caller of `compileScopedFilterToSql`. The security service's read filter, the auto-bridged default, composes concrete values and is not affected. - -Not changed: a scope with no placeholder compiles to the same SQL and parameters as before. The caller's own `where` is untouched: `AnalyticsService` already resolves its placeholders and answers an unresolvable one `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED` / 400 with its message. The ObjectQL execute face is untouched. diff --git a/.changeset/20078-field-predicate-reference-traversal-refused.md b/.changeset/20078-field-predicate-reference-traversal-refused.md deleted file mode 100644 index bb617cf51c8..00000000000 --- a/.changeset/20078-field-predicate-reference-traversal-refused.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/lint': minor -'@objectstack/spec': minor ---- - -fix(lint)!: a field-level predicate that reads through a reference field is refused at `objectstack validate` (#20078) - - - -**BREAKING** in the accept-set sense — a stack that validates today can fail tomorrow. Landing in -the launch window as `minor` (`major` is refused by `check-changeset-no-major`); breaking-ness is -carried by this banner, the `!` above and the ADR-0087 registration. - -Clause-②: no - -A field `requiredWhen` / `readonlyWhen`, or a select option's `visibleWhen`, that reads THROUGH a -`lookup` / `master_detail` / `user` / `tree` field — `record.account.tier` — passed `objectstack -validate`, `build` and `lint`. It cannot work: the field level is never hydrated, so the reference -holds the related record's bare id and every read through it faults. At run time a traversing -`requiredWhen` refuses every write that reaches it, a traversing `readonlyWhen` refuses every -update that writes its field (ADR-0137 D2), and a traversing option predicate is never enforced -(option visibility is fail-open). The authoring pass now refuses all three as -`expression-invalid`, naming the slot, the reference path and the related column, before deploy. - -What to write instead — the refusal says the same: - -- **A `record..` read** — express the check as a `validations[]` rule of - `type: 'script'`. Its `condition` is the one predicate the server reads one hop through a - reference, and it states the FAILURE: for `requiredWhen: P` on `po_number`, - `P && (record.po_number == null || record.po_number == '')`; for `readonlyWhen: P` on - `discount`, `P && record.discount != previous.discount` with `events: ['update']`; for an - option gated by `P`, that option picked while `P` does not hold (the option is then offered to - everyone and refused on save). Or read a column the object itself declares. -- **A `previous..` or `parent..` read** — no seam hydrates - either root, a validation rule included, so read a column the bound record declares. - -Unchanged: the same traversal inside a `validations[]` `script` rule is accepted, as is reading -the reference itself (`record.account == 'acc_1'`, `record.account != null`), an object-valued -field that is not a reference (`record.ship_to.city`), and an option gated on `current_user` -(including `current_user.can(…)`). The runtime is untouched, and an object already stored in -`sys_metadata` is not re-validated by this. `@objectstack/spec` states the rule on the three -slots' `.describe()` text and registers the ADR-0087 semantic entry -`field-predicate-reference-traversal-refused`. diff --git a/.changeset/20080-dataset-filter-nested-relation-list.md b/.changeset/20080-dataset-filter-nested-relation-list.md deleted file mode 100644 index 571b0d03fe4..00000000000 --- a/.changeset/20080-dataset-filter-nested-relation-list.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: a dataset or measure filter with a list inside a nested relation is refused when it is saved, not when it is charted (#20080) - -**BREAKING**: an accept-set narrowing of a published authoring schema, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `dataset-filter-nested-relation-equality-array-refused-at-save`. - -## What changes - -`DatasetSchema.filter` and `DatasetMeasureSchema.filter` now refuse an ARRAY in the equality slot of a field inside a nested-relation condition. That covers the implicit form `{ account: { region: ['a'] } }` and the explicit form `{ account: { region: { $eq: ['a'] } } }`, the empty array included, at any relation depth and under `$and` / `$or` / `$not`. So `defineStack`, `os validate` and a save through the metadata protocol (`422 INVALID_METADATA`) refuse such a dataset at the filter's path, for example `filter.account.region` or `measures.0.filter.account.region.$eq`. - -Before this change the dataset saved clean. The analytics `where` door, which charts both filters on every path, flattens the relation to the dotted member `account.region` and refuses the list with `INVALID_FILTER` / 400. So every chart built on the dataset failed. Measured on `origin/main` `9e7824a445`: `DatasetSchema.safeParse` answered `success: true` for both forms. - -The refusal is the analytics door's sentence: the field, the received list, and the two operators a list in that slot stood in for. The door adds `at where.account.region`. The schema leaves that out, because the issue's `path` already says where it is. - -## What does NOT change - -- **The shared `FilterConditionSchema` keeps its reach.** Every other schema that carries a `FilterCondition` still accepts a list inside a nested relation, because the engine reads that spec as a deep-equality comparand. -- **Nothing stored is rewritten, and nothing is dropped.** The parse fails and strips nothing. The read path does not re-validate stored rows, so a stored dataset keeps loading, and its next save is refused. Such a filter has failed every chart, so the refusal is a repair. -- A list outside a nested relation is refused as before, once, by `FilterConditionSchema`. -- `$ne` carrying an array is not judged. The list operators keep their arrays, `$in: []` and `$nin: []` included. Every scalar, `null`, a `Date` and a `{ $field }` reference inside a nested relation pass as before. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `{ account: { region: ['a', 'b'] } }`, meaning "one of these values" | `{ account: { region: { $in: ['a', 'b'] } } }` | -| `{ account: { tags: ['a'] } }`, meaning "the stored list holds `a`" on a multi-value field | `{ account: { tags: { $contains: 'a' } } }` | -| `{ account: { region: ['a'] } }`, meaning one value | `{ account: { region: 'a' } }` | -| `{ account: { region: { $eq: [...] } } }` | any of the rows above | - -## Who is affected, measured - -Nothing shipped in this repository carries the shape. At `9e7824a445`, the dataset and measure filters of `examples/app-crm`, `examples/app-showcase`, `examples/app-todo` and the platform objects, plus the dataset examples in `content/docs` and `skills`, carry no list inside a nested relation. Deployed datasets were NOT measured. Validating each stack, or re-saving each dataset, finds every instance. - -Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. One comparand shape in one position of two carriers, which the analytics door already refused on every chart, is now refused on save as well. - - diff --git a/.changeset/20082-formula-default-can.md b/.changeset/20082-formula-default-can.md deleted file mode 100644 index 43353b20300..00000000000 --- a/.changeset/20082-formula-default-can.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -fix(objectql): a `formula` field and a CEL `defaultValue` answer `current_user.can(object, verb)` from the security service (#20082) - -Clause-②: no (narrowing) - - - -**BREAKING**, for one fault path only, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA, so this banner and the ADR-0087 disposition above carry the breaking-ness). An insert row whose CEL `defaultValue` calls `current_user.can(…)` is now refused with the resolution's own error when the registered security service fails to resolve the caller's effective object permissions. Before, that default was left unset with a `warn` and the row was written. - -`@objectstack/formula` answers `can` from `EvalContext.permissions`, and neither engine site passed one. With the security plugin registered: - -- a formula field calling `current_user.can(…)` read `null` on every `find`, `findOne` and write response, and logged nothing; -- a CEL `defaultValue` calling it was left unset with the `warn` "Failed to evaluate default expression". A `required` field defaulted that way therefore refused every insert. - -**What changes.** Both sites now evaluate with the acting subject's effective object permissions. That is the map `ISecurityService.getEffectiveObjectPermissions` returns, which an option's `visibleWhen` already reads. - -- A formula field reads `true` or `false`. -- A CEL default stores `true` or `false`, so a `required` field defaulted by `can` is admitted. - -The engine asks the security service at most once per operation: once per `find` (not per row), and once per write, shared by its defaults, its `can`-gated options and the formula fields on its response. It asks only when a formula, or a default that will be applied, calls `can`, and only when the operation has an acting user. The answer is never kept past the operation. - -**When there is no map.** - -- No security service is registered. No permission data is passed, as before. A formula field still reads `null`, and each operation now logs one `warn` naming the object, the fields and `reason: 'no-permission-source'`. A default is still left unset with its existing `warn`. -- The resolution fails: it throws, or it returns a map that is not the published shape. A formula field reads `null`, and one `warn` carries the error with `reason: 'permission-resolution-failed'`, because a read is not refused over one computed field. An insert row whose `can` default needed the map is refused with the resolution's own error. Under `insertMany` only that row is refused, and the `validate()` preview rejects the same way. Rows that supply the field, and objects whose defaults never call `can`, are unaffected. - -In no case is `can()` answered `true` without a grant, or `false` from an empty map. - -`evaluateFormulaField` (and `resolveRecordTitle`, which uses it) is synchronous and passes no map, so a formula calling `can` still yields `null` there. - -No spec key, export or route is added or removed. diff --git a/.changeset/20083-effective-map-plain-wildcard.md b/.changeset/20083-effective-map-plain-wildcard.md deleted file mode 100644 index f36971de73f..00000000000 --- a/.changeset/20083-effective-map-plain-wildcard.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/core': minor ---- - -fix(core): the effective object-permission map covers what a plain `'*'` grant covers, so `current_user.can()` agrees with the server for a wall-less org admin (#20083) - -`buildEffectiveObjectPermissions` builds the `objects` slot of `GET /auth/me/permissions` (`@objectstack/plugin-hono-server`) and the map `ISecurityService.getEffectiveObjectPermissions` returns (`@objectstack/plugin-security`), which the engine hands to `current_user.can(object, verb)` on the write path. It merged each permission set's EXPLICIT entries and kept `'*'` as a key of its own. The server's check does not stop there: `PermissionEvaluator.checkObjectPermission` resolves each set to its explicit entry for the object when it has one, and otherwise to its `'*'` — for a public object always, for a private one only when the wildcard carries a super-user bit. So an object reached only through a plain wildcard (no `viewAllRecords` / `modifyAllRecords`) had no entry in the map, and `can()` — which reads an absent entry as "no grant" — answered `false` where the server allows. - -The population it hit: `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins. With it and `member_default`, `current_user.can('crm_account', 'edit')` was `false` while the data plane accepted the edit, so a `can()`-gated option was refused on the write path, and a client that answers `can()` from `/auth/me/permissions` got the same `false`. `viewer_readonly` read the same way (`read` on every object it covers). - -**What changes.** A new step in `buildEffectiveObjectPermissions`, after the super-user seed and before the wildcard fold, applies each set's plain `'*'` to the registered objects that set does not name: - -- only registered objects, and only public ones (`access.default` other than `'private'`); -- a set that names the object keeps its explicit entry as its whole answer, as on the server; -- another set's plain wildcard widens an entry that is already present, bit by bit; -- only `true` grant bits are copied; a wildcard's `false` or unset bit adds nothing; -- an object the step would add, but whose entry grants no verb on its own, is left out. - -The step reads `name` and `access.default` off the `allSchemas` entries, so the element type of `allSchemas` on `buildEffectiveObjectPermissions`' schema source gains an optional `access?: unknown` member (the package exports no new name for it). That is a type widening only: every call that compiled before still compiles, and a schema literal carrying `access` now does too. A direct caller passes the registered schemas themselves there, as both in-repo callers do; an entry without `access` reads as public, exactly as the server reads it. - -**What a reader of `/auth/me/permissions` sees.** For a subject holding a plain wildcard, `objects` gains an entry for every registered public object the wildcard covers that had none, annotated with `apiOperations` by the same rule as every other entry. An entry that was already there may gain `true` bits. Nothing is removed. For a subject holding no plain wildcard — `admin_full_access`, a walled `organization_admin`, `member_default` alone — the response is byte-identical to before. The response shape, its keys and the route are unchanged. - -This closes the known gap that the `current_user.can()` write-path entry in this release describes: `organization_admin_no_bypass` now reads `true` from `can()` where the data plane allows. diff --git a/.changeset/20091-analytics-currency-mode.md b/.changeset/20091-analytics-currency-mode.md deleted file mode 100644 index 542ac7069db..00000000000 --- a/.changeset/20091-analytics-currency-mode.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -fix(service-analytics): a dataset measure column takes the source field's currency only when that field's `currencyConfig.currencyMode` is `'fixed'`. Otherwise it takes the tenant default (#20091) - -Clause-②: no - -`AnalyticsServicePlugin` passes each source field's metadata to the service through `sourceFieldMeta`. That hook passed on `currencyConfig.defaultCurrency` whatever `currencyMode` said, and `queryDataset` put it on the result column as `currency`. The column is resolved in this order: the measure's own `currency`, then that value, then `ExecutionContext.currency`. So a `dynamic` field's `defaultCurrency` showed on analytics, chart and dataset faces, where the tenant currency belonged. That covers a `currencyConfig` naming no mode too, which is `dynamic` by the schema default. Parsed through the spec, a `currencyConfig: {}` also carries the schema's placeholder `CNY`, and that reached the column as if an author had written it. - -- **What changes**: the relay now passes `defaultCurrency` on only under `currencyMode: 'fixed'`. This is the rule `CurrencyConfigSchema` declares, and objectui's field faces already follow it: only `fixed` gives a field one currency, and a field without one uses the tenant default at runtime. On both the live and the draft-preview path of `queryDataset`, the column now carries: - - the field's currency for a `fixed` field; - - `ExecutionContext.currency` for a `dynamic` field, a config naming no mode, an empty config, or no config at all. -- **What does not change**: a measure's explicit `currency` still wins, over a fixed field as well. A non-monetary measure still gets no code. `AnalyticsService.query` (the cube face) still carries no column currency. The `AnalyticsServiceConfig.sourceFieldMeta` type is unchanged. -- **Hosts that write their own `sourceFieldMeta`**: its `defaultCurrency` means the field's fixed currency. Return `currencyConfig.defaultCurrency` only when `currencyConfig.currencyMode === 'fixed'`, and return `undefined` otherwise. The TSDoc on `AnalyticsServiceConfig.sourceFieldMeta` states the rule. diff --git a/.changeset/20094-turso-remote-between-arity.md b/.changeset/20094-turso-remote-between-arity.md deleted file mode 100644 index d63bf5f9347..00000000000 --- a/.changeset/20094-turso-remote-between-arity.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/driver-turso': patch ---- - -fix(driver-turso): in remote mode, a `$between` that is not two bounds is refused with `INVALID_FILTER` / 400 and its message names no field or value (#20094) - -Clause-②: no - -In remote mode, `TursoDriver` lowers `$between` to `$gte` / `$lte` before the filter reaches its transport. When the range was not two bounds (`[x]`, `[x, y, z]`, `[]`, a number, a string, `null` or an object), the lowering threw a plain `Error` with no `code` and no `status`. Its message named the object and field and repeated the value, and every caller got it, including when the range came from a read scope such as `plugin-security`'s RLS or sharing predicate. Local mode refuses the same filter with `INVALID_FILTER` / 400 and withholds the field. - -The lowering now passes such a range on as written, and the transport refuses it the way it refuses every other filter it cannot compile: - -- `INVALID_FILTER` / 400, the code and status local mode answers; -- the message states the refusal's class, `Operator "$between" in this filter requires a [min, max] value array.`, behind the transport's `[RemoteTransport]` prefix, and says the field is withheld; -- the field and the value go to the driver's logger, at `warn`. - -Remote mode rebuilds every filter node before the transport sees it, so no provenance mark reaches the transport. As with the transport's other refusals, a filter marked as the caller's own therefore also gets the withheld message in remote mode. Local mode gives that caller the full text. - -Not changed: which filters are refused, local mode's answers, and how a two-bound `$between` is lowered, including the whole-day upper bound for a bare `YYYY-MM-DD` on a `datetime` field. diff --git a/.changeset/20098-objectql-icontains.md b/.changeset/20098-objectql-icontains.md deleted file mode 100644 index 31ba530dcfe..00000000000 --- a/.changeset/20098-objectql-icontains.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -fix(service-analytics): `$icontains` works on a query the ObjectQL strategy serves (#20098) - -Clause-②: no - -`ObjectQLStrategy` had no translation for the case-insensitive `$icontains` -operator, so every such filter on a datasource it serves failed. A valid -`{ name: { $icontains: 'acme' } }` included. `POST /analytics/query` answered -`500 INTERNAL_ERROR` ("ObjectQL strategy cannot express filter operator -"icontains""), while the native SQL face and the `/analytics/sql` echo served -the rows. - -The strategy now hands the engine the canonical `$icontains`, the same way it -passes `$contains`, `$notContains`, `$startsWith` and `$endsWith`. The engine -and the driver apply the case fold, so the ObjectQL face answers the same rows -as every other face: the fold is ASCII-only, so `'CAFÉ'` does not match -`'café'`. This covers the query's `where` in both spellings (`$icontains` and -the `FilterArray` `icontains`), under `$not`, a compiled dataset's scope and a -measure filter. An empty or non-string comparand is still refused with -`INVALID_FILTER` / 400 before the strategy runs. diff --git a/.changeset/20099-having-where-doors.md b/.changeset/20099-having-where-doors.md deleted file mode 100644 index ed810ccd76b..00000000000 --- a/.changeset/20099-having-where-doors.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: `engine.aggregate({ having })` takes the rest of the filter doors `where` takes — the comparand-type door, a check that `having` is a filter condition at all, refusals that no longer depend on the rows, and `{ $field }` references resolved against the aggregated row (#20099) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what `having` accepts on `engine.aggregate` (and on the REST aggregate query that forwards it there). Every refusal below is `INVALID_FILTER` / 400, raised once per query before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback. It ships as `minor` under the launch-window convention for accept-set narrowings. - -`having` took one of `where`'s filter doors, the comparand-shape face. The engine evaluates `having` itself, once per aggregated row, and that walker answered the shapes the other doors refuse — usually with no group and no error. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sqlite-wasm`, both paths, over groups with totals 500, 900 and 20 and a `max_cap` of 50, 5000 and 20: - -| you wrote in `having` | what it did before | write instead | -|:--|:--|:--| -| `{ total: { $eq: { v: 1 } } }`, `{ total: undefined }`, a `Map`, a function, a `Symbol`, a bigint beyond 2^53, or `{ $field: 5 }` as a comparand | depended on the operator. On the numeric `total`: in the implicit slot (the non-object values) and under `$eq`, `$gt` and `$gte` it kept no group; under `$ne` it kept every group; under `$lt` / `$lte` it kept no group, except the bigint, which kept every group. A `Symbol` under an ordering operator threw a raw `TypeError` with no `code` and no `status` on a populated grouped set. The same comparand in `where` is refused by the comparand-type door, and `having` now gets that door's refusal, with the path rooted at `having` | a string, number, bigint, boolean, `null` or `Date`; `{ total: { $eq: null } }` for "has no value" | -| `[['total', '>', 100]]`, `['total', '>', 100]` or `['and', …]` | kept no group: the array's index keys were read as column names | `{ total: { $gt: 100 } }`. The array form is input-only sugar declared on `where` alone, and `having` is declared as a filter condition object | -| `[]`, a string such as `'total > 100'`, a number, a boolean, a `Map` or a `Date` | kept every group, as if there were no `having` | the object form, or no `having` | -| an unknown or retired operator (`$median`, `$regex`), or an empty or non-string `$icontains` | refused only when a grouped row reached it: an empty grouped set, a condition on a column the row does not carry, or a `$or` whose earlier branch held all answered without an error | the operator the refusal names | -| `{ total: { $field: 'max_cap' } }` (a reference with no operator) | refused as an unsupported operator, again only when a grouped row carried `total` | `{ total: { $eq: { $field: 'max_cap' } } }`, or `$ne` / `$gt` / `$gte` / `$lt` / `$lte` | -| a `{ $field }` reference as an `$in` / `$nin` member, a `$contains` / `$startsWith` / `$endsWith` / `$notContains` pattern, or an `$exists` / `$null` operand | compared the reference object itself, so the answer never depended on the column it named: no group under `$in` / `$contains`, every group under `$nin` / `$notContains` / `$exists` | a literal there, or the comparison as one of the six scalar operators | -| a `{ $field }` reference naming a column the aggregated row does not have, or carrying an `addDays` that is not an integer or a `{ $field }` | compared the reference object itself, so the answer depended on the operator: no group under `$eq`; every group under `$ne`; under an ordering operator, no group against a number column, and against a text or date column an answer that follows each value's string order against the text `[object Object]` | a groupBy projection or an aggregation alias of the same query (the refusal lists them); a whole-day `addDays` | - -Not refused, but answering differently: - -- **A `{ $field }` reference as the whole comparand of `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` is now resolved against the aggregated row.** Before, it was compared as an object: `{ total: { $gt: { $field: 'max_cap' } } }` kept no group, and the same reference under `$ne` kept every group. It now keeps the groups whose `total` exceeds their own `max_cap`. The two-bound spelling the `{ $field }` `$between` refusal prescribes, `{ total: { $gte: { $field: 'max_cap' }, $lte: 1000 } }`, now works on `having`. The reference names another column of the same aggregated row: a groupBy projection or an aggregation alias. The comparison is the one the platform's in-memory filter evaluator makes and the SQL cross-field compiler matches row for row: an ordering against a missing value is false, `$eq` holds when both sides have no value, and `addDays` adds whole days to a date column. -- **The same resolution applies to a per-aggregation `filter`** (`aggregations[i].filter`), which the engine evaluates with the same walker against the source rows. `{ function: 'count', filter: { amount: { $gt: { $field: 'cap' } } } }` used to count no row. It now counts the rows whose `amount` exceeds their `cap`. -- **An exact-range bigint comparand is narrowed to a number, as it is in `where`.** `{ total: { $in: [500n, 20n] } }` kept no group, because `[500n].includes(500)` is false. It now keeps the 500 and 20 groups. The caller's `having` object is not edited. - -Who is affected: `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. Every `having` in this repository's docs and published skills is a scalar comparison against an aggregation alias (`{ order_count: { $gt: 5 } }` and the like), which answers exactly as before. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. - -Not changed: scalars, `null` in the equality slot, `$in` / `$nin` lists, a two-bound `$between`, scalar ordering bounds, `{}`, `null` and an omitted `having`, on both paths. The `$like` / `$ilike` operators are still refused on `having` (they are staged out of `FILTER_OPERATORS`), and `$ne` with a list is still answered until the shared face judges it. A `having` key that names no column is not judged by this change; since #20123 it is refused (`INVALID_FILTER` / 400) rather than keeping no group. diff --git a/.changeset/20101-page-type-default-served.md b/.changeset/20101-page-type-default-served.md deleted file mode 100644 index 6286eccacb2..00000000000 --- a/.changeset/20101-page-type-default-served.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): a page saved without `type` is stored and served with the `type` default that `PageSchema` declares (#20101) - -Clause-②: no - -`PageSchema` declares `type: PageTypeSchema.default('record')`, so a page authored without `type` is a record page. `saveMetaItem` parsed the body with that default and then stored the body as authored, and the `/meta` reads serve stored rows without parsing them. A record page authored without `type` was therefore served with no `type` at all. A renderer that picks an object's record page by `type === 'record'` never picked it. - -The declared default now reaches the served body at two points: - -- **Save.** When the schema gate accepts a page that omits `type`, the stored body gets the declared default, on draft and publish saves alike. A page with an explicit `type` is stored unchanged. Because the stored row and the served document are the same bytes, a client that re-saves the page it just read writes nothing: the checksum and the version stay the same. -- **Read.** A page row stored before this change is served with the declared default. This covers the list and single `/meta/page` reads, the cached read, draft reads and the `?preview=draft` list, the layered read, boot hydration into the registry, and the `searchAll` page sweep, whose hit now carries `pageType: 'record'` for such a page. The row itself is not rewritten, and `os migrate meta --stored` reports it canonical. - -The value is read from the registered `page` schema, never written out a second time. Only an absent `type` is filled: an explicit value, of any page type, is served as stored. - -What moves for a client: the served body of such a page gains `type: 'record'`, a key `PageSchema` already declares. The set of accepted bodies is unchanged. The ETag of the cached single read for such a page changes once, because the served content changed. Saving that page again after reading it stores `type` once. From then on a read-then-save round-trip writes nothing. diff --git a/.changeset/20102-retire-saved-report-stack.md b/.changeset/20102-retire-saved-report-stack.md deleted file mode 100644 index f81b7843e12..00000000000 --- a/.changeset/20102-retire-saved-report-stack.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/platform-objects': minor -'@objectstack/rest': minor -'@objectstack/client': minor -'@objectstack/cli': minor -'@objectstack/metadata-protocol': patch ---- - -feat!: retire the saved-report stack — `sys_saved_report` / `sys_report_schedule`, `/api/v1/reports`, `client.reports`, `IReportService`, the `reports` capability and `@objectstack/plugin-reports` (#20102) - -**BREAKING** — the saved-report stack is removed whole, with no deprecation window -(maintainer ruling 2026-09-25, 「A. 退役」). It persisted a raw object query -(`object_name` + `{ filter, fields, orderBy, limit, groupBy }`) with a render format -and an owner, and could e-mail it on a schedule. Measured on the main branch of this -repository, objectui and cloud before removal: zero callers of the routes, the SDK -namespace or the service contract outside their own tests, and no app declaring the -capability. - -**NOT affected: the `report` metadata kind.** `ReportSchema`, `defineReport`, -`/meta/report`, datasets and the analytics service are unchanged. The two shared the -word "report" and nothing else. - -FROM → TO, per surface: - -- `requires: ['reports']` → **refused** by `defineStack` (`STACK_CAPABILITY_UNKNOWN`, - 422) with the prescription "requires: 'reports' was removed in @objectstack/spec - 17.5.0 … Delete the token." Fix: delete the token. `os serve` on an older artifact - that still carries it warns with the same prescription and ignores it; `os validate` - and `os build` over a plain-object config (no `defineStack` call, so no parse-time - vocabulary check) report it as a non-fatal capability advisory carrying the same - prescription, never "check for a typo". The token is - gone from `PLATFORM_CAPABILITY_TOKENS` and `PLATFORM_CAPABILITY_PROVIDERS`; the new - `RETIRED_PLATFORM_CAPABILITY_GUIDANCE` (`@objectstack/spec/kernel`) carries the - prescription. -- `IReportService`, `SavedReport`, `ReportSchedule`, `ReportQuery`, `ReportFormat`, - `ReportRunResult`, `SaveReportInput`, `ScheduleReportInput` - (`@objectstack/spec/contracts`) → removed, no replacement export. Fix: delete the - import. -- `SysSavedReport`, `SysReportSchedule` (`@objectstack/platform-objects/audit`) and - the names `sys_saved_report` / `sys_report_schedule` in - `PLATFORM_PROVIDED_OBJECT_NAMES` → removed. A stack referencing either name is now - flagged as a probable typo instead of resolving. -- `GET|POST /api/v1/reports`, `GET|DELETE /api/v1/reports/:id`, - `POST /api/v1/reports/:id/run`, `POST /api/v1/reports/:id/schedule`, - `GET /api/v1/reports/:id/schedules`, `DELETE /api/v1/reports/schedules/:scheduleId` - → unmounted: each answers the standard unmatched-route `404`, byte-identical to a - path that never existed. Their nine error codes (`REPORTS_LIST_FAILED`, - `REPORT_DELETE_FAILED`, `REPORT_GET_FAILED`, `REPORT_NOT_FOUND`, - `REPORT_RUN_FAILED`, `REPORT_SAVE_FAILED`, `REPORT_SCHEDULE_FAILED`, - `SCHEDULES_LIST_FAILED`, `SCHEDULE_DELETE_FAILED`) leave `ERROR_CODE_LEDGER` with - their only emitter. -- `client.reports.*` (`list`, `save`, `get`, `delete`, `run`, `schedule`, - `listSchedules`, `unschedule`) → removed. Fix: delete the call. A report is `report` - metadata, read through `meta.*` and queried through `analytics.*`; a saved ad-hoc - object query is a ListView on that object. -- `RestServer`'s constructor keeps the position of the retired saved-report provider, - typed `undefined`, so no later positional argument re-binds. Pass `undefined` there; - passing a provider is a compile error. -- `@objectstack/plugin-reports` → no longer built or published from this repository, - and `@objectstack/cli` no longer depends on it or mounts it. Fix: remove the - dependency. There is no successor package and no scheduled-delivery replacement. - -**Existing databases.** `sys_saved_report` / `sys_report_schedule` tables in a deployed -database are left in place, untouched — no backfill, no reaper, no drop — under the -repository's convention for a retired platform object: the platform never drops a -table that metadata stops declaring, and `os migrate plan` lists such a table in its -informational unmanaged-tables section so an operator can decide. - -`@objectstack/metadata-protocol` (patch): the `INVALID_SORT` hint for a sort node -spelled `{ field, direction }` no longer names the retired saved-report contract as -the source of that vocabulary; it names the better-auth adapter's `sortBy`, which -still uses it. Code and status are unchanged. - -Breaking ships as `minor` per the launch-window convention -(`scripts/check-changeset-no-major.mjs`). - -**Clause-②: yes (narrowing)** — a published capability token, a service contract and -its types, two platform objects, eight routes, nine registered error codes and an SDK -namespace are removed; nothing previously refused is now accepted. - - diff --git a/.changeset/20105-action-name-refs-alert-header.md b/.changeset/20105-action-name-refs-alert-header.md deleted file mode 100644 index fed61e6e912..00000000000 --- a/.changeset/20105-action-name-refs-alert-header.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): `action-name-undefined` now resolves the `record:alert` call-to-action and the `page:header` action ids (#20105) - -`action-name-undefined` is the authoring gate for "a surface names an action that no action in the stack defines". It already walked list-view row/bulk menus, the `record:quick_actions` bar (`properties.actionNames`) and app navigation, but two page surfaces that bind an action by id were never read: - -- `record:alert` → `properties.action.actionName` (the banner's call-to-action button); -- `page:header` → `properties.actions` (the header's action ids). - -Both renderers resolve the id against the object's declared actions and draw nothing when it resolves nowhere: the alert keeps its banner and silently loses its button, and the header renders one button fewer with only a browser-console warning. The spec types both as plain strings, so a misspelled id passed spec validation and lint and vanished at runtime. - -The rule now walks both keys, each scoped to its component type (`element:button`'s `action` is an inline definition and `record:related_list` declares its own `actions`, so neither is read), and resolves them exactly as it resolves `actionNames`: against every action defined in the stack, global or object-embedded, with the same did-you-mean. A `page:header` array's inline-object elements are skipped (the spec refuses them on its own; they are definitions, not references), and every id is reported at its authored index. Each finding says what the author will actually see — a banner with no button, a header with no button — and the hint names the placement each surface needs: none for the alert, which runs its call-to-action by name, and `record_header` or `record_more` in `locations` for the header. - -**What moves for consumers.** A stack whose `record:alert` or `page:header` names an undefined action built clean before and now fails `os validate` / `os lint` / `os build` with `action-name-undefined` (severity `error`). That id never rendered a button, so nothing that worked stops working. The rule still does not run at the runtime publish door for `page` writes. A stack whose ids all resolve is unaffected: measured on the platform's own `sys_user` page, whose `resend_verification_email` call-to-action resolves clean. diff --git a/.changeset/20106-reclaim-space-full-freelist.md b/.changeset/20106-reclaim-space-full-freelist.md deleted file mode 100644 index 6832aafa5cf..00000000000 --- a/.changeset/20106-reclaim-space-full-freelist.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/driver-sql': patch -'@objectstack/driver-turso': patch ---- - -fix(driver-sql, driver-turso): `reclaimSpace()` returns the whole SQLite freelist, not one page per call (#20106) - -Clause-②: no - -`reclaimSpace()` is what the lifecycle service calls after every sweep that deleted rows (ADR-0057 §3.4). On SQLite it runs `PRAGMA incremental_vacuum`, and that statement frees one page per step. Two clients stepped it once: - -- **`SqlDriver` on better-sqlite3, and `TursoDriver` in local mode.** knex's better-sqlite3 client runs a statement that declares no result columns with `Statement.run()`, which steps it once. A database with 300 free pages had 299 after the call, read from a second connection, and the file barely shrank. `incremental_vacuum(N)` freed one page too. The method now drives that binding through its own `exec()`, which steps the statement until SQLite reports done: 300 → 0, and the file shrinks by those pages. -- **`TursoDriver` in remote mode.** The libSQL client's `execute()` stepped the statement once and left it unfinished. Over a libSQL `file:` client, the issuing connection read one page fewer, but a second connection read the freelist and the file size unchanged, and a row written after the call on the same connection never reached the file. The remote route now reads `PRAGMA freelist_count`, sends nothing more when it is `0`, and otherwise runs the vacuum through the client's `executeMultiple()`: 300 → 0 from a second connection, and the later write lands. A server that refuses either statement answers `DATABASE_ERROR` / 500, as before. What a hosted libSQL server does with either call is not measured. - -`SqliteWasmDriver` was already complete: its dialect steps every PRAGMA to the end (300 → 0 before and after this change). - -Nothing to migrate: `reclaimSpace()` keeps its signature, and a database whose `auto_vacuum` mode is not `INCREMENTAL` still reclaims nothing, as before. diff --git a/.changeset/20107-turso-remote-external-remote-name.md b/.changeset/20107-turso-remote-external-remote-name.md deleted file mode 100644 index 823ac8ac989..00000000000 --- a/.changeset/20107-turso-remote-external-remote-name.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/driver-turso": minor ---- - -`TursoDriver` in **remote** mode reads and writes a federated object's remote table, `external.remoteName`, the way the local and embedded-replica modes always have (#20107). - -Clause-②: yes (widening) - -**What was wrong.** `registerExternalObject` records a federated object's remote table (ADR-0015), and the local face reads that record for every statement. The remote face inherited the registration but never read it. Every remote data door handed `RemoteTransport` the object name, and the transport used it as the table. So an object `ext_customer` bound to the remote table `customers` was queried as a table named `ext_customer`. Measured on a remote face over a libSQL `file:` client, with a local driver over the same file answering every door from the mapped table: - -- `find`, `findOne`, `count` and every write door failed with a bare `LibsqlError` (`SQLITE_ERROR: no such table: ext_customer`). It carried no `status`, so REST served it as an unclassified 500. -- `aggregate` answered `[]`, because the transport reads "no such table" as "no rows". -- `distinct` answered `DATABASE_ERROR` / 500. - -**What changes, on the remote face only:** - -- Every data door (`find`, `findOne`, `count`, `aggregate`, `distinct`, `create`, `update`, `upsert`, `delete`, `bulkCreate`, `bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany`) compiles against the table the registration recorded. That is `external.remoteName` for a federated object, and the object's own name otherwise. It is read from the same `SqlDriver` registry the local face reads, and not copied. Managed objects send the same statements as before. -- A remote table name is quoted as one SQL name, with any embedded quote doubled, so a name the local face can read (`order-lines`) can be read here too. -- `find`, `findOne` and `count` now end in the local face's read-exit envelope. A statement the backend refuses, for example on a remote table that really is absent, answers `DATABASE_ERROR` / 500. The libSQL error rides under a non-enumerable `cause`, the dialect text goes to the server log, and the targeted table is declared for `isMissingTableError`. Before, the bare `LibsqlError` reached the caller. The write doors keep the local face's behaviour, where a write fault is classified at the REST boundary from its message. -- A federated object whose `external.columnMap` **renames** a column is refused with `NOT_IMPLEMENTED` / 501 on every remote data door, before any statement is sent. The remote transport addresses columns by field name and does not translate the map. With the table resolved, a filter on a renamed field would read a column the table does not have, and the transport answers that with an empty list rather than the matching rows. Before this change, an object whose `remoteName` differs from its name failed with `no such table` on every door but `aggregate`, which answered `[]`. A binding with no `remoteName`, or with `remoteName` equal to the object name, plus a renaming map is refused too: before, its unfiltered reads and its id-keyed writes answered, while its filtered reads were already silently wrong. A map whose entries rename nothing is served. **If this refusal fires for you:** use the local or embedded-replica transport for that object, or name its fields after the remote columns and drop the renaming entries. -- `RemoteTransport` (exported from the package root) gains an optional trailing `table` argument on its 14 data methods; omitted, it defaults to the object, which is what a transport used on its own always did. diff --git a/.changeset/20113-jsx-gate-parse-level-notice.md b/.changeset/20113-jsx-gate-parse-level-notice.md deleted file mode 100644 index 670fa9c201a..00000000000 --- a/.changeset/20113-jsx-gate-parse-level-notice.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os validate` / `os build` / `os lint` now say when the JSX page gate ran at parse level only, and refuse a project `sdui.manifest.json` they cannot use (#20113) - -Clause-②: no - -The gate that checks `kind: 'html'` pages (and the deprecated `kind: 'jsx'`) validates components and props only when an SDUI component manifest resolves: the project's `sdui.manifest.json` in the working directory, then the copy inside `@objectstack/console`. With neither, it falls back to parse-level checking (syntax, tag matching, forbidden constructs). All three commands used to do that silently and report success, so "passed" read as "components and props checked" when they were not. - -A page "to check" below means one the gate actually judges, wherever it is declared: at the top level of the stack, or inside a `packages[]` entry. The per-package pass judges those even when the top level carries its own `pages` key. - -- **No manifest, `kind: 'html'` pages to check: a notice, exit status unchanged.** Each command reports one notice, tagged `sdui/jsx-parse-level-only`. It gives the number of distinct pages checked at parse level only, top-level and package-carried alike, and names every place a manifest was looked for. `os validate` and `os build` print it as an info line plus a hint line; `os lint` lists it under Suggestions. `--json` carries it in the channel each command already publishes. It is an `info` record (the author-time finding shape) at the end of `warnings` on `os validate` / `os build`, and a `suggestion` in `os lint`'s `issues`, which also moves that run's `total` and `suggestions` counts by one. No top-level key is added. `--strict` does not promote it on any command, so a project without its own manifest exits exactly as before. -- **A project `sdui.manifest.json` that exists but cannot be used: refused (exit 1).** This applies when there is a `kind: 'html'` page to check, top-level or package-carried, and the file cannot be read, is not valid JSON, or is not a JSON object with a `components` map. The file and the reason are named on stderr and in the `--json` envelope's `error`. Before, invalid JSON, `null` and an unreadable file were ignored in silence (parse-level checking, exit 0), while `{}` or an array crashed the run with `Cannot convert undefined or null to object` (exit 1). **Fix:** correct the file, or remove it to check those pages at parse level only. -- **Unchanged:** a project with no `kind: 'html'` page to check, at the top level or in any package, says nothing and refuses nothing, whatever its manifest looks like. A resolvable manifest arms full validation exactly as before. `os init`'s scaffold check reads the manifest as it did. diff --git a/.changeset/20116-filter-save-door-face-parity.md b/.changeset/20116-filter-save-door-face-parity.md deleted file mode 100644 index a3d584964c3..00000000000 --- a/.changeset/20116-filter-save-door-face-parity.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: a filter carrying a comparand the query faces refuse is refused when it is saved (#20116) - -**BREAKING** — an accept-set narrowing of published authoring schemas, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The save door narrows to exactly what the query faces already refuse. The hand-migration prescription is registered under protocol major 18 as `filter-query-face-comparands-refused-at-save`. - -## What changes - -`FilterConditionSchema` now refuses, at parse, every comparand slot the query faces refuse: - -- a `$null` or `$exists` flag that is not a boolean — `"false"`, `"x"`, `null`, `1`; -- a `null` comparand of `$gt` / `$gte` / `$lt` / `$lte`; -- an `$in` or `$nin` comparand that is not a list, or a list holding `null`; -- a `$between` comparand that is not a two-element list, or whose endpoint is `null`, blank (`""`) or a `{ $field }` reference; -- an array under `$ne`. - -It judges the field entries of a condition and of every `$and` / `$or` / `$not` member. The dataset `filter` and measure `filter` also judge the same slots INSIDE a nested-relation condition, through the nested-relation walk they already carry, because the analytics `where` door flattens a relation and judges its entries. The shared comparand-shape face (`assertListComparandShapes`) is called read-only as the judge for each slot, so the save door refuses exactly what that face refuses on every query. The two boolean flags, which that face does not judge, are refused on the predicate every flag face uses (`driver-sql`, `driver-memory`, `driver-mongodb`, the read-scope compiler and the analytics `where` door): the comparand is not a boolean. - -Measured on `origin/main` `af32cf9a` before the change: a dataset `filter`, a dataset measure `filter`, a dashboard widget `filter` and a report `runtimeFilter` each parsed with `success: true` for one instance of every shape above. The comparand-shape face refused each one but the flags with `INVALID_FILTER` / 400, and the analytics `where` door refused all of them, inside a nested relation too. So such a document published clean and then failed every chart built on it. - -Every schema that carries a `FilterCondition` refuses on parse. That covers the dataset `filter` and measure `filter`, the dashboard widget `filter` and options-source `filter`, the report and joined-report-block `runtimeFilter`, the field `relatedListFilter` and rollup `summaryOperations.filter`, the solution-blueprint summary `filter`, the analytics query `where`, the dataset selection `runtimeFilter`, the query `where` and `having`, the data-engine aggregate call's `having`, the aggregation `filter`, and the query-filter `where`. So `defineStack`, `os validate` and a save through the metadata protocol (`422 INVALID_METADATA`) refuse such a document at the slot's path, for example `filter.stage.$null` or `measures.0.filter.amount.$between.0`. - -The words are the query face's. For an array under `$ne`, a non-list `$in` / `$nin` and a malformed `$between`, the refusal is the face's sentence without its location clause (`at where..`), because the issue's path carries the location. For a `null` ordering comparand, a `null` list member or endpoint, and a blank or `{ $field }` endpoint, it is the sentence the enforced operator slot (`FieldOperatorsSchema`) already prints for the same comparand. A non-boolean flag gets the query faces' sentence: `Operator "$null" on field "stage" requires a boolean comparand (true or false).`, then the received value and the prescription. - -Two request doors parse these carriers, and they now answer before the analytics compiler does. The REST dataset selection (its `runtimeFilter`, parsed against `DatasetSelectionSchema`) and the analytics query body (its `where`, parsed against `AnalyticsQueryRequestSchema`) answer `VALIDATION_FAILED` / 400 with the sentence at the field, for a top-level or combinator slot. Before, the compiler answered `INVALID_FILTER` / 400 for the same filter. - -This also changes the `$ne` note of the equality-slot change earlier in this release: an array under `$ne` is now refused on save too, in the sentence `FieldOperatorsSchema.$ne` and the face print. - -## What does NOT change - -- **Nothing stored is rewritten, and nothing is dropped.** The parse fails and strips nothing. The read path does not re-validate stored rows, so a stored document keeps loading, and its next save is refused. Such a filter has failed every query since the runtime refusal of its shape, so the refusal is a repair. -- **The shared reach is the face's, and no wider.** On every carrier but the two dataset ones, a field spec with no `$` key, such as the nested-relation condition `{ account: { region: { $in: ["a", null] } } }`, is not judged, because neither the face nor the drivers' flag checks descend one. A dashboard widget `filter` and a report `runtimeFilter` therefore still save that shape, and the analytics `where` door refuses it when they are charted. -- **What the face passes still passes:** `$eq: null` and `$ne: null` (the null predicate), a `{ $field }` reference as the whole comparand of a scalar comparison, `$in: []` and `$nin: []`, a whitespace-only `$between` endpoint, and falsy endpoints such as `[0, 0]`. -- **The data-engine calls' `where` option still parses.** Its type is a union whose first arm is an open record. The face refuses the shape when the call runs. -- **No key, export or JSON Schema changes.** The published JSON Schema cannot state a refinement, and `FilterCondition`'s already could not state the equality-slot one. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `{ stage: { $null: "true" } }`, `{ stage: { $null: 1 } }` | `{ stage: { $null: true } }` ("has no value") | -| `{ stage: { $exists: "false" } }`, `{ stage: { $null: null } }` | the boolean you meant: `$exists: false` is "has no value", `$null: false` is "has a value" | -| `{ amount: { $gt: null } }` | `{ amount: { $eq: null } }` ("has no value") or `{ amount: { $ne: null } }` ("has a value") | -| `{ stage: { $in: "won" } }` | `{ stage: { $in: ["won"] } }` or `{ stage: "won" }` | -| `{ stage: { $in: ["won", null] } }` | `{ $or: [{ stage: { $in: ["won"] } }, { stage: { $null: true } }] }` | -| `{ stage: { $nin: ["lost", null] } }`, meaning "has a value and is not `lost`" | `{ stage: { $nin: ["lost"], $null: false } }` | -| `{ stage: { $in: [null, ""] } }`, a filter builder's "is empty" | `{ $or: [{ stage: { $null: true } }, { stage: "" }] }` | -| `{ stage: { $nin: [null, ""] } }`, a filter builder's "is not empty" | `{ stage: { $null: false, $ne: "" } }` | -| `{ amount: { $between: [null, 5] } }`, `{ amount: { $between: ["", 5] } }` | `{ amount: { $lte: 5 } }`, or the bound you meant | -| `{ amount: { $between: [{ $field: "floor" }, 5] } }` | `{ amount: { $gte: { $field: "floor" }, $lte: 5 } }` | -| `{ amount: { $between: 5 } }`, `{ amount: { $between: [1] } }` | `{ amount: { $between: [1, 5] } }` | -| `{ stage: { $ne: ["won", "lost"] } }` | `{ stage: { $nin: ["won", "lost"] } }` | - -### FROM → TO at the HTTP doors - -Same status, different code: the refusal now comes from the route's schema door, located on the member, instead of from the analytics filter normalizer. - -| request | before | after | -|:--|:--|:--| -| `POST /analytics/dataset/query` with `selection.runtimeFilter: { amount: { $between: [10] } }` | `400 INVALID_FILTER` from the analytics normalizer, in the comparand-shape face's sentence | `400 VALIDATION_FAILED`, `details.fields[]` entry `selection.runtimeFilter.amount.$between` with the sentence `Operator "$between" on field "amount" requires a [min, max] value array. Received array ([10]). …` | -| the same route, any other slot above in `selection.runtimeFilter` (top level or in `$and` / `$or` / `$not`) | `400 INVALID_FILTER` | `400 VALIDATION_FAILED`, located on the slot, with that slot's sentence | -| `POST /analytics/query` (`AnalyticsQueryRequestSchema`) with the same shape in `where` | `400 INVALID_FILTER` | refused by the request schema at `where.amount.$between`, answered `400 VALIDATION_FAILED` | - -A client that branches on `INVALID_FILTER` for these shapes reads `VALIDATION_FAILED` instead. Both are 400 and both name the field. - -## Who is affected, measured - -A literal-comparand scan of every member shape, with a lit control per shape, over `examples/**` and the non-test `packages/**` of this repository at `af32cf9a`, the console repository at its pinned commit `f8a9d0fb05`, and the cloud repository's `main` at `48d70663ab`, found authored filters carrying one in one place. The console's filter-condition widget writes "is empty" as `{ field: { $in: [null, ""] } }` and "is not empty" as `{ field: { $nin: [null, ""] } }`. That widget edits a field's `relatedListFilter` and a rollup's `summaryOperations.filter` in the Studio field designer, and a sharing rule's criteria. Both shapes carry a `null` list member, which the face has refused on every query since the 2026-08-31 ruling, so a filter saved that way has been failing its related list or rollup since then. After this change, the Studio save is refused instead, with the `$or` / `$null` prescription. Every other hit is prose, a type table or a test fixture. Deployed datasets, dashboards and reports were NOT measured. Validating each stack, or re-saving each document, finds every instance the surface above lists. - -Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. Comparand shapes that every query face already refused are now refused on save as well. - - diff --git a/.changeset/20116-filter-save-door-type-face-and-widget.md b/.changeset/20116-filter-save-door-type-face-and-widget.md deleted file mode 100644 index 25f610084c0..00000000000 --- a/.changeset/20116-filter-save-door-type-face-and-widget.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: a filter carrying a comparand the comparand-type face refuses is refused when it is saved, and every charted presentation filter judges its nested relations (#20116) - -**BREAKING** — an accept-set narrowing of published authoring schemas, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The save door narrows to exactly what the query faces already refuse; this is stage 2 of the change whose first stage registered `filter-query-face-comparands-refused-at-save`. The hand-migration prescription is registered under protocol major 18 as `filter-comparand-types-and-widget-nested-slots-refused-at-save`. - -## What changes - -**The comparand-type face, asked on save.** `FilterConditionSchema` now refuses, at parse, every comparand the comparand-type face (`normalizeFilterComparandTypes`, the accepted set `string | number | bigint | boolean | null | Date`) refuses on every query: - -- a plain object where a single value belongs — `{ stage: { $eq: { a: 1 } } }`, and a `{ $field: … }` whose name is not a string; -- a `Map`, a class instance, a function or a Symbol; -- `undefined`, as `{ owner: undefined }` or under an operator; -- a bigint beyond ±2^53; - -as the comparand itself, as an implicit-equality comparand, or as an `$in` / `$nin` / `$between` list member (`{ stage: { $in: [{ a: 1 }] } }`). The face is called read-only as the judge, after the comparand-shape face, so the save door refuses exactly what it refuses and passes what it passes. A field value that is not a PLAIN object (a `Map`, a class instance) is the comparand the face calls it, never an empty nested relation. - -**Every charted presentation filter is an analytics carrier.** `DashboardWidgetSchema.filter`, `ReportSchema.runtimeFilter` and `JoinedReportBlockSchema.runtimeFilter` now declare the same filter as `DatasetSchema.filter` and `DatasetMeasureSchema.filter`, because the dataset executor ANDs each into the same analytics query. So they judge the slots INSIDE a nested-relation condition the way the analytics `where` door does: `{ acct: { stage: { $in: ["won", null] } } }`, `{ acct: { region: ["a"] } }` and `{ acct: { region: { $eq: ["a"] } } }` are refused on save at `widgets.0.filter.acct.…`, `runtimeFilter.acct.…` and `blocks.0.runtimeFilter.acct.…`, as on the two dataset carriers — every shape the earlier stage refuses at the top level, and every type-face value above. That declaration moved, verbatim, into its own module shared by the five carriers; every carrier's published JSON Schema body is byte-identical. - -Measured on `origin/main` `17bd3187` before the change: `FilterConditionSchema`, a dataset `filter`, a dataset measure `filter`, a dashboard widget `filter`, a report `runtimeFilter` and a joined report block `runtimeFilter` each parsed with `success: true` for `{ stage: { $eq: { a: 1 } } }`, `{ stage: { $in: [{ a: 1 }] } }` and a `Map` comparand, at the top level and inside a nested relation. The comparand-type face and the analytics `where` door refused each with `INVALID_FILTER` / 400. A dashboard widget `filter`, a report `runtimeFilter` and a joined report block `runtimeFilter` also parsed with `success: true` for the three nested-relation shapes above, which the analytics door refuses when they are charted. - -**One issue per slot at the top level and in the combinators — a dedupe; no verdict moves.** The faces' verdict on a slot is one refusal: the first the query doors give, in their order — the comparand-shape face, then the comparand-type face, then the `$null` / `$exists` flag rule. So `{ stage: { $null: { a: 1 } } }` reads as the type face's refusal, as it does on chart. At the top level of a filter and in its `$and` / `$or` / `$not` members, where a face refuses a slot the schema door's own `$icontains` and date-preset arms stay silent on it. Before, two issues could land there for one defect: `{ created_at: { $between: ["last_7_days"] } }` reported the malformed range at `created_at.$between` AND the preset endpoint at `created_at.$between.0`; now it reports the range only, and the preset is reported once the range is fixed. - -Inside a nested relation on an analytics carrier (a dataset or measure `filter`, a widget `filter`, a report or joined-block `runtimeFilter`) a slot can still carry TWO issues. There the carrier's nested-relation walk asks the faces, while the schema door's own `$icontains` and date-preset arms keep judging nested slots as they did before this change. So `{ acct: { name: { $icontains: new Map() } } }` gets both the `$icontains` sentence and the type face's at `filter.acct.name.$icontains`, and `{ acct: { created_at: { $between: ["last_7_days"] } } }` gets the malformed range at `filter.acct.created_at.$between` and the preset endpoint at `….$between.0`. The document is refused either way; only the issue count differs. - -Every document refused before is still refused, and every document accepted before is still accepted — the dedupe removes only a second issue on an already-refused slot at the top level and in the combinators. - -**The words are the type face's**, less its location clause (`at where..`), because the issue's path carries the location: for example `Filter comparand is a plain object ({"a":1}), which no driver can compare. A comparison value must be a string, number, bigint, boolean, null or Date. Refusing rather than guessing: …` at `filter.stage.$eq`, and at `filter.stage.$in.1` for a list member. - -`defineStack`, `os validate` and a save through the metadata protocol (`422 INVALID_METADATA`) refuse such a document at the slot's path. - -## What does NOT change - -- **Nothing stored is rewritten, and nothing is dropped.** The parse fails and strips nothing. The read path does not re-validate stored rows, so a stored document keeps loading, and its next save is refused. -- **What the face passes still passes:** a `Date`, a `{ $field: "column" }` reference, a `{placeholder}` string the engine resolves at request time (`{current_user_id}`, `{today}`), and a bigint within ±2^53. The face narrows such a bigint to its number on a query; the save door keeps it as written. -- **The shared reach is unchanged.** On every carrier but the three analytics ones, a field spec with no `$` key (a nested-relation condition) is not judged, because no face descends one. -- **The data-engine calls' `where` option still parses.** Its type is a union whose first arm is an open record. The face refuses the shape when the call runs. -- **No key, export or JSON Schema body changes.** The published JSON Schema cannot state a refinement; the new nested-relation rule is recorded as a dropped refinement at `ui/DashboardWidget` `filter`, `ui/Dashboard` `widgets.element.filter`, `ui/Report` `runtimeFilter` and the joined block's `runtimeFilter`, and at the same positions inside the installed-package manifests. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `{ stage: { $eq: { a: 1 } } }` | the one value you meant: `{ stage: { $eq: "won" } }` | -| `{ stage: { $in: [{ a: 1 }] } }` | a list of values: `{ stage: { $in: ["won", "lost"] } }` | -| `{ amount: { $gt: { $field: 5 } } }` | a column name: `{ amount: { $gt: { $field: "budget" } } }` | -| `{ owner: undefined }`, `{ owner: { $eq: undefined } }` | `{ owner: { $eq: null } }` ("has no value"), `{ owner: { $ne: null } }` ("has a value"), or omit the key | -| `{ tags: new Map(…) }`, a class instance | the value itself, as a string, number, boolean, `null` or `Date` | -| `{ qty: { $gt: 2n ** 60n } }` | a bound within ±2^53, or the value compared as a string | -| a widget `filter: { acct: { stage: { $in: ["won", null] } } }` | `{ $or: [{ acct: { stage: { $in: ["won"] } } }, { acct: { stage: { $null: true } } }] }` | -| a widget `filter: { acct: { region: ["a", "b"] } }` | `{ acct: { region: { $in: ["a", "b"] } } }` | -| a report or joined-block `runtimeFilter` with either nested shape above | the same rewrite, at `runtimeFilter` / `blocks..runtimeFilter` | - -### FROM → TO at the HTTP doors - -Same status, different code: the refusal now comes from the route's schema door, located on the member, instead of from the analytics filter normalizer. - -| request | before | after | -|:--|:--|:--| -| `POST /analytics/dataset/query` with `selection.runtimeFilter: { stage: { $eq: { a: 1 } } }` | `400 INVALID_FILTER` from the analytics normalizer, in the comparand-type face's sentence | `400 VALIDATION_FAILED`, `details.fields[]` entry `selection.runtimeFilter.stage.$eq` with the sentence `Filter comparand is a plain object ({"a":1}), which no driver can compare. …` | -| the same route with `selection.runtimeFilter: { stage: { $in: ["won", { a: 1 }] } }` | `400 INVALID_FILTER` | `400 VALIDATION_FAILED` at `selection.runtimeFilter.stage.$in.1` | -| `POST /analytics/query` (`AnalyticsQueryRequestSchema`) with either shape in `where` | `400 INVALID_FILTER` | refused by the request schema at `where.stage.$eq` / `where.stage.$in.1`, answered `400 VALIDATION_FAILED` | - -A client that branches on `INVALID_FILTER` for these shapes reads `VALIDATION_FAILED` instead. Both are 400 and both name the field. The other refused values cannot arrive over HTTP: JSON has no `Map`, `undefined` or bigint. - -## Who is affected, measured - -A literal scan of every shape, with a lit control per shape, over `examples/**` and the non-test `packages/**` of this repository, the console repository at its pinned commit `f8a9d0fb05` and the cloud repository's `main` at `96eb092fbf`, found no authored filter carrying a plain object where a value belongs, a `Map` or class instance, `undefined` or a bigint beyond 2^53 — every hit was prose, a driver's operator switch, or a conformance table — and no `filter` / `runtimeFilter` / `where` / `relatedListFilter` whose first entry is a nested relation holding a list or an operator map. A runtime walk of every filter in the example stacks (`app-crm`, `app-todo`, `app-multi-package`, and `app-showcase`'s metadata modules) compared the old and new doors on each and found none refused by the new one alone. Deployed datasets, dashboards and reports were NOT measured. Validating each stack, or re-saving each document, finds every instance the surface above lists. - -Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. Comparand values that every query face already refused are now refused on save as well, and a dashboard widget filter and both report runtimeFilters judge the nested-relation slots the analytics door already refused on chart. - - diff --git a/.changeset/20121-engine-where-shape-refused.md b/.changeset/20121-engine-where-shape-refused.md deleted file mode 100644 index 16f483e84a1..00000000000 --- a/.changeset/20121-engine-where-shape-refused.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -fix(objectql)!: an engine `where` that is not a filter — a string, a number, a `Map`, a boolean, a `Date` — is refused with `INVALID_FILTER` / 400 before any driver call, and a `multi: true` update or delete no longer rewrites or removes every row for it (#20121) - -Clause-②: no (narrowing) - - - -**BREAKING** — an accept-set narrowing on the engine's `where`, shipped as `minor` -under the launch-window convention (`check-changeset-no-major` refuses `major` -until GA; breaking-ness is carried by this banner and the ADR-0087 disposition -above, not by the level). - -**What changed.** `find`, `findOne`, `count`, `aggregate`, `update` and `delete` -on the engine now refuse a `where` that is neither absent, a filter object nor a -filter array. The refusal is thrown before a driver is asked for anything, as -`INVALID_FILTER` with `status` and `httpStatus` 400, and its message reads -"`('')`: 'where' must be a filter object or condition array, -received …. It was not applied, …" — the REST normalizer's words for the same -input. The refusal for an array that is not a filter (`[1, 2, 3]`, an infix -join) keeps its message and now carries the same `INVALID_FILTER` / 400 -envelope; it used to have no `code` and no `status`. - -**What it replaces.** A value with no filter keys fell through every check on -the seam and the driver ignored it. Measured on `driver-memory` and -`SqlDriver` (better-sqlite3) with four rows: - -- `find` / `count` / `aggregate` answered for every row, as if no `where` had - been given; `findOne` answered the first row (for a `Map`, its no-predicate - guard refused the call, with no `code`). -- `update(…, { where, multi: true })` rewrote all four rows, and - `delete({ where, multi: true })` deleted all four. That held without - `SecurityPlugin`, and with it under a system context. -- For a caller scoped by row-level security, it depended on the value. - - A string, a number, a `Map`, a `Date`, a `Set` or `true` was wrapped by the - security middleware into its `$and`, where the driver refused it - (`INVALID_FILTER`) and nothing was written. - - The empty string was not refused. The middleware's composition reads a - falsy `where` as absent and dropped it, so `find` answered all of the - member's rows, and `update(multi)` / `delete(multi)` rewrote or deleted - every row the member could reach (2 of 4 on both drivers). -- Such a `where` also stepped past the unscoped-write guard that a hook opts - into with `dispatchUnscopedMultiWrite`, because that guard treats only an - absent or `null` `where` as unscoped. - -**Unchanged.** An absent `where`, `null`, `{}` and `[]` still mean "no -filter". A filter object is accepted when it is a non-array object whose -built-in tag (`Object.prototype.toString`) is `[object Object]`. That covers a -plain object, an `Object.create(null)` object, an instance of your own class -carrying the filter on its own keys, a `Proxy` of one, and an object from -another realm, and each filters exactly as before. A well-formed filter array is -lowered as before. - -**One accepted shape is now refused.** An object that overrides -`Symbol.toStringTag`, as its own key or through its prototype chain, has a -different built-in tag. It is refused and named by that tag (for example -`received Criteria`). Before this change such an object filtered correctly on -its own keys. No producer in this repository creates one: the wire is JSON, and -the SDK builds arrays or plain objects. If yours does, pass its filter keys in a -plain object instead. The REST door already answered a non-filter `?filter=` with -`INVALID_FILTER` / 400 and is not touched. - -**Fix.** Pass the predicate you meant as a filter object, for example -`{ amount: { $gt: 100 } }`, or as a filter array (`[['amount', '>', 100]]`), and -leave `where` out when you mean every row. If the value came from somewhere -untyped, the refusal names what arrived (`received string "amount > 100"`, -`received Map`), and an un-awaited promise shows up as `received Promise`. diff --git a/.changeset/20122-aggregation-filter-row-independent.md b/.changeset/20122-aggregation-filter-row-independent.md deleted file mode 100644 index 40b648f72c2..00000000000 --- a/.changeset/20122-aggregation-filter-row-independent.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: a per-aggregation `filter` (`aggregations[i].filter`) on `engine.aggregate` is judged once, before any row is read — its refusals no longer depend on whether the table has rows, and it takes the shape gate and the comparand-type door `where` takes (#20122) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what a per-aggregation `filter` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query` with `aggregations`) that forwards it there. Every refusal below is `INVALID_FILTER` / 400, raised in the engine's per-aggregation loop, before any driver is asked for a row, identically on an empty and on a populated table. The refusal names the aggregation that carries the filter (`aggregations[1].filter`). It ships as `minor` under the launch-window convention for accept-set narrowings. - -The engine evaluates a per-aggregation filter itself, in the in-memory fallback, once per source row of each bucket, with the same walker `having` uses. So the walker's refusals were reached only when a row was. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sql`, and through `POST /data/:object/query` on both, over six rows in three groups, with `{ function: 'count', alias: 'n', filter }`: - -| you wrote in `aggregations[i].filter` | what it did before | write instead | -|:--|:--|:--| -| an unknown or retired operator (`{ amount: { $median: 1 } }`, `$nand`, `$regex`, `$regex` with `$options`), an operator of `where` the walker does not evaluate (`$like`, `$ilike`), a non-`$` key beside an operator, or an empty or non-string `$icontains` | refused on a populated table only. An empty table answered `200 []` with a `groupBy`, and `[{ n: 0 }]` without one. (At the REST door an empty or non-string `$icontains` was already refused at ingress, whatever the rows.) | the operator the refusal names | -| an unknown operator on a column the source row does not carry (`{ nope: { $median: 1 } }`) | counted no row, with no error, on a populated table too | the operator the refusal names | -| an unknown operator in a `$or` branch after one that held (`{ $or: [{ amount: { $gt: 0 } }, { amount: { $median: 1 } }] }`) | counted EVERY row on a populated table: the walk stopped at the branch that held | the operator the refusal names | -| `{ amount: { $field: 'cap' } }` (a reference with no operator) | refused as an unsupported `$field` operator on a populated table only | `{ amount: { $eq: { $field: 'cap' } } }`, or `$ne` / `$gt` / `$gte` / `$lt` / `$lte` | -| a `{ $field }` reference as an `$in` / `$nin` member, a `$contains` pattern, or an `$exists` operand | compared the reference object itself: no row under `$in` / `$contains`, every row under `$nin` / `$exists` | a literal there, or the comparison as one of the six scalar operators | -| a `{ $field }` reference whose `addDays` is not an integer (`1.5`, `'7'`) | counted rows by the in-memory evaluator's own reading of that offset, which `FieldReferenceSchema` refuses | a whole-day `addDays` | -| `{ amount: { $eq: { v: 1 } } }`, `{ amount: undefined }`, `{ $eq: new Map() }`, a function, an `undefined` `$in` member, a bigint beyond 2^53, or `{ $gt: { $field: 5 } }` | counted no row (each measured under the operator shown, or in the implicit slot). The same comparand in `where` is refused by the comparand-type door; the per-aggregation filter now gets that door's refusal, rooted at its own position | a string, number, bigint, boolean, `null` or `Date` | -| a `Symbol` comparand | under `$ne`, counted every row; under `$gt`, threw a raw `TypeError` with no `code` and no `status` on a populated table | a literal of one of the types above | -| a `filter` that is not a filter object: a string (`"stage = 'won'"`), a number, `true` / `false`, `''`, a `Map`, a `Date` or a `Set` | dropped: the aggregation read every row of its group, with no error. `driver-sql`'s native aggregate answered a non-empty string with `NOT_IMPLEMENTED` / 501. (The REST door already refused the JSON-expressible ones, through `AggregationNodeSchema`.) Now refused by the shape gate `where` takes, with the aggregation named (`'aggregations[1].filter' must be a filter object, received …`) | a filter object, `{ stage: 'won' }` | -| an array, `[]` included: a condition array (`[['amount', '>', 100]]`, `['and', …]`) or an empty one | a condition array counted NO row: the walker read its index positions as column names. `[]` was read as no filter. The REST door already refused every array here with `VALIDATION_FAILED` / 400. Now refused in-process too (`'aggregations[1].filter' must be a filter object, received an array (…)`), because `AggregationNodeSchema.filter` is declared `FilterConditionSchema`, which admits no array form: the condition-array sugar is lowered on `where` alone | the object form, `{ amount: { $gt: 100 } }`; omit `filter` for no filter | - -Not refused, but answering differently: - -- **An exact-range bigint comparand is narrowed to a number, as it is in `where`.** `{ amount: { $in: [400n, 20n] } }` counted no row, because `[400n].includes(400)` is false. It now counts the rows it names. The caller's aggregation entry is not edited. - -Who is affected: a per-aggregation filter reaches `engine.aggregate` from a direct engine call, from the REST aggregate query, and from the analytics service, which lowers a dataset measure's own `filter` onto it. On a populated table the refusals in the first and fourth rows above were already raised; what changes there is that an empty table refuses them too. The two shape rows are reachable in-process only: the REST door already refuses a non-object `filter` and every array. Callers in a deployment were NOT measured. - -Not changed: implicit equality, scalar ordering bounds, `$in` / `$nin` lists, a two-bound `$between`, `$icontains` / `$startsWith` with a non-empty string, `$ne: null`, `$exists`, `$null`, `$or` / `$not` composition, a `{ $field }` reference as the whole comparand of a scalar comparison (with or without a whole-day `addDays`), an exact bigint in the implicit slot, and `{}`, on both drivers, measured identical before and after. The zero-row filter `{ $not: {} }`, which the analytics service lowers FALSE to, still counts no row (a unit pin, green against the base code too). `null`, an absent `filter` and a null-prototype filter object are not refused by the shape gate, as they are not on `where`, and answer as before. diff --git a/.changeset/20123-having-unknown-column-refused.md b/.changeset/20123-having-unknown-column-refused.md deleted file mode 100644 index 25a54bdf153..00000000000 --- a/.changeset/20123-having-unknown-column-refused.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: a `having` key that names no column of the aggregated row is refused on `engine.aggregate`, instead of answering as if that column had no value in every group (#20123) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what `having` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query`) that forwards it there. A `having` key — at any depth under `$and` / `$or` / `$not` — must name a column of the aggregated row: a groupBy projection (the field name, or a structured item's `alias`) or an aggregation alias. Any other key is refused with `INVALID_FILTER` / 400, once per query, before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback, whether or not any group exists. It ships as `minor` under the launch-window convention for accept-set narrowings. - -The engine evaluates `having` itself, per aggregated row, and read a key the row does not carry as a column with no value. So a typo for an alias answered like a real query. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sql`, both paths, and through `POST /data/:object/query` on both, over three groups by `customer_id`, each with a positive `total` (a `sum` alias) beside a `count` alias `n`: - -| `having` | before | now | -|:--|:--|:--| -| `{ totl: { $gt: 100 } }`, `{ totl: 500 }`, or `{ amount: { $gt: 100 } }` (a source column the aggregated row does not project) | no group, no error | refused, naming the key, its position and the query's columns | -| `{ totl: { $ne: 1 } }`, `{ totl: { $exists: false } }`, or `{ $not: { totl: { $gt: 100 } } }` | EVERY group, no error | refused | -| `{ $or: [{ total: { $gt: 0 } }, { totl: { $gt: 100 } }] }` | every group whose `total` is positive: the walk stopped at the branch that held | refused | -| `{ $and: [{ total: { $gt: 0 } }, { totl: { $gt: 100 } }] }` | no group | refused | -| `{ 'customer_id.name': 'c1' }` (a dotted path) | no group | refused | -| `{ customer_id: 'c1' }` when the groupBy item is `{ field: 'customer_id', alias: 'cust' }` | no group: the row projects `cust` | refused; `{ cust: 'c1' }` answers | - -The refusal opens the way the REST ingress's refusal of an unknown `where` field does ("filters on 'totl' … which is not a column of the aggregated row"), names every unknown key, and lists the aggregated row's columns. Its code is `INVALID_FILTER`, the code of every other `having` refusal: the name is a column of the query's own projection, not a field of the object. It is judged after the rest of the clause: a condition on an unknown column that also carries an unknown operator (`{ nope: { $median: 1 } }`) is still refused for its operator first, as before. - -Who is affected: `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. Every `having` in this repository's docs and published skills names an aggregation alias of its own query (`{ order_count: { $gt: 5 } }` and the like), which answers exactly as before. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. - -Not changed: a key naming a groupBy column, a structured item's alias, a `count` / `sum` / `max` alias, or any of those under `$and` / `$or` / `$not`, answers exactly as before on both paths, measured identical before and after. diff --git a/.changeset/20126-currency-chain-spec-sites.md b/.changeset/20126-currency-chain-spec-sites.md deleted file mode 100644 index eb4d10fe7f4..00000000000 --- a/.changeset/20126-currency-chain-spec-sites.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The currency-chain text shipped in `@objectstack/spec` states that `currencyConfig.defaultCurrency` is read only under `currencyMode: 'fixed'`, and no longer cites ADR-0053 (the date / datetime record) for currency (#20126) - -Clause-②: no - -A `dynamic` currency field (the default mode) has no currency of its own. Its amounts display in the tenant default currency (`localization.currency`), or as a plain number when none is set, and its `defaultCurrency`, including the parse default `CNY`, is not read. Each reader does this: the analytics relay (`AnalyticsServicePlugin`'s `sourceFieldMeta`), objectui's `resolveFieldCurrency`, and the spec's own precision refinement in `CurrencyConfigSchema`. Several published texts still described an unconditioned `defaultCurrency` step, or called the chain "ADR-0053": - -- **`CurrencyConfigSchema.defaultCurrency` describe** (also regenerated into `content/docs/references/data/field.mdx`): - - FROM: `Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)` - - TO: ``The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency.`` -- **`AnalyticsResultResponseSchema` `data.fields[].currency` describe**: - - FROM: ``Resolved ISO 4217 code for a MONETARY measure (explicit measure `currency`, then source-field default, then tenant default). Absent on non-monetary columns, which must never render a symbol.`` - - TO: ``Resolved ISO 4217 code for a MONETARY measure (explicit measure `currency`, then the source field's fixed currency — its `currencyConfig.defaultCurrency`, read only under `currencyMode: 'fixed'` — then the tenant default). Absent on non-monetary columns, which must never render a symbol.`` -- **TSDoc**: `AnalyticsResult.fields[].currency` (`contracts`), the `AnalyticsResultResponseSchema` docblock and the percent-scale module header name the chain "the currency chain" and state its middle step as the field's fixed currency. -- **Liveness ledger** (`liveness/dataset.json` `currency`, `liveness/field.json` `currencyConfig`): the evidence is re-worded and re-anchored to the fixed-mode readers, and `verifiedAt` is re-dated. Both verdicts stay `live`. - -⛔ No behaviour changes. No type, schema, accept set, default, authorable key or export moves. Only describe strings, TSDoc and ledger text change, and they ship: `dist` carries the describes and the emitted TSDoc, `src/**/*.zod.ts` carries the two edited schema files as source, and `liveness` carries both ledger files. diff --git a/.changeset/20126-currency-mode-describe.md b/.changeset/20126-currency-mode-describe.md deleted file mode 100644 index e11717fe8ea..00000000000 --- a/.changeset/20126-currency-mode-describe.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -docs(spec): the `currencyConfig.currencyMode` description says what the runtime does with each mode, and no longer calls `dynamic` "user selectable" (#20126) - -Clause-②: no - -The old text read "dynamic (user selectable) or fixed (single currency)". No key, stored value or runtime path lets anyone pick a currency per record. The value is a bare number in both modes (ADR-0104 D1), and the currency code lives in field config. What the runtime does, and what the description now says: - -- **`fixed`**: the field has one currency, `defaultCurrency`. -- **`dynamic`** (the default): the field has no currency of its own. Amounts display in the tenant default currency, which is the `localization.currency` setting. When that setting is not set, amounts display as a plain number. `defaultCurrency` is not read. - -The field faces follow this rule, and so do analytics dataset measure columns and the publish-time precision check. - -Only the description text changes. The accepted keys and values, the defaults (`dynamic`, `CNY`) and parse output are the same as before. The generated reference page `content/docs/references/data/field.mdx` is regenerated from the new text. diff --git a/.changeset/20127-having-adddays-temporal-pair.md b/.changeset/20127-having-adddays-temporal-pair.md deleted file mode 100644 index 6b191f66b9e..00000000000 --- a/.changeset/20127-having-adddays-temporal-pair.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: in `engine.aggregate({ having })`, a `{ $field, addDays }` reference is evaluated only between two temporal columns of one class, with a numeric offset column, as `FieldReferenceSchema.addDays` declares, instead of answering by epoch-millisecond coercion (#20127) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what `having` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query`) that forwards it there. A `{ $field }` reference that carries `addDays` in one of the six scalar comparisons is now refused with `INVALID_FILTER` / 400 unless the column it filters and the column it references are both `date` or both `datetime`, and an `addDays` offset read from a column reads a numeric one. The refusal is raised once per query, before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback, whether or not any group exists. It ships as `minor` under the launch-window convention for accept-set narrowings. - -An aggregated row has no declared field types, so each column's class is now read off the query and the object's declaration, before any row exists: - -- a groupBy projection takes its field's declared type. A `day` date bucket is a `date`, because its label is `YYYY-MM-DD` on every face. A `week`, `month`, `quarter` or `year` bucket is a text label; -- `count`, `count_distinct`, `sum` and `avg` are numeric; -- `min` and `max` take the type of the field they read. - -A column whose class the declaration cannot tell is not judged: an object with no field map, a field it does not declare, or a `formula` field. - -`having` resolved every pair through `@objectstack/formula`'s evaluator, which reads a number as epoch milliseconds, while `driver-sql` refuses the same pair on `where`. The refusal reuses `driver-sql`'s sentences for the pair, naming each aggregated column's class where `driver-sql` names a stored type ("is numeric" for "is stored as numeric"), because an aggregated column is computed rather than stored. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sql`, both paths, and through `POST /data/:object/query` on both, over three groups with a `sum` alias `total`, a `max` of a number `max_cap`, `max` / `min` of two `date` fields, `max` / `min` of two `datetime` fields and a `count` `n`: - -| `having` | before | now | -|:--|:--|:--| -| `{ total: { $gt: { $field: 'max_cap', addDays: 1 } } }` (two numeric columns) | no group, no error | refused: "addDays adds whole days to a date or datetime column, and "max_cap" is numeric — an offset has no meaning on it." | -| `{ n: { $gte: { $field: 'n', addDays: 0 } } }` (a count against itself) | every group | refused, in the same words | -| a `date` column against a numeric column, a numeric column against a `date` one, or the `customer_id` groupBy text column against a `date` one | no group | refused, naming both columns and their classes: "… and a cross-class comparison answers differently in SQL (storage-class ordering) than in memory (JS coercion) — compare same-class columns." | -| a `date` column against a `datetime` one | one group | refused, in the cross-class words | -| a `datetime` column against a `date` one | two groups | refused, in the cross-class words | -| a `date` pair whose `addDays` reads a text column or a `date` column | no group | refused: "the addDays offset … is not a numeric column, and a day offset must be a number of days." | - -Who is affected: `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. No `having` in this repository's docs and published skills carries a `{ $field }` reference. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. - -Not changed, measured identical before and after on both paths: a `date` / `date` pair and a `datetime` / `datetime` pair, with a positive or negative whole-day literal or with an offset read from a numeric column (`max` of a number, or a `count`); a `day` date bucket against a `date` column; and any `{ $field }` comparison WITHOUT `addDays`, including a numeric pair and a numeric column against a `date` one. A per-aggregation `filter` (`aggregations[i].filter`) is not judged by this change: it reads the object's raw columns, and this change classifies only the aggregated row's. Since #20148 it is judged by the same class rule, against the object's declared fields. diff --git a/.changeset/20129-docs-audience-fail-closed.md b/.changeset/20129-docs-audience-fail-closed.md deleted file mode 100644 index 82e46e05127..00000000000 --- a/.changeset/20129-docs-audience-fail-closed.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -**The docs reads fail closed when a gate input cannot be read.** `GET /api/v1/meta/doc/:name` and `GET /api/v1/meta/doc` decide whether a caller may read a doc from the environment's books and, on the single read, the doc corpus those books claim over. A thrown read of either was treated as an empty list — and to the audience resolver an empty book list means "no `{ permissionSet }` book anywhere" (every doc readable by any signed-in member), and an empty corpus means "no book claims this doc" (so its audience is `org`). A metadata-store fault on those reads therefore served a permission-set-gated doc, **body included**, to a signed-in member who does not hold the set, and listed it for them. - -Now the fault is answered as the fault it is, through the route's error door — the same answer `GET /api/v1/meta/book/:name/tree` has always given when its own book read fails, so the three docs reads answer one fault one way. With `@objectstack/metadata-protocol` that is `503` / `SERVICE_UNAVAILABLE`: retry once the metadata store is reachable. While the store is failing, no doc is served or listed, because without the books the gate cannot tell which docs a gated book claims. Healthy reads answer exactly as before (`200` to a holder, `403` / `PERMISSION_DENIED` to a non-holder, `401` / `UNAUTHENTICATED` to an anonymous caller). - -Nothing to change in your metadata or your clients. A client that treated a `200` from these reads during a store outage as authoritative now sees the outage instead. diff --git a/.changeset/20134-super-user-entries-every-bit.md b/.changeset/20134-super-user-entries-every-bit.md deleted file mode 100644 index ae28819cf4b..00000000000 --- a/.changeset/20134-super-user-entries-every-bit.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/core': minor ---- - -fix(core): an effective-map entry reached through a super-user `'*'` carries every bit the server grants, so `current_user.can(object, 'transfer')` agrees with `checkObjectPermission` for a platform admin (#20134) - -`buildEffectiveObjectPermissions` builds the `objects` slot of `GET /auth/me/permissions` (`@objectstack/plugin-hono-server`) and the map `ISecurityService.getEffectiveObjectPermissions` returns (`@objectstack/plugin-security`), which the engine hands to `current_user.can(object, verb)` on the write path. For a subject holding a super-user wildcard — a `'*'` carrying `viewAllRecords` or `modifyAllRecords`, as `admin_full_access` and `organization_admin` do — its entries diverged from `PermissionEvaluator.checkObjectPermission` in three places, each in the refuse direction: - -- **`transfer`.** The fold put only read, create, edit and delete on an entry. `modifyAllRecords` also grants `transfer` on the server, so `can(object, 'transfer')` answered `false` for `admin_full_access` on every object, and for the walled `organization_admin` on every object its own set does not name. -- **A super-read wildcard's own bits.** A `'*'` carrying `viewAllRecords` beside plain bits (`allowEdit`, `allowTransfer`, …) put only the read on an entry, so `can(object, 'edit')` answered `false` where the server edits. -- **A super-user wildcard carrying `allowExport`.** The super-user seed skipped every unrestricted object whose export stays allowed, because it needs no `apiOperations`. That left no entry at all, and `can()` reads an absent entry as "no grant", so every verb answered `false` on those objects. - -**What changes.** The seed now places an entry for every registered object the merged map does not already carry. A new step after the fold then applies each set's super-user `'*'` to every entry that set does not name, and sets every bit the spec's `objectPermissionGrants` says that wildcard grants: `transfer` through `modifyAllRecords`, the wildcard's own plain bits, and its `allowExport`. This is the per-set reading `checkObjectPermission` applies. A set that names an object keeps its explicit entry as its whole answer for that object, and a private object is covered, as on the server. Only `true` bits are set. The step runs before the managed-write clamp, which still narrows create, edit and delete on a guarded managed object. No exported name or type changes. - -**What a reader of `/auth/me/permissions` sees.** For a subject holding a super-user wildcard, entries gain `true` bits (`allowTransfer`, and the wildcard's own plain and export bits). Where that subject's wildcards also grant `allowExport`, the map gains an entry for each registered unrestricted object that had none. That entry carries no `apiOperations` — unless the object declares `enable.apiEnabled: false`, which is annotated `[]` since #20135 — so for every other such object the operation channel says what it said before and a client's default-allow path is unchanged. Nothing is removed and no `true` bit turns `false`. The response is byte-identical for a subject holding no super-user wildcard: `member_default` alone, and `viewer_readonly` or `organization_admin_no_bypass` beside it. The response shape, its keys and the route are unchanged. On the write path, a `can(object, 'transfer')`-gated option or default is now admitted for these subjects wherever the server grants `transfer`. - -**Still broader than the server, unchanged here.** The fold still folds the merged super-user bits into an entry the super-user set itself names narrower. It also still pulls `allowCreate` on `modifyAllRecords` alone, which the server does not grant. Both over-grants are left exactly as they were by this change, and both are closed in this same release (`.changeset/20136-super-user-fold-per-set.md`). diff --git a/.changeset/20135-api-operations-rest-door-parity.md b/.changeset/20135-api-operations-rest-door-parity.md deleted file mode 100644 index fdd872dfc3d..00000000000 --- a/.changeset/20135-api-operations-rest-door-parity.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/core': patch ---- - -fix(core): the `apiOperations` of `/auth/me/permissions` offers only what the REST door serves — nothing on an object with `enable.apiEnabled: false`, and `export` only where the export door admits it (#20135) - -`buildEffectiveObjectPermissions` builds the `objects` slot of `GET /auth/me/permissions` (`@objectstack/plugin-hono-server`) and the map `ISecurityService.getEffectiveObjectPermissions` returns (`@objectstack/plugin-security`). Its last pass, `annotateEffectiveApiOperations`, attaches each entry's `apiOperations`: the operation set a client renders, where an absent annotation means default-allow. That set disagreed with the REST door in two places, both in the direction of offering an operation the door refuses: - -- **`enable.apiEnabled: false`.** The door answers `404 OBJECT_API_DISABLED` for every verb on such an object, whatever `apiMethods` says. The annotation ignored the switch. It carried the object's whole closure, or, for a subject whose export stays allowed on an otherwise unrestricted object, no annotation at all, which a client reads as default-allow. -- **The export slot.** It fell back to the merged `'*'` export bit whenever an entry carried no `allowExport` of its own. The merged bit cannot say which set's wildcard reaches which object, so `export` was offered on a private object that only a plain `'*': { allowExport: true }` reached (a plain wildcard never covers a private object), and on an object that the exporting set itself names without the grant. The export door answers both `403 EXPORT_NOT_PERMITTED`. - -Clause-②: no - -**What changes.** The annotation now asks the door's own two questions, entry by entry: - -- the object half is the spec's `canServeApiOperation`, the boolean face of `apiExposureDenialReason`, which `enforceApiAccess` in `@objectstack/rest` turns into its 404 and 405. An object with `enable.apiEnabled: false` is annotated `apiOperations: []`; -- the export half is the entry's own grant as the export door reads it: read and `allowExport`, through the spec's `objectPermissionGrants`. The coverage passes that run first have already put each set's `'*'` on exactly the entries that set reaches, per posture. - -The entry of an API-disabled object stays in the map with its grants: `apiEnabled` closes the API, not the data, and `current_user.can()` reads those grants on the server. Which entries carry an annotation keeps its rule: an unrestricted object whose every operation is still served gets none. - -**What a reader of `/auth/me/permissions` sees.** An object declaring `enable.apiEnabled: false` now reads `apiOperations: []` in every entry. That includes an unrestricted object whose export stays allowed, which used to carry no annotation at all; this release's notes for #18931, #18990 and #20134 name that exception. `export` leaves the annotation of a private object reached only through a plain wildcard export grant, and of an object named without the grant by the set whose wildcard carries it. A private, unrestricted object that lost its `export` this way is now annotated with its closure minus `export`. Nothing else moves: no entry is added or removed, no `allow*` bit changes, and no annotation gains an operation. The response shape, its keys and the route are unchanged. The REST door is unchanged, so no request changes its answer. - -**For a caller of the exported helper.** `annotateEffectiveApiOperations` keeps its signature. It no longer reads the map's `'*'` entry: it reads each entry's own grants, which `buildEffectiveObjectPermissions` puts there. A map composed some other way should be built with `buildEffectiveObjectPermissions`. diff --git a/.changeset/20136-super-user-fold-per-set.md b/.changeset/20136-super-user-fold-per-set.md deleted file mode 100644 index cb968bbd050..00000000000 --- a/.changeset/20136-super-user-fold-per-set.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/core': patch ---- - -fix(core): the effective object-permission map grants no cell the server refuses for a super-user subject — a super-user set's own narrower entry is that set's answer, and `modifyAllRecords` alone grants no create (#20136) - -`buildEffectiveObjectPermissions` builds the `objects` slot of `GET /auth/me/permissions` (`@objectstack/plugin-hono-server`) and the map `ISecurityService.getEffectiveObjectPermissions` returns (`@objectstack/plugin-security`), which the engine hands to `current_user.can(object, verb)` on the write path. For a subject holding a super-user wildcard — a `'*'` carrying `viewAllRecords` or `modifyAllRecords` — the map granted cells that `PermissionEvaluator.checkObjectPermission` refuses. Before building each set's own contribution, it ran a fold over the MERGED map that put the merged bypass bits on every entry: - -- **Into an entry the super-user set names itself.** The server answers each set with its explicit entry for the object when it has one, so the set's wildcard never reaches that object. The walled `organization_admin` names `sys_position`, `sys_permission_set`, `sys_position_permission_set`, `sys_user_permission_set` and `sys_user_position` read-only, and the identity tables write-denied; the map granted create, edit and delete on the first five anyway, and edit on `sys_organization`. With `member_default` that was 38 cells, and 44 without it (edit on `sys_user` and `sys_api_key` as well). An explicit `{}` entry read as readable and writable. An explicit entry granting `allowExport` without read read as readable and exportable, so `apiOperations` offered `export` where the export door answers `403 EXPORT_NOT_PERMITTED`. -- **`allowCreate` on `modifyAllRecords` alone.** The spec's `objectPermissionGrants` gives the write bypass no create cell, and the server grants none. A `'*': { modifyAllRecords: true }` without `allowCreate` read `create` and `import` as granted on every object the managed-write clamp does not cover. - -On the write path this failed OPEN: an option gated on `current_user.can('sys_position', 'edit')` was admitted for the walled `organization_admin`, whom the server refuses that edit. - -Clause-②: no - -**What changes.** The merged fold is no longer a step of `buildEffectiveObjectPermissions`. The super-user fold is the per-set one alone: each set's super-user `'*'` puts on every entry that set does not name exactly the bits the spec's `objectPermissionGrants` says that wildcard grants. A set that names an object keeps its explicit entry as its whole answer for that object, and another set's super-user wildcard still widens that entry bit by bit, as `checkObjectPermission` combines sets. The seed, the plain-wildcard coverage, the managed-write clamp and the `apiOperations` annotation are unchanged. `checkObjectPermission` and every route are unchanged, so no request changes its answer on the server. - -**What a reader of `/auth/me/permissions` sees.** For a subject whose super-user set names an object narrower than its wildcard, or whose only create grant was `modifyAllRecords`, the entry reads what the server enforces: `allowCreate`, `allowEdit`, `allowDelete` or `allowRead` turn from `true` to `false` on those cells, and an entry whose export no longer holds gains an `apiOperations` list without `export`. No entry is added or removed and no bit turns from `false` to `true`. The response is byte-identical for every subject whose super-user sets name no object narrower and grant `allowCreate` wherever they carry `modifyAllRecords` — `admin_full_access` alone or beside `member_default` — and for every subject holding no super-user wildcard. The response shape, its keys and the route are unchanged. On the write path, a `can()`-gated option for those cells is now refused and a `can()` default reads `false`, as the server refuses the write they describe. - -**For a caller of the exported helper.** `foldWildcardSuperUser` keeps its name, signature and body, and `@objectstack/plugin-hono-server` still re-exports it. It is no longer what the map is built from: over a merged map it cannot tell a set's own entry from another set's. Build the map with `buildEffectiveObjectPermissions`. diff --git a/.changeset/20139-rest-query-number-census.md b/.changeset/20139-rest-query-number-census.md deleted file mode 100644 index af02e22856e..00000000000 --- a/.changeset/20139-rest-query-number-census.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest): the remaining numeric query reads refuse a value they cannot read with `400 VALIDATION_FAILED`, instead of dropping it or handing on `NaN` and answering `200` (#20139) - -Clause-②: no (narrowing) - -**BREAKING**: shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA). The banner and the ADR-0087 -disposition below carry the breaking-ness, not the level. - -Five published doors still read a numeric query parameter with a bare `Number()`. -That coercion does not fail. It invents `NaN` or `0`, and the door dropped it or -served it with a `200`: -- `GET /meta/:type/:name/history?sinceSeq=abc` read the change log from the start; -- `GET /meta/:type/:name/audit?limit=abc` served the producer's default 100 events; -- `GET /meta/:type/:name/diff?from=abc` diffed a different pair of versions; -- `GET /search?perObject=abc` removed the per-object cap; -- `GET /approvals/requests?limit=abc` served the unpaged 500-row window instead of a page. - -Each door now reads the parameter the way `?limit=` on import jobs, export, history and -search already does: -- **`GET /meta/:type/:name/history`** reads `sinceSeq` through - `HistoryMetaItemRequestSchema.sinceSeq` (`z.number()`, so any finite number, as before). -- **`GET /meta/:type/:name/audit`** reads `limit` through - `AuditMetaItemRequestSchema.limit` (`z.number()`; the implementation's `[1, 500]` - clamp is unchanged). -- **`GET /meta/:type/:name/diff`** (`from` / `to`, and their `fromVersion` / - `toVersion` spellings), **`GET /search`** (`perObject`) and - **`GET /approvals/requests`** (`limit` / `offset`) declare no request schema. There the - value must be a whole number. Each service's own range handling is unchanged. - -A value the door cannot read answers `400` with the data surface's existing envelope, -`{ error, code: 'VALIDATION_FAILED', fields }`. `fields[0].field` names the parameter as -the caller spelled it, and `fields[0].code` is `invalid_type`. The service is never -called. On `GET /approvals/requests` this is a `400`, not the route's -`500 APPROVAL_REQUEST_LIST_FAILED`. - -What changes, per door (every row answered `200` before): - -| door | request | answered before | answers now | -|:--|:--|:--|:--| -| `GET /meta/:type/:name/history` | `?sinceSeq=abc`, `?sinceSeq=Infinity` | the change log from the start | `400`, `invalid_type` | -| `GET /meta/:type/:name/history` | `?sinceSeq=` (empty) | `sinceSeq: 0` applied as a cursor | `400`, `invalid_type` | -| `GET /meta/:type/:name/audit` | `?limit=abc`, `?limit=Infinity` | the default 100 events | `400`, `invalid_type` | -| `GET /meta/:type/:name/audit` | `?limit=` (empty) | one event | `400`, `invalid_type` | -| `GET /meta/:type/:name/diff` | `?from=abc`, `?from=Infinity` | the version before `to`, diffed instead | `400`, `invalid_type` | -| `GET /meta/:type/:name/diff` | `?to=abc` | the current body, diffed instead | `400`, `invalid_type` | -| `GET /meta/:type/:name/diff` | `?from=1.5`, `?to=2.5` | a diff against a version that cannot exist | `400`, `invalid_type` | -| `GET /search` | `?perObject=abc` | no per-object cap | `400`, `invalid_type` | -| `GET /search` | `?perObject=1.5`, `?perObject=Infinity` | a cap of 1.5 / clamped to 25 | `400`, `invalid_type` | -| `GET /approvals/requests` | `?limit=abc`, `?limit=Infinity` | the unpaged 500-row list, no `total` | `400`, `invalid_type` | -| `GET /approvals/requests` | `?limit=` (empty) | a one-row page | `400`, `invalid_type` | -| `GET /approvals/requests` | `?offset=abc` | the first page | `400`, `invalid_type` | -| `GET /approvals/requests` | `?offset=` (empty) | the service's 50-row paged mode | `400`, `invalid_type` | -| `GET /approvals/requests` | `?limit=1.5`, `?offset=1.5` | 1.5 handed to the engine | `400`, `invalid_type` | - -A blank value such as `?sinceSeq=%20` is refused on every one of these doors. - -**Unchanged:** -- An absent parameter keeps each door's default: the history log from the start, the - audit trail's 100 events, previous-vs-current on `/diff`, search's 5 per object, and the - unpaged approvals list. -- An empty `?perObject=`, `?from=` or `?to=` still means absent, as it always did there. -- Every conforming value reaches the service exactly as before, including the ranges no - card here takes a position on: history still forwards `sinceSeq=0` or `1.5`, audit still - forwards `limit=0` or `900` to its own clamp, `/diff` still forwards `from=0`, search - `perObject=50` is still clamped to 25, and approvals `limit=0` / `offset=-1` still reach - the service's own clamp. -- `GET /data/:object/export?page=` is unchanged. It sets only the export's chunk size, and - no value of it changes the rows exported. -- `POST /meta/:type/:name/rollback` `toVersion` is unchanged. It already refused an - unreadable value with `400 INVALID_REQUEST`. - -**Fix for a caller that now gets the `400`:** send the parameter as a number (a whole -number on `/diff`, search and approvals), or omit it to get the door's default. - - diff --git a/.changeset/20141-masked-on-read-field-types-declared.md b/.changeset/20141-masked-on-read-field-types-declared.md deleted file mode 100644 index e5efe4b67be..00000000000 --- a/.changeset/20141-masked-on-read-field-types-declared.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/objectql': patch ---- - -`@objectstack/spec/data` declares which field types are masked on read — `MASKED_ON_READ_FIELD_TYPES` and `isMaskedOnReadFieldType(fieldType, managedBy)` (#20141) - -Clause-②: yes - -The protocol used to state this only in prose (the `FieldType` comments), so -two consumers each carried their own hand-written copy: objectql's -`collectMaskedReadFields` (the generic read mask and the echoed-mask write -guard) and the renderer's masked-type set. The fact is now declared once: - -- `MASKED_ON_READ_FIELD_TYPES` — a deep-frozen per-type rule table: - `secret` is masked on every object; `password` is masked on every object - except `managedBy: 'better-auth'` ones. Each masked type carries its own - `exemptManagedBy` list, typed against `ObjectSchema.managedBy`'s enum. -- `isMaskedOnReadFieldType(fieldType, managedBy)` — the one reading of that - table. `managedBy` is a required argument (pass `undefined` when the object - has none), and exemptions fail closed: an absent or unlisted `managedBy` - never unmasks a masked type. - -`@objectstack/objectql`: `collectMaskedReadFields` and -`collectMaskedPasswordFields` now ask `isMaskedOnReadFieldType` instead of -carrying their own `type === 'secret'` / `'password'` arms. No behaviour -change: the masked-on-read answer is identical for every `FieldType` × -`managedBy` cell, pinned by a table test. - -`@objectstack/spec`: `ObjectSchema.create()`'s author-time warning for a `password` field on a non-auth object now reads its `managedBy` exemption from `isMaskedOnReadFieldType` instead of hard-coding `'better-auth'`, so it follows the declaration; which objects and fields warn is unchanged. - -A client that renders credential fields (show the mask, offer no copy) should -derive its set from `isMaskedOnReadFieldType` rather than keep its own list, -so the server's mask and the client's cannot drift apart. diff --git a/.changeset/20143-like-underscore-code-point.md b/.changeset/20143-like-underscore-code-point.md deleted file mode 100644 index 8e21ae81036..00000000000 --- a/.changeset/20143-like-underscore-code-point.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/formula": minor -"@objectstack/driver-memory": minor ---- - -`$like` / `$ilike`: `_` matches exactly one Unicode code point on every face, so an emoji or any other character outside the Basic Multilingual Plane is one `_`, as SQL `LIKE` and SQLite `GLOB` count it (#20143). - -The SQLite faces (`driver-sql` on better-sqlite3, `driver-sqlite-wasm`, `driver-turso` local and remote) already answered by code points. The JavaScript faces did not: they compiled the spec's `likePatternToRegexSource` with no regular-expression flags, so `_` read one UTF-16 code unit, which is half of an emoji. The same REST filter returned a different row set depending on which driver backed the object. Measured at `e7f69dbb` over values holding `😀` (U+1F600) and `𝒜` (U+1D49C), 48 answer cells on the JS faces differed from the SQLite faces; after this change, none do. - -- **`@objectstack/spec`**: a new export, `likePatternToRegExp(pattern, foldAscii?)`, compiles the translation with the `u` flag, the one compilation in which `_` is one code point. `matchesLikePattern` evaluates it. `likePatternToRegexSource` is unchanged and still exported; its source means one code point per `_` only under `u`. The `$like` description now says that a character is one Unicode code point. -- **`@objectstack/formula`**: `matchesFilterCondition` answers `$like` / `$ilike` by code points, through the spec's `matchesLikePattern`. Its own CEL `size()` already counted code points. -- **`@objectstack/driver-memory`**: all three `$like` doors (the `$like` filter and the AST `like` / `ilike` node through mingo, and the reference matcher) answer by code points. - -The answer set moves in both directions on those three faces, only for values holding a character outside the BMP: - -| pattern | a stored `😀` | `a😀b` | `a😀😀b` | -|---|---|---|---| -| `_` | now matches | — | — | -| `__` | no longer matches | — | — | -| `a_b` | — | now matches | — | -| `a__b` | — | no longer matches | now matches | - -`$ilike` moves the same way. No pattern is newly refused and no refusal is lifted. Values made only of characters inside the BMP answer exactly as before. - -Clause-②: yes (narrowing) - - diff --git a/.changeset/20146-app-ledger-repoint.md b/.changeset/20146-app-ledger-repoint.md deleted file mode 100644 index f56962808ce..00000000000 --- a/.changeset/20146-app-ledger-repoint.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`liveness/app.json`: every `AppSchema` row that cited objectui's `AppSidebar.tsx` as read evidence now cites the reader that is actually mounted, and `branding.logo` is `planned` instead of riding on its container's `live`. Ledger evidence and comment prose only. ⛔ No schema, parse or accept-set change. - -The ledgers ship inside this package (`files[]` includes `liveness`), and `@objectstack/lint` reads them to decide which authored keys draw an advisory warning, so the rows an upgrading reader or tool consults are these. - -- **Twelve rows re-pointed, and the file `_note`.** `label`, `description`, `icon`, `branding`, `active`, `hidden`, `navigation[].id`, `navigation[].visible`, `areas[].id`, `areas[].label`, `areas[].icon` and `areas[].navigation` cited `AppSidebar`. That component was deprecated, never mounted (the console renders `UnifiedSidebar`), and objectui has since removed it, so it was never evidence of a live reader. Each row now names its mounted reader, file and symbol: `AppSwitcher`, `UnifiedSidebar`, `AppHeader`, `ConsoleLayout` into `AppShell`'s `useAppShellBranding`, `HomeAppsStrip`, `filterActiveApps`, `NavigationRenderer`, `NavMenuRenderer` and `AppManagementPage`. All are read at the `.objectui-sha` pin f8a9d0fb, and each reads its key unchanged at objectui main fb91ac9b0. -- **`branding.logo` is `planned`.** Plugin-designer's app wizard and branding editor write it, and nothing mounted renders it: the console's branding handoff passes `primaryColor`, `accentColor` and `favicon` and never `logo`. The row names the objectui card that will render it (objectui#10827). It is not `authorWarn`'d: a logo URL is display metadata, and an author who sets it now loses nothing, because the value takes effect when that renderer lands. The `branding` row is drilled so `primaryColor`, `accentColor` and `favicon` keep their own `live` verdicts. Its entry in `undrilled-containers.baseline.json` goes away with the drill, and `state-counts.md` is regenerated (`app`: 56 → 59 classified, one `planned`). -- **`description` is still `live`, but narrower.** Its one mounted reader is the admin app list at `/system/apps`, which shows each app's description and searches it. The sidebar presentation this row used to describe has no successor. -- **Two corrections found while re-reading.** The `active` row's note said the sidebar's active-app lookup spans all apps. It skips `active: false` apps, and what keeps an inactive app routable by direct URL is the console's route resolver, which matches against every app. The two `AppSidebar` mentions in `ui/app.zod.ts` (the `recordId` template-variable note and the `AREA_ORDER_RETIRED` note) now name `NavigationRenderer` / `UnifiedSidebar`. diff --git a/.changeset/20148-aggregation-filter-keys-rest.md b/.changeset/20148-aggregation-filter-keys-rest.md deleted file mode 100644 index 4d50a11e343..00000000000 --- a/.changeset/20148-aggregation-filter-keys-rest.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor ---- - -fix(metadata-protocol)!: the read door judges the keys inside each per-aggregation `filter` with the gate an explicit `where` meets — an unknown key is `INVALID_FIELD` / 400, not a count of zero (#20148) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what `POST /data/:object/query` (`findData`) accepts in `aggregations[i].filter`. Each entry's filter now goes through the gate the explicit `where` meets, `assertFilterFieldsExist`: the same field set, the same verdicts, `INVALID_FIELD` / 400, with the entry's position as the parameter (`aggregations[1].filter`). The refusal is raised before the engine is called. It ships as `minor` under the launch-window convention for accept-set narrowings. - -Measured on the base through `POST /data/:object/query` on `driver-memory` and `driver-sql`, over six rows in three groups: - -| in `aggregations[i].filter` | before | now | -|:--|:--|:--| -| a key naming no field of the object (`{ nope: 1 }`), also under `$and` | `200`, that count 0 | `INVALID_FIELD` / 400, naming the key | -| the same key under `$ne`, `$not`, or behind a `$or` branch that holds | `200`, every row counted | `INVALID_FIELD` / 400 | -| an unknown key carrying an unknown operator (`{ nope: { $median: 1 } }`) | `INVALID_FILTER` / 400 from the engine | `INVALID_FIELD` / 400: the field gate runs first, as it does for the same key in `where` | -| a dotted key on a scalar head, or a key naming a `formula` field | `INVALID_FIELD` / 400 from the engine's own filter seam | `INVALID_FIELD` / 400 from this gate, in the words `where` gets | - -Run after the entry checks and the aggregated-field check, so an entry the spec cannot read keeps its shape refusal and an unknown aggregated `field` keeps its own. A filter that is not a plain object names no key here and is left to the engine's shape gate. - -Not changed: a filter on declared keys, on `id`, `created_at` or `updated_at`, and a relation head in the nested-object form reach the engine with the filter untouched. diff --git a/.changeset/20148-aggregation-filter-where-doors.md b/.changeset/20148-aggregation-filter-where-doors.md deleted file mode 100644 index fac395f4a5a..00000000000 --- a/.changeset/20148-aggregation-filter-where-doors.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: a per-aggregation `filter` (`aggregations[i].filter`) on `engine.aggregate` takes the doors `where` takes — the temporal-comparand door, a `{ $field }` referent that must be a declared field, and the `addDays` class rule — and a `Date` bound is compared as an instant, as it is in `where` (#20148) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what a per-aggregation `filter` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query` with `aggregations`) that forwards it there. Every refusal below is `INVALID_FILTER` / 400, raised in the engine's per-aggregation loop before any driver is asked for a row, identically on an empty and on a populated table. It ships as `minor` under the launch-window convention for accept-set narrowings. - -Measured on the base through `engine.aggregate` and through `POST /data/:object/query`, on `driver-memory` and `driver-sql`, over six rows in three groups, with the same condition written as the call's `where` beside each: - -| in `aggregations[i].filter` | before | now | -|:--|:--|:--| -| a comparand a declared temporal field cannot read: `{ placed_on: { $gt: 'not-a-date' } }` on a `date`, the same as an `$in` member, a `$between` endpoint, in the implicit slot, behind a `$or` branch that holds or under `$not`; a preset name (`'last_30_days'`) on a `datetime`; `'noon'` on a `time` | counted as written: no row for the bad bound, only the readable members of the `$in`, every row for the `$between`, held-`$or` and `$not` shapes. The same condition as a `where` was refused | refused by the temporal-comparand door `where` takes, run unchanged on this position, in its words. A `{placeholder}` is stepped around and resolved, as there | -| a `{ $field }` naming no declared field of the object (`{ amount: { $gt: { $field: 'nope' } } }`), a dotted referent, or an `addDays` offset column the object does not declare | no row counted; every row under `$ne`, `$not` or a held `$or`. `driver-sql` refused the same comparison in a `where` | refused | -| `addDays` on a pair `FieldReferenceSchema.addDays` does not declare it for: two numeric fields (`{ amount: { $gt: { $field: 'cap', addDays: 1 } } }`), a numeric referent, a text or a time pair, a `date` against a `datetime`, or an offset read from a column that is not numeric | answered by the evaluator's epoch-millisecond coercion: no row on the fixture, 4 of 6 for the `date` / `datetime` pair. `driver-sql` refused the same pair in a `where` | refused | - -The two `{ $field }` rows are refused in the withholding posture `driver-sql` applies to the same comparison in a `where`: the message names the aggregation that carries the reference (`aggregations[1].filter`) and the rule, and withholds the fields, the operator and the specific reason. The engine logs the withheld diagnostic at `warn`, once per refusal. A referent is judged against the object's declared field map plus `id`, `created_at` and `updated_at`, the set the REST field gate reads; on a host whose registry holds no field map for the object, nothing is judged. - -These refusals run after the walker's own (an unknown operator, a malformed reference), so a filter carrying both gets the walker's refusal first. - -Not refused, but answering differently: - -- **A `Date` bound is compared as an instant.** `{ opened_at: { $gt: new Date('2026-02-01') } }` against the ISO text a `datetime` column holds counted no row: JS compared the `Date` with the string by coercion. `$ne` and `$nin` counted every row, and a `$between` of two `Date`s counted every row. The same bound in a `where` counted 4 of 6 on both drivers. The comparison now reads the pair through `@objectstack/spec/data`'s `utcInstantMs`, the lift `@objectstack/formula`'s evaluator applies, whenever one side is a `Date` and both sides denote an instant. Every `datetime` row and a UTC-midnight `Date` on a `date` field now count what the same bound counts in a `where`. `having` shares this comparison, so a `Date` bound in `having` keeps the groups its ISO spelling keeps on a `datetime` column; on a `date`-class column (`min` / `max` of a `date` field) a UTC-midnight `Date` follows the calendar-day reading a `where` gives (`{ $gte: new Date('2026-02-01') }` keeps the group whose max is `2026-02-01`, and so does `$eq`), which the ISO-instant text, compared as text, did not. A `Date` is not JSON, so this reaches in-process callers only. Two readings still differed from a `where` in this change alone, because the per-row comparison held no declaration (#20176 closes both in the same release, reading every temporal comparand at this position by the column's storage rule): a `Date` carrying a time of day against a `date` field, which a `where` reads as that UTC calendar day, and a `Date` against a `time` field, which is not an instant and is left as before. - -Not changed, measured identical before and after on both drivers: every `where`, `groupBy` and `having` answer outside the `Date` shapes above, and a per-aggregation filter that uses implicit equality, ordering bounds, `$in`, `$between`, `$or`, `{}`, a `{ $field }` between two declared numeric fields, `addDays` between two `date` or two `datetime` fields (literal or read from a numeric column), and a reference to `created_at` or `id`. diff --git a/.changeset/20149-import-mapping-compound-parts.md b/.changeset/20149-import-mapping-compound-parts.md deleted file mode 100644 index 851ed01d48c..00000000000 --- a/.changeset/20149-import-mapping-compound-parts.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/rest": minor -"@objectstack/lint": minor ---- - -feat(spec,rest,lint): an import mapping target may name a declared part of a compound field (`mailing_address.street`), and the importer assembles the parts into one value (#20149) - -Clause-②: yes - -**What was missing.** A mapping could write each source column to one flat -field only, so nothing could build an `address` value from the separate -street / city / state / postal code / country columns a spreadsheet carries. -A dotted target such as `mailing_address.street` named no field and was -refused at `objectstack validate`, on the dry run and on the commit. - -**What changes.** - -- `@objectstack/spec`: `ImportFieldMappingSchema.target` declares the part - path. A target may name `field.part` when `field` is a declared field whose - stored value schema is a closed object of optional strings (today: - `address`), and `part` is a key that schema declares: `street`, `city`, - `state`, `postalCode`, `country`, `countryCode`, `formatted`. The part names - are read from the value schema, never listed by hand. The one verdict, - `judgeImportMappingTarget`, answers the new `{ kind: 'part', field, part }`; - `indexImportMappingTargets` carries each compound field's parts on - `parts`; `unknownImportMappingTargets` gives each refused target a `reason` - (`unknown` or `collides`) and, for a dotted target, what its `head` names. - `location` is not compound for import: its parts are required numbers, so a - value assembled from text cells would be the wrong type. -- `@objectstack/rest`: `applyMappingToRows` assembles every part target of a - row into one value under the field's key, before the engine sees the row, - whatever transform produced the part (`none`, `map`, `constant`, `join`, - each element of a `split`). A blank part cell (empty, whitespace or a - `nullValues` token) is left out, string parts are trimmed under - `trimWhitespace`, and a row whose parts are all blank leaves the field - unset, as a blank flat cell does. On an update the assembled value replaces - the stored one. The dry run and the commit judge the same assembled row. -- `@objectstack/lint`: `mapping-target-field-unknown` accepts a declared part - and reports what stays refused, naming the legal parts each time. - -**Still refused, at `objectstack validate`, on the dry run and on the commit -(`400 INVALID_FIELD`, before any row):** - -- a part the value does not declare (`mailing_address.stret`); the refusal - lists the declared parts; -- a dotted path on a field with no parts (`full_name.first`). A dotted target - never traverses a reference (`account.name`): map the column to the - reference field with transform `lookup`; -- a mapping that writes a field both whole and by part (`mailing_address` and - `mailing_address.street`): one row carries one value for the field. Map it - whole or by its parts, not both. - -**What to do.** Nothing, unless you want the capability: point each address -column at `field.part`, for example `{ source: 'Zip', target: -'mailing_address.postalCode' }`. diff --git a/.changeset/20150-import-mapping-target-names-a-field.md b/.changeset/20150-import-mapping-target-names-a-field.md deleted file mode 100644 index 33a1cf47dce..00000000000 --- a/.changeset/20150-import-mapping-target-names-a-field.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/rest": minor -"@objectstack/lint": minor ---- - -fix(spec,rest,lint): an import mapping target that names no field is refused on the dry run, on the commit and at `objectstack validate` alike (#20150) - -Clause-②: yes (narrowing) - - - -**BREAKING** in the accept-set sense only, landing in the launch window as -`minor`: the import route and `objectstack validate` now refuse a mapping they -used to pass, and every such mapping already failed on the commit. - -**What was wrong.** `ImportFieldMappingSchema.target` is declared as "Target -object field(s)", and nothing held a mapping to it. A mapping whose target named -no field of its `targetObject` (measured with `mailing_address.street` on an -object whose address field is `mailing_address`): - -- passed `objectstack validate`, `os lint` and `os build` with no diagnostic; -- answered `ok` for every row on `POST /api/v1/data/:object/import` with - `dryRun: true`; -- then failed every row on the commit with `INVALID_FIELD` ("Unknown field - 'mailing_address.street' on object '…'"). - -The dry run promised what the commit refused. - -**What changes.** - -- `@objectstack/spec` exports ONE verdict on what a target may name, beside the - schema it judges: `unknownImportMappingTargets(fieldMapping, objectDef)`, with - `indexImportMappingTargets`, `judgeImportMappingTarget`, - `importMappingEntryTargets` and `IMPORT_TARGET_ALWAYS_ADDRESSABLE_COLUMNS` - (from `@objectstack/spec/data`). A target may name a declared field, a column - the platform provisions on that object (`resolveInjectedSystemColumns`), or one - of `id` / `created_at` / `updated_at`, which the engine's write door admits on - every object. An object with no readable, non-empty field map is not judged. -- `@objectstack/rest`: `prepareImportRequest` refuses a named mapping (`mappingName`) - with a target that names no field, before any row, with `400 INVALID_FIELD` — - the code the commit's per-row refusal already carried. The dry run and the - commit give the same answer, and so does the async import-job route. -- `@objectstack/lint`: the reference-integrity suite (`os validate`, `os lint`, - `os build`) gains `validateMappingTargetFields`, rule id - `mapping-target-field-unknown` (`MAPPING_TARGET_FIELD_UNKNOWN`), severity - `error`, located at `mappings[i].fieldMapping[j].target`. It asks the same - spec verdict, so it never refuses a target the import door accepts. - -**What to do.** Point each reported target at a field the object declares. An -array target (`split`) is judged element by element. diff --git a/.changeset/20152-chart-drilldown-mode-guidance.md b/.changeset/20152-chart-drilldown-mode-guidance.md deleted file mode 100644 index 5985366bed8..00000000000 --- a/.changeset/20152-chart-drilldown-mode-guidance.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`ChartDrillDownSchema`'s refusal text for `drillDown.mode` names the one objectui block that reads it, not all three it used to - -An author (or an AI) who writes `drillDown.mode` on a chart gets this guidance -sentence back from `objectui validate` / `safeValidateSchema`. It used to read: - -> `mode` (`'filter'` | `'record'`) is a TABLE / PIVOT / METRIC drill key, not a -> chart one: … - -`object-metric` has refused `drillDown.mode` at its TypeScript door since -objectui#9002 (PR objectui#10681, merged, on the maintainer ruling recorded -there, comment `5643445104`), and `object-pivot` has refused it since -objectui#10685 (PR objectui#10710, merged). Measured on objectui `origin/main`: -neither block's renderer ever read `mode`; only `object-data-table` does. So -the old sentence sent an author who followed it straight into a second -refusal on two of the three blocks it named — right on `object-pivot` and -`object-metric` (both now refuse the key by name), and a stored JSON config -that carries it there is silently ignored with no diagnostic. - -The sentence now names the one block that actually reads `mode`: - -> `mode` (`'filter'` | `'record'`) is objectui's `object-data-table` drill -> key, not a chart one: … - -The reasoning clause and "Delete the key" are unchanged. The neighbouring -`report` guidance entry (a METRIC / PIVOT widget capability) is untouched — -its blocks were not remeasured on this card. - -⛔ No accept/reject behaviour changes. `mode` remains refused on -`ChartDrillDownSchema` exactly as before; only the refusal text changes. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/spec`'s published `files[]` ships both -`dist` and `src/**/*.zod.ts`, and `chart.zod.ts` is one of the latter — so the -sentence ships as source verbatim, and also as a runtime string built into -`dist/ui/index.js` / `dist/ui/index.mjs` (measured: present at 1 occurrence -each after a rebuild, with the old "TABLE / PIVOT / METRIC" spelling absent -from both). diff --git a/.changeset/20156-alternate-door-read-gates.md b/.changeset/20156-alternate-door-read-gates.md deleted file mode 100644 index 17d4721d459..00000000000 --- a/.changeset/20156-alternate-door-read-gates.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -**The read doors beside `GET /api/v1/meta/:type/:name` now apply the plain read's per-caller gates to docs, books and object schemas.** The plain read withholds a document per caller in several ways. On `doc` and `book` it applies the documentation audience: a `{ permissionSet }`-gated doc or book is `403 PERMISSION_DENIED` to a non-holder and `401 UNAUTHENTICATED` to an anonymous caller. On `app` it applies the navigation filter: an app whose `requiredPermissions` the caller lacks is `403`, an unpublished app is `404` to a non-builder, and gated entries are left out. On `object` schemas it applies the field mask. It also applies the optional-service widget gate on `dashboard`. The doors beside it served the same stored document with none of those gates: - -- `GET …/:name/layers`, and the deprecated `GET …/:name?layers=true`, served every layer. This exposed a gated doc's or book's body to any signed-in member. Through `?layers=true`, which sits on the route anonymous callers may reach for public docs, it also exposed any doc or book to a caller who was not signed in. -- `GET …/:name/published` served a gated doc's or book's body, a gated or unpublished app whole, a dashboard's widgets bound to an optional service this deployment lacks, and an object schema's unreadable fields. -- `GET …/:name/diff` served both compared versions' values, including a doc's content, an app's navigation and an object's fields. -- `GET …/:name/history` and `GET …/:name/audit` served the change log and audit trail of a doc, book or app the caller may not open. - -What each door answers now: - -- `/published` answers exactly what the plain read answers the same caller, for every type: the same refusal, or the same pruned or masked document. That includes the dashboard widget gate: a widget bound to an optional service this deployment does not register is left out of `/published`, as it is from the plain read. -- `/layers`, `?layers=true` and `/diff` refuse a `doc`, `book` or `app` the plain read refuses whole, with the same status and code. For an app, that means one whose `requiredPermissions` the caller lacks (`403`) or an unpublished app to a non-builder (`404`). They mask object fields as the plain read does. `/diff` of a `doc`, `book` or `app` with nothing behind the name answers `404 RESOURCE_NOT_FOUND`, as the plain read does. They do not apply the dashboard widget gate or any other per-deployment gate: they show the stored version, which is what an author edits. -- For an app the caller may open, `/layers`, `?layers=true` and `/diff` answer by who is asking. A caller who may save the app (the one `PUT /meta/app/:name` admits) receives the full stored version, including the navigation entries that `requiredPermissions` or the documentation audience withhold from them: Studio's designer saves back what it loads, so a pruned load would delete those entries. Every other caller receives the app without the entries withheld from them, left out as the plain read leaves them out, on every layer and on both sides of a diff. The plain read and `/published` prune for every caller, authors included, except the plain read's `?state=draft`: it serves the pending draft, a stored version, and answers as these three doors do. An app the plain read refuses whole is refused on these doors to an author too. -- `/history` and `/audit` answer the plain read's refusal when the plain read refuses the doc, book or app whole. Otherwise they serve the events, which carry no document body. - -Types no per-caller gate judges, such as `view` and `flow`, are unchanged on every door. `dashboard` is unchanged on every door except `/published`. A caller the plain read serves in full gets the same answers as before. A client reading these doors as a caller the plain read restricts, including an integration reading `?layers=true` anonymously, now receives the plain read's answer, except that a caller who may save an app reads it whole on `/layers`, `?layers=true` and `/diff`. To read a gated doc or book through any of these doors, hold the permission set its book names. diff --git a/.changeset/20156-app-author-exemption.md b/.changeset/20156-app-author-exemption.md deleted file mode 100644 index 03064eac4bc..00000000000 --- a/.changeset/20156-app-author-exemption.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -**The stored-version doors of an app answer by who is asking: whoever may save the app reads it whole, everyone else reads it pruned.** `GET /api/v1/meta/app/:name/layers`, the deprecated `?layers=true` and `…/diff` serve the versions Studio's designer loads and saves back. Until now they served an app the caller may open as stored to every such caller, including the navigation entries that `requiredPermissions` or the documentation audience withhold from them, which exposed those entries' names and targets to members the plain read hides them from. - -- A caller who may save the app receives the full stored version on these three doors, so a designer that saves back what it loaded keeps every entry. "May save" is exactly what `PUT /meta/app/:name` admits that caller: a system context or `manage_metadata`. `manage_org_presentation` does not save apps, so it does not qualify. -- Every other caller who may open the app receives it without the entries `requiredPermissions` or the documentation audience withhold from them, left out as the plain read leaves them out: on each layer, and in the values on both sides of a diff. A diff keeps all of its entries; only their values are pruned. -- Unchanged: the plain read (its `?preview=draft` included) and `/published` still prune for every caller, authors included. The plain read's `?state=draft` is the exception: it serves the pending draft, a stored version, and answers as these three doors do. An app the plain read refuses whole (an app-level `requiredPermissions` the caller lacks, or an unpublished app to a caller without Studio or Setup access) is still refused on every door, to an author too. `/history`, `/audit`, and the doc, book and dashboard answers do not change. - -A client that reads these doors as a non-author now receives fewer navigation entries. To read an app's full stored version there, read it as a caller the app's save door admits. - -For code that runs the shared read gate: `MetaReadGatePolicy.app` is `'gate'` or `'author-exempt'`, and a door that passes `'author-exempt'` supplies the caller's save verdict as `MetaReadGateCaller.mayWriteItem`. diff --git a/.changeset/20157-engine-judge-filter.md b/.changeset/20157-engine-judge-filter.md deleted file mode 100644 index 454bdc35800..00000000000 --- a/.changeset/20157-engine-judge-filter.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor ---- - -`IObjectQLEngine` gains an optional judge-only member, `judgeFilter(objectName, where, { operation?, context? })`, and `ObjectQL` implements it (#20157, #19995 ruling C). It answers "can this filter run against this object?" without running anything: `{ ok: true }`, or `{ ok: false, code, status, message }` with the same diagnostic execution would raise. - -- **The engine's own admission, not a copy.** The judge calls the two stage functions every verb that takes a `where` already runs, in their order. First the lowering doors: the shape gate, the list-comparand shape, the virtual-field and dotted-path refusals, the text operator over a non-text field, the uninterpretable temporal comparand and the comparand-type door. Then the filter-placeholder resolver. A new door on that pipeline is judged the day it lands. -- **Nothing executes.** No driver is resolved or called, and no hook or middleware runs. The member is synchronous, so a door that needs I/O cannot join it without a contract change. Driver-level refusals and the predicates middleware composes later (RLS, sharing, tenant scope) are not judged. -- **Placeholders resolve against `context`**, exactly as execution resolves them. A context placeholder the context cannot answer (`{current_user_id}` with no user) is refused with `FILTER_TOKEN_UNRESOLVED`, never resolved to `null`. -- **`operation`** (default `'find'`) names the verb the caller will run, so the message carries that verb's prefix. The verdict is the same on every verb. -- **The message is not redacted.** It names fields, operators and comparands. A caller judging a filter it must not disclose, such as a read-scope policy, withholds the message itself. - -Optional by the ruling. A caller probes for it (`typeof ql.judgeFilter === 'function'`) and keeps its current behaviour on an engine without it. Existing engine doubles and foreign engines need no change. Execution is unchanged for every CRUD caller: same diagnostics, same order. - -Clause-②: yes diff --git a/.changeset/20158-rls-policy-authoring-admission.md b/.changeset/20158-rls-policy-authoring-admission.md deleted file mode 100644 index 2c89d6434b1..00000000000 --- a/.changeset/20158-rls-policy-authoring-admission.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/lint': minor -'@objectstack/metadata-protocol': minor -'@objectstack/cli': minor ---- - -RLS policies are admitted when they are authored: the engine judges every read-scope `using`, at the save door and at `os validate` / `os build` / `os lint` - -A row-level-security policy (`rowLevelSecurity[]` on a permission set) could carry a `using` predicate that lowers cleanly and that the engine then refuses to run: a text operator (`startsWith` / `endsWith` / `contains`) aimed at a number field, a date field compared against a value its storage cannot read, a filter on a virtual (formula) field, or a `{…}` placeholder string. Nothing refused it when it was written. The first answer was a refused analytics query, long after the author had moved on. And the metadata save door (Studio, REST `/meta`, MCP) did not run the RLS predicate rule at all, so a predicate `os validate` already refused was accepted there. - -- **The engine's own verdict.** `validateRlsPredicateEnforceability` takes the engine's judge-only filter admission (`IObjectQLEngine.judgeFilter`) as an optional input and judges the lowered `using` of every `select` / `all` policy with it. A refusal is reported under the existing id `rls-predicate-unenforceable`, and the message quotes the engine's code, status and sentence verbatim. The rule never models the engine's checks: without the input it answers exactly as before. -- **Both doors hand in a real engine.** The metadata save door probes its host engine for `judgeFilter` and passes the bound method through the publish gate. The CLI commands build an engine with no driver from the stack's own objects and pass its method. -- **The save door now runs the rule for `permission` writes** (`surfaces: ['cli', 'runtime-publish']`, `runtimeTypes: ['permission']`), so every predicate `os validate` refuses is refused there too, as a `422 INVALID_METADATA` whose `issues[]` carries the same sentence. -- **New optional inputs.** `AuthoringRuleContext.judgeFilter` (and so `AuthoringRuleRun.judgeFilter` for `runAuthoringRules`), the `judgeFilter` argument of `runRuntimeAuthoringRules`, and an optional second parameter of `validateRlsPredicateEnforceability`. A caller that passes nothing gets the previous verdicts. - -**BREAKING**: a permission set whose read-scope `using` the engine cannot run now fails `os validate` / `os build` / `os lint`, and a publish of it through the metadata save door is refused with `422`. Stored rows keep being read, and a re-save of one is judged like any other publish. `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns the save-door refusal into a logged warning for a migration window. The refusal's hint names the fix for each class: write the caller's value as a `current_user` key rather than a `{…}` placeholder, point a text operator at a field that holds a string, compare a date field against a value its storage reads, or denormalise a computed value onto a stored field. Every policy authored in this repository, in its examples and in the default permission sets was measured, and none is refused. - -Two edges are not closed here, both deliberately: - -- The judge sees only `using` clauses in the read scope. A `check` is matched in memory against the post-image and never reaches the engine's filter admission. -- At the save door, the judge reads the engine's live registry. An object that exists only in the same publish batch, or only in an organization overlay, is one that registry does not hold, so it gets the engine's unknown-object answer: no field-type verdict, while the placeholder and comparand checks still run. At the CLI door, an object the stack does not define gets the same answer. - -Clause-②: yes (narrowing) - - diff --git a/.changeset/20161-joined-report-chart-retired.md b/.changeset/20161-joined-report-chart-retired.md deleted file mode 100644 index 37ceb9ce53e..00000000000 --- a/.changeset/20161-joined-report-chart-retired.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': patch -'@objectstack/platform-objects': patch ---- - -fix(spec): a `joined` report draws no chart — `blocks[].chart` is removed and a container `chart` on a joined report is refused (#20161) - -Clause-②: no (narrowing) - -**BREAKING** — shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by -this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, -never by the level). - -A `joined` report draws each of its blocks as a table. The renderer's joined -branch returns before its one read of the report's `chart`, and nothing ever -read a block's `chart` at all. So a chart on a joined report, on the container -or on any block, parsed green, passed the `validate-chart-bindings` lint, and -plotted nothing. Both coordinates now answer at parse: - -``` -FROM ReportSchema.safeParse({ name: 'overview', label: 'Overview', type: 'joined', - chart: { type: 'bar', xAxis: 'status', yAxis: 'task_count' }, - blocks: [{ name: 'open_block', dataset: 'tasks', rows: ['status'], values: ['task_count'], - chart: { type: 'pie', xAxis: 'status', yAxis: 'task_count' } }] }) - -> { success: true } // both charts silently never drawn - -TO -> { success: false, issues: [ - { code: 'unrecognized_keys', path: ['blocks', 0], - message: '… `report.blocks[].chart` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … Delete the key. …' }, - { code: 'custom', path: ['chart'], - message: 'a `joined` report draws no chart — it draws each block as a table and never reads `chart`, on the container or on a block. Delete `chart`; …' } ] } -``` - -**Fix.** Delete the `chart`. The report renders exactly as before, because -neither value was ever drawn. To plot one of the slices a block shows, give it a -non-joined report of its own with that `chart`. -`os migrate meta --from 17` lists the mechanical edits for existing sources. - -**What does not change.** `chart` on a `tabular` / `summary` / `matrix` report is -untouched: it is that report's live embedded chart. A joined report with no -`chart` parses byte-identically to before, and a block keeps every other key. - -### The retirement kit - -- **Schema.** `JoinedReportBlockSchema` is closed (`strictObject`), so `chart` is - removed from its shape and answered by its `guidance` table with the - prescription (build-schemas check (c) proof 4). `ReportSchema.chart` stays - declared; the joined arm of its refinement refuses it, beside the - `dataset` / `rows` / `columns` / `values` / `order` refusals already there. -- **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `ui/JoinedReportBlock:chart`, and - the D2 conversion `report-joined-chart-removed` (protocol 18, retired from the - load path) strips a block's `chart` and a joined container's `chart` from old - sources and stored `sys_metadata` rows as a lossless delete. Stored rows can - carry them: the Studio report form offered a block `chart` input until this - change. The family's D3 semantic entry, `ui-report-joined-chart-retired`, states - what the strip cannot decide: whether the chart was wanted. If it was, it moves - to a non-joined report of its own, because a joined report has no chart channel. -- **Form.** `reportForm` drops the block `chart` input and shows the container - `chart` only when `type` is not `joined`; the `platform-objects` metadata-form - translation bundles drop the `blocks.chart` label in all four locales. -- **Lint.** `validate-chart-bindings` no longer resolves the axes of a block chart - or of a joined container's chart against a dataset: it would be vouching for a - chart that is refused at parse and never drawn. A block's own `dataset` / - `rows` / `columns` / `values` are still checked. -- **Ledger and docs.** `liveness/report.json` names a reader for `chart` only on - non-joined reports and drops `chart` from the `blocks` row; - `content/docs/ui/reports.mdx` lists what a joined container refuses. - - diff --git a/.changeset/20168-decision-mode-beside-conditions-refused.md b/.changeset/20168-decision-mode-beside-conditions-refused.md deleted file mode 100644 index 04623a39d01..00000000000 --- a/.changeset/20168-decision-mode-beside-conditions-refused.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`DecisionConfigSchema` refuses `mode` on a `decision` that also declares a non-empty `conditions` list (#20168). `mode` belongs to the edge-branched decision alone: a `conditions` list is first-match on its own, so a `mode` beside one would be accepted and never read, and `mode: 'inclusive'` there would promise every matching branch while the run takes one. - -Clause-②: no - -This corrects a key that has not been released yet, so it narrows no published accept set. Timing, measured when this landed: the npm registry's `latest` `@objectstack/spec` is `17.4.0`, and its `json-schema/automation/DecisionConfig.json` declares `conditions` only, with `additionalProperties: false`. No `mode` was published. The Version Packages PR (`chore: version packages`) is open and unmerged. `mode` reaches its first release together with this refusal, so no ADR-0087 entry is owed. - -- **The refusal**: one issue at `mode`, for either member. It reads "`mode: 'inclusive'` is not valid on a decision that declares a `conditions` list — `mode` belongs to the edge-branched decision alone." and names the two ways out: - - delete `mode` and keep the list; - - or move the branches onto the out-edges (a `condition` on each branch edge, `isDefault: true` on the fallback), delete `conditions`, and keep `mode`. -- **Left alone**: `mode` on an empty `conditions` list, `mode` with `conditions` absent, and a `conditions` list with no `mode` all parse as before. A `mode` outside `'exclusive' | 'inclusive'` still gets its own value refusal first. -- **Where it binds**: the doors that parse `DecisionConfigSchema`. Today that is a direct parse, including the `SCHEMALESS_NODE_CONFIG_SCHEMAS.decision` handle. `decision` config is still export-only, so a flow's registration and `os validate` do not run it yet. The published JSON Schema cannot state the rule, because no arm of the closed refinement projection fits it. `automation/DecisionConfig` therefore joins `dropped-refinements.baseline.json` and carries the site as `x-dropped-refinements`. diff --git a/.changeset/20176-aggregation-temporal-storage-rule.md b/.changeset/20176-aggregation-temporal-storage-rule.md deleted file mode 100644 index 6b9d78d447f..00000000000 --- a/.changeset/20176-aggregation-temporal-storage-rule.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/objectql": minor -"@objectstack/driver-sql": patch -"@objectstack/driver-memory": patch ---- - -fix(objectql,core): a per-aggregation `filter` and `having` on `engine.aggregate` read a temporal comparand by the column's storage rule, the rule `where` already applies — one function, `temporalStorageForm`, now exported by `@objectstack/core` and shared by both drivers (#20176) - -A per-aggregation `filter` (`aggregations[i].filter`) and `having` are evaluated by the engine itself, over the rows (or aggregated rows) a driver returns. Both compared a temporal comparand exactly as written, while the same condition as a `where` is put into the column's storage form by the driver first. So they counted differently. Measured through `engine.aggregate` and through `POST /data/:object/query`, on `driver-memory` and `driver-sql`, over six rows: - -| in `aggregations[i].filter` (or `having`) | before | now, and the `where` twin | -|:--|:--|:--| -| an ISO instant on a `date` field, `{ placed_on: { $gte: '2026-02-01T00:00:00.000Z' } }` | 1 | 3 | -| the same instant under `$eq` | 0 | 2 | -| a bare day as the upper bound of a `datetime`, `{ opened_at: { $lte: '2026-02-01' } }`, or as a `$between` max | 2 | 3 | -| an epoch-millisecond bound on a `datetime` | 0 | 3 | -| a `Date` carrying a time of day on a `date` field, `$gte` / `$lt` / `$eq` (in-process only) | 1 / 5 / 0 | 3 / 3 / 2 | -| a `Date` on a `time` field (in-process only) | 0 | 3 | -| `having` on `max` of a `date` field with an ISO-instant bound | kept one group | keeps the two groups whose day is on or after it | - -The same holds for `$ne`, `$in` / `$nin` members, `$between` endpoints, implicit equality, an offset instant (`'…T18:00:00+08:00'`), an epoch-millisecond string, a zone-naive `'2026-02-01T10:00'`, and a short wall clock (`'11:00'`) or an ISO instant on a `time` field. On a `having` column, the class comes from the query, as the `addDays` rule already reads it: `min` / `max` take the class of the field they read, a `groupBy` projection takes its field's, and a `day` date bucket is a `date`. - -What the rule does, now in one place: - -- A comparand, and the row's value, are put into the column's storage form: canonical UTC ISO text for `datetime`, `YYYY-MM-DD` for `date`, and `HH:MM:SS` (`.fff` only when non-zero) for `time`. -- A bare `YYYY-MM-DD` used as the upper bound of a `datetime` (`$lte`, a `$between` max) means that whole day, as it does in a `where` (ADR-0053 D-D). On a `date` or `time` column it is not widened. -- A value the rule cannot read is compared as written, and so is every non-temporal column, presence tests (`$exists`, `$null`), the text operators and a `{ $field }` reference. -- An object whose declared fields the engine cannot see keeps the previous comparison. - -`@objectstack/core` exports the rule as `temporalStorageForm(value, kind)`, `kind` being `'datetime' | 'date' | 'time'`. `driver-sql` (`canonicalUtcDatetime`, `toDateOnly`, `canonicalTimeOfDay`) and `driver-memory` (`coerceTemporalValue`) each carried a copy of it; both now call it. The copies agreed on every shape measured when they were lifted, so the lift itself changes no `where`, write or read answer of either driver (#20203, in the same release, then reads an epoch-millisecond number on a `date` field as its UTC calendar day). MySQL still binds a `datetime` in its own literal spelling. - -`@objectstack/objectql`'s `applyInMemoryAggregation(rows, ast, timezone?, fields?)` takes the object's declared field map as an optional fourth argument, and a per-aggregation `filter` reads a temporal comparand by the rule only when it is given. Called without it, the function answers as before. - -Not changed, measured identical before and after on both drivers: every `where` answer, every refusal a per-aggregation `filter` or `having` gives, and every per-aggregation `filter` and `having` cell whose column is not temporal. diff --git a/.changeset/20186-view-overlay-judged-by-viewkind-arm.md b/.changeset/20186-view-overlay-judged-by-viewkind-arm.md deleted file mode 100644 index 30d1af93937..00000000000 --- a/.changeset/20186-view-overlay-judged-by-viewkind-arm.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec)!: a flattened `view` overlay is judged by the member its `viewKind` names, so a column-less list patch has its list keys judged instead of stripped by the form member (#20186) - -**BREAKING** accept-set change on the `view` write door (`PUT /api/v1/meta/view/:name`, the Studio and MCP save), and on every door that parses `ViewMetadataSchema` (the assembled-manifest `viewItems:` union included). It narrows, and it widens two small classes, both declared below. It ships as `minor` under the repo's launch-window convention for breaking changes. - -Clause-②: yes (narrowing) - -## What was wrong - -The two flattened overlay members of `ViewMetadataSchema` shared one `viewKind: 'list' | 'form'` enum. The list member required `columns`, so it refused a column-less `viewKind: 'list'` body. The union then tried the form member, which requires no list key and `.strip()`s every one, and ACCEPTED the body. `diagnoseViewMetadata` named `formOverlay`, the parse output was `type: 'simple'` (a form), and the body's `sort`, `searchableFields` and `timeline` were never judged. Measured through the real `saveMetaItem` on `origin/main` @ `4df101c3`, and again at `ce70876e`: a retired bare-string `sort`, a `timeline.metaFields` and a non-array `searchableFields` each answered `success: true`, and the row held them as sent. The mirror held too: the list member accepted a `viewKind: 'form'` body carrying list `columns`. - -That column-less list body is not a malformed one. It is what the console stores on every toolbar save (sort, hidden fields, inline edit, column widths) for a code-defined list view: objectui's `persistViewPatch` stores the patch and nothing else, per the maintainer ruling 「`persistViewPatch` 只存 patch,不存 merged base」. - -## What it does now - -- **One `viewKind` per member.** The list overlay member admits `viewKind: 'list'` only; the form overlay member admits `viewKind: 'form'` only. A flattened body is judged by the member its `viewKind` names, and `diagnoseViewMetadata` names that member. -- **The list member judges a column-less patch.** `columns` is optional on the flattened list overlay member only. The authoring `ListViewSchema` keeps it required: an authored list view is a full config, never a patch. The console's patch-only writes keep saving, stored verbatim, and a lean list patch now parses to a list (`type: 'grid'`), not to `type: 'simple'`. -- **A column-less body that names a `type` is still refused**, now located at `columns`: a body that sets `type` is a full inline config, and a full config lists its columns. The refusal names both ways out. -- **A field list under a form overlay's `columns` is refused** at `columns`: on a form view `columns` is the body-column count. -- The served JSON Schema (`/api/v1/meta/types/view`) is still an `anyOf` of four members. The only movements: each overlay member's `viewKind` enum names one value, the list overlay member no longer lists `columns` as required, and in the output direction it no longer lists `type` as required (the `grid` default is still declared and still applied). - -## FROM → TO - -Each row is refused now and was accepted before, on a flattened overlay: - -| you wrote | write instead | -|:--|:--| -| `viewKind: 'list'`, no `columns`, `sort: 'name desc'` | `sort: [{ field: 'name', order: 'desc' }]` — the bare string clause was retired in 17.5.0 | -| `viewKind: 'list'`, no `columns`, `sort: [{ field, direction: 'desc' }]` | `sort: [{ field, order: 'desc' }]` | -| `viewKind: 'list'`, no `columns`, `timeline: { …, metaFields: [...] }` | delete `metaFields`: the timeline block has no such key | -| `viewKind: 'list'`, no `columns`, `searchableFields: 'name'` | `searchableFields: ['name']` | -| `viewKind: 'list'`, no `columns`, `sharing: { enabled: true, publicLink, … }` (the form public-link block) | the list `sharing` block, `sharing: { type: 'personal' \| 'collaborative', lockedBy? }`, or delete `sharing` | -| any other list key the list view schema refuses, on a column-less list overlay | the value the list view schema accepts — the refusal names the key | -| `viewKind: 'form'` with `columns: ['name', …]` | a field list means a list view: `viewKind: 'list'`; for a form, `sections: [{ fields: ['name', …] }]` and `columns` as a count (`columns: 2`) | - -**The one-line fix:** read the refusal. It is located at the key it refuses and says what that key takes. - -A column-less list overlay that names a `type` (`{ viewKind: 'list', type: 'kanban', … }` without `columns`) was refused before and is refused now; only its location moved, to `columns`. Add `columns`, or drop `type` to save the body as a patch. - -## Declared widening (why `Clause-②: yes`) - -Two classes of column-less, type-less `viewKind: 'list'` bodies go from refused to accepted: - -- **W2** — a list-legal value under a key both members declare with different schemas: `aria` (the form member carries a retirement tombstone there), an i18n `description` (the form member takes a plain string only), the list `sharing` block (the form member's is the public-link block), and a valid legacy `options` bag (the form member pins `options` absent). Measured: `{ name, object, viewKind: 'list', aria: { ariaLabel: 'Leads' } }` was refused, and is accepted. This is the change working: a list body is judged by list rules. -- **W1** — an invalid value under one of the 19 form-only keys (`layout`, `sections`, `title`, …). Measured: `{ name, object, viewKind: 'list', isPinned: true, layout: 'diagonal' }` was refused (the form member judged `layout`), and is accepted with `layout` dropped unread. That is the list member's existing handling of a key it does not declare: a list overlay WITH `columns` and `layout: 'diagonal'` was already accepted the same way. It is a named residual, not a contract. - -## Census - -- **objectstack** @ `4df101c3` (examples, packages): no source writes a flattened `viewKind: 'list'` body without `columns` as a literal; every literal hit is a test fixture, a changelog line or a comment. `examples/**` authors views as containers (15 files with `listViews`) and carries no `viewKind` at all. -- **objectui**, at the `.objectui-sha` pin `f8a9d0fb0` and at `main` `25c7d584e`, and **cloud** `main` `48d7066`: no source literal either. The one real producer is dynamic: objectui's `buildPersistedViewBody` returns `{ ...patch, viewKind }` for an overlay and `updateViewConfig` stamps `object`, `name` and the overlay marker. Those bodies keep saving, now judged by the list member. -- **Production `sys_metadata` rows: NOT MEASURED.** No deployment's store is reachable from the repository. A stored row that fails keeps being read and served exactly as stored. It is refused only on its next save, and the refusal names the key. - - diff --git a/.changeset/20193-dispatcher-meta-read-gate.md b/.changeset/20193-dispatcher-meta-read-gate.md deleted file mode 100644 index 04e826281e6..00000000000 --- a/.changeset/20193-dispatcher-meta-read-gate.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -'@objectstack/runtime': patch -'@objectstack/rest': minor ---- - -fix(runtime): the dispatcher's `/meta` item reads ask the same per-caller read gate `RestServer` asks, and `@objectstack/rest` publishes it (#20193) - -Clause-②: yes - -`GET /meta/:type/:name` and `GET /meta/:type/:name/published` have two -implementations: `RestServer`, and the runtime dispatcher's `/meta` domain. On a -host that mounts only the `${prefix}/*` catch-all (`@objectstack/hono`'s -`createHonoApp`, the documented embed shape, and any adapter built on the public -`HttpDispatcher` API), the dispatcher is the only one that answers. Its item read -and its `/published` read applied **no** per-caller read gate. An authenticated -member who does not hold `crm_admin` got these answers through `dispatch()`: - -``` -request before after (= RestServer) -GET /meta/doc/crm_admin_runbook 200 + the gated body 403 PERMISSION_DENIED -GET /meta/doc/crm_admin_runbook/published 200 + the gated body 403 PERMISSION_DENIED -GET /meta/book/admin_guide (set-gated) 200 + the book 403 PERMISSION_DENIED -GET /meta/app/crm (and /published) 200, every entry 200, pruned -GET /meta/app/payroll (app-level perms) 200 403 PERMISSION_DENIED -GET /meta/app/launchpad (unpublished) 200 404, the same body as a missing name -GET /meta/dashboard/ops 200, every widget 200, minus the widget whose service is off -GET /meta/object/invoice/published 200, every field 200, the ADR-0106 mask applied -``` - -Holders are still served in full. Object reads through the plain item read are -masked as before. An anonymous caller still gets `401 UNAUTHENTICATED`. - -**One gate, not two.** `RestServer`'s gate moved unchanged into -`packages/rest/src/meta-item-read-gate.ts`. That covers the ADR-0046 §6.7 docs -audience, the app nav filter (`requiredPermissions`, the ADR-0045 §3 publish gate -and the docs-audience entry arm), the ADR-0057 D10 service gates and the #7912 -servability gate. Both transports now call it. Each transport supplies only its -own I/O (the caller, the protocol's list read, the security service and a -service probe) and writes the gate's data verdict in its own envelope. There is -no second audience resolver in `packages/runtime`. `RestServer` keeps its -private helper names as delegates, so its own answers are byte-for-byte -unchanged. - -A gate input that cannot be read is answered as that fault, never as the -document. This covers a books or doc-list read that throws, and a host whose -protocol has no list read at all (fail closed, ADR-0049). - -**`@objectstack/rest`'s published export surface widens**, and that is why this -changeset declares `Clause-②: yes`. Its only export subpath (`.`) gains one value -and five types: - -- `createMetaItemReadGate(sources, metaType, name, documents, policy)`: the gate - itself; -- `MetaItemReadGateSources`: the I/O a caller supplies (the caller, a metadata - list read, the security service, a service probe, a prune-log set); -- `MetaItemReadVerdict` and `MetaItemReadRefusal`: the data verdict (`serve`, or - `refuse` with `absent` / `app-permission` / `docs-audience`); -- `MetaReadGateCaller`: the slice of the execution context the gate reads; -- `MetaReadGatePolicy`: `arms` and `app`, how a door runs the gate. - -They are public because the runtime dispatcher's `/meta` domain in -`@objectstack/runtime` consumes this one gate. `@objectstack/rest` cannot import -the runtime, so the shared decision has to live here and travel as an export, -the way `repeatedQueryParamMessage` does. A caller outside the platform does not -need them. - -Nothing is removed or renamed, and no authorable key moves. The dispatcher's -refusals are not the widening: they pull a second transport back to the gate -the contract already declares (ADR-0046 §6.7, ADR-0045 §3, `apps.mdx`), which is -why `@objectstack/runtime` stays a `patch`. diff --git a/.changeset/20197-generate-object-namespace-prefix.md b/.changeset/20197-generate-object-namespace-prefix.md deleted file mode 100644 index 69f2b6c273f..00000000000 --- a/.changeset/20197-generate-object-namespace-prefix.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os generate` gives object names the project's namespace prefix, so `os init -t app` followed by `os g object order_line` passes `os validate` - -`os init my-app -t app` writes `manifest.namespace: 'my_app'`. Then `os g object order_line` wrote `name: 'order_line'`, and the next `os validate` exited 1 (`os compile` exited 2) with `Object 'order_line' is missing the package namespace prefix. Rename it to 'my_app_order_line'`. The page's own example, `os g object customer`, failed the same way. - -- **Object names are prefixed.** In a project whose manifest declares a `namespace`, every object name a scaffold writes now starts with `_`. That covers the `object` scaffold's `name`, a `view`'s `object`, an `action`'s `objectName`, a `flow` start node's `objectName` and an `app` navigation item's `objectName`. Generated scaffolds now also point at each other: `os g view order_line` binds the object `os g object order_line` wrote. The file name and the exported binding still come from the name you typed (`src/objects/order_line.object.ts`, `orderLine`), and the command prints the object name it wrote. -- **No double prefix.** A name that already carries the prefix (`os g object my_app_order_line`) is written as typed. The "already compliant?" check is the namespace-prefix gate's own `validateObjectNamespacePrefix`, so a `sys_*` name, which the gate exempts, is not prefixed either. A name the gate would still refuse after prefixing (the legacy `NS__SHORT` form) is refused before anything is written. -- **One namespace source.** The namespace is `manifest.namespace` of the config as loaded, the value `os validate` checks against. It is never re-derived from the directory or the `package.json` name. With no config, or a manifest without a `namespace`, nothing is prefixed, as before. If a config exists but does not load, a type that names an object is refused and nothing is written, because the namespace is unknown. `dashboard` and `skill` scaffolds name no object, so a config that does not load does not stop them, but `os g` loads the config after every write, theirs included, to report whether the scaffold reaches the stack. -- **Unchanged:** the names the gate does not check against the namespace. An action's, flow's, dashboard's, app's and skill's own `name`, and an action's flow `target`, are written as before; a view's own `name` now equals the object key it binds to, prefix included. -- `os generate --help` now lists all seven metadata types in the `TYPE` argument. It had omitted `skill`, and the list now comes from the generator table. diff --git a/.changeset/20200-turso-remote-sync-refused.md b/.changeset/20200-turso-remote-sync-refused.md deleted file mode 100644 index 10b22a52a89..00000000000 --- a/.changeset/20200-turso-remote-sync-refused.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/driver-turso': minor -'@objectstack/spec': patch ---- - -fix(driver-turso)!: `new TursoDriver` refuses `syncUrl` under a forced `mode: 'remote'`, and `sync` with no `syncUrl`, instead of building and ignoring them - -Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, and no exported symbol moves. Two driver configurations that the turso driver used to build and then ignore a key of are now refused when it is built. - -`new TursoDriver()` accepted `syncUrl` beside a forced `mode: 'remote'`. Remote mode sends every read and write straight to `url`, and the remote client is created without `syncUrl`, so no replica is built and no sync ever runs. Measured on the built driver before this change, a `libsql://` or `file:` url under `mode: 'remote'` with `syncUrl` and `sync` constructed and connected, and `isSyncEnabled()` answered `true`. No sync interval started, and the sync call rejected with `SYNC_NOT_SUPPORTED` (`SyncNotSupported("File")` on the `file:` url). It also accepted `sync` with no `syncUrl`, in any mode, where nothing reads it. `@objectstack/spec`'s `TursoConfigSchema` already refused both at authoring. A datasource row stored before that, or a config a host builds itself, reached the constructor unparsed and ran with a sync setting that did nothing. - -**BREAKING** accept-set narrowing on a published constructor, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). Refused now with `VALIDATION_ERROR` / 400, before any client or database is opened: - -- `syncUrl` under a forced `mode: 'remote'`. A remote url beside `syncUrl` with no `mode` was already refused, as a replica on a remote url; -- `sync` with no `syncUrl` (or with an empty one), in local, replica and remote mode alike. - -Each refusal's message is the spec contract's issue message for that key, byte for byte, so authoring and boot say the same thing. A test holds the copies equal. The spec's `syncUrl` message said the driver "runs no sync, so the setting changes nothing". It now reads "the turso driver refuses this configuration when it starts", like its sibling refusals, and the driver's own `TursoConfigSchema` mirror follows (`@objectstack/spec` patch: message text only). The ADR-0087 entry `turso-config-transport-mismatch-refused` now also records that the constructor refuses these two shapes at boot. - -Left accepted on purpose: `mode: 'replica'` on a `file:` url with no `syncUrl` (and no `sync`). It still runs as a plain local database. Refusing it in the constructor alone would refuse a config both schemas accept, so it is tracked separately. - -### Migration: FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `url: 'libsql://my-db.turso.io', mode: 'remote', syncUrl: …` (with or without `sync`) | a remote database: drop `syncUrl` and `sync`. An embedded replica: `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io'` and no `mode` | -| `url: 'file:./data/app.db', mode: 'remote', syncUrl: …` | the same two ways out | -| `sync: { … }` with no `syncUrl` | name the remote in `syncUrl` (with a `file:` url), or drop `sync` | - -A datasource row stored with one of these shapes is not re-parsed when it loads, so it now fails when the driver is built. `factory.create` throws the refusal. The connection service records the datasource as `failed-degraded` with the message, and a test connection answers `ok: false` ("Failed to build driver: …"). Under ADR-0062 D5, the boot fails fast when objects bind to that datasource or are routed to it, or when it is boot-critical, unless `OS_ALLOW_DRIVER_CONNECT_FAILURE` is set. Otherwise it is left unconnected with a warning. Before this change the same row booted, reported sync as enabled and never synced. The way out is the table above: drop `syncUrl` / `sync` from a remote config, or use a `file:` url with the remote in `syncUrl`. - -Blast radius, measured on this tree: no example, template, published skill or hand-written doc authors either shape, and no in-repo caller reads `isSyncEnabled()` or calls the driver's sync outside `@objectstack/driver-turso`'s own tests. Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero. - - diff --git a/.changeset/20201-d3-entry-per-family-major-18.md b/.changeset/20201-d3-entry-per-family-major-18.md deleted file mode 100644 index 16426ecdefd..00000000000 --- a/.changeset/20201-d3-entry-per-family-major-18.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): every protocol-18 retirement family now carries its D3 entry, including the 25 whose data repair is a lossless D2 conversion (#20201) - -Clause-②: no - -ADR-0087 D3 requires one semantic (D3) entry per retirement family, even when a -lossless D2 conversion already repairs the data: D2 carries the mechanical repair -only, and the D3 entry says what the consumer still has to decide. Twenty-five -protocol-18 families shipped a D2 conversion and no D3 entry, some of them -justified by "lossless, so no semantic residue". `MIGRATIONS_BY_MAJOR[18].semantic` -gains one entry per family, so `os migrate meta` lists each as a TODO on the -17 → 18 hop, with its reason and acceptance criteria. Among them: - -- the seven duration renames (`hook.timeout`, `job.timeout`, `apis[].cacheTtl`, - `dashboard.refreshInterval`, the connector health / trigger durations, the memory - driver's `autoSaveInterval` and the turso `timeout`). The rename keeps the value, - so only the author can say whether it was ever written in the unit the new key - names. -- `object.tenancy.organizationField`, `view.owner` / `view.hidden`, - `permission.objects.*.allowRestore` / `allowPurge` and the list-view `page` mount. - Each delete is lossless, and each leaves a belief the author held that the - platform never honoured. - -No accept set moves and no conversion changes. The registry's own test now fails -when a protocol-18-or-later step graduates a conversion that no D3 entry of that -step names. The prose that justified the missing entries is corrected, and the -protocol-17 docblock no longer calls that step's `semantic` list empty. diff --git a/.changeset/20203-epoch-ms-date-comparand.md b/.changeset/20203-epoch-ms-date-comparand.md deleted file mode 100644 index 6cf1e0a13cf..00000000000 --- a/.changeset/20203-epoch-ms-date-comparand.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/core": patch -"@objectstack/driver-sql": patch -"@objectstack/driver-memory": patch -"@objectstack/objectql": patch ---- - -fix(core): an epoch-millisecond number compared against a `date` field is read as the UTC calendar day of its instant, by every driver and at every position that compares it (#20203) - -Clause-②: no — no key, export or operator moves, and no comparand that was accepted is now refused: a number was already an accepted comparand on every `date` position, and its answer moves to the storage rule's reading. - -`temporalStorageForm(value, 'date')` in `@objectstack/core` returned a finite number unchanged, so each face compared it by its own type rules and they disagreed. Over six rows, with `1769940000000` (2026-02-01T10:00:00.000Z) against a `date` field: - -| face | `$gt` | `$lt` | `$eq` | `$in` (with a Jan 10 member) | `$between` (from Jan 2) | -|:--|:--|:--|:--|:--|:--| -| `where` on `driver-memory` | 0 | 0 | 0 | 0 | 0 | -| `where` on `driver-sql`, SQLite | 6 | 0 | 0 | 0 | 0 | -| `where` on `driver-sql`, PostgreSQL | `DATABASE_ERROR`, a 500 at REST | the same | the same | the same | the same | -| a per-aggregation `filter` on `engine.aggregate` | 0 | 0 | 0 | 0 | 6 | -| **now, on every face above** | **1** | **3** | **2** | **3** | **5** | - -The same holds through `engine.find` and `POST /data/:object/query`, and for `$gte`, `$lte`, `$ne`, `$nin` and implicit equality. PostgreSQL's server refused the bound number itself (`22008`, date/time field value out of range), on an empty table too. `having` over `max` of a `date` field kept no group for `$gt`, `$eq` or `$in`; it now keeps the groups whose day compares. - -A finite number is now read as the `datetime` rule already reads it, as epoch milliseconds. It takes the UTC calendar day of that instant: the day `new Date(value)` names, through the same conversion a `Date` takes. So a number and its `Date` always answer alike. A time of day is dropped, never rounded, a negative number is a day before 1970, and a fraction truncates toward zero as the `Date` constructor does. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call this rule, so they now agree. - -The rule is shared by the drivers' write and read paths too: - -- `create()` / `update()` on either driver, given a number for a `date` field, stores its UTC day. Before, `driver-memory` and SQLite stored the number, and PostgreSQL refused the statement. The engine and REST write doors refuse a number on a `date` field before a driver sees it (`VALIDATION_FAILED`), as before. -- A number already stored in a SQLite `date` column is read back as its UTC day by `find()`, a `groupBy` key and `distinct()`. Only a direct driver write could have put one there. - -Not changed, measured identical before and after: `NaN`, ±Infinity, a number outside the `Date` range (past ±8.64e15), a bigint, an epoch-millisecond string, every `Date` and every string on a `date` field (#20240, in the same release, then pads a `Date`'s or a number's year 0..999 to four digits and refuses one whose year falls outside 0..9999, a number past the `Date` range included; #20264, in the same release, narrows that to 0001..9999, so year 0 is refused rather than padded), and every `datetime` and `time` reading. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. diff --git a/.changeset/20206-lint-error-code-provenance-row.md b/.changeset/20206-lint-error-code-provenance-row.md deleted file mode 100644 index 4fbaaee3f79..00000000000 --- a/.changeset/20206-lint-error-code-provenance-row.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`ERROR_CODE_LEDGER['@objectstack/lint']` now lists `INVALID_ARTIFACT_PACKAGES`, the code `packages/lint`'s `packagesOf` reader stamps for a malformed `stack.packages` (#20206) — required by `check:error-code-provenance`, which refuses a registered code stamped by a package whose own owner key does not list it. - -Clause-②: yes - -Provenance, not identity: the code was already registered under `@objectstack/core` (`resolveArtifactPackageOrder`, the producer `packagesOf` deliberately mirrors rather than mints a new code for), so the `ErrorCode` union, the wire, and every other package's rows are unchanged. What widens is the per-package face a consumer reads from `ERROR_CODE_LEDGER['@objectstack/lint']`, newly present where it was absent before. Nothing to migrate. diff --git a/.changeset/20206-lint-packages-non-array-refused.md b/.changeset/20206-lint-packages-non-array-refused.md deleted file mode 100644 index cd3ff2a56e4..00000000000 --- a/.changeset/20206-lint-packages-non-array-refused.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -`packages/lint`'s five `stack.packages` readers (four named by #20206, plus one added by #20208 after that card's site census) now refuse a PRESENT non-array `packages` — `{}`, `0`, `'x'`, a keyed object, and (as of this round) `null` too — instead of silently reading it as "no packages" (#20206, ruling A on #15293 comment 5634034754; the `null` leg is ruling A on #19926, comment 5805260775: `null` is malformed, everywhere). For every shape other than `null`, this is the same way `@objectstack/core`'s `resolveArtifactPackageOrder` already refuses it, with the same registered code; `@objectstack/core`'s `resolveArtifactPackageOrder` refuses `null` the same way (#19926). - -Clause-②: no (narrowing) - - - -- **What changes**: `validateObjectReferences`, `validateTranslationReferences` and `validateMappingTargetFields` (the three public `@objectstack/lint` functions these readers sit behind) now throw an `INVALID_ARTIFACT_PACKAGES` error (ADR-0112, `status: 422`) instead of returning findings, when the stack they are handed carries a `packages` key that is present but not an array — `null` included. Only `os lint` reaches this refusal — exit 1, the message on stdout (`printError`), `code` under `--json`; `os validate` and `os build` already refuse a malformed `packages` earlier, at `ObjectStackDefinitionSchema.safeParse`, before these rules ever run. -- **What does not change**: an absent `packages` (the key omitted, or explicitly `undefined`) is still read as "no packages" — unchanged. A well-formed `packages[]` array is read exactly as before, junk entries dropped exactly as before. -- **Fix**: write `packages` as an array of `{ manifest: … }` entries, or omit the key entirely for a single-package stack. diff --git a/.changeset/20212-rls-compiled-filter-comparand-faces.md b/.changeset/20212-rls-compiled-filter-comparand-faces.md deleted file mode 100644 index 40520141522..00000000000 --- a/.changeset/20212-rls-compiled-filter-comparand-faces.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/lint": patch ---- - -fix(plugin-security)!: a row-level policy whose compiled filter carries a `null` list member or a `null` ordering bound now fails closed on both clauses, so its read and its write check agree (#20212) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what a row-level policy grants. A policy that returned rows on a read, or admitted a write, can now return no rows and refuse the write with 403. It ships as `minor` under the launch-window convention for accept-set narrowings. - -`RLSCompiler.compileFilter` now runs the platform's two shared comparand faces (`assertListComparandShapes` and `normalizeFilterComparandTypes` from `@objectstack/spec/data`) on every compiled policy filter, for `using` and `check` alike. These are the functions the engine already runs on a caller's own `where`. It runs them before the middleware chain adds the RLS filter to that `where`, so until now a compiled policy filter reached the driver unjudged. A policy they refuse is dropped the way a policy with an unresolved `current_user` variable, or a list under `==`, is already dropped. When no other applicable policy compiles, the clause answers `RLS_DENY_FILTER` and logs one `[RLS] DENY (fail closed)` WARN with `reason: 'refused-comparand'`. - -**Why.** The rulings refuse a `null` member of `$in` / `$nin` and a `null` comparand of `$gt` / `$gte` / `$lt` / `$lte` in every filter, because no two backends agree on what they match. On the RLS path they reached the backend, and the `check` clause of the same policy was evaluated in-process by another matcher. One policy then gave two answers. Measured with rows `open`, `closed` and a NULL status: - -| predicate | `using` read before, SqlDriver / InMemoryDriver | `check` insert `closed` / `open` before | after, both clauses | -| --- | --- | --- | --- | -| `!(record.status in ['open', null])` | the NULL row / the `closed` row | admitted / 403 | no rows, 403 | -| `record.status in ['open', null]` | the `open` row / the `open` and NULL rows | 403 / admitted | no rows, 403 | -| `record.status > null` | no rows / no rows | 403 / 403 | no rows, 403 | -| `record.status <= null`, `record.status in [null]` | no rows / the NULL row | 403 / 403 | no rows, 403 | - -On SqlDriver the first row's read hid the `closed` row that its own write check admitted. PostgreSQL answered as SQLite. - -**What now answers differently.** - -- A read (`find`, `findOne`, `count`) under such a policy returns no rows when no other applicable policy compiles, and logs the WARN. Beside another policy that compiles, this policy no longer contributes rows: the read returns what the other policies grant, with no WARN. -- A `check` (declared, or defaulted from `using`) refuses every insert and update it governs with the row-level CHECK denial, `403 PERMISSION_DENIED`, when no other applicable `check` compiles. -- `explain` reports the RLS layer as `denies` instead of `narrows`. -- Analytics: `getReadFilter` hands the deny sentinel to the analytics faces, which answer zero rows. They previously refused the whole query with `READ_SCOPE_COMPILE_FAILED` / 500. - -**Who is affected.** A deployment whose stored policies carry one of these shapes, for example a policy saved without `os validate`. No policy in this repository does: every `using` / `check` string under `examples/` and `packages/` (outside tests) that names `null` is a null check (`== null`, `!= null`), which is unchanged. - -**Fix.** Test for no value with `== null` and for a value with `!= null`. "One of these, or no value" is `record.status in ['open'] || record.status == null`. "Has a value" is `record.status != null`. `os validate` prints the rewrite for each finding. - -**Unchanged.** A policy whose compiled filter the faces accept compiles to the same filter as before, with the same WARNs. A caller's own `where` carrying these shapes is still refused `INVALID_FILTER` / 400 by the engine. The null checks `record.f == null` / `record.f != null` lower to `$null` and are not refused. - -`@objectstack/lint`: the `rls-predicate-unenforceable` finding for a `null` list member or `null` ordering bound now says what the runtime does: the policy is dropped on every request, with the clause's own fail-closed consequence. It used to say the policy survived and the backend answered. diff --git a/.changeset/20215-generate-scaffolds-reach-stack.md b/.changeset/20215-generate-scaffolds-reach-stack.md deleted file mode 100644 index c0f21a22e0d..00000000000 --- a/.changeset/20215-generate-scaffolds-reach-stack.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): what `os generate` writes now reaches the stack, or the command says it does not - -`os init my-app -t app` wrote a config that imported `./src/objects` alone. `os g view`, `action`, `flow`, `dashboard`, `app` and `skill` each wrote a file and a barrel `index.ts` that nothing imported, and `os validate` then exited 0 printing `UI: 0 Apps` and `Logic: 0 Flows`: a green that had judged nothing the command just wrote. - -**What `os init` now writes (`app` and `plugin` templates):** - -- `objectstack.config.ts` imports every directory `os generate` writes into (`src/objects`, `src/views`, `src/actions`, `src/flows`, `src/dashboards`, `src/apps`, `src/skills`) and hands each barrel's exports to `defineStack` under its key (`objects`, `views`, …). A file `os g` writes there is part of the stack with no edit to the config. The keys read the barrels through a small `exportsOf` helper declared in the config, because `Object.values` on an empty barrel does not type-check against `defineStack`'s collection types. -- An `index.ts` containing only `export {};` for each directory the template puts nothing in. An `index.ts` that already exists is kept as it is and never overwritten. -- `requires: ['automation', 'triggers']`. A flow that starts on a record change is fired by `triggers` and run by `automation`. If either one is missing, `defineStack` refuses the config as soon as it holds such a flow. - -**What `os generate` now does:** - -- After writing, it loads the project's config again and reports on the new item. Either the stack carries it, or it is **not wired** (the file is written, the config is left untouched, and the command prints the import and `defineStack` key to add). It never edits the config. -- It refuses a write that makes a config that loaded stop loading, for example an action or app bound to an object nobody declared, or a flow in a stack without `triggers` or without `automation`. It removes what it wrote, exits 1, and prints the stack's own reason. Generate the object first (`os g object customer`), then what binds to it. `dashboard` and `skill` now read the config too, so they can report, and they still generate when the config does not load. -- A view's own `name` is now the object it binds to, prefix included (`my_app_order_line`, not `order_line`). The server registers a view under its object and refused, at boot, a scaffold whose `name` disagreed. That never showed while the views barrel was not loaded. -- The barrel step asks the compiler whether the barrel already exports the name, instead of searching the file's text. `os g view order` after `os g view order_line` had found `order` inside `orderLine` and exported nothing. -- The `flow` scaffold's header states the `requires` it needs. - -**Projects scaffolded by an earlier release** keep their config. `os g` now tells you when a file it wrote is not wired, and prints the lines to add. diff --git a/.changeset/20216-view-container-object-refused.md b/.changeset/20216-view-container-object-refused.md deleted file mode 100644 index 353a41c6191..00000000000 --- a/.changeset/20216-view-container-object-refused.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -fix(lint)!: a view container whose `object` names no object is refused by `os validate`, `os build` and `os lint` (`object-reference-unknown`), and the refusal names the namespace-prefixed object when that is the one the stack declares (#20216) - -Clause-②: no (narrowing) - -**BREAKING** — an accept-set narrowing on one authored key, shipped as `minor` under the -launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness -is carried by this banner and the ADR-0087 disposition below, not by the level). - -**What changed.** `ViewSchema.object` is how a stack-level `views: [...]` container says -which object its views belong to, and it is the key the runtime indexes views by -(`getViewsByObject()` / `GET /meta/view?object=`). The schema declares it `z.string()`, and -nothing resolved it: `defineStack`'s cross-reference check reads a container's -`list.data` / `form.data` bindings, never the container's own key. So a container bound to a -name no object carries passed: `os validate` printed "Validation passed" and exited 0, -saying nothing about the view, and `os build` / `os lint` run the same rule table. At -runtime none of its views was found for any object. The common case is not a typo but a -missing namespace prefix — `object: 'order_line'` in a project whose object is -`my_app_order_line` — which is exactly what `os generate view` wrote in every namespaced -project until its template learned the prefix. - -The key now joins `validateObjectReferences` and rides the ladder every other object-name -site on that rule uses, resolved against the same set as a field's relationship target: - -1. the stack's own objects, or an object an entry of the artifact's `packages[]` provides → ok; -2. a known platform object (`PLATFORM_PROVIDED_OBJECT_NAMES`) → ok; -3. unresolved and not platform-prefixed → **`error`** `object-reference-unknown` at - `views[N].object`, so `os validate` / `os build` / `os lint` exit 1; -4. unresolved, platform-prefixed, registered by nothing → the existing - `object-reference-unregistered-platform` advisory. - -The refusal lists the objects the stack does declare, and when the bound name is exactly a -declared object minus the stack's `manifest.namespace` prefix, the hint names that prefixed -object outright. Not judged, on purpose: a container that carries no `object` (its binding -then falls back to `list.data.object` / `form.data.object` / its `name`, a different -reference), and a container authored at runtime (this rule does not run on a `view` write at -the runtime publish gate; that door is unchanged). - -## The accept set, before and after - -This is a behaviour table, not a rewrite: the FROM column is what the door did, the TO -column is what it does now. - -| where | FROM | TO | -|:--|:--|:--| -| `os validate`, `os build`, `os lint` on a view container bound to a name no object carries | exit 0, no finding | exit 1, `object-reference-unknown` at `views[N].object` | -| the same, on a platform-prefixed name nothing registers | exit 0, no finding | the `object-reference-unregistered-platform` advisory, exit unchanged | -| a runtime `view` write | unchanged | unchanged | - -Nothing an author writes changes spelling, and no key or value is retired. A container that -is refused was already dead at runtime; the finding's own hint says which object to bind it -to. - - diff --git a/.changeset/20219-install-body-residual-closed.md b/.changeset/20219-install-body-residual-closed.md deleted file mode 100644 index f532b66cf62..00000000000 --- a/.changeset/20219-install-body-residual-closed.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`PackageInstallBodySchema`'s docblock records its measured residual as closed: the install door answers every body the declaration refuses with `400`, not `201` - -Clause-②: no - -The docblock listed the bodies `POST /api/v1/packages` answered differently -from the declaration: a manifest with no `type`, unknown keys on either body -form, a string-typed `enableOnInstall` / `overwrite`, install options spelled on -the bare form, and (in the other direction) a whitespace-only `id`. It still said -the door answers `201` to the first four. Since the door parses its whole body -through `PackageInstallBodySchema` (#20218), it answers each of them `400` / -`VALIDATION_ERROR` and installs nothing. The whitespace-only `id` was already -refused by both, because `ManifestSchema.id` carries `MANIFEST_ID_PATTERN`. - -The section now records every class as closed, names the door-side pin for -each, and says what the declaration's parsed value still does not describe: -the door stores the manifest it was SENT, so parse-time defaults (`scope`, -`defaultDatasource`) are not stored, and an unknown key nested in a manifest -block the declaration leaves in strip mode is stored as sent. - -Two more sentences are corrected. The bare-form paragraph said the runtime's -two door drives post a manifest with no `type`; both have carried -`type: 'app'` since #20218 and parse green. The `enableOnInstall` docblock said -the door "reads the raw body"; it reads the key off the parsed wrapped request. - -⛔ No behaviour changes. No schema, accept set, export or runtime code moves. - -**Why this carries a changeset and not `skip-changeset`.** `@objectstack/spec`'s -`files[]` ships `src/**/*.zod.ts` verbatim, and the `PackageInstallBodySchema` -docblock is also emitted into `dist/api/index.d.ts` and `dist/api/index.d.mts`. -The published content changes, even though no line of code does. diff --git a/.changeset/20221-form-layout-inline-grid-retired.md b/.changeset/20221-form-layout-inline-grid-retired.md deleted file mode 100644 index 2106d32e8e8..00000000000 --- a/.changeset/20221-form-layout-inline-grid-retired.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: retire the `inline` and `grid` arms of form `layout` — every renderer folded both to `vertical`, and multi-column is `columns` (ADR-0049) - - - -**BREAKING** — form `layout` accepts exactly `'vertical' | 'horizontal'` on both surfaces -that declared the four-arm enum: the `object-form` page component -(`ObjectFormPropsSchema.layout`) and the form view (`FormViewSchema.layout` — `view.form`, -`view.formViews.*`, a form view item's `config`, and the flattened form overlay, which -spreads the form view's shape). `'inline'` and `'grid'` are refused at parse, each with a -prescription naming what to write instead. - -| surface | before | after | -|:--|:--|:--| -| `object-form` `layout: 'grid'` | parsed clean, rendered as `vertical` | refused — write `'vertical'` (or omit `layout`); for multi-column set `columns` | -| `object-form` `layout: 'inline'` | parsed clean, rendered as `vertical` | refused — write `'vertical'` (or omit `layout`) | -| form view `layout: 'grid'` | parsed clean, rendered as `vertical` | refused — write `'vertical'` (or omit `layout`); for multi-column set `columns` | -| form view `layout: 'inline'` | parsed clean, rendered as `vertical` | refused — write `'vertical'` (or omit `layout`) | -| `layout: 'vertical'` / `'horizontal'` | accepted | **unchanged** | -| `columns` | honoured under every layout | **unchanged** — the key multi-column always lived under | - -**What was actually wrong.** No renderer ever gave either value a behaviour of its own. -Measured at the `.objectui-sha` pin this repo builds against (`f8a9d0fb0`): the simple -`object-form` arm folds both to `vertical` (`ObjectForm.tsx:1406-1410`, under the comment -"Map 'grid' and 'inline' to 'vertical' as fallback"); the drawer and modal arms -(`ObjectForm.tsx:463`, `:499`) and `DrawerForm.tsx:575` / `ModalForm.tsx:597` pass only -`vertical` / `horizontal` through; `TabbedForm.tsx:556`, `SplitForm.tsx:445` and -`WizardForm.tsx:1075` hard-code `vertical`. So both values were a green parse for a value the -renderer threw away. The spec had admitted them from two declarations — the designer palette -and the registry `inputs` offered all four — never from a read. - -Under the maintainer's ADR-0049 family criterion (the capability exists on mainstream -platforms ⇒ build the consumer; it does not ⇒ retire), multi-column — what `grid` would -mean — already exists here under another key, `columns`, which the renderer honours under -every arm; `inline` is a toolbar / filter-row pattern, not a record-form layout. The two arms -are redundant vocabulary, retired with no alias window. - -## What to write instead - -```ts -// before — parsed clean, rendered single-column 'vertical' -{ type: 'object-form', properties: { objectName: 'crm_lead', layout: 'grid' } } -// after — what it rendered; add `columns` if a multi-column form was the intent -{ type: 'object-form', properties: { objectName: 'crm_lead', layout: 'vertical', columns: 2 } } -``` - -`'inline'` → `'vertical'` (or delete `layout`: `'vertical'` is the renderer default). A form -that wrote `'grid'` without `columns` always rendered single-column; only its author knows -whether more columns were meant — set `columns` to the count you meant. - -Existing sources: `os migrate meta --from 17` lists the mechanical edits; apply them by hand. - -The retirement kit: - -- both enums narrowed to `'vertical' | 'horizontal'`, each with a per-value error map keyed on - the input (the `record:chatter` `position` precedent), so only a value that used to be legal - is told it "was removed"; a never-vocabulary value keeps zod's own enum refusal -- the D2 conversion `form-layout-inline-grid-to-vertical` (protocol 18, retired from the load - path) rewrites both values to `'vertical'` and leaves `columns` untouched — on `object-form` - page components, on every form payload a view carries, and on the assembled-manifest - `viewItems` channel, so stored rows and assembled artifacts replay clean; wired into the - protocol-18 chain step -- one D3 entry for the family, `ui-form-layout-inline-grid-retired`, carrying the one judgement - the chain cannot make: whether a form that said `grid` wanted columns it never declared -- pin tests (`ui/form-layout-inline-grid-retired.test.ts`): both values refused with the - prescription on five doors (`ObjectFormPropsSchema`, the `object-form` props-map row, - `FormViewSchema`, a view container's `formViews`, the flattened form overlay), `vertical` / - `horizontal` green on each as controls, the conversion's rewrite parsing green on the door - that refused its input, and the chain registration -- the `columns` descriptions no longer say "grid layout"; the generated references - (`content/docs/references/**`, `skills/objectstack-ui/references/react-blocks.md`, the JSON - Schema) print the two-arm enum; the hand-written `layout-dsl` page and the `form.layout` - liveness row (still `live`) are updated -- `api-surface/` and `authorable-surface/` are unchanged, correctly: they ratchet export and - key existence, and no export or key leaves — `layout` is still declared, two values narrower - -Clause-②: no (narrowing) diff --git a/.changeset/20233-datasource-filter-action-data-element-migration-guidance-tracker-free.md b/.changeset/20233-datasource-filter-action-data-element-migration-guidance-tracker-free.md deleted file mode 100644 index 0dfa0a01ff4..00000000000 --- a/.changeset/20233-datasource-filter-action-data-element-migration-guidance-tracker-free.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): `os migrate meta` guidance for the `datasource-*`, `filter-*`, `action-*`, `data-*` and `element-*` migration entries states each lesson in words instead of citing tracker numbers - -Clause-②: no - -The ADR-0087 semantic entries of the `datasource-*` family (the publish-time credential, -placeholder and URL refusals, and the bound-secret pairs a mongo datasource cannot use), -the `filter-*` family (the retired `$regex`, the `$between` endpoint refusals and the -comparand shapes the save door now refuses), the `action-*` family (the retired -descriptor key, the `resumeAuthority` default flip, the action-session rename, the bulk -dispatch contract and the engine facade's query envelope), the `data-*` family (the -retired driver and engine contract members, the retired field-changed event and two -duration keys renamed with their unit) and the `element-*` family (the filter rule array -at the page binding and the element and block doors) are printed by `os migrate meta` as -the header, `why:` and `verify:` lines of a manual change. Their text sent the reader to -issue-tracker, decision-batch and ruling-record numbers — some of which no longer -resolve, and some in other repositories — for what a ruling, measurement or fix had -decided; it now says what was decided, in the sentence being read. ADR ids are kept. - -Text only: no entry id, `surface`, `from` / `to`, conversion or matching logic changes, -and the chain rewrites exactly what it rewrote before. The generated migration registry, -`spec-changes.json` and the protocol upgrade guide carry the same text. diff --git a/.changeset/20233-driver-kernel-system-migration-guidance-tracker-free.md b/.changeset/20233-driver-kernel-system-migration-guidance-tracker-free.md deleted file mode 100644 index 1da09deea62..00000000000 --- a/.changeset/20233-driver-kernel-system-migration-guidance-tracker-free.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): `os migrate meta` guidance for the `driver-*`, `kernel-*` and `system-*` migration entries states each lesson in words instead of citing tracker numbers - -Clause-②: no - -The ADR-0087 semantic entries of the `driver-*` family (the driver query-argument -narrowings, the inert capability bits, the SQL driver's unresolvable-column and -cross-row upsert refusals, and the retired Turso config keys), the `kernel-*` family -(preview mode, and the kernel duration keys that now carry their unit in the key name) -and the `system-*` family (the system duration keys renamed under the same rule) are -printed by `os migrate meta` as the header, `why:` and `verify:` lines of a manual -change. Their text sent the reader to issue-tracker and decision-batch numbers — some -of which no longer resolve — for what a ruling, measurement or fix had decided; it now -says what was decided, in the sentence being read. ADR ids are kept. - -Text only: no entry id, `surface`, `from` / `to`, conversion or matching logic changes, -and the chain rewrites exactly what it rewrote before. The generated migration registry, -`spec-changes.json` and the protocol upgrade guide carry the same text. diff --git a/.changeset/20233-engine-migration-guidance-tracker-free.md b/.changeset/20233-engine-migration-guidance-tracker-free.md deleted file mode 100644 index 2e6ae657d2c..00000000000 --- a/.changeset/20233-engine-migration-guidance-tracker-free.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): `os migrate meta` guidance for the `engine-*` migration entries states each lesson in words instead of citing tracker numbers - -Clause-②: no - -The five ADR-0087 semantic entries about the data engine's query and write -options (`engine-dotted-projection-refused`, `engine-find-formula-filter-refused`, -`engine-find-formula-order-by-refused`, `engine-update-upsert-retired`, -`engine-dotted-filter-refused`) are printed by `os migrate meta` as the -replacement, `why:` and `verify:` lines of a manual change. Their text sent the -reader to issue-tracker numbers for what a ruling or a fix had decided; it now says -what was decided, in the sentence being read. ADR ids are kept. - -Text only: no entry id, surface, `from` / `to`, conversion or matching logic -changes, and the chain rewrites exactly what it rewrote before. The generated -migration registry, `spec-changes.json` and the protocol upgrade guide carry the -same text. diff --git a/.changeset/20233-field-export-api-dataset-hook-metadata-migration-guidance-tracker-free.md b/.changeset/20233-field-export-api-dataset-hook-metadata-migration-guidance-tracker-free.md deleted file mode 100644 index f6a52ec1d1e..00000000000 --- a/.changeset/20233-field-export-api-dataset-hook-metadata-migration-guidance-tracker-free.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): `os migrate meta` guidance for the `field-*`, `export-*`, `api-*`, `dataset-*`, `hook-*` and `metadata-*` migration entries states each lesson in words instead of citing tracker numbers - -Clause-②: no - -The ADR-0087 semantic entries of the `field-*` family (the runtime `field` write door, the -`maxLength` / `minLength` / `scale` / `precision` refusals, `scale` on a currency field, -`multiple` on a type that holds one value, and predicates that read through a reference), -the `export-*` family (the export permission axis, the eight constraint keys retired from -`ExportFieldMeta` and the retired export-job API family), the `api-*` family (the runtime `api` write door, -the split API entry and two duration keys renamed with their unit), the `dataset-*` family -(the aggregate × field-type refusals and the nested-relation list refused at save), the -`hook-*` family (the retired hook-session `roles` and the two `registerHook` refusals) and -the `metadata-*` family (the retired customization protocol, the re-partitioned endpoint -switches, the metadata-manager cache keys and the retired `additionalTypes`) are printed by -`os migrate meta` as the header, `why:` and `verify:` lines of a manual change. Their text -sent the reader to issue-tracker, decision-batch and ruling-record numbers — some of which -no longer resolve, and some in another repository — for what a ruling, measurement or fix -had decided; it now says what was decided, in the sentence being read. ADR ids are kept. - -Text only: no entry id, `from` / `to`, conversion or matching logic changes, and the chain -rewrites exactly what it rewrote before. One entry's `surface` (the header line of -`dataset-measure-aggregate-field-type-refused`) drops the two tracker numbers it carried and -names nothing else differently. The generated migration registry, `spec-changes.json` and -the protocol upgrade guide carry the same text. diff --git a/.changeset/20233-rest-analytics-view-package-object-sharing-audit-flow-http-migration-guidance-tracker-free.md b/.changeset/20233-rest-analytics-view-package-object-sharing-audit-flow-http-migration-guidance-tracker-free.md deleted file mode 100644 index 0d1896aee93..00000000000 --- a/.changeset/20233-rest-analytics-view-package-object-sharing-audit-flow-http-migration-guidance-tracker-free.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): `os migrate meta` guidance for the `rest-*`, `analytics-*`, `view-*`, `package-*`, `object-*`, `sharing-*`, `audit-*`, `flow-*` and `http-*` migration entries states each lesson in words instead of citing tracker numbers - -Clause-②: no - -The ADR-0087 semantic entries of the `rest-*` family (the retired OpenAPI 3.1 block, the -endpoint `handlerStatus` marker, the REST server's dead config keys and the REST plugin -durations renamed with their unit), the `analytics-*` family (the retired query envelope, -the unknown-key refusals on cubes, and the closed date-range vocabulary and two-bound -window), the `view-*` family (the filter value shaped by its operator and its array and -absent-value refusals, the retired view-management protocol, the page-size default and the -judged overlay `options` bag), the `package-*` family (the explicit all-tenants uninstall, -the retired unmounted contract-map entries and rollback response, and the strict wrapped -install body), the `object-*` family (the array `sort` on object blocks, the converged grid -`data`, the rule-array `defaultFilters` and the index unknown-key refusal), the `sharing-*` -family (the retired `SharingExecutionContext` type and the reconciled rule recipients), the -`audit-*` family (the audit-log action values no writer produced), the `flow-*` family (the -retry count, the decision-branch and edge refusals, first-match edge branching and blank -predicate slots) and the `http-*` family (the retired error counter and server runtime -vocabulary) are printed by `os migrate meta` as the header, `why:` and `verify:` lines of a -manual change. Their text sent the reader to issue-tracker, decision-batch and ruling-record -numbers — some of which no longer resolve, and some in another repository — for what a -ruling, measurement or fix had decided; it now says what was decided, in the sentence being -read. ADR ids are kept. Two entries of other families are corrected the same way: -`api-error-retry-after-unit-in-key` now dates the population ruling its clause describes, -and `inline-grid-column-currency-scale-refused` names its two currency rulings by date -instead of by record number. - -Text only: no entry id, `from` / `to`, conversion or matching logic changes, and the chain -rewrites exactly what it rewrote before. One entry's `surface` (the header line of -`flow-edge-condition-evaluated-slot-source-required`) drops the two tracker numbers it -carried and names nothing else differently. The generated migration registry, -`spec-changes.json` and the protocol upgrade guide carry the same text. diff --git a/.changeset/20233-ui-plugin-migration-guidance-tracker-free.md b/.changeset/20233-ui-plugin-migration-guidance-tracker-free.md deleted file mode 100644 index 433130a1b92..00000000000 --- a/.changeset/20233-ui-plugin-migration-guidance-tracker-free.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): `os migrate meta` guidance for the `ui-*` and `plugin-*` migration entries states each lesson in words instead of citing tracker numbers - -Clause-②: no - -The ADR-0087 semantic entries of the `ui-*` family (component props rows, form-field -and list-view refusals, the react-tier `ListView` aliases, and the retired -interaction, notification, embed, widget and i18n vocabularies) and of the `plugin-*` -family (the plugin manifest, runtime, health-monitor and security-scanner retirements) -are printed by `os migrate meta` as the header, `why:` and `verify:` lines of a manual -change. Their text sent the reader to issue-tracker numbers — some of which no longer -resolve — for what a ruling, measurement or fix had decided; it now says what was -decided, in the sentence being read. The same holds for the two `surface` headers that -carried a number. ADR ids are kept. - -Text only: no entry id, `from` / `to`, conversion or matching logic changes, and the -chain rewrites exactly what it rewrote before. The generated migration registry, -`spec-changes.json` and the protocol upgrade guide carry the same text. diff --git a/.changeset/20237-dispatcher-meta-list-gate.md b/.changeset/20237-dispatcher-meta-list-gate.md deleted file mode 100644 index aef59c33a9a..00000000000 --- a/.changeset/20237-dispatcher-meta-list-gate.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -'@objectstack/runtime': patch -'@objectstack/rest': minor ---- - -fix(runtime): the dispatcher's `/meta/:type` list prunes what `RestServer`'s list prunes, through one list gate that `@objectstack/rest` publishes (#20237) - -Clause-②: yes - -`GET /meta/:type` has two implementations: `RestServer`, and the runtime -dispatcher's `/meta` domain. On a host that mounts only the `${prefix}/*` -catch-all (`@objectstack/hono`'s `createHonoApp`, the documented embed shape, -and any adapter built on the public `HttpDispatcher` API), the dispatcher is the -only one that answers. Its list branch applied **no** per-caller filter. An -authenticated member who does not hold `crm_admin` got these answers through -`dispatch()`: - -``` -request before after (= RestServer) -GET /meta/doc?include=content the set-gated doc listed WITH its body the doc left out -GET /meta/book the set-gated book listed the book left out -GET /meta/app an app whose requiredPermissions the that app left out; the other app - member lacks, and an ungated app with pruned of its gated entry - its requiredPermissions-gated entry -GET /meta/dashboard (anyone) every widget minus the widget whose service is off -``` - -The plural spellings (`/meta/docs`, `/meta/books`, `/meta/apps`) answer the -same. Holders are still listed everything in full. Object lists are masked as -before. An anonymous caller still gets `401 UNAUTHENTICATED`. - -**One gate, not two.** `RestServer`'s list filters moved unchanged into -`createMetaListReadGate`, beside the item gate in -`packages/rest/src/meta-item-read-gate.ts`. It covers the ADR-0046 §6.7 doc -and book audience prunes, the app nav filter (`requiredPermissions`, the -ADR-0045 §3 publish gate and the docs-audience entry arm) and the ADR-0057 -D10 dashboard widget gate. `RestServer`'s list route and the dispatcher's list -branch both call it, over the same ports the item gate takes, and each rewraps -the pruned items in its own list envelope. There is no second audience -resolver in `packages/runtime`. `RestServer`'s list answers are unchanged. - -Every exit of the dispatcher's list branch now runs the gate and the ADR-0106 -object mask: the protocol list and the two fallbacks, the runtime metadata -service's list and the ObjectQL registry. The fallbacks used to serve -unmasked object schemas as well as ungated docs, books and apps. A gate input -that cannot be read (a doc list's books read throws) is answered as that fault, -never as the unfiltered list. - -**`@objectstack/rest`'s published export surface widens**, and that is why this -changeset declares `Clause-②: yes`. Its only export subpath (`.`) gains one -value: - -- `createMetaListReadGate(sources, metaType)`: the list gate. It takes the - same `MetaItemReadGateSources` the item gate takes, and it answers a judge - from a list's items to the items this caller may be served. - -It is public because the runtime dispatcher's `/meta` domain in -`@objectstack/runtime` consumes this one gate. `@objectstack/rest` cannot import -the runtime, so the shared decision has to live here and travel as an export, -the way `createMetaItemReadGate` does. A caller outside the platform does not -need it. - -Nothing is removed or renamed from the package's exports, and no authorable key -moves. The dispatcher's prunes are not the widening: they pull a second -transport back to the rules the contract already declares (ADR-0046 §6.7, -ADR-0045 §3, `apps.mdx`'s `requiredPermissions` row), which is why -`@objectstack/runtime` stays a `patch`. diff --git a/.changeset/20240-date-year-four-digits.md b/.changeset/20240-date-year-four-digits.md deleted file mode 100644 index 123bf4ef75e..00000000000 --- a/.changeset/20240-date-year-four-digits.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/objectql": minor -"@objectstack/driver-sql": patch -"@objectstack/driver-memory": patch ---- - -fix(core,objectql)!: a number or `Date` compared against a `date` field spells its year with four digits, and one whose UTC year falls outside 0..9999 is refused `INVALID_FILTER` / 400, as its ISO string already was (#20240) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what a filter on a `date` field accepts. A number or `Date` whose UTC calendar day falls in a year below 0 or above 9999 used to answer 200 with the wrong rows, or a 500 on PostgreSQL; it now answers `INVALID_FILTER` / 400. It ships as `minor` under the launch-window convention for accept-set narrowings. - -`temporalStorageForm(value, 'date')` in `@objectstack/core` spelled the year of a `Date` or an epoch-millisecond number unpadded: `999-06-15`, `10000-01-01`, `-1-01-01`. The ISO string and the bare day of the same instant spelled `0999-06-15`, and as text an unpadded year sorts as no day does. Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query` (the two doors agree), on a `date` field holding six 2026 days and 0999-06-15, `$gt` / `$lt` / `$eq`: - -| position | comparand | before: memory · SQLite · PostgreSQL | now, on all three | -|:--|:--|:--|:--| -| `where` | a number (or, in-process, a `Date`) for 0999-06-15 | 0/7/0 · 0/7/0 · 6/0/1 | 6/0/1 | -| per-aggregation `filter` | the same | 0/7/0 on all three | 6/0/1 | -| `having` on `max(date)` | the same | no group / every group / no group | the three 2026 groups / none / the 0999 group | -| `where` | a number (or `Date`) for 10000-01-01 | 6/1/0 · 6/1/0 · 0/7/0 | `INVALID_FILTER` / 400 | -| `where` | a number (or `Date`) for -1-01-01 | 7/0/0 · 7/0/0 · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | -| per-aggregation `filter` | either of those two | 6/1/0 and 7/0/0 on all three | `INVALID_FILTER` / 400 | -| `where`, per-aggregation `filter` | the ISO string of either | `INVALID_FILTER` / 400 | unchanged | - -What changes: - -- The rule pads a year from 0 to 999 to four digits, for a `Date` and a number alike, so a number, its `Date` and its ISO string spell one day. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call it. -- `isUninterpretableTemporalComparand('date', value)` is now also true for a finite number or a valid `Date` whose UTC year is below 0 or above 9999, a finite number past the `Date` range (±8.64e15) included. The engine's temporal-comparand door refuses such a comparand on `where` for every verb (`find`, `findOne`, `count`, `aggregate`, `update`, `delete`), in both the object and the array spelling, and in a per-aggregation `filter`, before any driver read. `IObjectQLEngine.judgeFilter` runs the same door. -- The write path: `create()` / `update()` on `driver-memory` or SQLite, given a year-0..999 number or `Date` for a `date` field, now stores `0999-06-15` where it stored `999-06-15`; `engine.insert` of such a `Date` does the same. PostgreSQL and MySQL already stored a three-digit year's day, but not a shorter one: under its default `DateStyle` (`ISO, MDY`) PostgreSQL stored the unpadded `9-03-04` as 2004-09-03 and refused `99-03-04` (`22008`), and MySQL 8.0 stored `99-03-04` as 1999-03-04. All three dialects now store the day. A year outside 0..9999 keeps the spelling it had on the write and read paths; no ordered form is invented for it. - -**Who is affected.** A caller that compares a `date` field with an epoch-millisecond number or a `Date` in a year below 0 or above 9999. No writer that stores or queries such a day has been measured; the reach is the public query door. - -**Fix.** Compare against a `YYYY-MM-DD` day, or a number or `Date` whose UTC calendar day falls in a four-digit year. - -**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included (#20264, in the same release, then narrows the range to 0001..9999 on `date` and `datetime` alike: year 0 is refused too, and so is a `datetime` number, `Date` or string outside it, and the padding covers 0001..0999); every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 read back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch and #20280, in the same release, corrects by reading a MySQL `DATE` as its text. `having` reaches the same door in the same release (#20263), so a number or `Date` outside 0..9999 is refused there too. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. diff --git a/.changeset/20249-install-request-wrapped-strict.md b/.changeset/20249-install-request-wrapped-strict.md deleted file mode 100644 index 7a386b878bc..00000000000 --- a/.changeset/20249-install-request-wrapped-strict.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `PackageInstallRequestSchema`'s wrapped branch refuses an unknown top-level key by name (#20249) - -Clause-②: no (narrowing) - -**BREAKING for callers of the install door** — a WRAPPED install body -(`{ manifest, … }`) that carries any top-level key the declaration does not -name is now refused `400` / `VALIDATION_ERROR` at `POST /api/v1/packages`, and -`PackageInstallRequestSchema` / `PackageInstallBodySchema` refuse it at parse. -It used to parse green with the key silently DROPPED, and the door installed -the package and answered `201`. - -This one narrows the declaration itself. The manifest and the bare form (a -manifest as the whole body) already refused an unknown key by name; the wrapped -top level was the one position of the install contract still declared strip -mode. The sharp case is a misspelled option: `{ manifest, enabledOnInstall: -false }` had `enabledOnInstall` dropped, so the package was installed -**ENABLED** — the caller's explicit `false` inverted, with no word said. The -wrapped branch is now a `strictObject`, like the other two positions: one rule -for the whole install contract (decision batch #227 item 3, letter A; ruling -record `5856869656`). No alias and no grace window. - -The install door needs no edit and gets none: since the door started parsing -its whole body through `PackageInstallBodySchema`, it answers exactly what that -declaration says, so the refusal reaches `POST /api/v1/packages` the moment the -declaration moves. The same fact retires the sentence that had forbidden this -close — «the declaration must not refuse a body the door answers `201` to» held -only while the door did not parse its body. - -**What is not affected.** A wrapped body carrying only `manifest` and the -declared install options — `settings`, `enableOnInstall`, `overwrite`, -`platformVersion`, `artifactRef` — parses and installs exactly as before, and so -does a bare manifest. Boot-time and in-process installs reach -`SchemaRegistry.installPackage` / `ObjectQL.registerApp` directly and never pass -through this declaration. - -**Reach, measured first-party.** The SDK's `client.packages.install` sends only -`manifest`, `settings`, `enableOnInstall` and `overwrite`, and the objectui -package dialog sends only `{ manifest }`; neither breaks. -**Out-of-repo callers are NOT MEASURED** — there is no telemetry on them, so a -caller that sends its own private top-level key (a trace id, a source tag) now -gets a `400` naming that key. Check your own callers before upgrading rather -than inheriting this result. - -**Migration — FROM → TO.** The refusal names the key and, for a near-miss, -offers the declared one, so the prescription arrives with the `400`: - -- A misspelled option: FROM `{ "manifest": { … }, "enabledOnInstall": false }` - TO `{ "manifest": { … }, "enableOnInstall": false }` — respell it as the - declared option it meant. -- Any other undeclared top-level key: FROM `{ "manifest": { … }, "_source": - "studio" }` TO `{ "manifest": { … } }` — remove it. There is no place on this - request to carry it. - -It is registered as an ADR-0087 structured TODO rather than a conversion: an -unknown key has no mapping target, and deleting it automatically would repeat -the silent drop this change closes. - - diff --git a/.changeset/20252-cross-field-salesforce-examples-cel.md b/.changeset/20252-cross-field-salesforce-examples-cel.md deleted file mode 100644 index 8efc38508fa..00000000000 --- a/.changeset/20252-cross-field-salesforce-examples-cel.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Every example predicate in `packages/spec/src/data/validation.zod.ts` now reads fields through the `record.` root and describes the violation, so an author or agent who copies one gets a rule that evaluates and fires on the records it names as invalid. The fix covers the three `cross_field` "Salesforce Examples" in the `CrossFieldValidationSchema` TSDoc, the header `script` example, and the `.describe()` example on `CrossFieldValidationSchema.condition` (#20252). - -Clause-②: no - -- Example 1, "Close Date Must Be In Current or Future Month": `MONTH(close_date) >= MONTH(TODAY()) AND YEAR(close_date) >= YEAR(TODAY())` becomes `date(record.close_date) < addDays(today(), 1 - today().getDate())`. The old string did not parse (`AND` is not CEL, and `close_date` had no `record.` root). It was also inverted: a `cross_field` condition that evaluates TRUE is the violation, and the old condition was TRUE on the records the rule should accept. The new condition is TRUE when the close date falls before the first day of the current month. It uses only the formula stdlib's `date()`, `today()` and `addDays()` plus CEL's built-in `getDate()` timestamp accessor. -- Example 2, "Discount Validation": `discount > (amount * 0.40)` becomes `record.discount > (record.amount * 0.40)`. The bare fields were unknown variables. The direction is unchanged. -- Example 3, "Opportunity Must Have Products": `products = null AND stage = "closed_won"` becomes `isBlank(record.products) && record.stage == "closed_won"`. A lone `=` is a CEL parse error and `AND` is not CEL. The direction is unchanged. -- The header `script` example: `discount_percent > 0.40` becomes `record.discount_percent > 0.40`. The bare field was an unknown variable. The direction is unchanged. -- The `CrossFieldValidationSchema.condition` description: its example `record.end_date > record.start_date` refused every valid end-after-start range, because a TRUE condition is the violation. It becomes `record.end_date < record.start_date`, and the description now says that a TRUE condition fails validation. - -The Salesforce-formula side of each example is unchanged. This changes documentation text only (TSDoc and one `.describe()` string, with the generated reference page regenerated to match). There is no schema, behaviour or export change. diff --git a/.changeset/20263-having-temporal-comparand-door.md b/.changeset/20263-having-temporal-comparand-door.md deleted file mode 100644 index ff299a9a2de..00000000000 --- a/.changeset/20263-having-temporal-comparand-door.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: `having` on `engine.aggregate` takes the temporal-comparand door `where` and the per-aggregation `filter` take, so a comparand its aggregated column cannot read is refused `INVALID_FILTER` / 400 instead of keeping no group or every group (#20263) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what `having` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query`) that forwards it there. A comparand the aggregated column's storage rule cannot read used to answer 200; it is now refused with `INVALID_FILTER` / 400, once per query, before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback, on an empty and on a populated object. It ships as `minor` under the launch-window convention for accept-set narrowings. - -Measured through `engine.aggregate` and `POST /data/:object/query`, on `driver-memory`, `driver-sql` on SQLite and `driver-sql` on PostgreSQL 16, on both paths, with `groupBy` customer over four groups. The three drivers gave the same answer in every cell: - -| `having` | before | now | its `where` twin | -|:--|:--|:--|:--| -| `{ last_placed: { $lt: 'not-a-date' } }`, `last_placed` = `max(placed_on)` of a `date` field | 200, every group | 400 | 400 | -| the same under `$gt` | 200, no group | 400 | 400 | -| `'+010000-01-01T00:00:00.000Z'` on the same column, `$gt` | 200, every group | 400 | 400 | -| the number for 10000-01-01 on the same column, `$gt` (and its `Date`, in-process) | 200, every group | 400 | 400 | -| `'not-a-date'` on `min` of a `datetime` field or `max` of a `time` field, `$lt` | 200, every group | 400 | 400 | -| `'not-a-date'` on a groupBy key that is a `date` or `datetime` field or on a `day` bucket, `'noon'` on one that is a `time` field, `$gt` | 200, no group | 400 | 400 | -| the number for 10000-01-01 on a `day` bucket, `$gt` | 200, every group | 400 | 400 | - -Each one read the object once before; each is now refused with no read. - -What is judged: - -- The same walk and the same predicate, `isUninterpretableTemporalComparand` in `@objectstack/core`, that the door runs on `where` and on each per-aggregation `filter`. A change to that rule reaches `having` with it. -- The kind is the aggregated column's class, the one the `addDays` rule already reads: `min` / `max` of a `date`, `datetime` or `time` field keeps that kind, a groupBy projection of such a field takes its kind, and a `day` bucket is a `date`. `count`, `count_distinct`, `sum` and `avg`, a `week` / `month` / `quarter` / `year` bucket, and every other column are not temporal, so they are not judged. -- Every comparison and set operator's comparand, each `$in` / `$nin` member and `$between` endpoint, and the implicit-equality slot, under `$and`, `$or` and `$not`. As on `where`, a `{placeholder}` string, the empty string, `null` and a `{ $field }` reference are not judged. `having` resolves placeholders from the same release (#20334), after this door, so the refusal's remedy on a `date` or `datetime` column is the `where` refusal's and names them, e.g. `{30_days_ago}` / `{current_month_start}`. -- The text operators (`$contains`, `$notContains`, `$startsWith`, `$endsWith`, `$icontains`) are not judged. On `where` the text-operator declared-type door answers them first, and that door does not front `having`. -- The door runs after every other `having` door, so a clause one of them refuses (an unknown operator, a key naming no column, a comparand of no comparable type, an `addDays` pair, an array in the equality slot) keeps that refusal and its words. - -The refusal follows the `where` door's words. It names the `having` path, the column, what the column aggregates and its kind, and the comparand, for example: `` `having` on 'last_placed' (max(placed_on), a date column) compares against "not-a-date" at having.last_placed.$lt ``. Like the `where` refusal, it names the column's kind, and otherwise only what the query carries. - -**Who is affected.** `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. Every `having` in this repository's docs and published skills compares a numeric aggregation alias, which is not judged. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. - -**Fix.** Compare a `date` column with a `YYYY-MM-DD` day, a `datetime` column with an ISO-8601 instant, a bare day or epoch milliseconds, either one with a relative-date placeholder the resolver knows (`{30_days_ago}`, `{current_month_start}`; `having` resolves them from the same release, #20334), and a `time` column with an `HH:MM` or `HH:MM:SS` wall clock. - -**Unchanged**, measured identical before and after on the three drivers, both paths and both doors: every `where` and per-aggregation `filter` answer; every `having` on a temporal column whose comparand the rule reads (a `YYYY-MM-DD` day, an ISO instant, an epoch-millisecond number or string, an in-range `Date`, a zone-naive instant, a wall clock, an extended-year instant on a `datetime` column, which that rule reads, until #20264, in the same release, refuses a `datetime` year outside 0001..9999 through the same predicate); `{today}`-style placeholders, known or not; the empty and the whitespace-only string; `null`, `$exists`, `$in` / `$nin`, `$between`, `$not` / `$or` / `$and` and `{ $field }` references; `$contains` and `$startsWith`; every `count` / `sum` / `avg` column, a string comparand included; a `month` bucket; and every existing `having` refusal, in its words. diff --git a/.changeset/20264-temporal-year-range.md b/.changeset/20264-temporal-year-range.md deleted file mode 100644 index 7f001d0181f..00000000000 --- a/.changeset/20264-temporal-year-range.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/objectql": minor ---- - -fix(core,objectql)!: a `date` or `datetime` value names a year from 0001 to 9999, or it is refused: `INVALID_FILTER` / 400 as a comparand on `where`, a per-aggregation `filter` and `having`, and `VALIDATION_FAILED` / 400 as a written value (#20264) - -Clause-②: yes (narrowing) - - - -**BREAKING**: this narrows what a `date` or `datetime` field accepts, as a filter comparand and as a written value. A value whose year falls outside 0001..9999 used to answer 200 with the wrong rows, 201 with a non-day stored, or a 500 on PostgreSQL; it now answers 400. It ships as `minor` under the launch-window convention for accept-set narrowings. - -FROM a `date` or `datetime` value in year 0, before it, or after 9999 (`"+010000-01-01T00:00:00.000Z"`, `"-000001-…"`, `"0000-06-15"`, or the epoch-millisecond number or `Date` of such an instant) → TO `INVALID_FILTER` / 400 as a comparand and `VALIDATION_FAILED` / 400 (`invalid_date`) as a written value. The fix is one line: write a year from 0001 to 9999. - -Measured through `engine.find` / `engine.aggregate` / `engine.insert` and `POST /data/:object/query` / `POST /data/:object` (the two doors agree), over seven 2026 rows, `$gt` / `$lt` / `$eq`: - -| position | value | before: memory · SQLite · PostgreSQL 16 | now, on all three | -|:--|:--|:--|:--| -| `where` on a `datetime` | year 10000 or −1: a number, `Date` or ISO string | 7/0/0 · 7/0/0 · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | -| per-aggregation `filter`, `having` on `min` of a `datetime` | the same, `$gt` | 7 rows, every group, on all three | `INVALID_FILTER` / 400 | -| `where` on a `datetime` | year 0, every spelling | 7/0/0 · 7/0/0 · 500 | `INVALID_FILTER` / 400 | -| `where` on a `date` | year 0: a number, `Date`, ISO string or bare `0000-06-15` | 7/0/0 · 7/0/0 · 500 | `INVALID_FILTER` / 400 | -| create a `date` | `"+010000-01-01T00:00:00.000Z"` | 201, read back verbatim (not a day) · the same · 500 | `VALIDATION_FAILED` / 400 | -| create a `date` or a `datetime` | year 0, year −1, year 10000 | 201 · 201 · 500 | `VALIDATION_FAILED` / 400 | - -MySQL 8.0 answered the year-10000 and year-−1 cells with a 500 and the year-0 cells like SQLite. A `datetime` in year 10000 spells `+010000-…`, which sorts below every four-digit year as text (its `where` answer was 7/0/0 for `$gt` / `$lt` / `$eq`, where the right answer is 0/7/0); PostgreSQL's `DATE` and `timestamptz` have no year 0 (`22008`). Year 0 was answered right on memory and SQLite and a 500 on PostgreSQL; it is refused everywhere now, one answer on every driver. Each refused query or write now reaches no driver. - -What changes: - -- `@objectstack/core` exports `isOutsideTemporalYearRange(value, kind)`, the one range both doors ask. The year is the one the kind's storage rule reads: a `datetime`'s UTC year, a `date` string's leading `YYYY-MM-DD` year (otherwise the UTC year of the instant it names), never a `time`'s. -- `isUninterpretableTemporalComparand` is true for a `date` or `datetime` number, `Date` or readable string whose year falls outside 0001..9999; before, it judged only a `date` number or `Date`, against 0..9999. The engine's temporal-comparand door refuses such a comparand on `where` (every verb, both spellings), in a per-aggregation `filter` and on `having`, before any read, in words that name the year range. `IObjectQLEngine.judgeFilter` and `service-analytics`' raw-SQL decline read the same predicate. -- The record validator's `date` / `datetime` arm refuses a value outside the range on insert, update, a multi-row update and `engine.validate`, with the field's `invalid_date` code and its existing message. -- `temporalStorageForm(value, 'date')` pads a `Date`'s or a number's year to four digits for 0001..0999 only; year 0 keeps its unpadded spelling (`0-06-15`) like every other year outside the range. Only a direct driver write, which bypasses both doors, reaches that arm with year 0. - -**Who is affected.** A caller that filters on or writes a `date` or `datetime` in year 0, before it, or after 9999. No writer that stores or queries such a year has been measured; the reach is the public query and write doors. - -**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every year from 0001 to 9999 (the edges 0001-01-01 and 9999-12-31T23:59:59.999Z included) and every 2026 control; every `time` cell; every string the rules could not read before, refused in its existing words, except a `date`-column string whose instant names a year outside 0001..9999 (`+010000-01-01T00:00:00.000Z`, `-000001-…`, an out-of-range epoch-millisecond string), refused with the same code and status on `where`, the per-aggregation `filter` and `having` but now in the year-class words; `NaN`, ±Infinity and an Invalid Date, which name no year; the `datetime` storage rule's own spelling of any instant on the write and read paths. On MySQL 8.0, a `datetime` in years 0001..0099 is still stored right and read back a century late through mysql2's instant parser (`0009-03-04T10:00Z` as `2004-09-03T10:00Z`), which ADR-0053 D-F2 keeps and this change does not touch; from year 0100 up it reads back as written. `driver-mongodb` keeps its own copy of the storage rule and is not changed; both doors sit in the engine, in front of it. diff --git a/.changeset/20273-connector-resilience-keys-retired.md b/.changeset/20273-connector-resilience-keys-retired.md deleted file mode 100644 index 3e89ede4f66..00000000000 --- a/.changeset/20273-connector-resilience-keys-retired.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/connector-mcp': patch -'@objectstack/connector-openapi': patch -'@objectstack/connector-rest': patch -'@objectstack/connector-slack': patch -'@objectstack/service-automation': patch ---- - -feat(spec)!: retire the connector resilience family — `health` (health probe + circuit breaker), `status` and the nested `webhooks`, sixteen keys nothing read (#20273) - -**BREAKING** — `connector.health` (the `healthCheck` probe, eight keys, and the -`circuitBreaker`, six keys), `connector.status` and the connector-nested -`webhooks` are removed from `ConnectorSchema` and `DeclarativeConnectorEntrySchema` -— so from `defineConnector`, `stack.connectors[]`, the `PUT /api/v1/meta/connector/:name` -door and `AutomationEngine.registerConnector`. ADR-0049 enforce-or-remove, one -batch for the family, by the maintainer's criterion: does the mainstream platform -offer this capability? Author-configured health probes and circuit breakers are -not connector metadata in the mainstream (breakers live in API-gateway -infrastructure), and an authored status and a nested webhook list duplicate what -is already delivered here by other keys. - -Measured before removal, each against a lit control: zero reads of any of the -sixteen keys outside `packages/spec`. No loop ever polled a connector endpoint, -counted consecutive failures or tripped a breaker, and none of the four -`fallbackStrategy` behaviours existed. Nothing read an authored `status`: the -runtime's dispatchability answer is the COMPUTED `state` (`ready` / `degraded`) -on `GET /api/v1/automation/connectors`, which no authored value sets. A webhook -nested in a connector was never registered as a `webhook` item, so it was never -materialized into `sys_webhook` and never delivered. - -### FROM → TO - -| removed | what to write instead | -| --- | --- | -| `connector.health` (`healthCheck.*`, `circuitBreaker.*`, including `monitoringWindowMs` and the pre-rename `monitoringWindow`) | delete the block. Put health probes and circuit breaking in the connector provider or an upstream gateway. | -| `connector.status` | delete the key. `enabled: false` on a declarative entry is what withdraws a materialized instance or marks a catalog-only descriptor; whether a registered connector can be dispatched is the computed `state`. | -| `connector.webhooks` | delete the array. A webhook that is actually delivered is declared in the stack's top-level `webhooks:` collection — moving one there STARTS deliveries this connector never made, so decide per webhook. `events` and `signatureAlgorithm` have no counterpart there. | -| `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` (schemas, types, `…Parsed` types) | no replacement — nothing parsed or constructed them. | - -**The one-line fix: delete `health:`, `status:` and `webhooks:` from every connector.** -`os migrate meta --from 17` lists the mechanical edits for existing sources. - -⚠️ Runtime behaviour is deliberately **unchanged**: none of the sixteen keys ever -changed what a connector did. What changes is the answer an author gets — each -key is refused at parse with a prescription, and in `tsc` (its input type is -`never`), instead of being saved with no effect. - -### The retirement kit - -- **Tombstones.** `health`, `status` and `webhooks` are `retiredKey()` tombstones - on the private `ConnectorBaseSchema` both published carriers wrap (the schema - is not `.strict()`, so a bare deletion would be a silent strip, ADR-0104). - `RETIRED_KEYS_BY_MAJOR[18]`: `integration/Connector:{health,status,webhooks}` - and `integration/DeclarativeConnectorEntry:{health,status,webhooks}`. -- **Retired-default residue.** `status` was `.default('inactive')`, so every 17.x - parse emitted `status: 'inactive'` into every connector; that exact value joins - `connectionTimeoutMs: 30000` in the residue stage (accepted and stripped, so a - def a 17.x toolchain built still registers). Every other value is refused. -- **Seven defs leave whole** (`RETIRED_DEFS_BY_MAJOR[18]`): the four - `integration/` schemas and three enums listed above. -- **D2 conversion `connector-resilience-keys-removed`** (step 18, retired from - the load path): strips the three keys from `connectors[]` and from stored - `sys_metadata` connector rows (the rehydration seam replays it), one notice per - key, as a lossless delete. Nested webhooks are stripped, never moved. -- **The chain.** In the same step, `connector-health-and-trigger-durations-unit-in-key` - renamed `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs`. That - breaker half is absorbed by this removal: the renamed key is itself removed, so - an author holding either spelling ends with no `health` block. The - conversion's `triggers[].interval` → `intervalSeconds` rename is unaffected. -- **D3 entry `connector-resilience-keys-retired`** carries the family's - judgement: which probe, breaker or nested webhook the author actually relied - on, and where it goes now. -- **Writers deleted.** The four shipped connector packages wrote - `status: 'active'` and the automation service's degraded husk wrote - `status: 'error'`; nothing read either back, and both writes are gone. -- **No deprecation window**, per the project's startup-stage posture. - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` -is published, so this is breaking for consumers no telemetry was consulted for. - -Clause-②: no (narrowing) - - diff --git a/.changeset/20280-mysql-date-read-text.md b/.changeset/20280-mysql-date-read-text.md deleted file mode 100644 index c86edc0728e..00000000000 --- a/.changeset/20280-mysql-date-read-text.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): a MySQL `date` field reads back the day it stores, so a year below 100 no longer comes back a century late (#20280) - -Clause-②: no - -On MySQL the driver took mysql2's JS `Date` for a `DATE` column. mysql2 rebuilds it from the three stored numbers with `new Date(Date.UTC(y, m - 1, d))`, and `Date.UTC` reads a year from 0 to 99 as 1900 + year. The write was right and the read was wrong: `where placed_on $eq '0009-03-04'` found the row, then presented it as `1909-03-04`. Measured on MySQL 8.0.46 (server `time_zone='+08:00'`), on records created through `POST /api/v1/data/:object`: - -| stored (`CAST(… AS CHAR)`) | `find` / `findOne`, the engine, `…/query`, `GET …/:id`, a `groupBy` key, `min`, `distinct`: before | now | -|:--|:--|:--| -| `0009-03-04` | `1909-03-04` | `0009-03-04` | -| `0099-03-04` | `1999-03-04` | `0099-03-04` | -| `0000-06-15` | `1900-06-15` | `0000-06-15` | -| `0999-06-15` | `0999-06-15` | `0999-06-15` | -| `2026-03-04` | `2026-03-04` | `2026-03-04` | - -The MySQL connection now asks mysql2 for a `DATE` as its `YYYY-MM-DD` wire text (`dateStrings: ['DATE']`), and the read doors present that text through `temporalStorageForm`, the rule the write and `where` paths already use. PostgreSQL has read a day as text the same way since its calendar-day parser. - -**Unchanged**, measured identical before and after on MySQL through the driver, the engine and REST: every read of a year from 1000 to 9999 on a `date`, `datetime` or `time` field, and of a `TIMESTAMP` column and a `null`, on `find`, `findOne`, `count`, `aggregate` (`min`, `max`, `groupBy`), `distinct` and a write-then-read; every `$eq` / `$gt` answer. SQLite and PostgreSQL reads do not move. - -**Also moved, on MySQL only:** - -- A raw `execute()` read, and a `DATE` column read under a field that is not declared `date`, now receive the `YYYY-MM-DD` text where they received a `Date` (a `datetime` column still arrives as a `Date`). PostgreSQL already answers a `date` column this way. -- A zero day (`0000-00-00`, storable only with `NO_ZERO_DATE` off) is presented as that text, where mysql2 made up `1899-11-30`. -- A connection whose host already set `dateStrings` is left as the host set it. - -**Not changed:** a `datetime` field. A MySQL `DATETIME` in years 0..99 still reads a century late (`0009-03-04T10:00:00.000Z` comes back as `2004-09-03T10:00:00.000Z`). ADR-0053 D-F2 keeps the client parser's `Date` for an instant, so that half stays open on #20280. diff --git a/.changeset/20282-analytics-cube-public-enforced.md b/.changeset/20282-analytics-cube-public-enforced.md deleted file mode 100644 index 323861e4f64..00000000000 --- a/.changeset/20282-analytics-cube-public-enforced.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-analytics': minor ---- - -An analytics cube's `public` now takes effect, and it defaults to visible: `CubeSchema.public` defaults to `true` (it was `false`), and the analytics service hides a cube that declares `public: false` from discovery and refuses every query against it (#20282). - -Clause-②: yes (narrowing) - - - -**BREAKING**: this narrows what the analytics API answers. A query or SQL dry run against a cube declared `public: false` (`POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`) was answered before this change and is now refused with `404 CUBE_NOT_FOUND`, and `GET /api/v1/analytics/meta` no longer lists that cube. The same happens to every cube in an artifact built by `os compile` before this release, which carries a materialized `public: false` from the old default. The remedy: delete `public: false` from any cube that is meant to be queried (cubes are visible by default), and recompile pre-release artifacts. It ships as `minor` under the launch-window convention; the widening half is the default moving to visible. - -Until this change nothing read `public`. `GET /api/v1/analytics/meta` listed a `public: false` cube and every query door answered it, so the flag withheld nothing. Its declared default, `false`, could not simply be switched on: enforcing it as declared would have hidden every cube that omits the key. The default is now the Cube.dev default (visible), and an explicit `false` is enforced: - -- `GET /api/v1/analytics/meta` omits a cube declared `public: false`, and `?cube=` naming one answers `[]`, the same as a name no cube has. -- `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` refuse it with `404 CUBE_NOT_FOUND` — the same refusal, byte for byte, that an unknown cube name gets, so a caller cannot use it to learn that a hidden cube exists. The one shared message names both possibilities, so it still tells an author how to expose a hidden cube. The refusal comes before any SQL is built, and it is never an empty result. - -`public` is visibility on the analytics API, not row security. An object's records stay governed by its permissions and row-level security on every door, whether or not a cube over it is hidden. What `public: false` does is exactly the two points above: the cube is left out of `/analytics/meta`, and queries and SQL generation against it are refused. The cube's definition stays readable on the metadata door, like any other authored schema. - -What to expect after upgrading: - -- **A cube that omits `public`** stays visible and queryable. It was visible before too, because nothing read the key. A client that parses cube metadata through the published JSON Schema now materializes `public: true` where it materialized `false`. -- **A cube that writes `public: false`** is now hidden and refused. If you wrote it only because it was the old default, delete the line (cubes are visible by default). A dashboard or report that queries such a cube starts answering `404 CUBE_NOT_FOUND` until you do. -- **A compiled artifact built before this release** carries a materialized `public: false` on every cube, because `os compile` writes the parsed stack with its defaults applied. Recompile it with this release before serving cubes from it. -- **Cubes the platform mints itself** stay visible: the cube inferred for an ad-hoc query on an object (the KPI path), a compiled dataset's cube (`POST /api/v1/analytics/dataset/query`), and `CubeRegistry.inferFromObject`. Each wrote a literal `false`, the old default, and now writes `true`. - -The showcase example's `showcase_delivery` cube, which is the app's demonstration of `/api/v1/analytics/*`, drops its `public: false`. diff --git a/.changeset/20283-object-timeline-items-element-owner-describe.md b/.changeset/20283-object-timeline-items-element-owner-describe.md deleted file mode 100644 index 1fe51f3ad3d..00000000000 --- a/.changeset/20283-object-timeline-items-element-owner-describe.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`ObjectTimelinePropsSchema.items`' describe no longer says "the author owns the item shape". `items` stays `z.array(z.unknown())` — no schema-shape change — but the sentence now names the actual owner: each element is objectui's declared timeline element, `@object-ui/types`'s `TimelineFeedItem` (`variant` absent / `vertical` / `horizontal`) or `TimelineGanttItem` (`variant: 'gantt'`), the arm this node's `variant` selects. - -The element union is declared entirely inside objectui's `packages/types` (`TimelineFeedItem` / `TimelineGanttItem`, plain TypeScript interfaces) — nothing in this package imports or re-declares it, so this is a documentation-only correction, not a value-tightening. Value tightening (declaring the arms in this schema instead of `z.unknown()`) stays a later ratchet with its own inventory. diff --git a/.changeset/20289-os-test-names-tags.md b/.changeset/20289-os-test-names-tags.md deleted file mode 100644 index f0ee66a94f7..00000000000 --- a/.changeset/20289-os-test-names-tags.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/cli': minor -'@objectstack/core': minor -'@objectstack/spec': patch ---- - -`os test` reports the suite and scenario names an author writes, and selects scenarios with `--tags` (#20289) - -Clause-②: no - -A Quality Protocol suite's `name`, each scenario's `name` and `description`, and scenario `tags` were parsed at load and then read by nothing: the report headed each suite with its file's basename, printed every scenario by its `id`, and `os test --tags critical` failed with `Nonexistent flag: --tags`. - -- **Names in the report.** The suite heading is now the suite's `name` followed by its file — `📄 Running suite: Accounts smoke (accounts.test.json)` — and each scenario line is its `name` with the `id` in brackets — `✅ Scenario: An account can be created [acct-create] (12ms)` (the id alone when the two are equal). A failed scenario's `description` is printed under its line, before the error. A suite whose file fails to load is still headed by the file alone, since no name was parsed. -- **`--tags TAG[,TAG...]`** runs only the scenarios carrying AT LEAST ONE of the listed tags (any-of, exact, case-sensitive) — the comma-list reading of Odoo's `--test-tags` and the everyday use of Playwright's `--grep @a|@b`. With the flag, an untagged scenario is left out. Left-out scenarios are **deselected**: not run, counted on the summary (`--tags smoke selected 1 of 4 scenarios; 3 deselected (not run, not counted as passed).`), never counted as passed. A requested tag that no loaded scenario carries is named on the summary. An empty entry (`--tags smoke,`) is refused before anything runs. Without the flag nothing changes: every scenario runs. -- **Exit status.** A selection that matches no scenario takes the posture an empty pattern already has: exit `0` with `No scenario matched --tags …`, and exit `1` under `--fail-on-empty`, whose description now covers both cases. The `Found N test suites.` line and the `SUCCESS: All N scenarios passed.` / `FAILED: …` summary lines keep their spelling. -- **`@objectstack/core`:** `QA.TestResult` gains `scenarioName` and `description` on every result, and `suiteName` on every result `runSuite` produces (absent only from a lone `runScenario` call, which has no suite). -- **`@objectstack/spec`:** the liveness ledger (`liveness/qa.json`) moves the four keys above to `live`, citing their readers. `TestScenario.requires`, the family's fifth key, is checked in this same release and has its own note: an unmet `params` or `services` entry skips the scenario with its reason, and `requires.plugins` is retired into `requires.services`. diff --git a/.changeset/20289-requires-services-skip.md b/.changeset/20289-requires-services-skip.md deleted file mode 100644 index c07c678ad14..00000000000 --- a/.changeset/20289-requires-services-skip.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/core': minor -'@objectstack/cli': minor ---- - -feat(spec,core,cli)!: a scenario's `requires` is checked before it runs — unmet `params` or `services` SKIP it with a reason; `requires.plugins` is retired into `requires.services` (#20289) - -Clause-②: yes (narrowing) - -**BREAKING** — shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by -this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, -never by the level). - -A Quality Protocol scenario's `requires` block declared preconditions — -`params` (environment variables) and `plugins` (plugins that must be loaded) — -that nothing checked: measured on a stub target, a scenario naming a missing -plugin and an unset variable reported PASSED exactly like its no-requirements -control. ADR-0049 enforce-or-remove, verdict ENFORCE (the mainstream has -declared preconditions: JUnit `@EnabledIfEnvironmentVariable`, pytest `skipif`), -ruled B for the shape: each key is judged against something `os test` can -actually observe. - -- **`requires.params`** — each variable must be set to a non-empty value in the - environment of the process running `os test` (not the target server's, which a - suite cannot see). An empty value counts as unset: an unconfigured CI secret - arrives as an empty string. -- **`requires.services`** (new) — each entry is a discovery service key - (`CoreServiceName`: `auth`, `automation`, `analytics`, `ai`, `storage`, …; a - misspelling is refused when the suite loads) that the target must declare - `enabled` with status `available` in its discovery document (ADR-0076 D12). - It is read from the discovery request the HTTP adapter already makes once per - run; a suite that requires no service issues no extra request. -- **SKIPPED.** A scenario with an unmet entry runs no step — `setup` included — - and `os test` prints it with its reason, naming every unmet entry and, for a - service, the services the target does declare available: - `Skipped: requires.services 'ai' is not available on the target (enabled: false, status: unavailable). The target declares available: auth, data, metadata.` - It is counted on its own — `SUCCESS: 3 scenarios passed. 1 skipped (not run, not counted as passed).` — - and never as passed. Skips alone exit `0`; a run in which EVERY selected - scenario was skipped prints `No scenario ran: …` instead of `SUCCESS`, exits - `0`, and exits `1` under `--fail-on-empty`. With nothing skipped, the summary - lines keep their spelling. -- **`@objectstack/core`:** `QA.TestResult` gains `status` (`'passed' | 'failed' | 'skipped'`) - and, on a skipped result, `skipped` (`reason`, `unmet[]`, `availableServices`); - `passed` stays and is `false` on a skip. `TestRunner` takes an optional - `{ env }` (default: this process's environment), and `TestExecutionAdapter` - gains an optional `readTargetServices()` — `HttpTestAdapter` answers it from - its one discovery probe. An adapter without it skips a service requirement - rather than running it. - -``` -FROM { "id": "ai-summary", "requires": { "plugins": ["@objectstack/service-ai"] }, "steps": [...] } - -> ran anyway; the missing plugin surfaced as whatever failure it caused, or passed -TO -> os test refuses the suite at load: - ✗ scenarios.0.requires.plugins: `scenarios[].requires.plugins` was removed in - @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever checked it: … - Delete the key and name the service the scenario needs in `requires.services`, … - Plugin → service: @objectstack/service-analytics → analytics, @objectstack/plugin-auth → auth, … - -FROM { "requires": { "services": ["ai"] }, … } (new key) -TO -> against a target whose discovery does not declare `ai` enabled and available: - ⏭️ Scenario: Summarise an account [ai-summary] (skipped) - Skipped: requires.services 'ai' is not available on the target (…). The target declares available: … -``` - -**Fix.** `requires.plugins: [""]` → `requires.services: [""]`, -using the mapping the refusal prints (derived from `CORE_SERVICE_PROVIDER`, the -provider table discovery itself reports): `@objectstack/plugin-auth` → `auth`, -`@objectstack/service-analytics` → `analytics`, `@objectstack/service-automation` -→ `automation`, `@objectstack/service-storage` → `storage`, and so on; the `ai` -service is provided by ObjectStack Cloud/Enterprise. A plugin that fills no -discovery service slot has no service to require — gate that scenario with a -`params` variable or select it with `--tags`. `tsc` refuses `plugins` at a typed -authoring site (its input type is `never`). A `TestResult` consumer that counted -`!passed` as a failure should read `status` — a skipped result is `passed: false` -and is not a failure. - -**What does not change.** A scenario without `requires` runs exactly as before, -and a suite that requires no service issues no discovery request it did not -already issue. - -### The retirement kit - -- **Schema.** `TestScenarioSchema.requires` is a non-strict `z.object()`, so - `plugins` is a `retiredKey()` tombstone carrying its prescription (a bare - deletion would have stripped it in silence); `services` is new, closed over - `CoreServiceName`. -- **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `qa/TestScenario:requires.plugins`. - No D2 conversion: a QA suite is a loose JSON file `os test` loads, never a - stack collection member or a stored row. The family's D3 entry, - `qa-scenario-requires-plugins-retired`, carries the prescription to - `os migrate meta` and the upgrade guide. -- **Ledger and docs.** `liveness/qa.json` moves `qa.scenarios.requires` from - `dead` to `live`, citing the runner's judgement and the adapter as producer; - `state-counts.md` moves `qa` to 9 live / 0 dead. The `os test` section of the - CLI reference documents the check, the skip line and the exit posture, and the - generated `qa/testing` reference page is regenerated. - - diff --git a/.changeset/20290-draft-read-author-exemption.md b/.changeset/20290-draft-read-author-exemption.md deleted file mode 100644 index a142043d14c..00000000000 --- a/.changeset/20290-draft-read-author-exemption.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -**`GET /api/v1/meta/:type/:name?state=draft` now serves the pending draft as a stored version: whoever may save an app reads its draft whole, and nothing is left out because a service is off in this deployment.** Studio's app editors build their edit baseline by merging this draft over the layered view (`…/layers`) and save the result back as a draft. The draft read used to run the rendered read's gates, so it left out the navigation entries an author may not open. For every caller it also left out an entry, an app or a dashboard widget bound to an optional service this deployment does not register. The pruned draft replaced the whole navigation in the merge, and the author's next draft save deleted those entries without any error. - -- A caller who may save the app (the one `PUT /api/v1/meta/app/:name` admits: a system context or `manage_metadata`) receives the stored draft whole, including the entries that `requiredPermissions` or the documentation audience withhold from them. This is the answer `/layers`, `?layers=true` and `/diff` already give that caller. -- Every other caller who may open the app receives the draft without the entries `requiredPermissions` or the documentation audience withhold from them, as before. -- No caller has anything left out of a draft by a per-deployment gate any more: an app, a navigation entry or a dashboard widget whose `requiresService` names a service this deployment lacks is part of the stored draft, as on `/layers`. -- Unchanged: an app the plain read refuses whole (an app-level `requiredPermissions` the caller lacks, or an unpublished app to a caller without Studio or Setup access) is still refused on the draft read, to an author too. A read with no pending draft still answers `404 NO_DRAFT`. The rendered reads, meaning the plain read without `?state=draft`, its `?preview=draft` preview and `/published`, still prune for every caller, authors included. Docs, books and object schemas answer as before. - -A client that reads `?state=draft` as a caller who may save the app now receives every entry of the stored draft. A client that reads it as any caller now also receives the entries and widgets bound to an optional service this deployment lacks. diff --git a/.changeset/20291-expression-refusal-codes.md b/.changeset/20291-expression-refusal-codes.md deleted file mode 100644 index 688ba14f18f..00000000000 --- a/.changeset/20291-expression-refusal-codes.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/formula': minor -'@objectstack/spec': minor ---- - -Expression refusals now carry a stable `code` and typed `params` beside their English `message`, so a localized author surface can render its own words: `validateExpression` (every entry in `errors[]` and `warnings[]`), `collectCelRootIdentifiers` (its `ok: false` arm), `predicateSlotRefusal` and `structuralConditionRefusal` (#20291) - -Clause-②: yes - -Until now the only thing a refusal said was an English sentence, and a designer running in another locale could only show it verbatim, beside its own translated headings. Each refusal now also names its code — one of a closed, kebab-case set — and the values its sentence interpolates, so a consumer keys a catalogue row to the code and fills it from the params. The `message` is the same sentence, byte for byte; nothing is removed or renamed, so no existing reader changes. - -- `@objectstack/formula` exports `EXPRESSION_REFUSAL_CODES` (the closed set as a frozen list) and the types `ExpressionRefusalCode`, `ExpressionRefusalParams` (code → params), `ExpressionRefusal`, `ExprValidationCode`, `CelRootsRefusalCode`, `CelFieldRole`, `ExpressionSourceKind` and `CelRootIdentifiersResult`. `ExprValidationError` gains `code` and `params`. -- `@objectstack/spec/automation` exports `FLOW_SLOT_REFUSAL_CODES` (the closed set of both flow-slot refusal producers, as a frozen list) and the types `FlowSlotRefusalCode`, `FlowSlotRefusalParams` (code → params), `PredicateSlotRefusalCode`, `PredicateSlotRefusal`, `PredicateSlotValueKind`, `StructuralConditionRefusalCode`, `StructuralConditionRefusal` and `StructuralConditionValueKind`. `predicateSlotRefusal` returns `PredicateSlotRefusal` and `structuralConditionRefusal` returns `StructuralConditionRefusal`; each is its previous `{ message, source }` plus `code` and `params`. -- Narrowing on `code` narrows `params`. A code never changes once published: a reworded message keeps its code, and a new refusal gets a new one. -- A `detail` param is the CEL or template engine's own diagnostic, in English, passed through as the message carries it. -- These are authoring diagnostics returned as values, not ADR-0112 request error codes, which is why they are kebab-case. -- The `validate_expression` MCP tool forwards `validateExpression`'s `errors` and `warnings` as they are, so each entry in its answer now also carries `code` and `params`. diff --git a/.changeset/20294-openapi-info-publisher-overlay.md b/.changeset/20294-openapi-info-publisher-overlay.md deleted file mode 100644 index d6c9e0acc4c..00000000000 --- a/.changeset/20294-openapi-info-publisher-overlay.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/rest': minor ---- - -feat(spec,rest)!: the served OpenAPI `info` carries the publisher's `api.documentation` identity; `api.documentation.version` retired (#20294) - -Clause-②: yes (narrowing) - -**BREAKING** — shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by -this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, -never by the level). The breaking half is one key: `api.documentation.version`. - -`RestServerConfig.api.documentation` (`RestApiConfigSchema`) declared nine -members, and `RestServer` parsed them, copied them into its config — and never -read them back. Measured before this change, with every member authored: both -doors that serve the OpenAPI document (`{apiPath}/openapi.json` and its -environment-scoped twin) answered the bundled artifact's `info` unchanged, 0 of 9 -honoured. ADR-0049 enforce-or-remove, split by who owns each field: - -- **Enforced — the publisher's identity.** `title`, `description`, - `termsOfService`, `contact` (`name` / `url` / `email`) and `license` (`name` / - `url`) now overlay the served `info` on both doors. A member you leave unset - keeps the bundled value, and a config with nothing authored — no block, - `documentation: {}` — serves `info` byte-identical to - `@objectstack/spec/openapi.json`, exactly as before. `contact` and `license` - replace the bundled object **whole**: `license: { name: 'MIT' }` serves - `{ name: 'MIT' }` with no URL, never MIT at the bundled Apache-2.0 URL, and a - partial `contact` never keeps ObjectStack's name or URL. -- **Retired — `documentation.version`.** The served `info.version` is the - protocol version, the version of the `@objectstack/spec` package that generated - the document, with no configured override: an earlier ruling made it equal the - published artifact's so an integrator can read which protocol version they are - talking to. A publisher-set version would give the field a third meaning, so - the key is now refused. - -``` -FROM new RestServer(server, protocol, { api: { documentation: { title: 'Acme Orders API', version: '2.3.0' } } }) - -> constructed; GET /api/v1/openapi.json served info.title 'ObjectStack REST API' - and info.version = the spec version — both authored values ignored -TO -> throws: REST API configuration is invalid: `api` does not satisfy - `RestApiConfigSchema` … - - api.documentation.version: `api.documentation.version` was removed in - @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … Delete the key. To publish - your app's own release number, write it into `api.documentation.description`, … - -FROM new RestServer(server, protocol, { api: { documentation: { title: 'Acme Orders API' } } }) - -> GET /api/v1/openapi.json: info.title 'ObjectStack REST API' -TO -> GET /api/v1/openapi.json: info.title 'Acme Orders API' (and on the environment-scoped door) - -FROM RestApiConfigSchema.parse({ documentation: { description: 'd' } }).documentation - -> { title: 'ObjectStack API', description: 'd' } // a default no document ever served -TO -> { description: 'd' } -``` - -**Fix.** `api.documentation.version` → delete the key. The served -`info.version` is always the protocol version; to publish your app's own release -number, write it into `api.documentation.description`. `tsc` refuses the key at -the authoring site (its input type is `never`), and `RestServer` construction and -the REST plugin's `start` refuse it with that prescription. - -**What else changes.** `documentation.title` is `.optional()` instead of -`.default('ObjectStack API')`: that default was materialized into every present -block and never served, so the parsed block now carries exactly what was -authored (the parsed `title` is typed `string | undefined` now). `api.version` (the route identifier) and the runtime version still -never reach `info.version`. A host that authors none of these keys — every -CLI-started deployment, since `os serve` forwards only `enableProjectScoping` -and `projectResolution` — serves the same document as before. - -### The kit - -- **Schema.** The eight identity members carry describes naming the served - `info` field; `version` is a `retiredKey()` tombstone inside the live - `documentation` block (a non-strict `z.object()`, so a bare deletion would have - stripped it in silence), next to the `enabled` tombstone. -- **REST server.** `registerOpenApiEndpoints` builds `info` through a pure - helper that returns a NEW object — the cached artifact's own `info` is never - written — and the same handler serves both doors. -- **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains - `api/RestApiConfig:documentation.version`; the D3 entry - `rest-api-documentation-version-retired` carries the prescription to - `os migrate meta` and the upgrade guide. No D2 conversion: a `RestServerConfig` - is plugin TS configuration, never a stack collection member or a stored row. -- **Ledger and docs.** `liveness/rest_api.json`: the eight identity leaves and - the `contact` / `license` containers flip to `live` with the overlay as - evidence; the `version` row stays `dead` with a REMOVED note. The generated - `state-counts.md` moves `rest_api` from 12 live / 12 dead to 20 / 4; the - `rest-server` reference page is regenerated. - - diff --git a/.changeset/20295-rest-api-config-dead-keys-retired.md b/.changeset/20295-rest-api-config-dead-keys-retired.md deleted file mode 100644 index df9b34b26a1..00000000000 --- a/.changeset/20295-rest-api-config-dead-keys-retired.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/rest': minor ---- - -feat(spec,rest)!: retire `api.responseFormat` and `api.documentation.enabled` — parsed, defaulted, and read by nothing (#20295) - -Clause-②: no (narrowing) - -**BREAKING** — shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by -this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, -never by the level). - -Two keys of `RestServerConfig.api` (`RestApiConfigSchema`) were accepted, given -defaults and copied into the REST server's config by `normalizeConfig` — and no -site ever read them back. `responseFormat` (`envelope`, `includeMetadata`, -`includePagination`) toggled nothing: `envelope: false` unwrapped no response. -`documentation.enabled` was a second on/off switch for the OpenAPI document that -nothing consulted: `api.enableOpenApi` decides that mount. Measured before -removal, each against a lit control on the same instrument: no reader in this -repo's packages, and no author in objectui at its pinned commit or in cloud. -ADR-0049 enforce-or-remove; the verdict is RETIRE, by the maintainer's criterion — -mainstream data APIs keep a fixed response envelope that no administrator toggles -server-wide, and the OpenAPI switch already exists and is enforced. - -``` -FROM new RestServer(server, protocol, { api: { responseFormat: { envelope: false } } }) - -> constructed; `envelope: false` changed nothing -TO -> throws: REST API configuration is invalid: `api` does not satisfy - `RestApiConfigSchema` … - - api.responseFormat: `api.responseFormat` was removed in @objectstack/spec 17.5.0 - (ADR-0049 enforce-or-remove) — … Delete the key. Response shapes are fixed, … - -FROM RestApiConfigSchema.parse({ documentation: { enabled: false, title: 'My API' } }) - -> { documentation: { enabled: false, title: 'My API' }, … } // served the document anyway -TO -> ZodError { code: 'invalid_type', path: ['documentation', 'enabled'], - message: '`api.documentation.enabled` was removed in @objectstack/spec 17.5.0 (ADR-0049 - enforce-or-remove) — … Delete the key; `api.enableOpenApi: false` is the switch …' } -``` - -**Fix.** `api.responseFormat` → delete the key; response shapes are fixed, so -there is nothing to configure. `api.documentation.enabled` → delete the key; to -serve no OpenAPI document, set `api.enableOpenApi: false` (it leaves -`GET /openapi.json` and `GET /docs` unmounted). `tsc` refuses both keys at the -authoring site (their input type is `never`). - -**What does not change.** Every live key of the `api` block parses exactly as -before, including the rest of `documentation` (`title`, `description`, -`version`, `termsOfService`, `contact`, `license` — a separate decision). A -config without the two keys mounts the same REST surface: neither key ever -reached it. A `documentation` block no longer grows an `enabled: true` default. - -### The retirement kit - -- **Schema.** `RestApiConfigSchema` and its inline `documentation` object are - non-strict `z.object()`s, so each key is a `retiredKey()` tombstone carrying its - prescription (a bare deletion would have stripped it in silence). - `responseFormat` retires whole — its three members were its only members. -- **REST server.** `normalizeConfig` runs the tombstones (the `crud.patterns` - posture, not `requireAuth`'s warn-and-ignore), so a config carrying either key - now fails `RestServer` construction and the REST plugin's `start` with the - prescription, and the normalized config no longer carries or re-defaults them. -- **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `api/RestApiConfig:responseFormat` - and `api/RestApiConfig:documentation.enabled`. No D2 conversion: a - `RestServerConfig` is plugin TS configuration, never a stack collection member or - a stored row. The family's D3 entry, `rest-api-config-dead-keys-retired`, carries - the prescription to `os migrate meta` and the upgrade guide. -- **Ledger and docs.** `liveness/rest_api.json` keeps both rows `dead` with a - REMOVED note (`responseFormat`'s three child rows collapse into its one row); - the generated `state-counts.md` moves `rest_api` from 14 to 12 dead; the - reference page for `rest-server` is regenerated. - - diff --git a/.changeset/20296-understated-planned-rows.md b/.changeset/20296-understated-planned-rows.md deleted file mode 100644 index 201e9fa4458..00000000000 --- a/.changeset/20296-understated-planned-rows.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Liveness ledger: three rows that were graded `planned` are now `live`, because objectui reads them at the `.objectui-sha` pin this repo builds against. The prose these flips made false is corrected too. Ledger data, one author hint and comments only. ⛔ No schema, parse, `.describe()` or accept-set change. - -The ledgers ship inside this package (`files[]` includes `liveness`), and `@objectstack/lint` reads them, so the rows an upgrading reader or tool consults are these. `@objectstack/lint` warns on a `planned` row only when the row sets `authorWarn`, and none of the three flipped rows does, so the set of warnings does not change. One warning's hint text does change (see `flows` below). - -- **`action.onSuccess.navigate` and `action.onSuccess.openIn` are `live`.** The console's action runner performs the declared post-success hop after an `api` or `script` action succeeds. It interpolates `navigate` with the `${param.*}`, `${ctx.*}` and `${result.*}` scopes, refuses a URL that is neither http(s) nor relative, and opens a new tab only on `openIn: 'newTab'`, so the materialized `'self'` default has one source of truth. The action renderers and the declared-actions bar forward the block to the runner, and the console wires the runner's navigation to its router. Both rows had been `planned` since the contract landed spec-first ahead of this reader. -- **`translation.flows.screens` is `live`.** The console's screen-flow runner draws each screen's heading and each field's `label` / `placeholder` from `flows.FLOW.screens.NODE_ID` in the active language, and falls back to the authored string key by key. The bundle reaches it through the translations route and the console's language loader. -- **`translation.flows.label` stays `planned`, and the `flows` group keeps its `authorWarn`.** Nothing reads the flow's own label yet. So `os lint` still warns when a bundle authors `flows`, and the i18n coverage demand for `flows.*` stays held back. The warning's hint used to say no shipped runner reads the group. It now says the runner reads `screens`, and that only the flow `label` is stored and never shown. -- **Prose corrected.** The `onSuccess` JSDoc in `ui/action.zod.ts` now names the console reader (`ActionRunner`'s `navigateOnSuccess`). The `flows` JSDoc in `system/translation.zod.ts` and the `translateFlow` docblock in `system/i18n-resolver.ts` now say `screens` is read client-side by `FlowRunner` and the flow label is not. The liveness README's translation cell says the same. These are comments only: a comment-stripped transpile of the three source files is byte-identical to before. -- `state-counts.md` is regenerated: `action` has 46 live and 0 planned (was 44 and 2); `translation` has 23 live and 1 planned (was 22 and 2). diff --git a/.changeset/20299-display-annotations-ledger.md b/.changeset/20299-display-annotations-ledger.md deleted file mode 100644 index f2147ad88b3..00000000000 --- a/.changeset/20299-display-annotations-ledger.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Liveness ledger: `flow.description`, `hook.label` and `hook.description` are now `live`, not `dead`. Studio already shows all three to a human. Ledger data, one README cell per type and one gate test fixture only. ⛔ No schema, parse, `.describe()` or accept-set change. - -The ledgers ship inside this package (`files[]` includes `liveness`), and `@objectstack/lint` reads them to decide which authored keys draw an advisory warning. None of the three rows sets `authorWarn`, so the set of warnings does not change. - -- **What shows them.** These are display keys, so under the ledger's "Designer previews count as consumers" ruling, being shown to a human is the whole of their claimed effect. Neither `flow` nor `hook` registers its own list columns in the Studio metadata admin, so the Studio metadata list page falls back to its default columns: name, `label` and `description`. The Studio metadata quick-find indexes and shows every item's `label` and `description` too. Each row cites that reader at the `.objectui-sha` pin `dd3f7e1be`. -- **Where the values come from.** Each row names its producer: the Studio route that mounts the list page, the metadata client's `GET /api/v1/meta/:type` read, and this repo's shared list answer (`createMetaListAnswer`), which adds no projection for either type that would drop the keys. A booted read of the showcase app confirms it: `GET /api/v1/meta/flow` served 30 flows and `GET /api/v1/meta/hook` served 4 hooks, each with its authored `label` and `description` and the showcase's project-scoped package id. -- **Still kept, still not warned.** The re-grade reverses no ADR-0033 decision. All three rows stay docs-shaped annotation, deliberately kept and exempt from enforce-or-remove. Each row keeps the note it carried while `dead`, as history. -- The regenerated liveness counts are the `liveness/state-counts/flow.md` and `liveness/state-counts/hook.md` shards. `flow` has 35 live and 5 dead (was 34 and 6). `hook` has 21 live and 1 dead (was 19 and 3). The README's `flow` and `hook` Notes cells no longer list these keys as dead. The liveness gate test that borrowed `flow.description` as its sample `dead` row now uses the `flow.active` tombstone, which the gate holds at `dead`. diff --git a/.changeset/20308-blank-typed-value-null.md b/.changeset/20308-blank-typed-value-null.md deleted file mode 100644 index 7366a072bd8..00000000000 --- a/.changeset/20308-blank-typed-value-null.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: a cleared number, boolean, date, datetime or time field stores `null` on every backend, and a `progress` field refuses a non-numeric value (#20308) - -Clause-②: no (narrowing) - -**BREAKING** — shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by -this banner and the ADR-0087 disposition below, never by the level). The one -narrowing: a non-numeric string written to a `progress` field is now refused -with `invalid_number` on memory and SQLite, where it used to be stored. - -## What was wrong - -The record validator reads a blank string — `''`, or whitespace only — as -"missing", and returns before any type check. Nothing rewrote the value, so the -driver received the blank exactly as sent. The same clear of one field therefore -had three outcomes: - -- **memory and SQLite** stored `''` in a number, currency, percent, rating, - slider, progress, summary, boolean, toggle, date, datetime or time column, on - every write door (create, update, batch, `createMany`, `updateMany`). On SQLite - a stored `''` on a boolean then read back as `false`. -- **PostgreSQL** refused the statement (`invalid input syntax for type - numeric` / `boolean` / `date`), which REST answered as `500 DATABASE_ERROR` — - or as a failed row with `INTERNAL_ERROR` on the batch doors. - -objectui's edit form sends a cleared date, datetime or time box as `''`, so this -is the ordinary "clear the field and save" gesture. - -Separately, `progress` had no type check at all: a non-numeric string such as -`'abc'` was stored verbatim on memory and SQLite, and failed at the driver as a -`500` on PostgreSQL. - -## What changes - -- **The write door reads a blank on a non-string-typed column as `null`.** Every - field whose declared type is in the spec's `NON_TEXT_STORED_VALUE_TYPES` (the - numeric types including `progress` and `summary`, `boolean`, `toggle`, - `date`, `datetime`, `time`) has a blank string replaced by `null`. It happens - at the start of `ObjectQL.insert()` and `ObjectQL.update()`, before the - middleware, the hooks, the defaults and validation read the payload, and at - the same point in `ObjectQL.validate()` (the dry run). Every REST, batch and - import door writes through those methods. The caller's own objects are never - mutated. -- **What that means for a write:** the column stores `null` on every backend, - and PostgreSQL no longer refuses the request. A blank on a `required` field is - refused with `required`, exactly as `null` is. On create, a blank takes the - field's `defaultValue` exactly as `null` does, so a blank on a required field - that declares a `defaultValue` is now accepted with the default. -- **String-stored columns are untouched.** A text, lookup or select `''` is still - stored as `''`. -- **`progress` joins the numeric type check.** A non-numeric string on it is - refused with `invalid_number`, as on `number`. No `min`, `max` or `scale` is - newly enforced on it. This is the narrowing above. -- **`summary` is exempt from that type check.** It is in the spec's - `COMPUTED_VALUE_TYPES` ("never client-written; shape is producer-owned"), so - the roll-up producer decides its value's shape. A `max` or `min` roll-up over a - date, datetime or time child field keeps recomputing on memory and SQLite as - before, and a non-numeric value written to a `summary` is not judged by this - check. A blank on a `summary` still becomes `null`. - -## Rows already stored - -This fixes new writes only. Rows written earlier on SQLite (and on memory, -MongoDB or libSQL) may still hold `''` in such a column; on SQLite a boolean -holding `''` reads back as `false`, and one holding whitespace as `true`. -PostgreSQL never stored one. To repair a SQLite table, run this once per -non-string-typed column (a field of one of the types listed above): - -```sql -UPDATE "" SET "" = NULL - WHERE typeof("") = 'text' AND trim("", ' ' || char(9) || char(10) || char(13)) = ''; -``` - - diff --git a/.changeset/20309-number-arm-non-string-refused.md b/.changeset/20309-number-arm-non-string-refused.md deleted file mode 100644 index 7de539952ec..00000000000 --- a/.changeset/20309-number-arm-non-string-refused.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: a number, currency, percent, rating, slider or progress field refuses an array, a boolean or an object with `invalid_number` (#20309) - -Clause-②: no (narrowing) - -**BREAKING**: shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by -this banner and the ADR-0087 disposition below, never by the level). The -narrowing: an array, a boolean or an object whose `Number()` is finite, such as -`[500]`, `[]`, `true` or `false`, written to one of those fields is now refused -with `400 VALIDATION_FAILED` / `invalid_number`. It used to be accepted and -stored as sent. - -## What was wrong - -The record validator judged `Number(value)` on a number-typed field, but the -write carried `value` itself. Every value that JavaScript coerces to a finite -number therefore passed the check and reached the driver unchanged: - -- **SQLite** stored `[500]` as the TEXT `'[500]'`, which a read returned as the - string `"[500]"`; `[]` as the TEXT `'[]'`; and `true` / `false` as `1` / `0`. -- **memory** stored the array or the boolean itself. - -`[5, 7]` and `{}` were already refused, because `Number()` of each is `NaN`. - -## What changes - -- On `number`, `currency`, `percent`, `rating`, `slider` and `progress`, a value - that is neither a number nor a string is refused with `invalid_number`: an - array, a boolean, a plain object, a `Date`. This holds on every engine, REST, - batch and import write door, because they all write through the same - validator. -- A number is judged and stored exactly as before, and so are the `min`, `max` - and `scale` checks and their messages. -- A string is also unchanged. It is still judged by `Number()` and stored as - sent. Which strings a number field accepts is a separate change. -- `summary` is still not judged by this check (it is in the spec's - `COMPUTED_VALUE_TYPES`). A blank still becomes `null` before the check runs. - -## Rows already stored - -This refuses new writes only; a stored value is never re-read by the check. -Rows written earlier on SQLite may hold such a value as TEXT in a numeric -column. To find them, run this once per number-typed column: - -```sql -SELECT id, "FIELD" FROM "OBJECT" WHERE typeof("FIELD") = 'text'; -``` - -OBJECT is the object name and FIELD is the field name. A match is a cell that -SQLite could not store as a number: an array written as TEXT, or a string such -as `'0x10'`. Decide its number by hand; nothing here rewrites it. - - diff --git a/.changeset/20309-number-arm-numeric-string-grammar.md b/.changeset/20309-number-arm-numeric-string-grammar.md deleted file mode 100644 index 60a7169a9fc..00000000000 --- a/.changeset/20309-number-arm-numeric-string-grammar.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: a number, currency, percent, rating, slider or progress field reads a string by the platform's numeric grammar and stores the number it denotes (#20309) - -Clause-②: no (narrowing) - -**BREAKING**: shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by -this banner and the ADR-0087 disposition below, never by the level). The -narrowing: a string that `Number()` reads as a finite number but the platform's -numeric grammar does not is now refused with `400 VALIDATION_FAILED` / -`invalid_number`. It used to be accepted. - -**What a caller sees, before → after.** One of these strings written to one of -those fields: `201`, stored as sent (memory kept the string; SQLite kept -`'0x10'` as TEXT and the others as numbers by column affinity) → `400 -VALIDATION_FAILED` with the field code `invalid_number`, nothing stored. The -REST create, batch, update and updateMany routes all answer it, and `validate` -(the dry run) predicts it. The forms: - -- a radix literal: `'0x10'`, `'0X1A'`, `'0o17'`, `'0b101'`; -- a whitespace-padded number: `' 12 '`, `'12\n'`, `'\t-3'`; -- a spelling that is not a JSON number: `'+5'`, `'.5'`, `'5.'`, `'007'`. - -The fix, when a write is refused: send a JS number, or the number's plain JSON -spelling — `'16'`, `'12'`, `'-3'`, `'5'`, `'0.5'`, `'7'`. `String(n)` of any -finite number always qualifies, exponent forms included (`'1e-7'`, `'1e+21'`). - -## What was wrong - -This is the separate change the earlier #20309 note (arrays, booleans and -objects refused) left open. The record validator judged a string by `Number()` -while the write carried the string itself, so an accepted string reached the -driver as sent: - -- **memory** stored `'12'` as the string `'12'` and read it back as a string; -- **SQLite** stored `'0x10'` as the TEXT `'0x10'` (read back as `16`), and the - other accepted strings as numbers through the column's affinity. - -One write, two stored shapes, depending on the backend. - -## What changes - -- A string is judged by `parseNumericString` from `@objectstack/spec/data`, the - one numeric grammar the filter door also reads: the whole string is a JSON - number literal naming a finite double. Its case table, - `NUMERIC_STRING_GRAMMAR_CASES`, decides every form. No second grammar lives in - the engine. -- An admitted string is stored as the number it denotes, on every backend: - `'12'` is written as `12`, `'1e3'` as `1000`. The rewrite runs at the write - door, before the middleware, the hooks, the `readonlyWhen` locks and - validation read the payload, so a `before*` hook now sees the number. The - caller's own object is not mutated. -- `min`, `max`, `scale` and `precision` read that number, exactly as they read - a number: `'12.50'` passes `scale: 1` (it is `12.5`), and `'150'` over - `max: 100` is `max_value`. -- This holds on every engine, REST, batch and updateMany door, and in - `validate` (the dry run). The server `/import` route is unchanged: its own - cell reader turns a numeric cell into a number before the write, so the - grammar never sees a string from it. -- A blank is still `null` before the check (#20308). `summary` is still not - judged. A number, and an array, boolean or object, are answered as before. - -## Who sends numeric strings - -objectui's CSV import wizard, on its legacy per-row fallback (`legacyImport`, -used only when the connected client cannot reach the server `/import` route), -posts each raw cell to `create` after a client check of -`!isNaN(Number(value))`. Its parser trims cells, so of the refused forms it can -send the radix literals and the non-JSON spellings. Those rows now fail with -`invalid_number` instead of storing a string. The fix there is the wizard's -default path: import through the server `/import` route, whose cell reader -converts the number before the write. Every interactive form widget sends a JS -number or `null`, and is unaffected. - -## Rows already stored - -This judges new writes only; a stored value is never re-read by the check. On -memory, an accepted string stayed a string until the record is next written. -On SQLite, the earlier #20309 note's query finds a numeric column holding TEXT -(such as `'0x10'`): - -```sql -SELECT id, "FIELD" FROM "OBJECT" WHERE typeof("FIELD") = 'text'; -``` - -OBJECT is the object name and FIELD is the field name. Nothing here rewrites -such a cell; decide its number by hand. - - diff --git a/.changeset/20311-empty-filter-operator-staged.md b/.changeset/20311-empty-filter-operator-staged.md deleted file mode 100644 index 555c53457f4..00000000000 --- a/.changeset/20311-empty-filter-operator-staged.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): declare the `$empty` filter operator — what 「is empty」 means, once, per field type — staged ahead of its executors (#20311) - -Clause-②: yes (widening) — a declared operator slot and five exports are added. `FieldOperatorsSchema.parse({ $empty: true })` used to strip the undeclared key and now keeps it. The one refusal that comes with the declared type sits on a key nothing writes (see below). - -**⚠️ Authoring `$empty` today is refused at query time.** The operator is declared but STAGED: it is deliberately absent from `FILTER_OPERATORS`, so no query executor answers it yet. A hand-written `{ "f": { "$empty": true } }` gets `INVALID_FILTER` / 400 from `driver-sql` (and the drivers that inherit its compiler), `driver-turso`'s remote transport, `driver-memory`, `driver-mongodb`, objectql `having` and the analytics `where` compiler; `READ_SCOPE_COMPILE_FAILED` / 500 (fail-closed) from the analytics read-scope SQL compiler; and `@objectstack/formula`'s write-side `matchesFilterCondition` answers `false` for every record, its fail-closed posture for an operator it has no arm for. Until each of those faces has its arm, write 「is empty」 with the view operator `is_empty`, which is unchanged. - -**What the operator means.** Its description is the ruled per-type table (ruling B on #20311, spelled as an operator by ruling A on #20399): - -| field type | `$empty: true` matches | -|---|---| -| text-like (`STRING_VALUE_TYPES`: text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) | null or `''` | -| multi-value (`isMultiValueField`: multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with `multiple: true`) | null or `[]` | -| every other type | null only | - -`$empty: false` is the exact complement. A face that holds no field declaration (the formula matcher, objectql `having`) judges by the value: null, `''` and `[]` are empty. - -**The one expansion every face calls**, exported from `@objectstack/spec/data`: - -- `expandEmptyOperator(field)` — keyed on the field DEFINITION (type plus `multiple`), because a `lookup` is `null_only` and a `lookup` with `multiple: true` is `multi_value`. Returns one of the frozen `EMPTY_OPERATOR_ARMS` rows: `{ arm, emptyString, emptyList }` (`EmptyOperatorArm`, `EmptyOperatorExpansion`). -- `isEmptyFilterValue(value, expansion?)` — the value-level half: with an expansion, the declared row; without one, the by-value reading for the declaration-free faces. - -**What does not change.** - -- The `is_empty` / `is_not_empty` view operators still lower to `{ "$null": true | false }`. A later change flips that lowering to `$empty` once every face answers it; no stored filter changes result in this release. -- An empty list is still refused as an equality comparand: `{ "tags": [] }` and `{ "tags": { "$eq": [] } }` keep their refusal. The multi-value row lives in the operator precisely because it cannot be spelled as a lowered equality. -- `FILTER_OPERATORS` is unchanged, so every executor that derives its accepted set from it (`driver-memory`'s gate among them) keeps refusing `$empty` rather than dropping it. - -**One new refusal, on a key nothing writes.** A NON-boolean `$empty` (`"true"`, `1`, `null`) is refused where the declared boolean flags `$null` / `$exists` already are: at the operator slot, and at the save door (`FilterConditionSchema` and the analytics filter carriers that share its slot check), in the flags' own first sentence. `$empty` appears nowhere in this repository or in objectui's `main` before this change (0 occurrences in either). - -**Stored sharing rules** (ruling B's landing measurement): the criteria sharing rules in this repository's examples and objectui's fixtures that use 「is empty」 are 0, and this release changes no lowering, so none changes result. Production sharing rules are NOT MEASURED: they are unreadable from here. diff --git a/.changeset/20316-flow-node-config-required-keys-refused.md b/.changeset/20316-flow-node-config-required-keys-refused.md deleted file mode 100644 index d3e8c52ef6d..00000000000 --- a/.changeset/20316-flow-node-config-required-keys-refused.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': minor ---- - -fix(spec)!: a flow node config its executor cannot run — a key its contract requires, left out, or a decision branch list it cannot read — is refused at authoring (#20316) - -Clause-②: no (narrowing) - - - -**BREAKING** — an accept-set narrowing on authored flow-node `config`, shipped as -`minor` under the launch-window convention (`check-changeset-no-major` refuses -`major` until GA; breaking-ness is carried by this banner and the ADR-0087 -disposition above, not by the level). - -**What changed.** A flow node's `config` is an open record, so what its executor -requires was checked by no build door. `FlowSchema.parse`, `AutomationEngine.registerFlow` -and `objectstack validate` all admitted a node that left out a key its executor -contract requires — and the executor's own contract parse then refused the node on -every run that reached it. A `decision` branch with no `label` was worse: it never -failed, the matched branch reported no label, and traversal took EVERY out-edge, so -the flow ran green down the wrong paths. All three doors now refuse these shapes -through one judge, `flowNodeConfigRefusals` (new in `@objectstack/spec/automation`): - -- **A key a builtin's executor contract requires, left out.** Each builtin node's - config is parsed against the very contract its executor parses against - (`getBuiltinNodeConfigContracts()`, new, reconciled against the executors' own parse - calls), and only the keys left out are kept — a present value of the wrong type, and - an undeclared key, are judged where they were before. The keys: `objectName` on - `get_record` / `create_record` / `update_record` / `delete_record`; `recipients` on - `notify` (and `title` when there is no `template`); `url` on `http`; `function` on - `script`; `flowName` on `subflow`; `collection` and `flowName` on `map`; - `collection` on a `loop` that has a `body`; `branches` on `parallel`; `try` on - `try_catch`; and on `screen`, each field's `name`, each option's `value` and - `label`, and a `lookup` field's `reference`. A key a rule of the contract requires - (the `notify` title, the `lookup` reference) is refused in the contract's own words. -- **A `decision` branch list its executor cannot read.** `conditions` present and not - `null` must be an array; every branch must be an object; every branch's `label` must - be a non-blank string (absent, `null`, blank or non-text all name no out-edge). - -Each refusal is a `custom` issue anchored at the key (`nodes.1.config.objectName`, -`nodes.1.config.fields.0.name`, `nodes.1.config.conditions.0.label`, or the region -path `nodes.1.config.body.nodes.0.config…`), met at `registerFlow` and -`objectstack validate` through that same parse, and reported by -`validateStackExpressions` for a stack handed to it directly. The refusal codes join -`FLOW_SLOT_REFUSAL_CODES`: `node-config-key-missing`, `node-config-key-required-by-rule`, -`decision-conditions-not-array`, `decision-branch-not-object`, -`decision-branch-label-missing`. - -The Studio flow designer writes refused shapes when a node is added and saved before -it is configured, when a decision branch row's label cell is left empty, and when a -screen field row's name cell is left empty. Where such a node already sits, the whole -flow is refused: registered from the metadata registry or `sys_metadata` at boot, it -is skipped with a `failed to register flow` warn naming it while the flows beside it -register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the -whole stack; an artifact file is refused whole at load. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `{ type: 'get_record', config: { outputVariable: 'rows' } }` | the object it reads — `config: { objectName: 'account', outputVariable: 'rows' }` (the same for `create_record` / `update_record` / `delete_record`) | -| `{ type: 'loop', config: { body: { … } } }` | the array it iterates — `config: { collection: '{rows}', body: { … } }` | -| `{ type: 'map', config: { flowName: 'per_row' } }` | `config: { collection: '{rows}', flowName: 'per_row' }` | -| `{ type: 'http', config: { method: 'GET' } }` | `config: { url: 'https://api.example.com/v1/items', method: 'GET' }` | -| `{ type: 'script' }` | the registered function it calls — `config: { function: 'recalc_totals' }` | -| `{ type: 'notify', config: { recipients: ['{record.owner}'] } }` | a content source — `title: 'Deal won'`, or a `template` | -| `conditions: [{ expression: 'record.amount > 1000' }]` on a `decision` | the out-edge it routes to — `[{ label: 'large', expression: 'record.amount > 1000' }]`, beside an out-edge labelled `large` | -| `conditions: ['record.amount > 1000']` | `[{ label: 'large', expression: 'record.amount > 1000' }]` | - -**One-line fix:** write the key the node was meant to carry. To branch on the -out-edges instead of on `conditions`, delete `conditions` and put each predicate on its -edge's `condition`. - -**Unchanged.** A node carrying every key its contract requires parses, registers and -validates as before; a legacy flat-graph `loop` (no `body`) still needs no -`collection`; a `decision` with no `conditions`, `conditions: null` or an empty list -still routes by its out-edges; `assignment`, `wait`, `connector_action` and plugin node -types are not judged by this rule; and a key spelled by a D2 alias (`object`, `flow`, -`functionName`, …) is still canonicalized before `registerFlow` and `objectstack -validate` judge it. diff --git a/.changeset/20320-dispatcher-meta-read-parity.md b/.changeset/20320-dispatcher-meta-read-parity.md deleted file mode 100644 index d4203b09f2a..00000000000 --- a/.changeset/20320-dispatcher-meta-read-parity.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -'@objectstack/rest': minor -'@objectstack/runtime': patch ---- - -fix(rest, runtime): the runtime dispatcher's `/meta` reads answer what `RestServer`'s answer — one list chain, one `public`-audience predicate, and the `?state=draft` read (#20320) - -Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains five value exports (`createMetaListAnswer`, `translateMetaList`, `metaRequestLocale`, `isPublicAudienceRead`, `STORED_VERSION_DOOR_POLICY`) and six type exports (`MetaListAnswer`, `MetaListAnswerSources`, `MetaListRequest`, `MetaListTranslationSources`, `MetaPublicReadRoute`, `MetaRequestHttp`), so its published surface grows; nothing it exported before is removed, renamed or narrowed. `@objectstack/runtime` publishes no new surface and stays a `patch`. - -A host that mounts only the `${prefix}/*` catch-all (`createHonoApp`, and any -adapter written on the public `HttpDispatcher` API) serves `/meta` through the -runtime dispatcher. Its reads now give the same answers as `RestServer`'s -`GET /meta/:type` and `GET /meta/:type/:name`. Until now a dispatcher-only host -answered: - -- **`GET /meta/app?id=crm`** — every app the caller may see, not `[crm]`. An - `?id=` that matches nothing listed every app instead of an empty list. -- **`GET /meta/view?object=lead`** — every view, not the lead views sorted for - the switcher. -- **`GET /meta/docs`** (the plural spelling) — every doc WITH its body. The - content slim compared the raw segment, so it ran only for `/meta/doc`. -- **any doc list** — each doc with its `translations` map and in no locale. - `RestServer` collapses each doc to the request's locale. -- **every translatable list** (`app`, `view`, `object`, `page`, `dashboard`, - `action`, `dataset`) — untranslated labels, whatever `Accept-Language` or - `?locale=` asked for, and no `Vary: Accept-Language` header. -- **`GET /meta/api`** — every stored `api` declaration, including ones the - endpoint matcher does not serve (their routes answer 404). -- **an anonymous `GET` of a `public` book or doc** (list or item) — - `401 UNAUTHENTICATED`. `RestServer` serves it (ADR-0046 §6.7). -- **`GET /meta/:type/:name?state=draft` from a caller who may read drafts** — - the ACTIVE item. It should be the pending draft, whole for a caller who may - save the app and pruned per caller for everyone else, or `404 NO_DRAFT` when - nothing is pending. A caller who may not read drafts is still answered the - plain read, byte for byte. - -**What changed.** The list route's whole post-read chain moved out of -`RestServer` into `createMetaListAnswer` in `@objectstack/rest`, unchanged. That -chain is the `api` served-set face, the per-caller list gate, `?id=`, -`?object=`, the doc locale collapse and content slim, the transport's own -object mask, and the translation. Every exit of the dispatcher's list branch -now hands its answer to that same function. The anonymous gates on both -transports ask one exported predicate, `isPublicAudienceRead`. It admits only -`GET` reads of book and doc, so every other type keeps the anonymous deny, and -the §6.7 audience gate still refuses `org` and `{ permissionSet }` audiences. -The dispatcher's `?state=draft` read runs the exported -`STORED_VERSION_DOOR_POLICY`, the constant `RestServer`'s draft branch runs. - -`RestServer`'s own answers are unchanged: the move is a refactor on that side, -and every existing REST test passes unedited. - -New exports from `@objectstack/rest`: `createMetaListAnswer`, -`translateMetaList`, `metaRequestLocale`, `isPublicAudienceRead`, -`STORED_VERSION_DOOR_POLICY` and their types. Nothing is removed or renamed. diff --git a/.changeset/20321-rls-policy-tags-retired.md b/.changeset/20321-rls-policy-tags-retired.md deleted file mode 100644 index c8148b2ec56..00000000000 --- a/.changeset/20321-rls-policy-tags-retired.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: retire `rowLevelSecurity[].tags` — no mainstream platform tags a row-level policy, and nothing here ever read one (#20321) - -Clause-②: no (narrowing) - -**BREAKING** — shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by -this banner, the `(narrowing)` arm above and the ADR-0087 disposition below). - -`tags` is removed from the row-level security policy (`RowLevelSecurityPolicySchema`, -the entries of a permission set's `rowLevelSecurity`). ADR-0049 -enforce-or-remove, graded RETIRE by the maintainer's criterion for -declared-but-unenforced families — does a mainstream platform have the -capability? None does: Salesforce sharing rules, Dataverse security roles and -PostgreSQL RLS policies carry no tag attribute, and compliance reporting there -keys on the rule itself. - -The key promised "categorization and reporting" for governance and compliance. -Nothing ever read it. Measured before removal, each against a lit control: the -RLS compiler reads a policy's `name`, `object`, `operation`, `positions`, -`enabled` and predicates, never `tags`; objectui's permission preview renders -the policy COUNT and its policy editor neither seeds nor reads the key; cloud -has no reader. No example, default permission set or cloud source wrote it. - -### FROM → TO - -| removed | what to write instead | -| --- | --- | -| `rowLevelSecurity[].tags` | delete the key. To limit whom a policy applies to, list the positions in `positions` — a tag never did that. To say why a policy exists, use `description`. | - -**The one-line fix: delete `tags:` from every row-level security policy.** -`os migrate meta --from 17` lists the mechanical edits for existing sources; -apply them by hand. - -⚠️ Runtime behaviour is deliberately **unchanged**. No access decision ever -depended on a tag, so removing the key removes no behaviour. What changes is the -answer an author gets: a policy carrying `tags` is now refused at parse, with the -prescription, instead of being stored with no effect. An author who wrote a tag -such as `managers_only` believing it scoped the policy now learns that only -`positions` does. - -### The retirement kit - -- **A `retiredKey()` tombstone** on `RowLevelSecurityPolicySchema` (the - `priority` posture one key over): `tsc` types the key `never`, and every parse - raises the prescription rather than a bare unknown-key verdict. The shape's - did-you-mean never offers it: a near-miss `tag` is refused as unknown. -- **D2 conversion `permission-rls-tags-removed`** (step 18, retired from the load - path): a lossless delete over `permissions[].rowLevelSecurity[]`, so a stored - permission row that still carries the key replays clean through the - rehydration seam, while a live author is refused rather than rewritten. -- **`RETIRED_KEYS_BY_MAJOR[18]`**: `security/RowLevelSecurityPolicy:tags`, and - the family's D3 entry `permission-rls-tags-retired`, which states what the - strip cannot decide — any report, audit filter or review process built on the - belief that policy tags were read needs another path. -- **The liveness row stays**, `dead`, under its tombstone (the key is still in - the walked shape); `authorable-surface/security.json` carries it as - `security/RowLevelSecurityPolicy:tags [RETIRED]`, and the generated reference - pages print the prescription in place of the old describe. -- **No deprecation window**, per the project's startup-stage posture. - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` -is published, so this is breaking for consumers no telemetry was consulted for. - - diff --git a/.changeset/20323-action-aria-removed.md b/.changeset/20323-action-aria-removed.md deleted file mode 100644 index c578e67c6f0..00000000000 --- a/.changeset/20323-action-aria-removed.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**BREAKING** — `aria` on an action (`ActionSchema`, top-level `actions[]` and `objects[].actions[]`) is now refused at parse: no action surface ever applied it. Write the accessible name in the action's rendered `label`, and name the region that places the actions with `ariaLabel` / `ariaDescribedBy` / `role` in the placing node's `aria` block (`page.components[].aria` or the list view `aria`). - -Clause-②: yes - -`ActionSchema` declared a per-action ARIA block, and the liveness ledger graded it `live` on an uncited note — 「PARTIAL — honored by a few objectui renderers, not the core action buttons/menus」 — with no reader behind it. Re-measured at this checkout's own `.objectui-sha` pin `f8a9d0fb05`: none of the surfaces that render an action reads an action's `aria` — not `action:button`, `action:icon`, `action:menu`, `action:group` or `action:bar`, not the grid's row and bulk action menus, not `record:quick_actions`, not the declared-actions bar. The only `schema.aria` readers there are the placing nodes' own blocks (the `record:*` page components, the list view, `element:button`'s props), none of which looks inside an action. So an author — or an AI — who filled in `aria` got no accessible name on the rendered button, and nothing said so. - -It is the fourth member of the `aria` family retired for exactly this, after `dashboard.aria`, `dashboard.widgets[].aria` and the chart config's `aria`. - -**Removed rather than enforced** (ADR-0049 enforce-or-remove; the triage direction on the card, following the chart config retirement `2bf6ef18d`). The capability is already delivered under another key. Every one of those surfaces derives the accessible name from the action's **required** `label` — the visible button or menu-item text, and the `aria-label` of the icon-only `action:icon` and of the overflow-menu trigger — and the node that places the actions carries the node-level `ariaLabel` / `ariaDescribedBy` / `role`. The reversal condition the triage named (an icon-only action rendered with no accessible name at all) was measured and does not hold on any of them. A per-action block would be a second spelling of both, behind a precedence rule nobody has written. - -## FROM → TO - -| you wrote (17.4 and earlier) | write instead | -| --- | --- | -| `aria: { ariaLabel: 'Escalate this case' }` on an action, top-level or under `objects[].actions[]` | the name in the action's `label` — it is what every action renderer announces | -| `aria: { ariaDescribedBy: … }` / `aria: { role: … }` on an action | delete it; to describe or role the toolbar or list the actions sit in, put it in the `aria` block of the node that places them — `page.components[].aria` or the list view `aria` | -| `ariaLabel` / `ariaDescribedBy` / `role` on a page, page component or list view | unchanged — the shared `AriaProps` block stays live there | - -**The one-line fix:** delete `aria` from the action; put the accessible name in its `label`. - -`os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. - -## The retirement kit - -- **A `retiredKey()` tombstone, not a bare deletion** — even though `ActionSchema` is a `strictObject`. A bare delete would still be loud, but only as a generic unrecognized-key report that cannot carry the prescription; the tombstone types the key `never` for `tsc` and raises the upgrade text at parse. The key therefore stays in the walked shape: its liveness row stays (regraded `live` → `dead` with a `REMOVED` note that records the uncited 「PARTIAL」 claim it replaces) and the authorable-surface baseline marks `ui/Action:aria` `[RETIRED]`. -- **The D2 conversion `action-aria-removed`** (protocol 18, retired from the load path) strips the key from stack `actions[]` and from `objects[].actions[]` as a pure lossless delete — it never had an effect to lose. Its D3 record is the semantic entry `action-aria-retired`: its own family, not a member of the chart config's. -- **`AriaPropsSchema` is untouched** — a key retirement, not a def retirement; it stays live on pages, page components, the list view and the element props. -- **No form input and no locale bundle move.** The key never reached `action.form.ts`. The Studio action inspector's "More fields" section is derived from the served schema, where a tombstone node is dropped from the payload, so the served `aria` column goes with this release. - -## Reach, measured - -- This repository: **0** authors of `aria` on an action in `examples/**`, `packages/**` fixtures or the published skills (control: 15 `variant:` lines in `examples/**`). Two hand-written docs pages taught the key and are corrected here. -- HotCRM at `origin/main` `2f7b2326`: **0** on an action; its 6 `aria:` blocks are all page-level `page.aria`, which stays live (control: HotCRM authors actions — 7 files under `src/**/actions/` declare `locations:`, 17 times). -- Other out-of-repo authors: NOT MEASURED. - -## What an operator with a STORED action sees - -A `sys_metadata` `action` or `object` row written before this release can carry the key. Nothing breaks at read: the conversion replays on rehydration and strips it, so the row is served canonical and parses. `os migrate meta --stored --apply` rewrites the rows. - - diff --git a/.changeset/20331-validate-view-container-name.md b/.changeset/20331-validate-view-container-name.md deleted file mode 100644 index fc86180226c..00000000000 --- a/.changeset/20331-validate-view-container-name.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/objectql': minor ---- - -fix(cli): `os validate` refuses a `views:` container whose own `name` disagrees with the object it binds to, the stack the server refuses at boot (#20331) - -Clause-②: yes - -A view container is registered under the object it binds to. When its own `name` -is set to something else, for example `{ name: 'order_line', object: 'my_app_order_line', list: { … } }`, -the server refuses the whole stack at boot. `os validate` used to pass that stack -at exit 0, so the first sign of the mistake was a server that would not start. - -`os validate` now runs the same check the server runs at boot and prints the same -message. The text form and `--json` both exit `1`. The `--json` failure payload lists -one `errors` entry per refused container, with `path` (for example `views[0]`, or -`packages[1].manifest.views[0]` in a multi-package stack), `code: 'VALIDATION_ERROR'`, -`httpStatus: 400` and `message`. Every other exit, and the success payload, are -unchanged. - -**Fix:** remove the container's `name`, or set it to the object name the message names. - -**New in `@objectstack/objectql` (the widening):** two new exports on the package's -root entry, `viewContainerNameRefusal(container, sourceLabel, ownerId)` and its -return type `ViewContainerNameRefusal`. The function returns the refusal the boot -registrar throws, or `undefined`. It returns `undefined` for a container whose -derived object key is empty, because the boot registrar skips that entry with a -warning and never refuses it. The boot registrar now calls this function. What it -refuses, its message and its `VALIDATION_ERROR` / `400` envelope are unchanged. - -`os build` runs the same check as well (#20393, its own entry), so it no longer -writes an artifact carrying such a container. diff --git a/.changeset/20332-triggers-require-automation.md b/.changeset/20332-triggers-require-automation.md deleted file mode 100644 index 66bae8a8532..00000000000 --- a/.changeset/20332-triggers-require-automation.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `defineStack` refuses an auto-launched flow whose stack declares `triggers` without `automation` — the pair installs the trigger, `triggers` alone installs nothing (#20332) - -Clause-②: no (narrowing) - -**BREAKING** — shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by -this banner and the ADR-0087 disposition below, never by the level). - -**What is refused now.** A stack whose `requires` includes `'triggers'` but not -`'automation'`, and which declares a `record_change`, `schedule`, -`time_relative` or `api` flow, used to pass `defineStack` and `os validate`, -boot, and never fire the flow. Every trigger plugin installs its trigger into -the automation service when the kernel is ready, and without that service it -logs `automation service not available — … trigger NOT installed` and installs -nothing. No runtime resolves `triggers` into `automation`. `defineStack` now -refuses that stack with the same `STACK_TRIGGER_CAPABILITY_REQUIRED` code -(`status: 422`), one finding per flow: - -```text -flow 'task_fanout' declares a 'record_change' trigger but `requires` does not include 'automation' — 'triggers' installs the 'record_change' trigger into the automation service, and without it no 'record_change' trigger would be registered, so the flow would never auto-launch. Add 'automation' to requires: ['automation', 'triggers'] (@objectstack/service-automation runs the flow; @objectstack/trigger-* only fires it). -``` - -**The fix is the one the message names:** add `'automation'` to `requires`, so -it reads `requires: ['automation', 'triggers']`. Nothing is renamed or removed. - -**Also changed: the message for a stack that declares neither token.** An empty -or absent `requires` with such a flow was told to add `requires: ['triggers']`, -which would now be refused a second time. It is told to add both: - -```text -flow 'task_fanout' declares a 'record_change' trigger but `requires` does not include 'automation' or 'triggers' — no 'record_change' trigger would be registered, so the flow would never auto-launch. Add requires: ['automation', 'triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-* and install into @objectstack/service-automation — 'triggers' alone installs nothing). -``` - -Unchanged: `requires: ['automation']` with such a flow keeps the message it has -always had (add `'triggers'`), word for word. `requires: ['automation', -'triggers']` is accepted, in any order. A stack with no auto-launched flow -(none at all, a `screen` flow, or an `autolaunched` flow started by hand) owes -neither token, and `obsolete` / `invalid` flows are still skipped. The refusal -code, the message header and the `issues` shape are the same, and no export is -added. - -In-tree producers measured: `examples/app-showcase` and `examples/app-todo` -already declare both tokens; `examples/app-crm` and the `create-objectstack` -`blank` template declare `automation` without `triggers` and no auto-launched -flow, so they are untouched. - - diff --git a/.changeset/20333-create-objectstack-wire-barrels.md b/.changeset/20333-create-objectstack-wire-barrels.md deleted file mode 100644 index 63c9cfb3782..00000000000 --- a/.changeset/20333-create-objectstack-wire-barrels.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'create-objectstack': patch ---- - -fix(create-objectstack): the blank starter wires every directory `os generate` writes into - -`npm create objectstack` scaffolded an `objectstack.config.ts` that imported `./src/objects` alone. `os g view`, `action`, `flow`, `dashboard`, `app` and `skill` each wrote a file and a barrel `index.ts` that nothing imported, and `os validate` then exited 0 printing `Logic: 0 Flows`: the generated metadata was never loaded. - -**What a new blank project now ships** is the wiring `os init` writes: - -- `objectstack.config.ts` imports every directory `os generate` writes into (`src/objects`, `src/views`, `src/actions`, `src/flows`, `src/dashboards`, `src/apps`, `src/skills`) and hands each barrel's exports to `defineStack` under its key (`objects`, `views`, …). A file `os g` writes there is part of the stack with no edit to the config. The keys read the barrels through a small `exportsOf` helper declared in the config, because `Object.values` on an empty barrel does not type-check against `defineStack`'s collection types. -- An `index.ts` containing only `export {};` in each of those directories except `src/objects`, which keeps the sample object. -- `requires: ['automation', 'triggers']`. `automation` was already there for the three connector plugins. `triggers` fires a flow that starts on a record change, the kind `os g flow` writes, and without it the config stops loading as soon as it holds one. A project with no flow boots as before. - -**Projects scaffolded by an earlier release** keep their config. `os g` says when a file it wrote is not wired, and prints the lines to add. diff --git a/.changeset/20334-aggregate-positions.md b/.changeset/20334-aggregate-positions.md deleted file mode 100644 index 8056e4f071f..00000000000 --- a/.changeset/20334-aggregate-positions.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: `having` on `engine.aggregate` resolves `{placeholder}` tokens through the resolver `where` uses, so an unknown one is refused `FILTER_TOKEN_UNKNOWN` / 400 instead of keeping no group with a 200; and the per-aggregation `filter`'s temporal and text-operator refusals name `aggregations[i].filter` instead of `where` (#20334) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what `having` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query`) that forwards it there. A `{placeholder}` in `having` is now resolved by the same resolver, with the same refusals, as one in `where`, once per query, before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback. It ships as `minor` under the launch-window convention for accept-set narrowings. - -Measured before and after through `engine.aggregate` and `POST /data/:object/query`, on InMemoryDriver, SqlDriver on SQLite and SqlDriver on PostgreSQL 16, on both `having` paths, four groups whose `max(placed_on)` falls between 2026-01-02 and 2026-03-01: - -| `having` | before | now | the same token in `where` | -|:--|:--|:--|:--| -| an unknown token: `{ last_placed: { $gte: '{not_a_token}' } }`, or a near miss such as `'{TODAY}'`, on any column, under `$and` / `$or` / `$not` or as an `$in` member | 200, keeps no group | `FILTER_TOKEN_UNKNOWN` / 400, no read | the same refusal, in the same words | -| `'{current_user_id}'` with no user on the request, `'{current_org_id}'` with no active organization, `'{record_id}'` always | 200, keeps no group | `FILTER_TOKEN_UNRESOLVED` / 400, no read (a REST request with no user is answered 401 before it reaches the engine, as before) | the same refusal | -| a known token: `{ last_placed: { $gt: '{current_year_start}' } }` on `max(placed_on)` | compared as its own text, which sorts after every digit: keeps no group | compares as `'2026-01-01'`: keeps all four | resolved | -| any known token, as a comparand, an `$in` member or a `$between` endpoint, under `$and` / `$or` / `$not` | compared as its own text (on a date or datetime column, `$gt` / `$gte` kept no group and `$lt` / `$lte` every group) | the groups the resolved value keeps, the same as that value written out | resolved | -| `{ customer_id: '{current_user_id}' }` on a groupBy key, as user `c2` | keeps no group | keeps `c2` | resolved | - -Tokens resolve the way they do in `where`: a date macro to a `YYYY-MM-DD` day (a sub-day macro to an ISO instant) in the request's timezone, `{current_user_id}` and `{current_org_id}` from the request. A string that only contains braces (`'a{b}c'`) is not a placeholder and compares as written, as in `where`. The `having` doors run first, as `where`'s do: a clause an earlier `having` door refuses (an unknown key, an unknown operator, a comparand its temporal column cannot read) keeps that refusal, in that door's words. - -**The per-aggregation `filter`'s refusals name their position.** A comparand a declared temporal field cannot read, and a text operator aimed at a field that never holds a string, in `aggregations[1].filter` said `at where.placed_on.$gt` / `at where.amount.$contains`, a `where` the author did not write. They now say `at aggregations[1].filter.placed_on.$gt` / `at aggregations[1].filter.amount.$contains`, as that filter's list-shape and comparand-type refusals already did. The code (`INVALID_FILTER`), the status (400) and every other word are unchanged at the engine. Over REST, the message keeps its existing 500-character bound: a `date` field's refusal of a string such as `'not-a-date'`, which fitted within it, now loses the end of its remedy (`"{current_month_s…` at the shortest path), and the refusals that already exceeded the bound still do. - -**The `having` temporal refusal's remedy is `where`'s.** A string a `date` or `datetime` aggregated column cannot read (`{ last_placed: { $lt: 'last_30_days' } }` on `max(placed_on)`) was refused with a remedy that named the literal forms only (`Write a "YYYY-MM-DD" calendar day.` on a `date` column), written when `having` resolved no placeholder. Now that it resolves them, the refusal ends in the remedy the same comparand gets in `where`, which names the placeholder: `Write a "YYYY-MM-DD" calendar day, or a relative-date placeholder the resolver knows, e.g. "{30_days_ago}" / "{current_month_start}".` on a `date` column, and the `where` remedy for a `datetime` field on a `datetime` column. The code (`INVALID_FILTER`), the status (400), what is refused and every word before the remedy are unchanged, and so are a `time` column's refusal and the refusal of a number or `Date` whose year falls outside 0000 to 9999. Over REST the message keeps its 500-character bound, which the longer remedy now reaches, measured with an object named `ledger_having`. A `date` column's refusal still arrives whole in every cell measured, the longest at 498 characters (`'+010000-01-01T00:00:00.000Z'` on `max(placed_on)`), and it stays whole while the object name, the column, what it aggregates, the comparand as quoted and its path take at most 90 characters together. A `datetime` column's refusal no longer fits whatever those are, because its fixed words and remedy alone take 502 characters: over REST it now ends inside the remedy, before the placeholder it names (`…epoch milliseconds, or a relative-date pl…` on `min(opened_at)`), as the `where` refusal for a `datetime` field already did. A preset name such as `'last_30_days'` does not reach this refusal over REST: the query schema refuses it first (`VALIDATION_FAILED`), before and after. - -**Who is affected.** `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. The five `having` clauses in this repository's docs and published skills compare a numeric aggregation alias with a number, and none carries a placeholder; no runtime code or example app in this repository composes a `having`. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. - -**Fix.** For an unknown token, write one the resolver knows (the refusal lists them: `{today}`, `{current_quarter_start}`, `{30_days_ago}`, `{current_user_id}`, …) or the literal value. For `{current_user_id}` / `{current_org_id}`, send the request with a user or an active organization. - -**Unchanged**, measured identical before and after on the three drivers, both paths and both doors: every `where` answer, placeholders and refusals included; every `having` that carries no placeholder, and its refusals in their words other than the `date` / `datetime` temporal refusal's remedy above; every per-aggregation `filter` answer other than the two refusals' paths above, placeholders included. diff --git a/.changeset/20335-pg-aggregate-numbers.md b/.changeset/20335-pg-aggregate-numbers.md deleted file mode 100644 index 9b159061e8c..00000000000 --- a/.changeset/20335-pg-aggregate-numbers.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -fix(driver-sql): `count` / `count_distinct` / `sum` / `avg` answer JS numbers on PostgreSQL and MySQL, as they do on SQLite and on the engine's rows path - -Clause-②: no - -`SqlDriver.aggregate` handed the SQL client's answer straight through. node-postgres parses -`bigint` (`count`, and `sum` over an integer column) and `numeric` (`sum` / `avg` over the -numeric family's exact-decimal column, `avg` over an integer column) to strings, and mysql2 -does the same for `DECIMAL` (`SUM` / `AVG`). So one grouped query answered - - { "n": "2", "total": "500.000000000000000000000000000000" } - -on PostgreSQL's native path and `{ "n": 2, "total": 500 }` on SQLite and on the rows path of -every dialect. The engine's `having` compares values as they -arrive, so `having { n: { $in: [2] } }` kept no group on PostgreSQL alone, and -`having { total: { $in: [500, 20] } }` kept no group on PostgreSQL or MySQL, while a string -comparand such as `{ total: { $lt: 'not-a-date' } }` kept every group there and none anywhere -else. - -Those four functions now answer a JS number on every dialect, through `SqlDriver.aggregate`, -`engine.aggregate` and `POST /api/v1/data/:object/query`. The presentation is keyed on the -aggregate function the query asked for; it only rewrites a string, so SQLite's answers are -byte-identical to before. `min` / `max` are unchanged: they answer a value of the column and -keep that column's presentation (a declared numeric field was already a number). -Non-aggregate reads (`find()`, `distinct()`) are unchanged, and no connection-level type -parser is touched. - -**Precision policy.** The answer is one JS number (an IEEE-754 double) on every dialect. A -`sum` / `avg` whose exact value needs more than a double's 15 to 17 significant digits, or an -integer total at or above 2^53, is rounded to the nearest double. That is the same bound -`find()` already puts on a read of the same exact-decimal column, and the bound the rows path -has always had. A total that fits keeps its exact value (`500`, `30.75`, `0.375`). The answer -is never a string, including for large totals: an answer whose type depended on its size would -break the same `having` or chart for exactly those totals. - -A consumer that read these values through `Number(...)` gets the same number it computed -before. A consumer that compared them as strings, or checked `typeof value === 'string'`, -now receives a number. diff --git a/.changeset/20336-number-comparand-declared-type-contract.md b/.changeset/20336-number-comparand-declared-type-contract.md deleted file mode 100644 index 52f9342cd94..00000000000 --- a/.changeset/20336-number-comparand-declared-type-contract.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`@objectstack/spec/data` declares the platform's numeric grammar for a string, and the contract of the number-comparand declared-type door: which string comparands a field whose declared type is numeric may be compared against (#20336). - -Clause-②: yes - -**The grammar.** A string is numeric when its whole content is a JSON number literal (`-?(0|[1-9][0-9]*)(.[0-9]+)?([eE][+-]?[0-9]+)?`) that names a finite number; it then means what the same characters mean as a JSON number. `NUMERIC_STRING_PATTERN`, `parseNumericString(s)` (the number, or `undefined`) and `readNumericString(s)` (the number, or which of eight named forms the string is: `empty`, `padded`, `placeholder`, `radix-prefix`, `non-finite`, `digit-separator`, `non-json-spelling`, `not-a-number`). `NUMERIC_STRING_GRAMMAR_CASES` records every form with the reason it is admitted or refused: `"12"`, `"-3"`, `"12.5"`, `"1e3"` and every string `String(n)` produces for a finite number are admitted; `""`, `" 12 "`, `"0x10"`, `"Infinity"`, `"NaN"`, `"1,000"`, `"+5"`, `".5"`, `"007"` and any `{placeholder}` are not. - -**The door's contract.** `numberComparandDoorVerdict(field, comparand)` answers, for a comparand at a value position (implicit equality, `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`, and each member of `$in` / `$nin` / `$between`) of a filter on a field whose declared type is in `NUMERIC_VALUE_TYPES` (or a `formula` whose `returnType` is `number`): `door-refusal` (`INVALID_FILTER` / 400) for a non-numeric string, `narrows` with the number for a numeric one, `passes` for any other comparand or field, `deferred` for a `formula` without a readable `returnType`. `numberComparandRefusalMessage(site, context?)` is the refusal the door prints: the field, its declared type, the comparand, its position and what is wrong with it. A fixture object and a derived case table (`NUMBER_COMPARAND_DOOR_FIXTURE`, `NUMBER_COMPARAND_DOOR_CASES`) are published for the engine suite that pins the door. - -**What moves for consumers.** Nothing yet. This is the contract only, and no door reads it in this release: a non-numeric string compared with a number field still reaches the driver as written (a 500 on PostgreSQL, an empty or different result elsewhere). The engine door that refuses it with `INVALID_FILTER` / 400 and narrows a numeric string lands with #20351, and the record validator's number arm adopts the same grammar for writes with #20309. diff --git a/.changeset/20338-draft-read-builder-gate.md b/.changeset/20338-draft-read-builder-gate.md deleted file mode 100644 index 578c819ca9f..00000000000 --- a/.changeset/20338-draft-read-builder-gate.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/rest": patch -"@objectstack/runtime": patch ---- - -**Pending metadata drafts are now served only to a caller with an authoring capability — the check `GET /api/v1/meta/_drafts` already made.** A pending draft is unpublished authoring work. Until now, every door that reads one, other than `/meta/_drafts`, served it to any signed-in caller who could open the item. The draft access that the `previewDrafts` / `state` request declarations, ADR-0106 D4 and ADR-0037 described as admin-gated upstream is now gated. - -Clause-②: no - -- **The doors:** `GET /api/v1/meta/:type/:name?state=draft` and `?preview=draft`, `GET /api/v1/meta/:type?preview=draft`, and `POST /api/v1/analytics/dataset/query` with `previewDrafts: true` or `?preview=draft` on `RestServer`, plus the runtime dispatcher's `/meta` item and list `?preview=draft`. -- **Who may read drafts:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the same predicate `/meta/_drafts` asks, not a second rule. -- **Everyone else gets the read as if the draft switch were absent.** They receive the published version, pruned for them as the plain read prunes it. For a name that has nothing published, they receive that door's own absence answer: `404` on the item read, `404 NOT_FOUND` for a dataset by name, and the item simply missing from a list. The answer is byte-identical to the plain read, so it does not reveal whether a draft exists. For example, `?state=draft` on an app with no pending draft answers such a caller with the published app, not `404 NO_DRAFT`. A dataset preview run by such a caller uses live rows, never a pending seed draft's rows. -- **Unchanged:** callers with an authoring capability read exactly what they read before. Whoever may save an app reads its `?state=draft` whole, and everyone else pruned per caller. `/meta/_drafts` still answers `403` to a caller without the capability, because it lists drafts and has no published answer to fall back to. `/diff` and `/history` are not changed by this release. diff --git a/.changeset/20339-flows-reader-text.md b/.changeset/20339-flows-reader-text.md deleted file mode 100644 index a301074f38b..00000000000 --- a/.changeset/20339-flows-reader-text.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -docs(cli): give the true reason the `flows` translation group stays author-warned (#20339) - -The doc comment on `authorWarnedTranslationGroups` (published in `dist/` as -`utils/i18n-extract.js` and `.d.ts`) said no shipped runner reads the `flows` -group, so a translated wizard string is stored and never shown. That stopped -being true when the liveness ledger flipped `translation.flows.screens` to -`live`: the console's screen-flow runner reads each screen's `title` and each -field's `label` / `placeholder`. The comment now matches the ledger's `flows` -row: only the flow's own `label` is read by nothing yet (#20318), and the warn -is group-level, so it still covers the whole group. - -No behaviour moves. The `flows` row is still `planned` with `authorWarn`, so -`os lint` and `os i18n extract` still hold back every `flows.*` key exactly as -before; that lifts when the row flips, with no edit to the CLI. - -Clause-②: no diff --git a/.changeset/20347-cross-field-comparison-class-authoring.md b/.changeset/20347-cross-field-comparison-class-authoring.md deleted file mode 100644 index 5338e200e21..00000000000 --- a/.changeset/20347-cross-field-comparison-class-authoring.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/lint": minor ---- - -A row-level-security predicate or a sharing-rule condition that compares two fields of different comparison classes — a text field with a number field, a field with a single image or file field, a field with a formula field — is refused when it is authored, at `os validate` / `os build` / `os lint` and, for a permission set, at the metadata save door (#20347). The classification it is judged by is exported once, from `@objectstack/spec/data`. - -**BREAKING** — an accept-set narrowing in `@objectstack/lint`, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. `@objectstack/spec` gains exports only. - -Clause-②: yes (narrowing) - -`record.status != record.amount` (text vs number) and `record.status != record.photo` (text vs a single image) lower to a legal `{ status: { $ne: { $field: … } } }` filter and hold no list, so no authoring rule refused them. Measured before this change, through the real `os validate` and the real plugin-security and ObjectQL on driver-sql: `os validate` reported both valid; the read a `using` scopes answered `INVALID_FILTER` / 400 and a by-id update or delete it scopes `PERMISSION_DENIED` / 403, because driver-sql compiles a column-to-column comparison only between two columns of one comparison class; and a single-record insert judged by the `check` — or by a `using` standing in as the check — was admitted and stored, because the in-process write check compares the two raw values. A formula field (`record.status != record.is_open`) answered the same three ways. The same-class control (`record.status != record.note`) read, updated, deleted and inserted normally. For a sharing rule, the condition lowers and is seeded, and every criteria query it runs meets the same driver-sql refusal. - -What changes: - -- `@objectstack/spec/data` (`filter-cross-field-comparison-class.ts`): the cross-field comparison classification. `CROSS_FIELD_COMPARISON_CLASSES` names the six classes (`numeric`, `text`, `boolean`, `date`, `datetime`, `time`); `CROSS_FIELD_NO_CLASS_REASONS` the three families with none (`list-or-object`, `file`, `formula`); `CROSS_FIELD_COMPARISON_TYPE_CLASSES` classifies every `FieldType` member exactly once, by reference to the existing value-class sets; `crossFieldColumnVerdict` answers one declared column (a multi-capable type flagged `multiple: true` holds a list); and `crossFieldComparisonVerdict` answers two (`comparable`, `cross-class`, `no-class`, or `unjudged` for a type outside `FieldType`). It is lifted case for case from driver-sql's cross-field boundary, and a pairwise parity test in driver-sql holds the two equal over every declared field type. -- `@objectstack/lint`: `validateRlsPredicateEnforceability` reports `rls-predicate-unenforceable`, and `validateSharingRuleEnforceability` reports `sharing-rule-unlowerable-condition`, for every lowered field-to-field comparison (`==`, `!=`, `>`, `>=`, `<`, `<=`, either side, under `!` too) whose two declared columns are not `comparable`. It judges `using` and `check` on every operation, and sharing-rule conditions. The finding names each comparison, each column's declared type and class, and the clause's run-time consequence; the hint lists every class with the declared types it holds, read from the spec. A comparison either side of which holds a list or an object stays the existing list-holding finding, and a clause either arm refuses is not also handed to the engine's filter judge, so one defect earns one finding. - -Not changed: driver-sql and the in-process write check keep their own behaviour here; moving both onto the exported classification is the engine-lane half. A comparison between two columns of one class (`record.amount > record.budget`, `record.stage == record.account`), a file or formula field compared with a literal or tested against `null`, and any column the stack does not declare or declares with a type outside `FieldType`, are not reported. - -No shipped predicate moves: of the 163 `using` / `check` / `condition` string literals in this repository's packages and examples, the 105 that lower hold two field-to-field comparisons, both same-class (`spent > budget`, a hook condition; `a > b`, a gate fixture), and neither is an RLS predicate or a sharing-rule condition. - -To keep such a rule, compare a field only with a field of the same class, or with a literal or a `current_user` value; test a file field with `!= null`; or store the value the rule keys on in a field of the right type. If the two columns really hold comparable values, one of them is declared with the wrong type, and the declaration is what to fix. - - diff --git a/.changeset/20349-object-permission-form-rows.md b/.changeset/20349-object-permission-form-rows.md deleted file mode 100644 index 8e8c8b93278..00000000000 --- a/.changeset/20349-object-permission-form-rows.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/platform-objects": patch ---- - -Clause-②: no - -Five live structured metadata keys are authorable in the metadata form: `object.access`, `object.highlightFields`, `object.requiredPermissions`, `object.searchableFields` and `permission.adminScope`. Each was **declared** by its schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each now has exactly one row, whose control mirrors a row a registered form already carries for the same node shape: - -- `highlightFields` and `searchableFields` (Basics, beside `nameField`) — `widget: 'string-tags'`, the view form's `searchableFields` row: a free-text chip list over `string[]`. A field picker is not offered because the object draft carries no source object for one to read its catalog from. A misspelt entry is not dropped quietly: publishing refuses it (`object-field-ref-unknown`, `searchable-field-unknown`, both at `error`), and so does `os validate`. The object schema's own parse does not judge these names. -- `access` (Advanced, beside `sharingModel`) — a `composite` over one declared `default` select (`public` / `private`), the `lifecycle` row's shape. Absent still resolves to `public`. -- `requiredPermissions` (Advanced) — `widget: 'json'`, **never** `string-tags`: the value is a union of `string[]` and a `{read, create, update, delete}` map, and the tag widget reads a non-array as an empty list and writes the list back, which would silently replace a stored per-operation map. With the `json` hint the renderer resolves the face from the stored value's branch, so a stored map is edited as a map. -- `permission.adminScope` (System Permissions) — `widget: 'json'`, the hint every structured row on the permission form carries; the renderer derives a nested form over its six keys, and edits merge into the stored scope. - -The help text states what the runtime does with each value, including what absence resolves to. The renderer behaviour described above is objectui's metadata-admin form engine at this repository's `.objectui-sha` pin. - -⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles, whose 12 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. - -⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers only. diff --git a/.changeset/20351-number-comparand-door.md b/.changeset/20351-number-comparand-door.md deleted file mode 100644 index fbbd71619a9..00000000000 --- a/.changeset/20351-number-comparand-door.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: a string compared against a number field must be a number: a non-numeric one is refused with `INVALID_FILTER` / 400 on `where`, a per-aggregation `filter` and `having`, and a numeric one is narrowed to its number (#20351) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what a filter may compare a number field with. A string the platform's numeric grammar does not read as a number used to answer 200 with no rows (every row under `$ne`) on memory and SQLite and a 500 on PostgreSQL; it now answers 400, before any read, on every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. `@objectstack/objectql`'s root exports are unchanged. - -FROM a string that is not a JSON number spelling of a finite number (`"abc"`, `""`, `" 12 "`, `"0x10"`, `"1,000"`, `"+5"`, `"007"`, `"Infinity"`, a `{placeholder}`), compared against a `number`, `currency`, `percent`, `rating`, `slider`, `progress` or `summary` field (or a `count` / `sum` / `avg`, or a numeric `min` / `max` / groupBy column in `having`) at the implicit comparand, `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`, or a member of `$in` / `$nin` / `$between` → TO `INVALID_FILTER` / 400, naming the field, its declared type, the comparand, its position and what is wrong with it. The fix is one line: send the number (`12`, `-3.5`, `1e3`) or a string of exactly that spelling (`"12"`). - -Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query` (the two doors agree), three rows (5, 12, 30): - -| position | comparand on a `number` field | before: memory · SQLite · PostgreSQL 16 | now, on all three | -|:--|:--|:--|:--| -| `where` | `$gt` / `$eq` / implicit / a `$in` member `"abc"`; `$eq ""` | no rows · no rows · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | -| `where` | `$ne "abc"` | every row · every row · 500 | `INVALID_FILTER` / 400 | -| `where`, over REST | `$gt "{current_user_id}"` (resolved to the user's id) | no rows · no rows · 500 | `INVALID_FILTER` / 400 | -| per-aggregation `filter` | `$gt "abc"` (`$ne "abc"`) | count 0 (3), on all three | `INVALID_FILTER` / 400 | -| `having` on `sum(amount)` | `$gt "abc"` (`$ne "abc"`) | no group (every group), on all three | `INVALID_FILTER` / 400 | -| `where` | `$gt "12"` / `$eq "12"` | **no rows** · 1 row · 1 row | 1 row, the number's answer | - -What changes: - -- A new door at the engine's single filter collection point, after the temporal-comparand door. It reads `@objectstack/spec/data`'s published contract (`numberComparandDoorVerdict` over `NUMERIC_VALUE_TYPES`, the numeric grammar, and `numberComparandRefusalMessage` for the words); the engine carries no numeric grammar of its own. -- It runs on `where` in both spellings (the filter object and the `FilterArray` sugar) on `find`, `findOne`, `count`, `aggregate`, `update` and `delete`, and on `IObjectQLEngine.judgeFilter`; on each per-aggregation `filter`, against the object's declared fields; and on `having`, over the columns the engine classes numeric. -- A numeric string is rewritten to its number, copy-on-write, before any driver or in-memory evaluator reads it. InMemoryDriver used to compare `"12"` as a string and match nothing; it now matches what `12` matches, as SQLite and PostgreSQL already did. -- A `{placeholder}` compared against a number field is refused unresolved: every filter token resolves to an id or a date, never a number. - -**Who is affected.** A caller that compares a number field with a string that is not a plain number, through any door that reaches the engine: a REST query parameter (`?amount=abc`), a `where` / `$filter` / `filter` body of `POST /data/:object/query`, or an in-process engine call. A caller that sends a number, or a numeric string such as `"12"` or `"1e3"`, is unaffected, except that memory now answers it as the other drivers do. - -**Unchanged.** A numeric comparand: the `$gt 10` controls at `where`, the per-aggregation `filter` and `having` answered identically before and after on memory, SQLite and PostgreSQL, through the engine and REST. Not judged by this door, so the filter reaches the driver as written (pinned per case in the engine suite): a string compared against a non-numeric field; `$null` / `$exists` / `$empty`; the text operators (a text operator over a number field keeps its own refusal); a `{ $field }` reference; a dotted key; a key that names no declared field. A `formula` field is still refused one door earlier with `INVALID_FIELD`. RLS, sharing and tenant predicates the security layer composes onto a query are not judged by this door; a policy predicate is judged at authoring where the host hands the rule the engine's `judgeFilter`. A boolean or a `Date` compared against a number field is outside this contract (it judges strings) and keeps its old answer, measured: `$gt true` no rows on memory, every row on SQLite and a 500 on PostgreSQL; a `Date` no rows on memory and SQLite and a 500 on PostgreSQL. diff --git a/.changeset/20355-rls-write-check-cross-class.md b/.changeset/20355-rls-write-check-cross-class.md deleted file mode 100644 index 58ae899c58c..00000000000 --- a/.changeset/20355-rls-write-check-cross-class.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -'@objectstack/plugin-security': minor -'@objectstack/formula': minor -'@objectstack/driver-sql': patch -'@objectstack/lint': patch -'@objectstack/spec': patch ---- - -fix(security)!: the RLS write check refuses a field-to-field comparison the read refuses — one comparison class, one answer per policy (#20355) - -Clause-②: yes (narrowing) - - - -**BREAKING** — an accept-set narrowing on the row-level write check, shipped as `minor` -under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; -breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the -level). The hand-migration prescription is registered under protocol major 18 as -`rls-predicate-cross-class-field-comparison-refused`, one ADR-0087 D3 entry for the whole -family: the authoring arm `os validate` gained in #20347 and this write-check arm. - -**What changed.** A row-level policy that compares two fields of no shared comparison -class — `record.status != record.amount` (text and a number), `record.status != -record.photo` (text and a file field), `record.status != record.is_open` (text and a -formula field), `record.status != record.meta` (text and a json field) — already had -every read it scopes refused with `INVALID_FILTER` / 400 on the SQL drivers, because -driver-sql compiles a column-to-column comparison only within one class. The write -check did not know the rule: it compared the two raw values in-process, so an insert -or update the policy's `check` judges (or its `using`, standing in as the check) was -admitted and stored whenever that comparison happened to hold. Measured through -plugin-security and ObjectQL on SQLite, sqlite-wasm and PostgreSQL. The write check -now refuses the comparison too, with the read's envelope, `INVALID_FILTER` / 400, for -every insert (single or array), by-id update and predicate update it judges, and -nothing is stored. The same-class comparisons it always compared are compared as -before. The 400 names no column of the policy; the server log names the policy and -both columns. A comparison against a json or `multiple` field is refused by its -declared type now, where it used to be judged by the value each record held. - -**`@objectstack/formula`.** `matchesFilterCondition(record, filter, options?)` takes an -optional third argument: `options.fields`, the object's declared columns (`type` and -`multiple` per field name). Given it, every `{ $field }` comparison between two -declared columns is judged by `crossFieldComparisonVerdict` from -`@objectstack/spec/data` before any record is read, and one the platform defines no -answer for throws `INVALID_FILTER` / 400. Without it the evaluator behaves exactly as -before. Two new exports go with it: `findCrossFieldClassRefusal(filter, fields)`, the -pure judgement, and `crossFieldClassRefusalCarriedBy(error)`, which reads the refused -comparison off the error for a server-side log. - -**`@objectstack/driver-sql`.** `crossFieldComparisonClass` reads the same export -(`crossFieldColumnVerdict`) instead of keeping its own copy of the classification, and -layers above it only its internal type aliases. Every read answers as before. - -**`@objectstack/lint`.** The `rls-predicate-unenforceable` finding for such a -comparison now states the write answer the runtime gives: the in-process write check -refuses it by the same classification and stores nothing. - -**If a policy of yours is refused.** The platform defines no comparison between those -two columns on any path, so the policy never protected a read either. Compare a field -only with a field of the same class — a number with a number, text with text, a -boolean with a boolean, a date with a date, a datetime with a datetime, a time of day -with a time of day — or, if the two columns do hold comparable values, correct the -declaration of the one declared with the wrong type. `os validate` names every such -comparison. diff --git a/.changeset/20356-query-dataset-request-scope.md b/.changeset/20356-query-dataset-request-scope.md deleted file mode 100644 index ec9142341c8..00000000000 --- a/.changeset/20356-query-dataset-request-scope.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -`AnalyticsService.queryDataset` no longer writes the service-wide cube and dataset registries: each call compiles its dataset into a scope of its own (#20356). - -Clause-②: no - -- **What changes**: a dataset query — an inline draft or a saved definition passed to `queryDataset` — used to register its compiled cube and compiled dataset under the dataset's name before it ran. From then on the name meant that request's definition for every later reader (`getMeta()` and `GET /api/v1/analytics/meta`, and every query by that name) until restart, whatever the request's own admission answered. The dataset is now compiled for the call only. The queries it runs resolve its name through a request-local lookup that overlays the shared registry read-only: the cube, the object-level admission and read-scope object sets, the join allowlist and the dataset scope all come from the call's own dataset, and a measure the call infers stays with the call. -- **What does not change**: the request is served as before, from its own definition, with the same admission, read scope and refusals. `registerDataset` still compiles and registers into the shared registry — the configuration door behind `AnalyticsServiceConfig.datasets` and embedders — and configured cubes are untouched. No refusal is added for a dataset whose name matches a configured cube. -- **The one observable difference**: a cube that only a `queryDataset` call ever compiled is no longer listed by `getMeta()`, and is no longer queryable by name through `query()` / `POST /api/v1/analytics/query` after that call returns. To make a dataset addressable by name, register it through `registerDataset` or `AnalyticsServiceConfig.datasets`. diff --git a/.changeset/20358-aggregate-honours-search.md b/.changeset/20358-aggregate-honours-search.md deleted file mode 100644 index d7afb81de14..00000000000 --- a/.changeset/20358-aggregate-honours-search.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor -"@objectstack/metadata-protocol": patch ---- - -A grouped or aggregated query now honours `search`: the groups and every aggregated number are computed over the searched rows, exactly the rows the same query without `groupBy` / `aggregations` returns. - -Clause-②: yes (widening) — `EngineAggregateOptionsSchema` gains two OPTIONAL keys, `search` and `searchFields`, so the accept set of the aggregate options grows. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write them. - -`QuerySchema.search` (ADR-0061) is declared on the query beside `groupBy` and `aggregations`, with no carve-out. Until now, `POST /data/:object/query` accepted a body such as `{ groupBy: ["business_unit"], aggregations: [{ function: "count", alias: "count" }], search: "harbour" }` and answered it with the UNSEARCHED groups — no error and no warning — while the same body without `groupBy` / `aggregations` returned only the searched rows. A grouped list view under a toolbar search would therefore show group headers that ignore what the user typed. - -- **`@objectstack/spec`** — `EngineAggregateOptionsSchema` declares `search` (the bare string, or the structured `FullTextSearchSchema` form) and `searchFields`, identically to `EngineQueryOptionsSchema`. A parse used to strip them. -- **`@objectstack/objectql`** — `engine.aggregate()` (and `ctx.api.object(name).aggregate()`) accepts the two keys it used to refuse as unknown options, and expands them through the same ADR-0061 expansion `find()` uses: the same server-resolved searchable fields, the same `searchFields` narrowing, AND-ed with `where` before the security middlewares run. There is one expander, not two. It applies on both aggregate paths, native `driver.aggregate()` and the in-memory lowering. A key the verb still does not execute, such as `$search`, is refused as before. -- **`@objectstack/metadata-protocol`** — `findData`'s grouped branch passes `search` / `searchFields` to `engine.aggregate()`. `searchFields` is validated on that branch exactly as on the flat one: a column search cannot scan is `400 INVALID_FIELD`. - -Nothing to migrate. A caller that worked around the gap, for example by grouping a page of searched rows on the client, can send the grouped query with its `search` instead. diff --git a/.changeset/20361-liveness-counts-sharded.md b/.changeset/20361-liveness-counts-sharded.md deleted file mode 100644 index 1a4cb23024e..00000000000 --- a/.changeset/20361-liveness-counts-sharded.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`liveness/state-counts.md` is replaced by `liveness/state-counts/.md`: the generated liveness counts are one shard per governed type, and no total is committed anywhere. - -Clause-②: no — no schema key moves, no accept set widens or narrows, no export changes. What changes is the layout of a generated table that ships in the tarball, and no count in it moves. - -The ledgers ship inside this package (`files[]` includes `liveness`), so the changed tarball bytes are: `liveness/state-counts.md` removed, forty `liveness/state-counts/.md` shards added (each carries exactly the row that file published for its type), and the prose in `liveness/README.md`, `liveness/book.json` and `liveness/translation.json` that named the removed file. - -- **Where a count now lives.** A type's row is `liveness/state-counts/.md`, byte-for-byte the row the single file carried. The table's total is not in any file: `pnpm --filter @objectstack/spec check:liveness` sums the shards when it reads them, prints the sum on its success line, and carries it in `--json` as `countsTotal`. At this release the sum is the total the removed file published: 940 live · 5 experimental · 1 live-elsewhere · 148 dead · 9 planned = 1103 classified. -- **Why.** Every change that moved a liveness verdict rewrote the single file's total row, and GitHub's server-side merge runs no custom merge driver, so any two such changes in flight conflicted on that one line. With one file per type, changes that move different types touch different files. -- **Anything that read `liveness/state-counts.md` from the published package** reads the shard for the type it wants, or sums the shards for the total. `gen:liveness-counts` rewrites only the shards whose counts moved and deletes the removed file if a merge brings it back; `check:liveness` fails while it is present. diff --git a/.changeset/20367-one-stack-authoring-shape.md b/.changeset/20367-one-stack-authoring-shape.md deleted file mode 100644 index 607c41d8216..00000000000 --- a/.changeset/20367-one-stack-authoring-shape.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/cli": minor ---- - -**BREAKING — one authoring shape for a stack config.** `objectstack validate` and `objectstack build` now refuse a config whose default export was not built by `defineStack(...)` (either mode) or `composeStacks(...)`, with `STACK_PROVENANCE_MISSING` and exit 1, right after the config loads and before any other check. `composeStacks` refuses an input no producer built the same way. - -Why: the stack family's cross-field refusals (`STACK_CAPABILITY_UNKNOWN`, `STACK_CROSS_REFERENCE_INVALID`, `STACK_NAMESPACE_PREFIX_INVALID`, `STACK_SINGLE_APP_VIOLATION`, `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`, `STACK_TRIGGER_CAPABILITY_REQUIRED`) run inside `defineStack` only. The same defective stack exported as a plain object passed both commands at exit 0, and `objectstack build` shipped it. Re-running those refusals on whatever the config exports cannot fix that: a built stack carries each bound action twice, so the re-run refuses every correct project that has one. So the commands check who BUILT the export instead. - -- `@objectstack/spec`: `defineStack` and `composeStacks` stamp a non-enumerable `Symbol.for` provenance mark on what they return. The mark is invisible to the schema, to `Object.keys` and to `JSON.stringify`, so no compiled artifact changes. New export: `hasStackProvenance(value)` — `true` only for a value one of the two producers returned. New registered error code: `STACK_PROVENANCE_MISSING` (422), raised by `composeStacks` for an unbuilt input. -- `@objectstack/cli`: `loadConfig` reads the mark off the default export before merging named exports into it (the merge is a spread, which drops the mark), and exposes it as `LoadedConfig.stackProvenance`. `objectstack validate` / `objectstack build` refuse on `false` through their existing error path: under `--json`, `error` + `code: 'STACK_PROVENANCE_MISSING'`. The envelope has no new fields. `objectstack dev` compiles through `objectstack build`, so it refuses the same way when it compiles. `objectstack serve`, `objectstack migrate`, `objectstack lint` and `objectstack generate` load configs exactly as before. - -**Migration** — FROM a plain-object (or copied) default export TO the value `defineStack` returns: - -```ts -// FROM -export default { - manifest: { id: 'com.example.app', namespace: 'app', version: '1.0.0', type: 'app', name: 'App' }, - objects: [/* … */], -}; -// or: export default { ...defineStack({ … }), api: { … } }; - -// TO -import { defineStack } from '@objectstack/spec'; - -export default defineStack({ - manifest: { id: 'com.example.app', namespace: 'app', version: '1.0.0', type: 'app', name: 'App' }, - objects: [/* … */], - // every stack key inside the call — `api`, `plugins`, `requires`, … -}); -``` - -One-line fix: wrap the export in `defineStack(...)`, and move any key spread onto a copy into the call. For compositions, wrap each input: `composeStacks([defineStack({ … }), …])`. Once wrapped, a config that used to pass can now fail with one of the family's own codes. Those findings were always there; the plain export hid them. Fix each one as its message says. Host-style configs whose `plugins` hold plugin instances are covered by the same rule, and the same wrap fixes them (`defineStack` accepts plugin instances). A project already exporting `defineStack(...)` or `composeStacks([...])` of `defineStack` inputs is unaffected. - -Clause-②: yes (narrowing) - - diff --git a/.changeset/20371-component-props-action-element-rows.md b/.changeset/20371-component-props-action-element-rows.md deleted file mode 100644 index 444f82e773a..00000000000 --- a/.changeset/20371-component-props-action-element-rows.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`ComponentPropsMap` declares `action:button`, `action:group`, `action:menu`, `action:icon`, `element:definition-list` and `element:repeater` — six blocks in objectui's curated public vocabulary that had no row (#20371). Each row is strict from birth, with its key set measured from the objectui renderer's own read points at the `.objectui-sha` pin, not transcribed from objectui's `UIActionSchema`, the registrations' `inputs`, or this package's object-metadata `ActionSchema`. - -Clause-②: yes - -Six new declared rows on a published surface, and two types the `element:` vocabulary now answers for, so the accept set a consumer writes against grows. Nothing previously accepted by a declared row is refused and nothing is retired. - -What changes at the authoring doors (`os validate` / `os build` / `os lint`): - -- **`element:definition-list` and `element:repeater` are no longer refused as `component-type-unknown`.** Both sit inside the reserved `element:` namespace; with no enum member and no row, the vocabulary refused them although objectui registers, publishes and offers both in the Studio page designer. They join the `element:` vocabulary through their rows (no enum member), and a typo inside the namespace (`element:repeatr`) is still refused. -- **The props gate now judges all six.** The four `action:*` types sat outside every reserved namespace, so an authored `properties` bag on them was skipped — a misspelled key parsed, stored and did nothing. Findings stay at the gate's existing warning tier. - -Measured decisions worth knowing when you author these blocks: - -- **`action:button` / `action:icon`** — `name` is optional (the renderer reads `name ?? label`). The executor is `actionType`; `type` inside `properties` is refused with a rename to `actionType` (on a page component `type` is the component itself). `visible` / `disabled` take a boolean, a CEL string or a `{ dialect, source }` envelope. The legacy `enabled` fallback and the host-only `autoTrigger` flag are refused with a prescription. `action:icon` reads no `size`. `objectName` names the object the action acts on (forwarded to the runner; omitted, the action acts on the page's object). -- **`action:group` / `action:menu`** — `actions` is a LIST of action objects (a member's executor is its own `type`); a bare list of action names is refused. A member's `objectName` rides the member object; the containers themselves read no `objectName`. `action:group` reads no group-level `name`, so it is refused with a prescription. `variant` / `size` take the Button primitive's vocabulary; `primary` and `md` are accepted only on `action:button` (and `primary` on `action:icon`), where the renderer maps them. -- **`element:definition-list`** — `items` of strict `{ term, description? }`; `columns` is the NUMBER `1` or `2` (the string `'2'` renders one column and is refused). -- **`element:repeater`** — `object` is required; `filter` / `sort` take the family's one orthography (`ViewFilterRule[]`, `SortItem[]`); `fields` takes a field name or `{ field }` (an unrendered `label` is refused). diff --git a/.changeset/20374-plain-text-faces-unescaped.md b/.changeset/20374-plain-text-faces-unescaped.md deleted file mode 100644 index fe962590fe2..00000000000 --- a/.changeset/20374-plain-text-faces-unescaped.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/plugin-email": patch ---- - -A template's plain-text faces — the subject and `body_text` — now render their `{{x}}` values verbatim instead of HTML-escaping them, so a link in the text part keeps its literal `&` (#20374). - -Clause-②: no - -- **What was wrong.** `EmailService` rendered every face of a `sys_email_template` row through the HTML escaper. In the text/plain part of the built-in verification, password-reset, invitation and magic-link mails the link read `…?token=…&callbackURL=%2F`: a plain-text client, or a user copying the link, got a parameter named `amp;callbackURL`, and the post-verification redirect fell back to `/`. The same escaping put `&` / `'` into subjects built from names such as `R&D` or `O'Brien`. -- **What changes.** Escaping now follows the face. `body_html` is markup and is rendered exactly as before: `{{x}}` HTML-escaped, `{{{x}}}` not. The subject and `body_text` are plain text: every hole renders its value as-is, and triple braces mean the same as double there. The switch is in the renderer, so it covers every row that reaches `sendTemplate` / `renderTemplate` — the built-in auth templates, declared `emailTemplates` and rows authored in Studio — with no template edit. -- **Who sees it.** The persisted `sys_email.body_text` and `subject`, the delivered text part and Subject header, and `IEmailService.renderTemplate()`'s `text` / `subject` (which the messaging inbox channel stores as a notification's body and title). A row with no `body_text` is unchanged: its text part was already derived from the HTML with the entities decoded. -- **Unchanged.** The exported `renderTemplate()` helper is still the HTML renderer. Nothing an author writes needs to change. diff --git a/.changeset/20376-plugin-dev-literal-app-imports.md b/.changeset/20376-plugin-dev-literal-app-imports.md deleted file mode 100644 index 2222d11cea1..00000000000 --- a/.changeset/20376-plugin-dev-literal-app-imports.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/plugin-dev": patch ---- - -`DevPlugin` now loads `@objectstack/setup` and `@objectstack/account` through literal `import('…')` specifiers, like every other declared dependency it loads, instead of one variable specifier shared by a loop (#20376). - -Clause-②: no - -- **What was wrong.** The setup / account app-package loop imported `spec[0]`. A variable specifier cannot be resolved when the file is transformed. Under vitest, every `DevPlugin.init()` in a test therefore made two round trips to the main test process, even with both packages mocked, inside every clocked test window that boots `DevPlugin`. The main process is shared by the whole run, so on a busy CI shard those round trips wait on other files' work, against this package's 5000 ms test budget. -- **What changes.** Each loop entry carries its own literal loader. The `try` / `catch` and the absent-package report around each load are unchanged: a missing package is still logged as, for example, `✘ @objectstack/setup not installed — skipping its app`, and a present one that fails is still reported as present-but-failed. -- **Unchanged.** Nothing an author or operator configures or sees changes. Both packages were already declared dependencies of `@objectstack/plugin-dev`, and both the ESM and the CJS build keep a native `import("…")` for each. diff --git a/.changeset/20378-diff-history-authoring-doors.md b/.changeset/20378-diff-history-authoring-doors.md deleted file mode 100644 index f78652a5ddc..00000000000 --- a/.changeset/20378-diff-history-authoring-doors.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -**`GET /api/v1/meta/:type/:name/diff` and `GET /api/v1/meta/:type/:name/history` are now authoring doors: a caller without an authoring capability is refused, as `GET /api/v1/meta/_drafts` refuses.** Before this release, any signed-in caller who could open an item could read its version diff and its change history. Both doors read the metadata version log, which records a draft save exactly as it records a published save. So a member could read an item's unpublished draft through `/diff`, either by naming the draft save's version in `from`/`to` or through the default range once a draft was pending. Through `/history`, the same member could read the draft-save events. This follows the maintainer's ruling on #20378 (letter B, comment 5865708652), which pulls both doors back into the declared contract: draft and preview reads are admin-gated upstream (ADR-0106 D4). It narrows the earlier ruling that let every caller who may open an app read `/diff` pruned, for these two doors only. - -Clause-②: no - -- **Who may read them:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the predicate `/meta/_drafts` and every draft switch already ask, not a second rule. -- **Everyone else:** `403` with code `FORBIDDEN`, in the same nested `error` envelope `/meta/_drafts` answers. The refusal is decided on the caller before the query is parsed and before any item or version is read. So it is the same answer for an item that exists, one that does not, and one that exists only as a draft, and it carries no item name, version or event. The message names the door, not drafts. -- **Unchanged:** callers with an authoring capability read both doors exactly as before, per-caller pruning included: on `/diff`, whoever may save an app reads both sides whole, and any other admitted caller reads them pruned. `/layers` and the deprecated `?layers=true` read the active row, so they keep answering every caller who may open the app with the pruned plain-read answer. `/audit` is unchanged. - -A client that read `/diff` or `/history` as a member now receives `403 FORBIDDEN`. To read them, call as a caller holding one of the three capabilities above. diff --git a/.changeset/20379-currency-precision-d3-text.md b/.changeset/20379-currency-precision-d3-text.md deleted file mode 100644 index a325d9b287a..00000000000 --- a/.changeset/20379-currency-precision-d3-text.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Reword the `CurrencyConfigSchema.precision` clause in two major-18 D3 migration entries (`os migrate meta`) — the key it describes as "unchanged" is retired in this same protocol major (#20379). - -`18.field-scale-precision-integer-refused.ts` and `18.ui-form-field-precision-scale-integer-refused.ts` each carried a sentence distinguishing `CurrencyConfigSchema.precision` from the field/row-level `scale`/`precision` keys those entries retire, saying the currency key is a different surface and is unchanged. `currency-config-precision-removed` (D3 `currency-config-precision-retired`) retires that same key in this same major, so the sentence became false the moment that conversion landed. Reworded only that clause in each entry to say the key was retired in this same protocol major by `currency-config-precision-removed`. - -The form-field entry's other clause was also wrong on its own terms: it named a "gantt `scale` enum" that `GanttConfigSchema` does not declare (its granularity key is `viewMode`; `strictObject` refuses `scale` there). Corrected it to name the surface that actually carries an enum-valued `scale` — `TimelineConfigSchema.scale`, still unchanged — and to say plainly that the gantt view has no `scale` key. - -Clause-②: no - -No schema, key, conversion or verdict changes — text only. `packages/spec/src/migrations/registry.ts` regenerated with `gen:migration-registry` so the printed `os migrate meta` text matches. diff --git a/.changeset/20381-adhoc-cube-request-scope.md b/.changeset/20381-adhoc-cube-request-scope.md deleted file mode 100644 index 5575ef76f48..00000000000 --- a/.changeset/20381-adhoc-cube-request-scope.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -`AnalyticsService.query()` and `generateSql()` no longer write the service-wide cube registry before the object-level read admission has admitted the request, and never write a caller-named measure into a registered cube (#20381). - -Clause-②: no - -- **What changes**: both ad-hoc doors — `query()` (`POST /api/v1/analytics/query`) and `generateSql()` (`POST /api/v1/analytics/sql`) — resolved the query's cube and recorded what `ensureCube` minted straight into the shared registry, ahead of the admission check. A request refused `PERMISSION_DENIED` still left the cube it inferred for the refused object in the registry, and a suffix measure a caller named on a registered cube (`_sum`, `_count_distinct`, …) was appended to that cube for every later reader, whether the request was refused or admitted. Both doors now run in the same request-local scope `queryDataset` runs in: what `ensureCube` mints stays with the call, and the admission, read scope and strategy all read it from there. -- **What does not change**: every request is served as before, with the same admission, read scope, refusals, codes and statuses, and a caller-named suffix measure is still served to the caller who named it. A cube inferred for an ADMITTED ad-hoc query is no longer registered either; the separate #20381 entry that retires inferred-cube registration describes that change. Configured cubes and datasets registered at construction (`AnalyticsServiceConfig.cubes` / `datasets`) are untouched. -- **What `getMeta()` lists, the one observable difference**: `getMeta()` and `GET /api/v1/analytics/meta` no longer list a cube inferred for a refused request, and no longer list a suffix measure some caller named on a registered cube — a registered cube is listed as it was registered. diff --git a/.changeset/20381-retire-inferred-cube-source.md b/.changeset/20381-retire-inferred-cube-source.md deleted file mode 100644 index f83b96e93a0..00000000000 --- a/.changeset/20381-retire-inferred-cube-source.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -A cube `AnalyticsService` infers for an ad-hoc `query()` or `generateSql()` request is no longer registered in the service-wide cube registry, even when the request is admitted, so `getMeta()` and `GET /api/v1/analytics/meta` list configured cubes only (#20381). - -Clause-②: no - -- **What changes**: an ad-hoc request naming an object that no cube is configured over (`POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`) is still served from a minimal cube inferred from that request's own members. That cube now lives only in the request that inferred it, like a suffix measure a caller appends to a configured cube. Before, an admitted request left it in the shared registry, so `getMeta()` listed it to every caller, including callers who may not read the object, together with the member names the first caller used. Its contents depended on who had queried what since boot, and it was lost on restart. -- **What does not change**: every request is served as before, with the same answer, admission, read scope, refusals, codes and statuses. A repeat request for the same object infers the cube again, through the same existence and source-field checks, and gets the same answer. Configured cubes (`AnalyticsServiceConfig.cubes`) and datasets registered through `registerDataset` (the constructor's `datasets`, or an embedder) are registered and listed as before, and they are now the registry's only writers. -- **What to do**: nothing, unless something reads `getMeta()` / `GET /api/v1/analytics/meta` expecting to find a cube that only an ad-hoc query inferred. No consumer in this repository does. Author that cube explicitly (`defineCube`, or the analytics service's `cubes` config) so that it is listed, and listed the same way after a restart. diff --git a/.changeset/20386-progress-min-max-enforced.md b/.changeset/20386-progress-min-max-enforced.md deleted file mode 100644 index bbb650cdd7a..00000000000 --- a/.changeset/20386-progress-min-max-enforced.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: a `progress` field's declared `min` / `max` are enforced on writes — a value outside them is refused with `min_value` / `max_value`, exactly as on `number` (#20386) - -Clause-②: no (narrowing) - -**BREAKING** — a narrowing of the write accept set on `@objectstack/objectql`, shipped as `minor` under the repo's launch-window convention (`check-changeset-no-major` refuses `major` until GA); the breaking-ness is carried by this banner and the ADR-0087 disposition, never by the level. Nothing an author writes changes spelling: `min` and `max` keep their keys, their type and their legality on every field type. - -`FieldSchema.min` / `max` declare a check ("Checked on the WRITTEN value only") with no type exclusion, but the record validator returned for a `progress` field right after its finite-number check, above the bounds. So a `progress` field declaring `max: 100` stored `150`, and one declaring `min: 0` stored `-5`, with `201` on memory and SQLite, while a `number` field with the same bounds refused both. The bounds now bind on `progress` at the one place a write is judged. - -**What a caller sees, before → after.** A `progress` write outside a declared bound: `201`, stored as sent → `400 VALIDATION_FAILED` with field code `max_value` (`constraint: { max }`) or `min_value` (`constraint: { min }`), nothing stored. That is the `number` field's answer, envelope for envelope, in all four locales. The REST create, batch, update and updateMany routes all answer it, and `validate` (the dry run) predicts it. A value inside the bounds, or on either bound (both are inclusive), writes exactly as before. Only a write that CARRIES the field is judged: a stored value outside a bound is never re-read and survives an update that does not send it. - -The fix, when a write is refused: send a value inside the bounds, or widen or delete the field's `min` / `max` to match what it really holds. - -⛔ Only the bounds. `scale` and `precision` stay unread on `progress`: each key's own contract names the types it binds on, and `progress` is in neither set, so `33.5` still writes into a `progress` field that declares `scale: 0`. - -**Who is affected, measured** on `origin/main` `dc0ab6a2e`: the two example-app `progress` fields (`examples/app-showcase` `showcase_task.progress` and the field zoo's `f_progress`) both declare `min: 0, max: 100`, and every value their seeds and actions write (12 seed rows, one `progress: 100` action) is inside. The console's `progress` editor, objectui's `SliderField`, drives a Radix slider bounded by the field's declared `min` / `max`, so it cannot emit a value outside them. - - diff --git a/.changeset/20387-aggregate-one-double.md b/.changeset/20387-aggregate-one-double.md deleted file mode 100644 index 691961c7a1a..00000000000 --- a/.changeset/20387-aggregate-one-double.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -fix(driver-sql): `sum` / `avg` accumulate in double on PostgreSQL and MySQL, as they do on SQLite and on the engine's rows path - -Clause-②: no - -`SqlDriver.aggregate` let PostgreSQL (`numeric`) and MySQL (`DECIMAL`) add exact decimals, -while SQLite and the engine's rows path add JS doubles. So a `number` column holding `0.1` -and `0.2` summed to `0.3` on the PostgreSQL and MySQL native paths and to -`0.30000000000000004` everywhere else, and `having { s: { $eq: 0.3 } }` kept the group on -those two faces only. `avg` over an integer column diverged too: MySQL rounds a decimal -average to 4 places (`avg` of 1, 2, 2 answered `1.6667`), and PostgreSQL's `numeric` -average rounds to 16 places before the answer becomes a double (`11 / 9` answered -`1.2222222222222222`, where every other face answers `1.2222222222222223`). - -**The precision policy, applied to the arithmetic.** The policy already stated for the -answer's type (one JS double on every dialect, the loss beyond a double's precision declared) -now also decides how the answer is computed: - -- `avg` accumulates in double on PostgreSQL and MySQL, over every declared numeric or - boolean column. -- `sum` accumulates in double over a column that holds fractions: `number`, `currency`, - `percent`, `slider`, `progress`, `summary`, and the driver's `float` alias. -- `sum` over an integer-valued column (`rating`, the `integer` / `int` aliases, a boolean) - keeps the database's exact integer total, rounded once to the double. -- `count`, `count_distinct`, `min` and `max` are unchanged. SQLite is unchanged. - -Each value added is the column's text parsed as a double: the value the SQL client hands -`find()`, and so the value the rows path adds. For the exact-decimal columns this equals a -plain cast. For a binary `real` / `FLOAT` column, which a table created before the -exact-decimal columns still has, a plain cast would add the widened binary value -(`0.30000000447034836` for `0.1 + 0.2`). MySQL's `CAST(… AS DOUBLE)` needs MySQL 8.0.17 or -later. - -Route chosen: (a), accumulate in double on the native faces. The other route, (b), was to make -the rows path add exact decimals and round once. It was rejected because SQLite's native `sum` -adds the stored doubles (`0.30000000000000004`), so the rows path would then disagree with SQLite -for exactly `0.1 + 0.2`. - -**Residual, stated.** On PostgreSQL and MySQL the double sums are added in row order, one after -another, without compensation. SQLite 3.43 and later adds with compensated summation, and since -#20489 so does the engine's rows path. So for a group of three or more fractions, the PostgreSQL -and MySQL native answer can still differ from SQLite's and the rows path's in the last place -(`0.1 + 0.2 + 0.3`: PostgreSQL / MySQL native `0.6000000000000001`, SQLite and the rows path -`0.6`). Before #20489, SQLite's own two paths differed there too. Two addends cannot differ. - -A consumer that compared `sum` / `avg` over a fractional column with a decimal literal on -PostgreSQL or MySQL (`$eq: 0.3`) now gets the answer SQLite and the rows path already gave: -compare with a range, or with the double the arithmetic produces. diff --git a/.changeset/20389-open-posture-verification-optout.md b/.changeset/20389-open-posture-verification-optout.md deleted file mode 100644 index b7e1c7e560a..00000000000 --- a/.changeset/20389-open-posture-verification-optout.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/plugin-auth': minor -'@objectstack/spec': patch ---- - -**plugin-auth: under the `open` audience posture, the deployment can turn email verification off** - -Clause-②: yes - -Under `audience.posture: 'open'`, an explicit `emailAndPassword.requireEmailVerification: false` -declared by the **deployment** is now honoured instead of refused at config entry. The deployment -declares it through its stack config (the `AuthManager` constructor), host code calling -`AuthManager.applyConfigPatch()`, or the `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false` env override -of the `auth.require_email_verification` setting. A sign-up is then signed in at once, with no -verification mail. This is for a deployment with no mail transport that trusts its sign-ups, -such as a pre-production environment, which otherwise dead-ends every new account at the verify -page. - -Nothing changes for anyone who does not opt out: - -- `open` with the value absent or `true` still forces verification on. -- `email_domain` still refuses an explicit `false`, from any source, with the same message. The - domain allowlist is the only gate there, so an unverified sign-up could claim a colleague's - address. -- `invite_only` is unchanged. -- A `false` stored only through the settings console is still refused under `open`. The console - can agree with the deployment's opt-out, never make one. - -The opt-out is loud. `AuthPlugin` logs one warning at boot naming the posture and the -consequence: anyone can register an address they do not control, and an organization invitation -sent to that address can then be accepted by that account. `getPublicConfig()` reports -`requireEmailVerification: false`, the value actually wired, because the wiring and the -advertisement now read one resolver. - -`AuthManager.applyConfigPatch()` takes an optional second argument, -`{ requireEmailVerificationFrom: 'deployment' | 'console' }`. It defaults to `deployment`; the -settings binding passes `console` for a stored value and `deployment` for an env override. diff --git a/.changeset/20390-conversion-retired-after-window.md b/.changeset/20390-conversion-retired-after-window.md deleted file mode 100644 index c96c4700994..00000000000 --- a/.changeset/20390-conversion-retired-after-window.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-core': minor -'@objectstack/metadata': patch ---- - -feat(spec,metadata-core)!: every retired ADR-0087 conversion carries `retiredAfter`, and the artifact door opens its window per entry (#20390) - -Clause-②: yes - - - -**BREAKING** for code that implements `MetadataConversion` itself — shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above). `MetadataConversion` is now a type alias of a live-or-retired union: an entry with `retiredFromLoadPath: true` must also carry `retiredAfter`, a stable `x.y.z` string, and a live entry carries neither. tsc names the missing member (`Property 'retiredAfter' is missing`). No in-repo conversion is left unstamped, and no metadata an author writes changes. - -**What the field means.** `retiredAfter` is the last published `@objectstack/spec` version whose authoring surface still accepted the entry's old shape. It is a fact when the entry lands: the package's own version label at that moment, because `main` carries the last release's label until the next release is cut. Every published retired entry is stamped from the published tarballs — the stable release just before the first tarball that carries it retired — and each entry not yet in any published tarball carries the current label, `17.4.0`. - -**Why the artifact door needed it.** Between two releases, `main` refuses keys that the next release retires while its label still reads the last release. The artifact-ingestion door (`applyArtifactForwardConversions`) compared an artifact's `engines.protocol` floor with that label alone, so an artifact built by the last published CLI — floor `^17.4.0`, dashboard `chartConfig.type`/`xAxis`/`yAxis` and page `assignedProfiles` — read as "authored current": nothing was converted and the strict parse refused the boot. The door now replays a registry entry when the floor is below the runtime label, **or** at or below that entry's `retiredAfter`. After a release the rule reduces to the old one, and an artifact whose floor is above an entry's `retiredAfter` still meets that entry's tombstone — a floor of `^17.5.0` on a 17.5.0 runtime is refused, not converted. `DEFAULT_FLIPS_NOT_REPLAYED_HERE` is still read first. - -**`@objectstack/metadata-core`.** `ArtifactForwardConversionVerdict` gains `'converted-retired-after'`: the floor is at or above the runtime label, but at or below the `retiredAfter` of at least one retired entry, and only those entries are replayed. `ArtifactForwardConversionResult` gains `replayedRetirements` (exported element type `ArtifactReplayedRetirement`): under that verdict, each retirement this runtime enforces past the artifact's floor, with its `retiredAfter`; empty for every other verdict. A consumer that switches exhaustively over the verdict adds that arm. - -**`@objectstack/metadata`, the artifact door — the arm added.** `MetadataPlugin` now reads which verdicts open the window from one total table over `ArtifactForwardConversionVerdict`, with `'converted-retired-after'` on the open side. The #12915 unbound form-predicate notice rides that same reading, so a 17.4.0-built artifact carrying a bare-root form predicate on `main` is announced now, rather than only once the package label moves past 17.4.0. A verdict added later fails to compile until it is placed on one side of the window. Under the new verdict the conversion summary no longer says the artifact "predates this runtime's spec" beside a runtime version equal to its floor: it names the retirement this runtime enforces past the artifact's floor, with the release that last accepted the shape, and says the artifact converts again on every boot until it is rebuilt with tooling from a release that ships the retirement. Summaries are still one per conversion per artifact, naming the site count. - -**Census.** 94 retired entries when this landed: 73 published (first retired in 15.1.0: 5, 17.0.0: 45, 17.1.0: 5, 17.2.0: 2, 17.3.0: 8, 17.4.0: 8) and 21 unpublished. `packages/spec/src/conversions/retired-after.census.json` holds the raw per-release facts, and `retired-after.census.test.ts` pins every value against it, offline. `packages/spec/scripts/build-retired-after-census.ts` re-derives the census from the npm registry (tarball integrity checked). Run it after each stable publish; `docs/releases-maintenance.md` lists that step in the GA release flow. diff --git a/.changeset/20393-build-view-container-name.md b/.changeset/20393-build-view-container-name.md deleted file mode 100644 index b1c6466232e..00000000000 --- a/.changeset/20393-build-view-container-name.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os build` / `os compile` refuses a `views:` container whose own `name` disagrees with the object it binds to, and writes no artifact the server would refuse at boot (#20393) - -Clause-②: no - -A view container is registered under the object it binds to. When its own `name` -is set to something else, for example `{ name: 'order_line', object: 'my_app_order_line', list: { … } }`, -the server refuses the whole stack at boot. `os validate` has refused that stack -since #20331, but `os build` still exited `0` and wrote `dist/objectstack.json` -carrying the container, so `os serve` then refused the artifact it was handed. - -`os build` now runs the same check `os validate` runs, right after the schema -check and before anything is written, and prints the message the server prints -at boot. The text form and `--json` both exit `1`, and no artifact is written. The -`--json` failure payload is `{ success: false, errors, warnings, conversions }`, -with one `errors` entry per refused container: `path` (for example `views[0]`, or -`packages[1].manifest.views[0]` in a multi-package stack), `code: 'VALIDATION_ERROR'`, -`httpStatus: 400` and `message`, the same rows `os validate --json` reports. A stack -the server accepts builds exactly as before, with the same output. - -**Fix:** remove the container's `name`, or set it to the object name the message names. diff --git a/.changeset/20397-diff-default-range-labels.md b/.changeset/20397-diff-default-range-labels.md deleted file mode 100644 index 663c490e8bb..00000000000 --- a/.changeset/20397-diff-default-range-labels.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-protocol): `diffMetaItem`'s default range labels its to side with the active row's own version, so `GET /meta/:type/:name/diff` with no `from` / `to` names the versions it compares while a draft is pending (#20397) - -With no `toVersion`, the to side is the current active `sys_metadata` row. Its body was compared, but `toVersion` came from the newest `sys_metadata_history` row, which is a draft save whenever a draft is pending: every draft save appends a history row. The labels and the bodies then named different rows. Measured on the real REST stack, an app with one active save and two draft saves answered `fromVersion 2 → toVersion 3` over its version-1 body, and a view with one active save and one draft save answered "no changes" labelled `1 → 2` while version 2 differs. - -- **Now:** `toVersion` is the active row's own `version`, read in the same read as its body. The default `fromVersion` rule is not changed by this entry (#20451, in the same release, then moves it to the nearest earlier version whose body differs from the to side's). An item whose active row is version 2 with a draft pending answers `1 → 2`, the same answer as `?from=1&to=2`. -- **No active row** (a draft-only item, or a deleted one): the to side is absent, and both labels are `null` with empty buckets, as the response schema declares for an absent side. Before, a draft-only item was labelled with its newest draft save, and its from side could be an earlier draft save's body. A deleted item was labelled `N-1 → N` up to its tombstone. That deletion is still read by naming its versions (`?from=N-1&to=N`). -- Unchanged: the response shape, explicit `from` / `to` ranges, and the default range of an item with no draft pending. diff --git a/.changeset/20400-datasource-waiver-missing-only.md b/.changeset/20400-datasource-waiver-missing-only.md deleted file mode 100644 index d9d2723b4f4..00000000000 --- a/.changeset/20400-datasource-waiver-missing-only.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -`component-props-invalid` no longer hides a wrong `object` prop on a component that also carries a `dataSource` binding - -Clause-②: no - -A page component whose `dataSource.object` names the object may leave the flat -`properties.object` shorthand out: the binding supplies it, so the rule does not -report the props schema's required `object` as missing. That waiver was matched -on the issue's path alone, so it also swallowed every other issue the props -schema raised at `object`. A present but wrong value, such as `object: 7` or -`object: null`, was reported without a binding and silently passed with one. - -The waiver now covers what its contract says: a missing `object`, meaning no -key or an explicit `undefined`. A value the author did write is judged as -written, and it is reported at `properties.object` exactly as it is on the same -component without a binding. - -Effect on `os validate`, `os lint` and `os build`: a document that sets both a -`dataSource` binding and a wrong-typed `properties.object` now gets one -`component-props-invalid` warning it did not get before. The rule stays -advisory: without `--strict` nothing that validated before is refused; under -`os validate --strict` or `os lint --strict` the new warning fails the run, as -every warning does. A component that binds -through `dataSource` and omits `properties.object` is still clean. diff --git a/.changeset/20408-dispatcher-meta-item-parity.md b/.changeset/20408-dispatcher-meta-item-parity.md deleted file mode 100644 index 6e7ec88297b..00000000000 --- a/.changeset/20408-dispatcher-meta-item-parity.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@objectstack/rest': minor -'@objectstack/runtime': patch ---- - -fix(rest, runtime): the runtime dispatcher's `/meta` doors scope a caller to the organization `RestServer` scopes them to, and its item read, book tree and list answer what `RestServer`'s answer (#20408) - -Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains seven value exports (`createMetaItemAnswer`, `createMetaBookTreeAnswer`, `metaCallerOrganizationId`, `metaReadOrganizationId`, `projectMetaObjectSchema`, `refuseUnknownMetaListType`, `translateMetaEnvelope`) and five type exports (`MetaItemAnswer`, `MetaItemAnswerSources`, `MetaItemRequest`, `MetaBookTreeAnswer`, `MetaBookTreeSources`), and `MetaListAnswer` gains an optional `cacheControl`. `MetaListAnswerSources`, new in this same release with `createMetaListAnswer`, takes the transport's object-schema masker (`resolveObjectMasker`) instead of a whole-mask port, so the chain decides the cache posture for both transports. Nothing any published version exported is removed, renamed or narrowed. `@objectstack/runtime` publishes no new surface and stays a `patch`. - -A host that mounts only the `${prefix}/*` catch-all (`createHonoApp`, and any -adapter written on the public `HttpDispatcher` API) serves `/meta` through the -runtime dispatcher. Until now, on such a host: - -- **A member removed from an organization kept its metadata partition.** The - dispatcher's `/meta` doors took the organization from the session's - `activeOrganizationId` as stored. Under a wall-enforcing tenancy posture the - identity resolver DROPS a claim naming an organization the caller no longer - belongs to, and `RestServer` reads that vetted value. The dispatcher did not, - so for the rest of the session the removed member was served that - organization's org-scoped overlays (`view`, `dashboard`, `report`, - `translation`, `email_template`) by the item read, the list, `/published` and - `?state=draft`, listed its pending drafts on `GET /meta/_drafts`, and had a - `PUT /meta/:type/:name` land in its partition. Every `/meta` door here now reads - the vetted organization on the execution context, the value `RestServer` reads. -- **`GET /meta/:type/:name` answered a different body.** Nothing was translated - whatever `Accept-Language` or `?locale=` asked for. A doc kept its whole - `translations` map, in no locale. An object schema came with no - `sortability`. The answer had no `Vary: Accept-Language`. -- **`?preview=DRAFT`** (any casing but lower) from a builder read the published - world on the item read and the list. `RestServer` compares it - case-insensitively. -- **`GET /meta/object/:name?preview=draft`** from a builder answered the ACTIVE - schema, never the pending draft. -- **`GET /meta/totally_invented_type`** answered `200 {"items": []}`. `RestServer` - refuses a segment that names no metadata type with `400 INVALID_REQUEST`. -- **`GET /meta/book/:name/tree`** was no route: `404 ROUTE_NOT_FOUND` to a signed-in - reader and `401` to an anonymous reader of a `public` book (ADR-0046 §6.7). -- **An object schema served under an undetermined field visibility** (ADR-0106 - D6 tier 2: served unmasked) carried no `Cache-Control`. `RestServer` answers - `private, no-store`. This was true of the list, the item read, `/published` and - the legacy one-segment object read. - -**What changed.** Everything `RestServer`'s `GET /meta/:type/:name` does after -the store read moved, unchanged, into `createMetaItemAnswer`: absence, the item -gate, the doc locale collapse, the object mask and its cache posture, and the -body (the translation and `sortability`, `translateMetaEnvelope`). The book-tree -route's whole answer moved into `createMetaBookTreeAnswer`, and the list's -unknown-type refusal into `refuseUnknownMetaListType`. The list chain now -applies the object mask itself (`projectMetaObjectSchema`) and reports the cache -posture. The dispatcher's `/meta` domain calls each of these, and takes its -organization from `metaCallerOrganizationId` / `metaReadOrganizationId`, which -`RestServer`'s list and item reads ask too. - -`RestServer`'s own answers are unchanged: the move is a refactor on that side, -and every existing REST test passes unedited. diff --git a/.changeset/20412-auth-settings-sibling-isolation.md b/.changeset/20412-auth-settings-sibling-isolation.md deleted file mode 100644 index 56627338b04..00000000000 --- a/.changeset/20412-auth-settings-sibling-isolation.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -**plugin-auth: a refused auth setting no longer drops the other settings saved with it** - -Clause-②: no - -The `auth` settings pass used to go to `AuthManager.applyConfigPatch()` as ONE patch. When the -manager refused one key in it, the whole patch was dropped: password policy, MFA, rate limits, -session lifetime and social providers from that pass were not applied. The settings console -still showed every one of them as saved, and the only trace was a `warn`. - -Reachable examples: - -- posture `open` (declared in stack config, or opened earlier through the console) with - `auth.require_email_verification: false` stored through the console; -- posture `email_domain` with `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false`; -- the SCIM/admin coherence refusal on the `plugins` block, once `OS_SCIM_ENABLED` appears after - the manager was constructed with `plugins.admin: false`. - -Now the pass is applied in pieces, split where the manager can refuse. Each key that lands in -the `emailAndPassword` or `plugins` block is applied on its own; every other key goes out in one -application the manager does not validate. `mfa_required` stays one piece with the `twoFactor` -plugin it turns on, so MFA is never enforced without its enrollment endpoints. The manager's -verdict on each piece is the verdict; no validation moved into the plugin. - -A refusal is logged once, at `error`, naming the key: -`[auth] auth settings REFUSED (auth.require_email_verification) — the standing runtime value -keeps ruling …`, followed by the manager's own message, which carries the remedy. Every other -setting in the pass still applies. A pass that fails as a whole, such as a settings namespace -that cannot be read, is also logged at `error` (`[auth] auth settings NOT APPLIED — …`). - -The old `Auth: failed to apply auth settings:` warning is gone. A log alert that matched it -should match `[auth] auth settings REFUSED` and `[auth] auth settings NOT APPLIED` instead. diff --git a/.changeset/20413-boot-report-verification-text.md b/.changeset/20413-boot-report-verification-text.md deleted file mode 100644 index 42314cd7bba..00000000000 --- a/.changeset/20413-boot-report-verification-text.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -The `no_sign_in_account_at_boot` boot report states the email-verification rule each audience posture enforces. - -Clause-②: no - -The report's recovery advice said an `open` or `email_domain` posture forces email verification on the invited login. Later in the same message it said an `open` deployment can turn verification off. The first sentence stopped being true when posture `open` began honouring a deployment's opt-out. The message now states the rule once, as the auth plugin enforces it, and applies it to the invited login and to a new self-registered address alike: - -- `email_domain` always forces email verification on. -- `open` forces it on unless the deployment turns it off, with `emailAndPassword.requireEmailVerification: false` or `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false`. A `false` stored only through the settings console is refused. -- `invite_only` follows the deployment's declaration and is off by default. - -The advice keeps its order: on a deployment with no mail transport, close the posture back to `invite_only`, with verification left at its default off, before the invited person registers. - -Text only: no admission decision, verification default or accepted value changes. diff --git a/.changeset/20413-verification-posture-statements.md b/.changeset/20413-verification-posture-statements.md deleted file mode 100644 index 782439e2934..00000000000 --- a/.changeset/20413-verification-posture-statements.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/service-settings": patch ---- - -The auth settings console states the email-verification rule each audience posture enforces (#20413). - -Clause-②: no - -The Audience group description and the `audience_posture` help said every posture other than invitation-only forces email verification on. That stopped being true when posture `open` began honouring a deployment's opt-out. Both strings, in the manifest and in the `en`, `zh-CN`, `es-ES` and `ja-JP` bundles, now state the rule the auth plugin enforces: - -- `email_domain` always forces email verification on. -- `open` forces it on unless the deployment turns it off, with `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false` or `emailAndPassword.requireEmailVerification: false` in the stack config. -- A `false` saved in the settings console is refused under `open`. - -Text only: no key, option, default or accepted value changes. diff --git a/.changeset/20418-connector-action-config-required.md b/.changeset/20418-connector-action-config-required.md deleted file mode 100644 index b3dc187c34d..00000000000 --- a/.changeset/20418-connector-action-config-required.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec)!: a `connector_action` flow node its executor cannot dispatch — no `connectorConfig` block, or an empty `connectorId` / `actionId` — is refused at authoring (#20418) - -Clause-②: no (narrowing) - - - -**BREAKING** — an accept-set narrowing on authored `connector_action` flow nodes, shipped -as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` -until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by -the level). - -**What changed.** A `connector_action` node's contract is its sibling `connectorConfig` -block — the executor reads nothing else, and refuses the node when `connectorId` or -`actionId` is empty. The block was optional on the node and both ids were any string inside -it, so `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` all -admitted a node with no block, or with an empty id, and every run that reached the node then -failed at the executor's guard. The flow parse now refuses what that read refuses, at any -depth including an ADR-0031 region body, and `registerFlow` and `objectstack validate` meet -the refusal through that parse: - -- **No `connectorConfig` block** — a `custom` issue at `nodes.N.connectorConfig`, whose - message prescribes the block and says that keys left under `config` are not read. -- **`connectorId` or `actionId` empty, or only whitespace** — a `custom` issue at - `nodes.N.connectorConfig.connectorId` / `.actionId`. Whitespace is refused with the empty - string (the spec's one notion of blank): a connector `name` is a snake_case identifier, so - it names nothing a dispatch can reach. - -The rule is judged in the flow walk, not by `FlowNodeSchema` alone, so a node nested in a -`loop` / `parallel` / `try_catch` body is refused at the path the author wrote -(`nodes.N.config.body.nodes.M.connectorConfig`). `FlowNodeSchema.parse` of a lone node is -unchanged. - -The Studio flow designer seeds a new connector node with `connectorId: ''` and -`actionId: ''`, so a connector node added and saved before it is configured is now refused -at save. Where such a node already sits, the whole flow is refused: registered from the -metadata registry or `sys_metadata` at boot, it is skipped with a `failed to register flow` -warn naming it while the flows beside it register; a `defineStack({ flows })` source throws -`StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. - -The `node-config-key-missing` refusal (`FLOW_SLOT_REFUSAL_CODES`) now describes the old -behaviour in the past tense — "the flow used to register, and then every run that reached -this node failed there" — because the doors that message is shown at refuse the flow. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `{ type: 'connector_action', label: 'Post' }` | the connector and the action it dispatches — `connectorConfig: { connectorId: 'slack', actionId: 'chat.postMessage', input: { channel: 'C0WINS000', text: 'Done' } }` | -| `connectorConfig: { connectorId: '', actionId: '' }` | the registered connector's `name` and one of its action keys — `{ connectorId: 'rest', actionId: 'request' }` | -| `config: { connectorId: 'slack' }` (no `actionId`, no block) | the complete pair in the block — `connectorConfig: { connectorId: 'slack', actionId: 'chat.postMessage' }` | - -**One-line fix:** write the `connectorConfig` block the node dispatches by, or delete a -connector node you cannot configure yet — there is no placeholder connector. - -**Unchanged.** A connector node carrying a complete block parses, registers and dispatches -as before, and `input` stays optional. A complete `connectorId` / `actionId` / `input` trio -written under `config` is still lifted into the block before `registerFlow` and -`objectstack validate` judge it (the `flow-node-connector-config-lift` conversion). Other -node types are not asked for a `connectorConfig`. diff --git a/.changeset/20424-turso-remote-missing-table-column-refused.md b/.changeset/20424-turso-remote-missing-table-column-refused.md deleted file mode 100644 index ce3d1465aee..00000000000 --- a/.changeset/20424-turso-remote-missing-table-column-refused.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/driver-turso": minor ---- - -`TursoDriver` in **remote** mode refuses a read over a missing table or a missing column with the same code the local mode answers, instead of answering "no rows" (#20424). - -Clause-②: no (narrowing) - - - -**BREAKING** — an accept-set narrowing on the remote face of `TursoDriver`'s read doors, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the level). Remote-face users meet this refusal for the first time here. - -**FROM → TO.** A remote-face read (`aggregate`, `find`, `findOne`, `count`) that answered `[]` / `null` for a missing table or a missing column now refuses, as the local face does: `DATABASE_ERROR` / 500 for a table that is absent, `INVALID_FIELD` / 400 for a `groupBy` or aggregation column that is absent, `INVALID_FILTER` / 400 for a `where` column that is absent. **The fix:** run schema sync so the declared field has its column (or the object its table), or name a column the table has. `RemoteTransport.find` and `RemoteTransport.aggregate`, exported from the package root, now raise the backend's error where they answered `[]`. - -**What was wrong.** Two catches in `RemoteTransport` read a backend "no such table" or "no such column" as an empty result. `aggregate` answered `[]` for both. `find` (and `findOne` through it) answered `[]` (`null`) for a missing column once its projection retry was spent, or when there was no projection to drop. So on a remote Turso database a schema drift or a missing table read as "there is no data", while the local mode of the same driver, over the same file, refused it. Measured with a local driver over the same libSQL file as the control, for a federated and for a managed object alike: - -| read | local | remote before | -|:--|:--|:--| -| `aggregate` on a table that is really absent | `DATABASE_ERROR` / 500 | `[]` | -| `aggregate` grouped by, or aggregating, a declared field whose column is absent | `INVALID_FIELD` / 400 | `[]` | -| `aggregate` whose `where` names that field | `INVALID_FILTER` / 400 | `[]` | -| `find` / `findOne` whose `where` names that field | `INVALID_FILTER` / 400 | `[]` / `null` | -| `count` whose `where` names that field | `INVALID_FILTER` / 400 | `DATABASE_ERROR` / 500 | -| `find` ordered by that field | the rows, unordered | `[]` | - -**What changes, on the remote face only:** - -- `aggregate`, `find`, `findOne` and `count` answer each row above the way the local face does. The backend's error is classified by the local face's own inherited seam, `SqlDriver.aggregateBackendFault`, and not by a second copy: an unresolvable column named by a `groupBy` or an aggregation is `INVALID_FIELD` / 400, one named by the `where` is `INVALID_FILTER` / 400, and anything else is `DATABASE_ERROR` / 500. The dialect text goes to the server log, never to the caller. -- `find` keeps the local face's recovery ladder: a projection naming a column the table lacks is dropped first, then an ORDER BY on one, and the rows answer. A `where` is never dropped. Before, the ORDER BY rung was missing and the sort answered `[]`. -- A refusal the transport raises while it compiles the statement (a filter or aggregate-vocabulary refusal, the timeout envelope) keeps its own code and status. -- `RemoteTransport.find` and `RemoteTransport.aggregate`, used on their own, now raise the backend's error where they answered `[]`. - -This is the refusal the registered migration entry `driver-sql-unresolvable-where-column-refused` already names for `driver-sql` "and its `TursoDriver` / `SqliteWasmDriver` subclasses": the remote face of `TursoDriver` now delivers it. **If a read now refuses for you:** the table or column it names is missing from the remote database. Run schema sync so the declared field has its column (or the object its table), or correct the name the query uses. diff --git a/.changeset/20426-reclaim-space-wal-sidecar.md b/.changeset/20426-reclaim-space-wal-sidecar.md deleted file mode 100644 index 7aad0c271d2..00000000000 --- a/.changeset/20426-reclaim-space-wal-sidecar.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -fix(driver-sql): `reclaimSpace()` returns the freed bytes from the SQLite `-wal` sidecar too, and never waits on another connection (#20426) - -Clause-②: no - -On a file-backed SQLite database in WAL mode, the default, `reclaimSpace()` returned the whole freelist but left the freed bytes in the `-wal` sidecar. At 25,754 free pages the database file went from 103,149,568 to 16,384 bytes while the `-wal` file went from 4,255,992 to 94,430,432 bytes, and it kept that size until the last connection closed. The lifecycle sweep calls this method after every sweep that deleted rows, and it reported the datasource as reclaimed. - -On better-sqlite3 (`SqlDriver`, and `TursoDriver` in local mode) the vacuum now runs in chunks of a quarter of the connection's page cache, 1,000 pages at the default cache size, with a `PASSIVE` checkpoint after each chunk. One `TRUNCATE` checkpoint closes the call, taken with a busy timeout of 0, so it never waits on another connection. On the same database, file plus `-wal` goes from 107,405,560 to 16,384 bytes while the driver is still open. - -When another connection holds a read transaction, the call still returns without waiting (47 to 66 ms measured; a `TRUNCATE` checkpoint that waits blocked the process for the connection's 5-second busy timeout). The pages are off the freelist, and their bytes leave the files at a later checkpoint. The call no longer grows the pair either: 107,405,560 bytes before and after, where the single statement grew it to 197,580,000. - -A database in rollback-journal (`delete`) mode behaves as before. The remote `TursoDriver` route and `SqliteWasmDriver` are unchanged. Nothing to migrate: `reclaimSpace()` keeps its signature. diff --git a/.changeset/20432-field-name-list-refs.md b/.changeset/20432-field-name-list-refs.md deleted file mode 100644 index 4008b29c1a4..00000000000 --- a/.changeset/20432-field-name-list-refs.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -fix(lint)!: `object-field-ref-unknown` judges the field-name lists on a field — `relatedListColumns`, `lookupColumns`, `lookupFilters[].field`, `dependsOn` — and an object's `indexes[].fields` - - - -**BREAKING** in the accept-set sense — a declaration that passes today can fail tomorrow. -Landing in the launch window as `minor` (the lockstep convention: `major` is refused by -`check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087 -disposition above). - -**Clause-②: no (narrowing)** — the rule refuses more than it did; no key is added to any -published payload and no public surface grows. Narrowing is still a semantic-surface change, -which is why it is declared here rather than shipped silently. - -Each of these five lists holds bare field names that the schema cannot judge, and until now no -authoring door read them for existence, so a misspelling surfaced only when a user opened the -view or the picker — or never: - -- a misspelt `relatedListColumns` entry asked the child object for a column it does not have, - when the parent's detail page opened; -- a misspelt `lookupColumns` entry rendered an empty picker column; -- a misspelt `lookupFilters[].field` filtered the picker's query by a field the referenced - object lacks; -- a misspelt `dependsOn` name kept its field gated for good; -- a misspelt `indexes[].fields` column made the SQL driver skip the WHOLE index at sync, with a - warning, and drift dropped it too — so a `unique` index was silently unenforced while - everything looked normal. - -`os validate`, `os build` and `os lint` now refuse each of them at `error` (exit 1), under the -existing rule id `object-field-ref-unknown`, and so does the runtime publish door on an object -write (`422`), exactly as they already did for `highlightFields` and -`publicSharing.redactFields`. The finding sits at the exact path — -`objects[i].fields..lookupColumns[j].field`, `objects[i].indexes[j].fields[k]`, and so -on — names the string that was written and the object it was judged against, offers the -nearest name when one is close, and lists that object's fields. - -**Which object a name is judged against** — read off each key's runtime reader, not assumed: - -| Position | Judged against | -|:---|:---| -| `relatedListColumns[]` | the object that owns the field — the related list shows that (child) object's rows | -| `lookupColumns[]`, both arms | the referenced object — the picker lists its records | -| `lookupFilters[].field` | the referenced object — the picker's query runs on it | -| `dependsOn[]` name, or `{ field }` | the object that owns the field — the form gate reads this record | -| `dependsOn[]` `param` (or the bare name, on a picker) | the referenced object — the picker filters its candidates by that key | -| `indexes[].fields[]` | the object itself, including the columns the platform injects (`created_at`, `organization_id`, …) | - -The referenced-object positions are judged on `lookup`, `master_detail` and `user` fields (a -`user` field references `sys_user`), and only when the referenced object is in the stack being -checked. `lookupColumns`, `dependsOn` and index columns are read verbatim by their readers, so a -dotted name there is refused as a name that is not a field. The family's three skips hold -unchanged: an object outside the stack, an object with no readable field map (ADR-0015 -`external`), and a registry-injected column resolved per object. - -**What an author does.** Nothing is renamed or rewritten for you. Fix the name the finding -points at, or drop the entry. On a lookup whose `dependsOn` field is spelled differently on the -two records, write the entry with its `param` naming the referenced object's field. An existing -object carrying one of these misspellings is refused when it is next republished through the -publish door, and `os validate` reports it on the next run. - -Unchanged: the object schema's own parse still admits these names, so a draft save does not -judge them. An index column that resolves to a real but virtual field (a `formula`) passes this -rule; whether the column is materialized stays the SQL driver's question at sync. diff --git a/.changeset/20432-skipped-index-durability.md b/.changeset/20432-skipped-index-durability.md deleted file mode 100644 index df9c6b6ad4b..00000000000 --- a/.changeset/20432-skipped-index-durability.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -'@objectstack/driver-sql': minor -'@objectstack/spec': patch -'@objectstack/platform-objects': patch -'@objectstack/lint': patch ---- - -fix(driver-sql): a declared index that can never be built is logged at `error` and reported in drift - -**Clause-②: yes (widening)**: the exported `DriftOp` union gains one member, `unbuildable_index`. -No accept set changes. Nothing an author could write before is refused now. - -A declared index names a column that no declaration will ever create when: - -- the name is not a field of the object, for example a misspelling that the Studio save door - admits (`os validate` / `os build` already refuse it); or -- the name is a virtual `formula` field, which is computed on read and has no column. The same - applies to a field-level `unique` on a formula field. - -The SQL driver skips such an index at every sync. It used to say so at `warn`, and the drift -report dropped the index from the expected set, so `os migrate plan` showed nothing. For a -`unique` index, the declared constraint was not enforced and duplicate rows were accepted, -while everything looked normal. - -- **The sync logs the skip at `error`**, on the same durability channel as the duplicate-row - refusals in the same loop. One line per skipped index per sync names the object, the index, - each missing column with its reason (not a field of the object, or a formula field), and - whether the index is `UNIQUE`. The structured meta carries `index`, `missing` and `unique`. -- **Drift reports it** as a report-only entry: `kind: 'index_mismatch'`, `actual: '(absent)'`, - `category: 'needs_confirm'`, `severity: 'error'` for a unique index and `'warning'` otherwise. - Its op is the new member: - - ```ts - { type: 'unbuildable_index'; table: string; column?: string; indexName: string; - unique: boolean; missingColumns: string[] } - ``` - - `missingColumns` lists only the columns that will never materialize. A declared column that - is merely not added yet is pending additive work, not this finding. - -**What a consumer that reads `op.type` now sees.** A new value, `'unbuildable_index'`. It has -no reconciler arm, and none can exist, because there is no column to build over. The remedy is -a metadata edit. It is in `INDEX_DRIFT_OPS`, so `isIndexDriftOp` answers `true` and it never -triggers a SQLite table rebuild. `applyMigrationEntries` reports it `skipped` on every dialect. -`os migrate plan` lists it under "Needs confirmation", addressed by its index name. `os migrate -apply` counts it like any `needs_confirm` entry (so it asks for `--yes`), and then reports it -skipped. The artifact-pinned boot warns about it and still starts, because -only `destructive` entries refuse a boot. A `switch` over `op.type` that treats unknown values -as "not applied" needs no change. An exhaustive `switch` with a `never` check gets one more case -to handle. - -**The object form's help text follows.** The `indexes` → Fields help in the Studio object form -said the skip left "a warning in the server log". It now says an error, in English and in the -zh-CN, ja-JP and es-ES translations. Nothing else in the text changes. - -**The lint message follows too.** `object-field-ref-unknown`, on a misspelt `indexes[].fields` -name, said the SQL driver skips the index "with only a warning, and drift drops it too". It now -says the skip is logged at error and `os migrate plan` reports the index as unbuildable. The rule, -its severity and its prescription are unchanged. - -**Upgrade note:** on a database that already carries such an index, `os migrate plan` now -reports one entry per index, and so does the boot's drift warning. That entry clears only when -the metadata names stored fields or drops the index. diff --git a/.changeset/20439-hook-condition-expression-row.md b/.changeset/20439-hook-condition-expression-row.md deleted file mode 100644 index 3c2258a236e..00000000000 --- a/.changeset/20439-hook-condition-expression-row.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`hook.form.ts`'s `condition` row now declares `language: 'expression'`, matching the CEL predicate `HookSchema.condition` actually is. - -Clause-②: no - -The row previously declared `language: 'javascript'` — the same declared language as a real script row (`body.source`) — over a field that is `EvaluatedExpressionInputSchema`, a CEL predicate. A consumer keyed on the row's declared language could not tell the predicate apart from a script. The `helpText` moves from "Optional formula — skip the hook when this evaluates to false" to "CEL predicate — the hook runs only when TRUE", matching the phrasing every sibling predicate row (`field.form.ts` / `object.form.ts`'s `visibleWhen` / `readonlyWhen` / `requiredWhen`, and the formula `expression` row) already uses. - -No key is added, removed, narrowed or widened, and no parse verdict changes — `type: 'code'` and `language` are already-declared form-DSL vocabulary. `metadata-form-declared-rows.pin.test.ts` pins the new value, with a control against a sibling predicate row. diff --git a/.changeset/20441-audit-authoring-door.md b/.changeset/20441-audit-authoring-door.md deleted file mode 100644 index 74ecdfd2774..00000000000 --- a/.changeset/20441-audit-authoring-door.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -**`GET /api/v1/meta/:type/:name/audit` is now an authoring door: a caller without an authoring capability is refused, as `/diff`, `/history` and `GET /api/v1/meta/_drafts` refuse.** Before this release, any signed-in caller who could open an item could read its protection-audit trail. Every save appends a row to that trail, a draft save included, and the row carries `note: "draft"`, the actor and the time. So a member could learn that an item had unpublished authoring work, who saved it and when. For an item that had never been published, where the plain read answers `404`, the member could learn that it existed at all. This carries the maintainer's ruling on #20378 (letter B, comment 5865708652) to this door, as triage graded on #20441: draft and preview reads are admin-gated upstream (ADR-0106 D4), and the audit trail, like the version log, has no published-only answer to fall back to. - -Clause-②: no - -- **Who may read it:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the predicate `/meta/_drafts`, `/diff`, `/history` and every draft switch already ask, not a second rule. -- **Everyone else:** `403` with code `FORBIDDEN`, in the same nested `error` envelope `/meta/_drafts` answers. The refusal is decided on the caller before the protocol is resolved, before the query is parsed and before any event is read. So it is the same answer for an item that exists, one that does not, and one that exists only as a draft, and it carries no event, actor or item name. The message names the door, not drafts. -- **Unchanged:** callers with an authoring capability read the trail exactly as before, including the per-caller refusal of an item the plain read refuses them and the organization scope of the read. - -A client that read `/audit` (`client.meta.getAudit`) as a member now receives `403 FORBIDDEN`. To read it, call as a caller holding one of the three capabilities above. diff --git a/.changeset/20444-empty-operator-engine-arms.md b/.changeset/20444-empty-operator-engine-arms.md deleted file mode 100644 index 6229e228fc3..00000000000 --- a/.changeset/20444-empty-operator-engine-arms.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/driver-sql': minor -'@objectstack/driver-turso': minor -'@objectstack/driver-memory': minor -'@objectstack/driver-mongodb': minor -'@objectstack/formula': minor -'@objectstack/objectql': minor -'@objectstack/spec': minor ---- - -feat(drivers,formula,objectql): the engine's filter faces answer the staged `$empty` operator (#20444) - -Clause-②: yes (widening) - -`$empty: true | false` is declared by `@objectstack/spec` (`FieldOperatorsSchema`) with a per-type meaning: a text-like field is empty when it is null or `''`, a multi-value field (multiselect, checkboxes, tags, or a select / radio / lookup / user / file / image with `multiple: true`) when it is null or `[]`, and every other type only when it is null. `$empty: false` is the exact complement. Until now every face in this list refused it (`INVALID_FILTER` / 400), except `matchesFilterCondition`, which answered `false` for every record. **A driver or evaluator called directly now answers it:** - -- **By the field's declared type**, through the spec's one expansion (`expandEmptyOperator`): `driver-sql`'s filter compiler (and so `driver-sqlite-wasm` and `driver-turso`'s local transport, which inherit it), `driver-turso`'s remote transport, `driver-memory`'s query path (`find` / `count` / `update` / `delete`) and `driver-mongodb`'s `translateFilter` (its `find`, its aggregate `$match`). The declaration is the one each driver already receives — `initObjects` / `registerObjectMetadata` / `registerExternalObject` on the SQL family, `syncSchema` on the others. On SQL a multi-value field's empty list is tested as stored JSON per dialect (SQLite `json_array_length` behind a `json_valid` guard, PostgreSQL a `jsonb` comparison, MySQL `JSON_LENGTH`), never as an equality comparand. -- **By value** — null, a missing value, `''` and `[]` are empty (`isEmptyFilterValue`) — on the faces that read no field declaration: `@objectstack/formula`'s `matchesFilterCondition` (the RLS write-side `check`), `driver-memory`'s reference matcher, and `@objectstack/objectql`'s `having` and per-aggregation `filter`. In `having`, a `count` or `sum` holding `0` is not empty. - -**Refused, never guessed** (`INVALID_FILTER` / 400): `$empty` on a field whose declaration the driver does not hold (a table built outside its registration, a builtin column such as `id`, a field with no `type`, or `translateFilter` / `RemoteTransport` used standalone without a declaration), a multi-value field on a SQL dialect the driver does not model, and a flag that is not a boolean. `driver-memory`'s analytics (cube) face refuses `$empty` as an operator it cannot compile, as it does `$null`. - -New optional API: `translateFilter(where, temporalKind?, valueShape?)` in `@objectstack/driver-mongodb` takes a declared-value-shape resolver (type `ValueShapeResolver`), and `buildAggregationPipeline` a `valueShape` option; `RemoteTransport.setDeclaredValueShapeResolver` in `@objectstack/driver-turso`, which `TursoDriver` wires. `@objectstack/spec`'s shared `FILTER_LOGIC_CASES` table gains seven `$empty` cases: a backend that runs it answers `$empty` or goes red, and its harness must declare the fixture's columns. - -`$empty` stays staged: it is not in `FILTER_OPERATORS`, so the engine's front door still refuses it until the flip card adds it, and the view operators `is_empty` / `is_not_empty` still lower to `$null`. diff --git a/.changeset/20445-analytics-empty-operator-arms.md b/.changeset/20445-analytics-empty-operator-arms.md deleted file mode 100644 index 3f9abb12cf8..00000000000 --- a/.changeset/20445-analytics-empty-operator-arms.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/service-analytics': minor ---- - -fix(service-analytics): both analytics filter faces answer `$empty` by the field's declared type (#20445) - -Clause-②: yes (widening) - -`$empty: true | false` is declared by `@objectstack/spec` (`FieldOperatorsSchema`) with a per-type meaning: a text-like field is empty when it is null or `''`, a multi-value field (multiselect, checkboxes, tags, or a select / radio / lookup / user / file / image with `multiple: true`) when it is null or `[]`, and every other type only when it is null. `$empty: false` is the exact complement. Both of this package's filter faces now answer it by that table, through the spec's one expansion (`expandEmptyOperator`), instead of refusing it: - -- **The analytics `where`** (`/analytics/query`, `/analytics/sql`, dataset filters): `NativeSQLStrategy` and the `ObjectQLStrategy` SQL echo compile the field's declared row; the ObjectQL execute path hands `{ $empty }` to the data engine, which answers it once the engine's own arm lands (until then the engine refuses it, `INVALID_FILTER` / 400, as it does today). -- **Row-level read scopes** compiled to SQL (`compileScopedFilterToSql`): same rows, in the read-scope envelope. - -A multi-value field's empty list is tested with a JSON function per SQL dialect (`json_array_length` on SQLite, a `jsonb` comparison on Postgres, `JSON_LENGTH` on MySQL). - -**Refused, never guessed** — `INVALID_FILTER` / 400 on the `where` face, `READ_SCOPE_COMPILE_FAILED` / 500 on a read scope — when the host cannot name the field's declared type (no `sourceFieldMeta` wired, or no such field), when a multi-value field's datasource dialect is unknown, and when the flag is not a boolean (`$empty: 'true'` is refused like a non-boolean `$null`). - -Host API (two new optional members, hence `minor`): `AnalyticsServiceConfig.sourceFieldMeta` may now answer `multiple` beside `type`, and `AnalyticsServicePlugin` relays it from the field definition; `compileScopedFilterToSql` takes an optional `declaredValueShape` option. A host whose `sourceFieldMeta` answers `type` but not `multiple` has every multi-capable field it declared `multiple: true` (select / radio / lookup / user / file / image) read as single-valued, which is the null-only row. On such a field a read scope's `$empty: false` then admits rows holding `[]`, and `$empty: true` misses them. Relay the field's `multiple` from its definition to get the list row. - -`$empty` stays staged: it is not in `FILTER_OPERATORS`, and the view operators `is_empty` / `is_not_empty` still lower to `$null`. diff --git a/.changeset/20450-view-filter-operator-input-typed.md b/.changeset/20450-view-filter-operator-input-typed.md deleted file mode 100644 index cc35473e23e..00000000000 --- a/.changeset/20450-view-filter-operator-input-typed.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: a view filter rule's `operator` is typed as the canonical `ViewFilterOperator`, not `unknown` - -**BREAKING for TypeScript code that writes a view filter rule through a published type**: `ViewFilterRule`, and every carrier of it — `ListView.filter`, a view tab's `filter`, `InterfacePageConfig.filterBy`, and the related-list, record-picker and `object-*` block filter doors. A narrowing of a published TYPE, landing as `minor` (the bump level is not the carrier; this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no value and no export changes. - -`operator` is a `z.preprocess` over the alias fold, and zod types a preprocess's input from its function's parameter. That parameter was `unknown`, so `ViewFilterRule['operator']` was `unknown`: `{ field: 'status', operator: 42 }` compiled as a rule on every carrier, and was refused only when the schema parsed it. The input type is now `ViewFilterOperator`, the vocabulary the alias table's own contract says new producers emit, so an alias spelling or a non-string is refused by the compiler. - -What does not change: - -- **The runtime.** `ViewFilterRuleSchema` still folds every legacy spelling it folded before (`eq`, `gt`, `notIn`, `isNull`, …) to its canonical id, and still refuses a non-string at `operator` with the enum's own issue. Stored `sys_metadata` rows, YAML and JSON bodies and plain-JS producers that carry an alias parse exactly as before, and `os validate` answers as before. -- **`normalizeFilterOperator`.** Its parameter stays `unknown`: it exists to fold untyped stored metadata, and its callers pass raw strings by design. -- **The parsed type.** `ViewFilterRuleParsed['operator']` was already the canonical enum. - -## FROM → TO - -| Wrote (TypeScript) | Write instead | -| --- | --- | -| `{ field: 'status', operator: 'eq', value: 'open' }` | `{ field: 'status', operator: 'equals', value: 'open' }` | -| `{ field: 'amount', operator: 'gte', value: 100 }` | `{ field: 'amount', operator: 'greater_than_or_equal', value: 100 }` | -| `{ field: 'stage', operator: 'notIn', value: ['lost'] }` | `{ field: 'stage', operator: 'not_in', value: ['lost'] }` | -| `operator: someString` (a value typed `string`) | type the unvalidated rule `unknown` and `ViewFilterRuleSchema.safeParse` it, or fold it with `normalizeFilterOperator` and check it against `VIEW_FILTER_OPERATORS` first | - -The one-line fix: write the canonical id. Every alias maps to exactly one, and `VIEW_FILTER_OPERATOR_ALIASES` is that map; the rewritten rule selects the same rows, because the schema already folded the alias to that id. - -Clause-②: no (narrowing) - - diff --git a/.changeset/20451-diff-default-from-differs.md b/.changeset/20451-diff-default-from-differs.md deleted file mode 100644 index a09da07e44d..00000000000 --- a/.changeset/20451-diff-default-from-differs.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch -'@objectstack/rest': patch ---- - -fix(metadata-protocol): `GET /meta/:type/:name/diff` with no `from` compares against the nearest earlier version whose body differs, so the default diff right after a publish shows what the publish changed (#20451) - -Clause-②: no — no key, export, route, parameter or response field moves; only which version the default `from` side names. - -Every draft save appends a `sys_metadata_history` row, and publishing the draft appends the same body again as the next row. The default `from` side was the history row immediately before the `to` side, so right after a publish it was the draft save the publish came from, and the default diff answered "no changes". The change the publish carried was reachable only by naming `?from=`. - -- **Now:** with no `from`, `diffMetaItem` walks back from the `to` side over the history rows it already reads and takes the nearest earlier row whose body differs, by the diff's own equality (all three buckets empty means equal). A body-less row, a delete's, compares as an empty body, so the walk stops on it and the answer names the deletion. With no earlier row that differs, the `from` side is absent: `fromVersion: null`, everything added. -- **Measured on the real REST stack**, before → after: - -| history | default range before | default range now | -|:--|:--|:--| -| v1 active, v2 draft save, v3 publish | `2 → 3`, no changes | `1 → 3`, the change the publish carried | -| the same with a v4 draft pending | `2 → 3`, no changes | `1 → 3` | -| create, delete, draft save, publish | `3 → 4`, no changes | `2 → 4`, everything added | -| create, delete, active recreate | `2 → 3`, everything added | unchanged | -| a new item draft-saved, then published | `1 → 2`, no changes | `null → 2`, everything added | -| a single version | `null → 1`, everything added | unchanged | - -- **Unchanged:** an explicit `?from=` / `?to=` names exactly its versions (`?from=2&to=3` over the first row still answers "no changes"); the default `to` side is the active version; the response shape; the one history read, with no cap. The walk compares the stored bodies before redaction, as the diff itself does, so a credential-only change still stops it and its values are still not served. -- `@objectstack/rest`: the route's OpenAPI summary states the new default. diff --git a/.changeset/20456-view-console-round-trip-keys.md b/.changeset/20456-view-console-round-trip-keys.md deleted file mode 100644 index 119c2f13220..00000000000 --- a/.changeset/20456-view-console-round-trip-keys.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): the console's round-trip keys on a stored `view` row are declared on the wire, so a parse keeps them (#20456) - -Clause-②: yes (narrowing) - - - -**BREAKING** accept-set narrowing on the `view` write door (`PUT /api/v1/meta/view/:name`, the Studio and MCP save) and on every door that parses `ViewMetadataSchema`, shipped as `minor` under the repo's launch-window convention. The newly declared keys are typed, so a non-boolean `isPinned`, a non-integer `sortOrder`, a `visibility` outside `private` / `team` / `organization` / `public`, or an `_isOverride` other than `true` is now refused at the parse (`422 INVALID_METADATA` at the save door), where the strip used to swallow the key and the save stored the body as sent. To fix a refused body, correct the value or delete the key. The console writes none of these values: its pin toggle writes a boolean, its reorder an integer index, and it stamps the marker as `true`. The diff also widens: the keys are now declared, and `VIEW_CONSOLE_ROUND_TRIP_KEYS` is a new export. - -`saveMetaItem` stores a `view` body exactly as it was sent (ADR-0005 appendix (c)), and the members of `ViewMetadataSchema` that judge a stored row `.strip()` every key they do not declare. So the keys the console writes onto a stored view and reads back were in the store and nowhere in the contract. A census of objectui's console (at the `.objectui-sha` pin) measured which ones the parse dropped: - -- `isPinned` and `sortOrder` on a flattened list overlay (they were already declared on the ViewItem record); -- `visibility`, on both the flattened list overlay and the ViewItem record; -- `_isOverride`, the marker that tells the console a row is the settings overlay of a code-defined view and not a saved view of its own. - -## What it does now - -- The ViewItem wire member (`ViewItemWireSchema`) and the flattened list overlay (`VIEW_METADATA_MEMBERS.listOverlay`) declare `isPinned`, `sortOrder` and `visibility` from one shared declaration, each with its meaning. The flattened list overlay also declares `_isOverride: true`, and its existing `isDefault` now carries its meaning. A parse of a console-written row keeps every one of them. -- **New export `VIEW_CONSOLE_ROUND_TRIP_KEYS`** (`@objectstack/spec/ui`): each round-trip key, mapped to the members whose rows the console writes it on (`isDefault`, `isPinned`, `sortOrder`, `visibility`, `columnState`, `_isOverride`). -- `visibility` is display grouping only (`private` / `team` / `organization` / `public` in the view switcher). It restricts nobody, and its declared meaning says so. -- None of these keys is authorable. `defineViewItem` still refuses each of them by name, and `visibility` now gets a prescription that says what it is. - -## What does not change - -- **What is persisted.** The save still stores the request body verbatim. Storing the parsed body is a later, separate change. -- The alias spellings the census found keep their declared spellings: `objectName` is `object`, and a top-level `id` is `name`. The console's filter / sort builder row ids stay `VIEW_CONSOLE_ROW_DECORATIONS`, removed before the parse. diff --git a/.changeset/20462-zh-cn-object-labels.md b/.changeset/20462-zh-cn-object-labels.md deleted file mode 100644 index e78c4881a17..00000000000 --- a/.changeset/20462-zh-cn-object-labels.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -fix(platform-objects): the zh-CN platform-object bundle no longer ships English labels, options and help as copies of the source (#20462) - -Clause-②: no - -A zh-CN console showed English on Setup surfaces: the Invite user dialog listed -the membership roles as 所有者 / 管理员 / Delegated Admin / 成员, and a team record -read MEMBER COUNT. The keys were present in `zh-CN.objects.generated.ts`, but -their values were byte copies of the English source that `os i18n extract ---fill=default` seeds. - -- 320 of the 362 zh-CN string leaves that equalled their `en` source are now - translated: labels, plural labels, select options, help, descriptions and - empty states. `delegated_admin` reads 受托管理员 on both - `sys_member` and `sys_invitation`, the word the console already uses for that - role. -- The other 42 stay English by design, and each is recorded with its reason in - `objects-zh-cn-echo-decisions.test.ts`: sign-in provider brands (Google, GitHub, - …), the bare `ID` initialism, protocol names (JWKS, IdP SSO URL, Message-ID, - UI / API), the OAuth credential names in the create-application result dialog, - and placeholders that show a value the admin types. - -`zh-CN.source-hashes.generated.ts` was regenerated by `pnpm i18n:extract`: it -records a leaf only while the leaf is a copy of its source, so it now lists -exactly those 42. `ja-JP` and `es-ES` are unchanged. diff --git a/.changeset/20466-gantt-timezone-describe.md b/.changeset/20466-gantt-timezone-describe.md deleted file mode 100644 index 1f7b76ce074..00000000000 --- a/.changeset/20466-gantt-timezone-describe.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): correct `GanttConfig.timeZone`'s describe — persisted gantt drops on a `date` field are not "real instants" (#20466) - -Clause-②: no - -The `GanttConfig` `timeZone` member's `.describe()` said "persisted data stays real -instants" for every field. That is false for a `Field.date` column: per the spec's own -storage rule (`temporalStorageForm` / ADR-0053), a gantt drop on a `date` field writes the -calendar day it landed on, as a timezone-naive `YYYY-MM-DD`, while a `datetime` field -still writes the real instant. Only the false clause is replaced — "a datetime value is -still written as the real instant, and a date value as the calendar day it was dropped on -in this zone's calendar (`YYYY-MM-DD`)" — the rest of the describe, and every other -member, is unchanged. - -No key moves and no verdict moves: this corrects a published describe's prose to match -the contract it already had, it does not add, remove or re-scope anything authorable. The -JSON Schema and reference docs regenerate from the corrected source. diff --git a/.changeset/20477-dispatcher-vetted-org-source.md b/.changeset/20477-dispatcher-vetted-org-source.md deleted file mode 100644 index 6e13b7cf9ba..00000000000 --- a/.changeset/20477-dispatcher-vetted-org-source.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): the dispatcher's `/packages` doors scope a caller to the organization the identity step vetted, not the session's stored claim (#20477) - -Clause-②: no - -The runtime dispatcher's nine organization-scoped `/packages` doors took the caller's organization from the auth session's `activeOrganizationId` as stored. Under a wall-enforcing tenancy posture (`isolated` or `group`), the identity step drops that claim when no membership backs it any more, and the request resolves with no active organization (the maintainer's ruling B on #15409). The doors never saw the drop. So a member removed from an organization kept that organization's packages for the rest of the session: its commit history and whole-package export were served to them, and their publish-drafts, discard-drafts, commit revert, rollback, adopt-orphans, duplicate and uninstall ran inside it. - -The doors now read the vetted organization on the request's execution context, the value `RestServer` scopes by and the dispatcher's `/meta` doors already read. The fix is in the one source the nine doors share (`HttpDispatcher`'s `resolveActiveOrganizationId`), so every door changes together, on both HTTP entries (the `createHonoApp` catch-all and the dispatcher plugin's explicit package routes). - -- **A removed member whose session still names the organization they left:** every door is handed no organization. Reads and writes reach only the env-wide package state, as for any session with no active organization. An uninstall is refused `400 TENANT_SCOPE_REQUIRED` and deletes nothing. The left organization's rows are neither read nor written. -- **A caller authenticated by an API key:** the doors now use the organization the key is bound to. The session read found no session for a key, so these doors used to get no organization for it. -- **Unchanged:** a current member reaches their own organization exactly as before, a member who switched to an organization they belong to reaches that one, and an anonymous caller is refused `401` before any package operation. Single-posture deployments are unchanged, because no claim is dropped there. diff --git a/.changeset/20478-dispatcher-meta-layers.md b/.changeset/20478-dispatcher-meta-layers.md deleted file mode 100644 index 0d93acc7038..00000000000 --- a/.changeset/20478-dispatcher-meta-layers.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/rest': minor -'@objectstack/runtime': patch ---- - -fix(rest, runtime): the runtime dispatcher serves the layered view, `GET /meta/:type/:name/layers` and the deprecated `?layers=` flag, as `RestServer` serves it (#20478) - -Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains three value exports (`createMetaLayeredAnswer`, `wantsMetaItemLayers`, `metaItemLayersDeprecationHeaders`) and two type exports (`MetaLayeredAnswer`, `MetaLayeredRequest`). Nothing any published version exported is removed, renamed or narrowed. `@objectstack/runtime` publishes no new surface and stays a `patch`. - -A host that mounts only the `${prefix}/*` catch-all (`createHonoApp`, and any -adapter written on the public `HttpDispatcher` API) serves `/meta` through the -runtime dispatcher. Until now, on such a host: - -- **`GET /meta/:type/:name?layers=true` answered the plain read.** The body was - `{ type, name, item }` with a `200`, so a client reading `code`, `overlay` or - `effective` read `undefined`. There was no `Deprecation` header and no `Link` - to the successor. An author (a caller the item's save door admits) was served - the app pruned, where the layered view serves them every layer whole. -- **`GET /meta/:type/:name/layers` was no route.** It answered a located - `404 ROUTE_NOT_FOUND`. - -Both spellings now answer what `RestServer` answers: the three layers, each -judged by the per-caller read gate under the stored-version doors' policy -(whole for a caller who may save the item, pruned as the plain read prunes it -for everyone else), each projected through the object-schema field mask, and -`private, no-store` when the caller's field visibility could not be determined. -The read is scoped to the caller's vetted organization and to `?package=`. The -flag's answers, refusals included, carry `Deprecation: true`, and a `Link` to -`/layers` built from the request's own URL (every `createHonoApp` request -carries one; a host that hands `dispatch()` no URL gets `Deprecation` alone). The route answers `501 NOT_IMPLEMENTED` where the protocol has no -layered read, and the flag is then the plain read, on both transports. - -**What changed.** Everything `RestServer`'s layered helper does after the store -read moved, unchanged, into `createMetaLayeredAnswer`, and the flag's parse and -headers into `wantsMetaItemLayers` and `metaItemLayersDeprecationHeaders`. The -dispatcher's `/meta` domain calls all three. `RestServer`'s own answers are -unchanged: every existing REST test passes unedited. diff --git a/.changeset/20481-date-write-iso-only.md b/.changeset/20481-date-write-iso-only.md deleted file mode 100644 index 836527e89ed..00000000000 --- a/.changeset/20481-date-write-iso-only.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: a `date` string is written in its `YYYY-MM-DD` form, or it is refused with `VALIDATION_FAILED` / 400 (`invalid_date`) — `"2026/07/15"` is no longer stored verbatim as a non-day (#20481) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what a `date` field accepts as a written value. It ships as `minor` under the launch-window convention for accept-set narrowings (`check-changeset-no-major` refuses `major` until GA; the breaking-ness is carried by this banner and the ADR-0087 disposition above). - -FROM a `date` field written as a string with no leading `YYYY-MM-DD` that `Date.parse` still reads — `"2026/07/15"`, `"07/15/2026"`, `"07/08/2026"`, `"15 July 2026"`, `"July 15, 2026"`, `"2026-7-15"`, `"2026.07.15"`, `"+002026-07-15"` → TO `VALIDATION_FAILED` / 400 with the field code `invalid_date` and its existing message, nothing written. The fix is one line: send `YYYY-MM-DD` (`"2026-07-15"`), or a JS `Date`. - -No other spelling is read for you, on purpose: `07/08/2026` is July 8 in one locale and August 7 in another, and a guess stores the wrong day silently. - -Measured through `POST /api/v1/data/:object` and a read-back, before this change, the process in America/New_York and PostgreSQL 16 at `DateStyle` `ISO, MDY`: - -| written to a `date` | memory | SQLite | PostgreSQL | now, on all three | -|:--|:--|:--|:--|:--| -| `"2026/07/15"`, `"07/15/2026"`, `"15 July 2026"`, `"2026-7-15"`, `"2026.07.15"`, `"July 15, 2026"` | 201, read back verbatim | 201, read back verbatim | 201, `"2026-07-15"` | 400 `invalid_date` | -| `"07/08/2026"` | 201, verbatim | 201, verbatim | 201, `"2026-07-08"` (a `DMY` server reads August 7) | 400 `invalid_date` | -| `"+002026-07-15"` | 201, verbatim | 201, verbatim | 500 | 400 `invalid_date` | - -A verbatim `"2026/07/15"` is not a day: it sorts and compares as text beside real days, so it falls out of every date range and every date filter. PostgreSQL's reading was its server's `DateStyle`, not the writer's. - -What changes: - -- The record validator's `date` arm asks one more question of a string: does the `date` storage rule read it? That rule (`@objectstack/core`'s `temporalStorageForm`) collapses a string with a leading `YYYY-MM-DD` to that day and hands every other string back unchanged. The question is asked through `isUninterpretableTemporalComparand`, the predicate the engine's temporal-comparand door already refuses such a `date` comparand with, so a `date` string refused on `where` is refused as a written value too. It applies on insert, update, a multi-row update and `engine.validate` (the dry run), before any driver write. - -**Who is affected.** A caller that writes a `date` field as a locale or free-form string: a REST or SDK client, a flow, an MCP `create_record` / `update_record` call written by a model. The server import (`POST /api/v1/data/:object/import`) is not affected: it already turns a date cell into `YYYY-MM-DD` before the write. A row that already holds such a string keeps it, since nothing re-reads stored rows. An update that omits the field is not affected; one that sends the old string back is refused, so re-write the field as `YYYY-MM-DD`. - -**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through REST: - -- a string with a leading `YYYY-MM-DD`, still stored as that day: `"2026-07-15"`, `"2026-07-15T10:00:00Z"`, `"2026-07-15 10:00"`, `" 2026-07-15"`; -- a `Date`, still stored as its UTC calendar day; -- an epoch-millisecond number, still refused with `invalid_date`; -- a string `Date.parse` cannot read, still refused: `"20260715"`, `"15/07/2026"`; -- the year range 0001..9999; -- every `datetime` and `time` value. diff --git a/.changeset/20489-rows-path-compensated-sum.md b/.changeset/20489-rows-path-compensated-sum.md deleted file mode 100644 index dd0d38b5422..00000000000 --- a/.changeset/20489-rows-path-compensated-sum.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): the engine's rows path adds `sum` / `avg` with compensated summation, as SQLite does - -Clause-②: no - -`engine.aggregate` answers a `sum` / `avg` on one of two paths: the driver's own aggregate, or -the rows path (`applyInMemoryAggregation`), which aggregates `find()` rows in JavaScript and is -taken for a per-aggregation `filter`, a non-UTC date bucket, or a driver without native -aggregation. SQLite 3.43 and later adds with Kahan-Babuska-Neumaier compensation; the rows path -added naively. So on SQLite one query answered two doubles depending on the path. A `number` -column holding `0.1`, `0.2` and `0.3` in one group: - -| | `sum` | `avg` | `having { s: { $eq: 0.6 } }` | -|:--|:--|:--|:--| -| SQLite native | `0.6` | `0.19999999999999998` | keeps the group | -| rows path, before | `0.6000000000000001` | `0.20000000000000004` | keeps no group | -| rows path, after | `0.6` | `0.19999999999999998` | keeps the group | - -The rows path now adds with the same compensation, transcribed from SQLite's own, so on SQLite -both paths answer the same double, through `engine.aggregate` and -`POST /api/v1/data/:object/query` alike. It is also the more accurate sum: `1e16 + 1 - 1e16` is -`1`, where the naive fold answered `0`. - -Unchanged: two addends (the compensated `a + b` is the naive one, so `0.1 + 0.2` is still -`0.30000000000000004`), integers whose running total stays within 2^53, a non-finite total, -`null` and non-numeric cells, and the empty group (`sum` `0`, `avg` `null`). The answer is still a -JS number. - -**Residual, stated.** PostgreSQL and MySQL add `sum` / `avg` natively in double without -compensation, and that arithmetic is the database's own. So over three or more fractions their -native path can still differ from the rows path in the last place (`0.1 + 0.2 + 0.3`: native -`0.6000000000000001`, rows path `0.6`). The `@objectstack/driver-sql` entry for the double -accumulation states the same residual: the difference is no longer SQLite's native path against -every other face; it is PostgreSQL / MySQL native against SQLite and the rows path. The -in-memory driver (`@objectstack/driver-memory`) still adds naively in its own `aggregate`, so on -that driver the two paths can now differ in the same last place. An exact `$eq` on a fractional -sum compares doubles: compare with a range. diff --git a/.changeset/20492-uninstall-refuse-before-mutate.md b/.changeset/20492-uninstall-refuse-before-mutate.md deleted file mode 100644 index 9f3f7efa56a..00000000000 --- a/.changeset/20492-uninstall-refuse-before-mutate.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/runtime': patch -'@objectstack/spec': patch ---- - -fix(runtime): `DELETE /packages/:id` refuses an uninstall that names no organization before it touches the running registry (#20492) - -Clause-②: no - -A caller holding `manage_metadata` with no active organization — a member removed from an organization whose session still names it, or a caller who never selected one — sent `DELETE /api/v1/packages/:id` and was answered `400 TENANT_SCOPE_REQUIRED`. The dispatcher had already run the registry uninstall by then, so the package and every object it registers had left the running process for everyone it serves, while its stored rows still said it was installed. The state lasted until a restart re-seeded the registry. - -The door now asks the persisted delete's organization-scope question first, from the same organization value it hands `deletePackage`, and only when a persisted delete will run. The same refusal (`400 TENANT_SCOPE_REQUIRED`) now arrives before anything changes: the package stays served, listed and registered, and its stored rows are untouched. The refusal's message names what an HTTP caller can do, which is to select an organization they are a member of and retry. - -- **Unchanged:** a caller acting in an organization uninstalls exactly as before. A read-only package is still refused `422 WRITABLE_PACKAGE_REQUIRED` first. A host with no persisted delete (no `deletePackage` on its `protocol` service) still uninstalls from the registry alone, because there is no refusal to mirror there. The protocol keeps its own refusal as a second line. - -- **`@objectstack/spec`:** `PROVENANCE_WAIVERS` (the error-code ledger) gains one entry: `@objectstack/runtime` stamps `TENANT_SCOPE_REQUIRED`, which stays registered under `@objectstack/metadata-protocol`. The door mirrors `deletePackage`'s refusal and does not emit a second vocabulary. The registered code union and `ErrorCode` are unchanged. diff --git a/.changeset/20493-ja-es-object-labels.md b/.changeset/20493-ja-es-object-labels.md deleted file mode 100644 index 01a2f0c06b8..00000000000 --- a/.changeset/20493-ja-es-object-labels.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -fix(platform-objects): the ja-JP and es-ES platform-object bundles no longer ship English labels, options and help as copies of the source (#20493) - -Clause-②: no - -A ja-JP or es-ES console showed English on Setup surfaces: the Invite user -dialog listed the membership roles with **Delegated Admin** among the -translated ones. The keys were present in `ja-JP.objects.generated.ts` and -`es-ES.objects.generated.ts`, but their values were byte copies of the English -source that `os i18n extract --fill=default` seeds. - -- ja-JP: 340 of the 383 string leaves that equalled their `en` source are now - translated: labels, plural labels, select options, help, descriptions and - empty states. `delegated_admin` reads 委任管理者 on both `sys_member` and - `sys_invitation`, the word the console already uses for that role. -- es-ES: 338 of the 392 are now translated the same way. `delegated_admin` - reads Administrador delegado on both objects, again the console's word. -- The rest stay as written by design, each recorded with its reason in a - per-locale ledger (`objects-ja-jp-echo-decisions.test.ts`, - `objects-es-es-echo-decisions.test.ts`). ja-JP keeps 43: sign-in provider - brands, the bare `ID` initialism, protocol names (JWKS, IdP SSO URL, - Message-ID, UI / API), the `Web` client type as the bundle already renders it, - the Cc / Bcc header abbreviations, and placeholders that show a value the - admin types. es-ES keeps 54: the same brands, `ID`, JWKS, Message-ID, - UI / API, `Web` and placeholders, plus words Spanish spells as English (Error, - Actor, Global, Variables), the loanwords the bundle already uses (Token, - Checksum, Slug) and Cc. - -`ja-JP.source-hashes.generated.ts` and `es-ES.source-hashes.generated.ts` were -regenerated by `pnpm i18n:extract`: each records a leaf only while the leaf is -a copy of its source, so they now list exactly those 43 and 54. zh-CN is -unchanged. diff --git a/.changeset/20494-milestone-type-default-describe.md b/.changeset/20494-milestone-type-default-describe.md deleted file mode 100644 index 3eb6d4b3f7c..00000000000 --- a/.changeset/20494-milestone-type-default-describe.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`activityMilestones[].type`'s `.describe()` now states the real default: an unset `type` keeps the update row's kind, `updated` (#20494) - -Clause-②: no - -No behaviour changes and no schema shape change. `object.zod.ts`'s `activityMilestones[].type` field described its default as `"completed"`; the runtime never wrote that. `audit-writers.ts` starts `activityType` from `activityTypeFor(action)`, and a milestone can only fire on the UPDATE branch (`create` / `delete` return their own summary before the milestone match ever runs), so an unset `type` has always emitted `updated`. `milestone.type` overrides it only when the author actually sets it — that half of the describe was correct and is unchanged. - -The corrected string is the published half: it ships in `packages/spec/dist/*.d.ts`, in the JSON Schema under `packages/spec/json-schema/`, and in the generated `content/docs/references/data/object.mdx` (regenerated with `gen:docs`, never hand-edited). A repo-wide search for the old wording found no other hand-written copy; `object.form.ts`'s `activityMilestones.type` help text ("Unset: updated.", shipped with PR #20485) already stated the real default and is unchanged. - -`packages/plugins/plugin-audit/src/activity-type-vocabulary-enforcement.test.ts` already measured the runtime's real answer — its title and docblock are corrected in the same PR to stop describing a divergence and stop saying the finding was "filed separately" (this card, #20494, is where it was filed). Its assertions are byte-for-byte unchanged. diff --git a/.changeset/20497-import-number-thousands-group.md b/.changeset/20497-import-number-thousands-group.md deleted file mode 100644 index c48ead9c197..00000000000 --- a/.changeset/20497-import-number-thousands-group.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest): `POST /api/v1/data/:object/import` reads a comma in a number cell only as a thousands group, and refuses every other comma instead of storing a different number (#20497) - -Clause-②: no (narrowing) - -**BREAKING for callers of the import door.** A cell for a numeric field -(`number`, `currency`, `percent` and the other numeric value types) that -carries a comma is now admitted only when the comma groups thousands: 1 to 3 -leading digits, then groups of exactly three digits, and only before any `.` -(`1,000`, `12,345.67`, `-1,234,567.89`). Any other -comma makes the cell that row's `invalid_number` error, the same code the -plain write doors (create, update, batch) already answer for the cell. The -reader used to strip every comma before parsing, so each of these was stored -as a DIFFERENT number while the import reported `ok 1, errors 0`. For each -shape, change the cell FROM the refused spelling TO the one that says what you -mean: - -- **A decimal comma.** FROM `3,14`, `1,5`, `0,5`, `1.000,5` (stored as `314`, - `15`, `5`, `1.0005`) TO a `.` decimal point with no grouping, or with comma - grouping: `3.14`, `1.5`, `0.5`, `1000.5` or `1,000.5`. A file exported with a - decimal-comma locale has to be converted before import. No locale is - guessed: `1,500` is always one thousand five hundred. -- **A comma that does not group thousands.** FROM `1,2,3`, `1,23`, `1,0000`, - `1234,567`, `,123`, `1,000,` or a comma after the `.` (`12,345.6,7`), each - stored with its commas removed, TO the number with no separators, or with - well-formed thousands grouping. -- **Grouping by twos (`12,34,567`, `1,00,000`).** FROM that grouping TO - `1234567` / `100000`, or `1,234,567` / `100,000`. These used to import as - the number they denote. They are refused now because the reader cannot tell - a two-digit group from a decimal comma, and it no longer guesses. - -**What is not affected.** Every cell without a comma reads exactly as before. -That is measured over the platform numeric grammar's 41 case rows: the only -row whose import reading changed is `1.000,5`. A well-formed thousands grouping -reads as before, with or without a leading currency symbol, a trailing `%`, a -sign or accounting parentheses (`$1,000`, `1,234%`, `(1,234)`). A JSON number, -and an xlsx cell Excel stores as a number, never pass through this reading. -The dry run answers the same verdicts as the real write. A refused cell fails -only its own row, and the rest of the batch imports as before. - -**If you are refused.** The row's result carries `code: 'invalid_number'` and -quotes the cell, so the file can be corrected and re-imported. - - diff --git a/.changeset/20502-number-comparand-non-string.md b/.changeset/20502-number-comparand-non-string.md deleted file mode 100644 index 07bd3aa3f9e..00000000000 --- a/.changeset/20502-number-comparand-non-string.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor ---- - -fix(spec)!: a boolean, a `Date` or an array compared against a number field is refused with `INVALID_FILTER` / 400 at `where`, a per-aggregation `filter` and `having`, the same as a non-numeric string - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what a filter may compare a number field with. `numberComparandDoorVerdict`, the published verdict the engine's number-comparand door consumes, judged strings only; it now also answers `door-refusal` for a boolean, a `Date` and an array, so the engine refuses them before any read, on every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. The string rule is unchanged, and so are both packages' root exports except three additions to `@objectstack/spec/data`: `NON_NUMERIC_VALUE_FORMS` and the types `NonNumericValueForm` and `NonNumericComparandForm` (the refusal's `form` and the refusal site's `value` widen to carry a non-string). - -FROM `true` / `false`, a `Date`, or an array where one value belongs (a scalar operator's comparand, or a member of `$in` / `$nin` / `$between`), compared against a `number`, `currency`, `percent`, `rating`, `slider`, `progress` or `summary` field (or a `count` / `sum` / `avg`, or a numeric `min` / `max` / groupBy column in `having`) → TO `INVALID_FILTER` / 400, naming the field, its declared type, the comparand, its position and what is wrong with it. The fix is one line: send the number the filter means (`12`, `-3.5`, `1e3`), or compare a `Date` with a date or datetime field. - -Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query`, three rows (5, 12, 30) of a `number` field: - -| position | comparand | before: memory · SQLite · PostgreSQL 16 | now, on all three | -|:--|:--|:--|:--| -| `where` | `$gt true` | no rows · every row · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | -| `where` | `$ne true` | every row · every row · 500 | `INVALID_FILTER` / 400 | -| `where` | `$between [true, 20]` | no rows · two rows · 500 | `INVALID_FILTER` / 400 | -| `where` | `$gt` a `Date` (in-process callers) | no rows · no rows · 500 | `INVALID_FILTER` / 400 | -| `where` | `$gt [1]` | a driver's own 400, in each driver's words | `INVALID_FILTER` / 400, in one set of words | -| `where` | a `$in` member `[1]` | no rows · a driver 400 · a driver 400 | `INVALID_FILTER` / 400 | -| per-aggregation `filter` | `$gt true` / `$gt [1]` (`$gt` a `Date`) | count 3 (count 0), on all three | `INVALID_FILTER` / 400 | -| `having` on `sum(amount)` | `$gt true` / `$gt [1]` (`$gt` a `Date`) | every group (no group), on all three | `INVALID_FILTER` / 400 | -| all three positions | `$gt 10` (the numeric control) | 2 rows / count 2 / both groups | the same | - -**Who is affected.** A caller that compares a number field with a boolean or an array through any door that reaches the engine (a `where`, `$filter` or `filter` body of `POST /data/:object/query`, or an in-process engine call), or with a `Date` in-process (a flow, a hook, server code). No example app, platform object or other in-repo producer compares a number field that way. - -**Unchanged.** A number, a `bigint` (narrowed or refused by the comparand-type door, as before) and `null` (the null test) are answered as before. A value outside the accepted comparand types (`undefined`, a plain object, a `Map`) keeps the comparand-type door's own refusal and words. An array at an equality slot (implicit, `$eq`, `$ne`) keeps the comparand-shape door's refusal. A boolean or a `Date` compared against a boolean, date or datetime field is not this door's subject. Driver-direct callers that never pass through the engine keep each driver's native binding. diff --git a/.changeset/20507-layers-absent-name-oracle.md b/.changeset/20507-layers-absent-name-oracle.md deleted file mode 100644 index c99573c1ff6..00000000000 --- a/.changeset/20507-layers-absent-name-oracle.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -**The layered view, `GET /api/v1/meta/:type/:name/layers` and the deprecated `?layers=true` flag, now answers a name with nothing behind it with the plain read's `404 RESOURCE_NOT_FOUND`, the answer it already gave a member for an unpublished app.** Before this release, a name with no layer behind it answered `200` with `code`, `overlay` and `effective` all `null`. A member asking for an unpublished app got `404`, so the difference between the two answers told the member which unpublished apps exist. ADR-0045 §3 declares a hidden app externally unobservable on every surface, and the plain read already kept that promise. This follows triage's grade on #20507. - -Clause-②: no - -- **What changed:** `createMetaLayeredAnswer`, the one chain both transports call after the store read (`RestServer` and the runtime dispatcher's `/meta` domain), answers a layered read with no layer present as the name's absence, before the per-caller gate runs. Each transport writes that absence in its own envelope, the one it already uses for an unpublished app: `RestServer`'s nested `{ error: { code: "RESOURCE_NOT_FOUND", message } }`, and the dispatcher's `404` error envelope. The flag's `Deprecation` and `Link` headers still ride that answer. -- **Who it applies to:** every caller. The plain read answers an absent name `404` whoever asks, and so does the layered view now. A builder (`studio.access` or `setup.access`) is still served an unpublished app on both spellings. A `?package=` scope that leaves no layer behind the name is that name's absence too. -- **Unchanged:** a name with any layer behind it is judged and served exactly as before. An item whose code layer is scoped away by `?package=` but whose overlay row answers is still served, with `code: null`. - -A client that read `/layers` for a name that has never been published, and took a `200` with every layer `null` as "not saved yet", now receives `404`. Treat that `404` as the same answer. Studio's metadata client already maps a `404` from this route to every layer `null`, so the designer's "open an item that exists only as a draft" path is unchanged. diff --git a/.changeset/20508-scoped-layers-link.md b/.changeset/20508-scoped-layers-link.md deleted file mode 100644 index d992d44ecfc..00000000000 --- a/.changeset/20508-scoped-layers-link.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -fix(rest): the environment-scoped `?layers=true` answer's successor `Link` names the path the request used, not the route template (#20508) - -Clause-②: no - -On `RestServer`'s environment-scoped mount (`api.enableProjectScoping`), -`GET /api/v1/environments/env_1/meta/view/lead_all?layers=true` answered its -`Deprecation` header with a successor `Link` naming -`/api/v1/environments/:environmentId/meta/view/lead_all/layers`: the route -template, with a literal `:environmentId` in it. A client that followed the -header requested that path. The `Link` now names -`/api/v1/environments/env_1/meta/view/lead_all/layers`. - -`RestServer` builds the `Link` from the request's own path, read the way the -runtime dispatcher reads its request URL, so both transports name the successor -the same way. The unscoped mount's `Link` is unchanged for every name that needs -no percent-encoding. A percent-encoded name now stays encoded in the `Link` -(`lead%20all`, where the header used to carry a raw space), because the path is -parsed as a URL path instead of being assembled from decoded route parameters. -A request that carries no path of its own is still answered `Deprecation: true`, -and names no successor. The body, the status and the `Deprecation` header are -unchanged on both mounts. diff --git a/.changeset/20515-orgless-grants-global-only.md b/.changeset/20515-orgless-grants-global-only.md deleted file mode 100644 index f5be029cac0..00000000000 --- a/.changeset/20515-orgless-grants-global-only.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/plugin-security": minor ---- - -A grants resolution with no active organization now applies only the global grants. `resolveUserAuthzGrants` applies a grant scoped to an organization only while that organization is the active tenant, and one rule decides it for all three kinds of grant row it reads: position assignments (`sys_user_position`), permission-set grants (`sys_user_permission_set`) and the organization's own position rows whose bound permission sets it collects (`sys_position`). - -**BREAKING** for a principal acting with no active organization. Before, "no organization" read as "every organization": each organization-scoped grant the user held anywhere applied, with no organization boundary left on it. That is the resolution a session falls back to when it names an organization its owner no longer belongs to, so a member removed from an organization kept the capabilities that organization had granted until someone revoked each grant by hand. Such a principal now holds its global grants and nothing scoped to an organization. - -- **Unchanged:** a principal with an active organization resolves exactly as before, and a global grant (no organization) applies everywhere as before. Platform-admin standing is unchanged: it was only ever derived from the unscoped `admin_full_access` grant or the declared administrator list, never from an organization-scoped grant. -- **If a principal relied on it:** act in the organization. Select it as the active organization, or mint the API key from a session that has it active, or grant the permission set globally (no organization) when it is meant to apply everywhere. -- **No "every organization" mode.** No option asks the resolver for every organization's grants, and nothing falls back to that reading. -- **`@objectstack/plugin-security`:** `buildContextForUser(ql, userId, nowMs?, tenantId?)` takes the organization to resolve the user in. The access explainer (`explainAccessForCaller`) resolves the explained user in the caller's organization, and the delegator behind an on-behalf-of principal is resolved in the live principal's organization, so the delegated intersection counts the delegator's grants where the request actually runs. - -Clause-②: yes (narrowing) - - diff --git a/.changeset/20525-temporal-write-real-day-iso.md b/.changeset/20525-temporal-write-real-day-iso.md deleted file mode 100644 index c0b45c6d6df..00000000000 --- a/.changeset/20525-temporal-write-real-day-iso.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql)!: a `date` / `datetime` string is written on a calendar day that exists, and a `datetime` string in an ISO 8601 spelling, or it is refused with `VALIDATION_FAILED` / 400 (`invalid_date`) — `"2026-02-30T10:00:00Z"` is no longer stored as March 2, and `"07/15/2026 10:00"` is no longer read in the server's zone (#20525) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows what a `date` and a `datetime` field accept as a written value. It ships as `minor` under the launch-window convention for accept-set narrowings (`check-changeset-no-major` refuses `major` until GA; the breaking-ness is carried by this banner and the ADR-0087 disposition above). - -Two kinds of string are now refused with `VALIDATION_FAILED` / 400, the field code `invalid_date` and its existing message ("must be a valid date (ISO-8601)" / "must be a valid datetime (ISO-8601)"), naming the field, before anything is written: - -- **A day that does not exist**, as a `date` or as the day part of a `datetime`: `"2026-02-30"`, `"2026-02-29"` (2026 is not a leap year), `"2026-04-31"`, `"2026-02-30T10:00:00Z"`. `"2028-02-29"` is a real day and is accepted. -- **A `datetime` string in any spelling but these ISO 8601 ones**, after trimming: `YYYY-MM-DD` (midnight UTC); `YYYY-MM-DDTHH:MM[:SS[.fraction]]` followed by `Z`, a `±HH:MM` or `±HHMM` offset, or nothing (a zone-naive wall clock is UTC, ADR-0074); and `YYYY-MM-DD HH:MM[:SS[.fraction]]` with no zone (UTC the same way). Refused now, for example: `"2026/07/15 10:00"`, `"07/15/2026 10:00"`, `"15 July 2026 10:00"`, `"07/08/2026"`, `"2026-07-15 10:00 PM"`, `"Wed, 15 Jul 2026 10:00:00 GMT"`, `"2026"`, `"2026-07"`, `"2026-07-15t10:00:00z"` (lower case), `"+002026-07-15T10:00:00Z"`, and a space-separated time carrying a zone, `"2026-07-15 10:00:00+08:00"` (write it with a `T`). - -The fix is to send the value in one of those spellings — `"2026-07-15T10:00:00Z"`, `"2026-07-15T10:00:00+08:00"` or `"2026-07-15 10:00"` — or a JS `Date`. No other spelling is read for you, on purpose: `07/08/2026` is July 8 in one locale and August 7 in another, and a wall clock with no zone was read in whatever zone the server process ran in. - -What a caller sees, before and after, through `POST /api/v1/data/:object` and a read-back, the process in America/New_York, PostgreSQL 16 at `Asia/Shanghai`: - -| written | memory | SQLite | PostgreSQL | now, on all three | -|:--|:--|:--|:--|:--| -| `date` `"2026-02-30"` | 201, read back `"2026-02-30"`, a day that does not exist | the same | 500 `DATABASE_ERROR` | 400 `invalid_date` | -| `datetime` `"2026-02-30T10:00:00Z"` | 201, read back `"2026-03-02T10:00:00.000Z"` | the same | the same | 400 `invalid_date` | -| `datetime` `"2026/07/15 10:00"`, `"07/15/2026 10:00"`, `"15 July 2026 10:00"` | 201, `"2026-07-15T14:00:00.000Z"`, the server process's zone | the same | the same | 400 `invalid_date` | -| `datetime` `"07/08/2026"` | 201, `"2026-07-08T04:00:00.000Z"`, month-first in the process zone | the same | the same | 400 `invalid_date` | -| `datetime` `"2026"` | 201, `"1970-01-01T00:00:02.026Z"` | the same | the same | 400 `invalid_date` | - -The stored instant of a non-ISO `datetime` was a property of the deployment host: the same request landed hours apart on two servers. - -What changes: the record validator's `date` / `datetime` arm asks two more questions of a string, on insert, update, a multi-row update and `engine.validate` (the dry run), before any driver write. Does its leading `YYYY-MM-DD` name a day that exists (month 01..12, day up to that month's length, February 29 only in a leap year)? And, for a `datetime`, is it one of the ISO spellings above? A `Date` names a real instant and keeps its answer. - -**Who is affected.** A caller that writes a `date` or `datetime` field as a string: a REST or SDK client, a flow, an MCP `create_record` / `update_record` call written by a model. A row that already holds such a value keeps it, since nothing re-reads stored rows. An update that omits the field is not affected; one that sends the old string back is refused, so re-write it in an ISO spelling. The server import (`POST /api/v1/data/:object/import`) turns a `datetime` cell into ISO text itself before the write, so its `datetime` cells reach this check already converted; a `date` cell naming a day that does not exist (`2026-02-30`) is now a per-row `invalid_date`, where memory and SQLite stored it and PostgreSQL failed the row. - -**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through REST: - -- a real leap day: `date` `"2028-02-29"`, `datetime` `"2028-02-29T10:00:00Z"`; -- each ISO spelling above, stored as the same instant: `"2026-07-15T10:00:00Z"`, `"2026-07-15T10:00:00+08:00"` (`"2026-07-15T02:00:00.000Z"`), `"2026-07-15 10:00"` and `"2026-07-15T10:00"` (`"2026-07-15T10:00:00.000Z"`, UTC, not the host zone), `"2026-07-15"` (`"2026-07-15T00:00:00.000Z"`); -- a `date` string with a leading real `YYYY-MM-DD`, still stored as that day; -- a `Date`, still accepted; an epoch-millisecond number, still refused with `invalid_date`; -- the year range 0001..9999; -- every `time` value, and every filter comparand (`where`, a per-aggregation `filter`, `having`). diff --git a/.changeset/20529-api-trigger-requires-secret.md b/.changeset/20529-api-trigger-requires-secret.md deleted file mode 100644 index 98ee07818fd..00000000000 --- a/.changeset/20529-api-trigger-requires-secret.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/trigger-api': minor -'@objectstack/service-automation': minor ---- - -fix(trigger-api,service-automation): an `api` flow with no per-flow secret is refused, at arm time and at registration (#20529) - -Clause-②: no (narrowing) - -**BREAKING** — shipped as `minor` under the launch-window convention -(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by -this banner and the ADR-0087 disposition below, never by the level). - -ADR-0041's `trigger-api` acceptance criteria name a per-flow secret and HMAC -signature verification. The trigger used to arm a flow's inbound hook without a -secret, with only a warning, and that hook skipped signature verification. An -`api` flow whose start node carries no non-blank `config.secret` is now refused -in two places: - -- **At registration** (`@objectstack/service-automation`). `registerFlow` refuses - a flow whose binding resolves to the `api` trigger (`type: 'api'`, or a start - node with `triggerType: 'api'`) when the start node declares no non-blank - `config.secret`, whatever the flow's `status`. The error names the flow and - `config.secret`. The `/automation` create, update and clone doors answer it as - `400 VALIDATION_FAILED`, like every other registration refusal. At boot the - flow is skipped and the existing `[Automation] failed to register flow` warning - names it. -- **At arm time** (`@objectstack/trigger-api`). `ApiTrigger.start()` throws, - naming the flow and `config.secret`, before it stores a hook or subscribes a - queue consumer. The engine logs `Failed to bind flow` and the flow stays - unbound. This covers a host that binds the trigger without the engine. The - arm-time `armed WITHOUT a secret` warning is gone, since that state no longer - exists. Every armed hook verifies the signature on every post. - -**Fix.** Give the flow's start node a non-blank `config.secret` and sign each -post with it, as the `x-objectstack-signature` header already documents. A flow -that is only ever started explicitly (`engine.execute()`, or the `/automation` -trigger route) and is not meant to receive inbound posts is an `autolaunched` -flow. Declare it `type: 'autolaunched'`, with no `triggerType: 'api'` on its -start node, and it needs no secret. - -Unchanged: a flow that already carries a secret registers, arms and verifies -exactly as before. - - diff --git a/.changeset/6009-backfill-julian-day-guard.md b/.changeset/6009-backfill-julian-day-guard.md deleted file mode 100644 index 02115ed774a..00000000000 --- a/.changeset/6009-backfill-julian-day-guard.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -`backfillCanonicalDatetimes` / `backfillCanonicalTimes` no longer write SQLite's julian-day reading of a bare-numeric cell over the bytes on disk (#6009). - -Both migrations use the read path's own repair expression as their `SET` value, and that expression's `else` arm is `coalesce(strftime(...), col)` — on the stated understanding that a value SQLite cannot parse falls through the `coalesce` unchanged. SQLite's time-value grammar has one limb that defeats it: a **bare number** is a julian day (`DDDD.DDDD`, the last of its documented formats), and `now` is the wall clock. For those two shapes `strftime` does not return NULL, so `coalesce` never fires and a confident wrong answer is what gets written. Measured on better-sqlite3 13.0.3 / SQLite 3.53.4 against a TEXT-affinity column: - -```text -'12' -> -4713-12-06T12:00:00.000Z '2026' -> -4707-06-11T12:00:00.000Z -'86400' -> -4476-06-15T12:00:00.000Z '2440587.5' -> 1970-01-01T00:00:00.000Z -'now' -> whatever the clock said '2026-08-06' -> 2026-08-06T00:00:00.000Z (correct) -``` - -On a read that was a temporary misreading and the disk was untouched. On the `SET` side it is a write, and the original value is unrecoverable afterwards. - -- **The guard is the structural complement of the parseable spellings, not a heuristic.** Every other format SQLite's date parser accepts goes through `parseYyyyMmDd` (which needs a `-`) or `parseHhMmSs` (which needs a `:`), so a cell the parser answers for while containing neither character reached it through the julian-day limb or the `now` limb — there is no third way in. `sqliteNonTemporalTextSql` is that predicate; it never inspects the magnitude of the number and never decides what the cell means, only that the migration must not rewrite it. -- **A withheld row also blocks the canonical mark, and that is what keeps every query answer identical.** Skipping the row costs nothing while the read-side repair still applies to it — it keeps reading as the same instant it always did. Marking the column clean is what would move an answer: `needsLegacyDatetimeRepair` would drop the repair and the raw `'2026'` would be compared as TEXT. Unlike the `coalesce` fixpoints, these rows are not invariant under the repair, so they cannot ride through the mark. The column stays un-marked, reads keep their (unindexed) repair, and one `warn` names the count, the reason and the remedy. -- **The read path is untouched, deliberately.** The maintainer's 2026-08-03 ruling on cloud#1005 refused teaching the shared read expression to recognise numeric-looking text: it is a public contract for every SQLite consumer, it runs on every read, and there it would misread a legitimate numeric-string column. Everything here runs once, as a migration, only on a column the metadata declares `Field.datetime` / `Field.time`, and it only ever declines to write. -- **`previewDatetimeConvergence` / `previewTimeConvergence` inherit the same exclusion**, so `os migrate plan` still promises exactly the rows `apply` rewrites. - -Reaching the julian branch needs a temporal column with TEXT affinity. A column knex creates for `Field.datetime` is declared `datetime`, which is NUMERIC affinity, so `'2026'` is converted to INTEGER on the way in and takes the epoch branch instead — the local write path mostly dodges this. A table that already existed when `initObjects` first saw it keeps whatever affinity it was created with, and `initObjects` adds missing columns without ever retyping one. diff --git a/.changeset/6009-turso-remote-julian-guard.md b/.changeset/6009-turso-remote-julian-guard.md deleted file mode 100644 index 7c92db8347b..00000000000 --- a/.changeset/6009-turso-remote-julian-guard.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/driver-turso': minor ---- - -`driver-turso`: the REMOTE canonical temporal backfill no longer overwrites a bare-number cell with the date SQLite reads it as (#6009). - -`RemoteTransport.mapFieldTypeToSQL` declares every temporal column `TEXT`, so a `Field.datetime` / `Field.time` column can hold a digits-only string such as `'2026'`, `'86400'` or the keyword `'now'`. SQLite's time-value grammar accepts a bare number as a JULIAN DAY and `now` as the wall clock, so for those two shapes `strftime` answers confidently instead of returning NULL and the `coalesce(strftime(…), col)` that is supposed to preserve unparseable values never fires. The convergence `UPDATE` in `backfillRemoteCanonicalColumn` therefore wrote that answer over the stored bytes, and no later run could get them back — measured on better-sqlite3 13.0.3 / SQLite 3.53.4 with the column declared `TEXT`: - -```text -'2026' -> -4707-06-11T12:00:00.000Z 'now' -> the wall clock, now -'86400' -> -4476-06-15T12:00:00.000Z '12' -> -4713-12-06T12:00:00.000Z -``` - -The local (Knex) half of this was fixed for `SqlDriver` in the same tracker; the remote path is a separate module that builds its own statements and did not inherit it. - -- **The rows are withheld from the `UPDATE` and they also BLOCK the canonical mark.** Withholding alone would not be enough: a marked column drops the read-side repair, and the raw `'2026'` would then compare as TEXT instead of as the instant the repair reads it as. A withheld row is by construction `col IS NOT canonical`, so it stays inside the probe's `residual`, which the mark already requires to be zero. The column keeps its (unindexed) repair and every query answer is bit-for-bit what it was. -- **The predicate is the driver's own, handed across the module boundary — never copied.** `TursoDriver` now passes `{ canonical, nonTemporalText }` where it passed a bare canonical expression, the second arm being `SqlDriver.sqliteNonTemporalTextSql`. Nothing in the shared READ expression changes: `sqliteCanonicalDatetimeSql` / `sqliteCanonicalTimeSql` still misread a bare number exactly as before, deliberately, per the 2026-08-03 cloud#1005 ruling that refused a heuristic in a public expression that runs on every read. -- **The third position of `probeRemoteCanonicalColumns`, `backfillRemoteCanonicalColumn` and `backfillRemoteCanonicalColumns` now accepts either shape**, so a caller compiled against the earlier release keeps compiling: `RemoteBackfillSqlRules` (`{ canonical, nonTemporalText }`) is the shape to pass, and a bare `CanonicalSqlFor` is FAIL-CLOSED rather than a fallback — with no guard to withhold by, the convergence phase is refused, the column reports `error` and stays unmarked, and reads stay correct on the repair. The 后果 B epoch recovery still runs on that arm. To move off it: pass `{ canonical: , nonTemporalText: }`. -- **New on the per-column report: `nonTemporalTextRowsWithheld`** — what the guard declined to write, or `null` when no guard was supplied and the count was therefore never measured. It is deliberately NOT folded into `unresolvedEpochTextRows`, whose documented meaning is *recorded and harmless to the mark*; these rows are the opposite. -- **A documented sentence is corrected in the same change.** The module said rows outside the epoch-recovery band "do not block the canonical mark, because they are fixpoints of the shared repair". That is true only ABOVE SQLite's julian-day ceiling, where `strftime` returns NULL. Below it — `'12'`, `'2026'`, `'86400'` — the repair answers, the row is not a fixpoint, and dropping the repair changes what it matches. Both halves are now stated with the measurement behind them. -- **The two bands do not meet, so the guard costs the epoch recovery nothing.** A bare number is read as a julian day only for `0 <= v < 5373484.5` (`'5373484.4'` parses, `'5373484.5'` returns NULL); `REMOTE_BACKFILL_EPOCH_MS_MIN` is `1e12`. The epoch-text `UPDATE` is also structurally out of reach for a second, independent reason: its SET wraps `cast(col as real)`, whose `typeof()` is always `'real'`, so the canonical expression takes its `'unixepoch'` limb and never the `coalesce`/julian one. - -No read path, no filter compilation and no stored value changes for any column that holds none of this shape: a table with nothing but ordinary legacy rows converges and is marked exactly as before. diff --git a/.changeset/7898-auth-gate-fail-close.md b/.changeset/7898-auth-gate-fail-close.md deleted file mode 100644 index 093df3dda59..00000000000 --- a/.changeset/7898-auth-gate-fail-close.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/core': patch ---- - -fix(core): an absent or empty path is no longer exempt from the ADR-0069 auth gate (#7898) - -`isAuthGateAllowlisted` answered `true` for a falsy path — it treated "no path" -as allow-listed. That is a fail-OPEN default on an authorization seam: any -caller that reached the ADR-0069 gate with an absent or empty `path` was exempt -on **every** route, and a transport author who simply forgot to populate `path` -disabled the gate with no diagnostic of any kind. - -``` -FROM isAuthGateAllowlisted(undefined) -> true // exempt, on every route - isAuthGateAllowlisted('') -> true - -TO isAuthGateAllowlisted(undefined) -> false // exemption must be earned - isAuthGateAllowlisted('') -> false -``` - -Exemption is now something a path has to EARN by naming an allow-listed route, -so the failure mode of omission is a `403` rather than a bypass. The predicate -is split in two so it carries exactly one meaning: a private -`matchesAllowlistedRoute` answers the route question for a real, non-empty path -— its body is unchanged, the #16839 anchoring rules included — and the exported -predicate answers "is this request exempt", which a request with no path is not. - -**No current caller's behaviour moves.** The caller census was re-run: the same -four production call sites, and no fifth. Two of them (`RestServer.enforceAuth`, -`shouldDenyAnonymous`) already guard for a non-empty path and so only ever reach -the predicate with a real string; a corpus differential against the pre-flip -predicate over more than 10,000 paths moves exactly one input — the empty string -— and nothing else, in either direction. - -**The one exemption that remains for a genuinely pathless caller is explicit**, -and lives at the one seam that really routes by body: `shouldDenyAnonymous` -declares `path` optional and decides the no-path case itself (it denies), ahead -of this predicate. That guard is deliberately kept rather than collapsed into -the now-agreeing default — a seam's contract should not be re-derived from what -a predicate happens to do with a falsy argument. - -**Known follow-up, tracked as #17625.** The dispatcher's bare-root -`` `${prefix}/` `` arrives as `cleanPath === ''` (the trailing slash is -stripped), which was exempt via the fail-open default and is not exempt now, so -a *gated* session — one carrying an `authGate`, i.e. an expired password or a -required MFA enrollment — reaching the bare root gets a `403` instead of the -discovery payload. Every named remediation route (`/auth/*`, `/health`, -`/ready`, `/discovery`, `/me/apps`, `/me/localization`) is unaffected, so -remediation itself stays reachable. Normalising that empty `cleanPath` is step 2 -of the same ruling and is **not** a tolerance re-added here. diff --git a/.changeset/action-confirmation-gate-enforced.md b/.changeset/action-confirmation-gate-enforced.md deleted file mode 100644 index 1b1e44cb008..00000000000 --- a/.changeset/action-confirmation-gate-enforced.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/mcp": minor ---- - -fix(runtime,mcp): `action.ai.requiresConfirmation` is ENFORCED at the AI-facing action door — an unconfirmed call is refused, and `run_action` grows the `confirm` member that satisfies it (#15942) - -**Behaviour change — read this if any of your actions declare `ai.requiresConfirmation: true`.** An AI-facing invocation of such an action (`invokeBusinessAction`, reached from the MCP `run_action` tool) is now REFUSED unless the request carries the confirmation member. A call that succeeded before starts answering `428 ACTION_CONFIRMATION_REQUIRED`, and nothing dispatches: the action body does not run, and the subject record is not even read. - -FROM → TO, for a caller of a gated action: - -``` -run_action({ actionName: 'archive_lead', recordId: 'lead_1' }) // was: ran -run_action({ actionName: 'archive_lead', recordId: 'lead_1', confirm: true }) // now: required -``` - -The refusal is machine-readable so the retry is mechanical rather than guessed — `error.details` carries `{ actionName, objectName?, confirmationMember }`, and `confirmationMember` echoes the member's exact spelling (`AI_ACTION_CONFIRMATION_MEMBER`, `@objectstack/spec/contracts`). The `run_action` tool schema advertises `confirm` as an optional boolean, so an agent discovers the retry from the tool definition rather than from prose. - -**What is NOT gated**, because this narrows a published accept set and the narrowing is deliberately as small as the author's own declaration: - -- Only the DECLARED flag gates. `ai.requiresConfirmation: true`, set by the action's author, and nothing else. The wider `list_actions` heuristic — `mode: 'delete'` / `variant: 'danger'` on an action whose author declared nothing — still reports `requiresConfirmation: true` to advise a client, and still does NOT refuse. An explicit `ai.requiresConfirmation: false` never refuses. -- Only the boolean `true` confirms. `'true'`, `1` and `false` are not attestations. -- Only the AI-facing doors. The enforced set is the doors that enforce `ai.exposed` — today `invokeBusinessAction` via MCP `run_action`. REST `/actions` is not `ai.exposed`-gated and sits outside this gate. -- `list_actions` is unchanged. - -**A gate, not a queue.** Nothing is parked, nothing is held for an operator, and there is no resume path: a refused call simply did not run, and the caller confirms with its human and retries. And `confirm: true` is an unverifiable caller claim — an agent that always sends it bypasses the gate. The gate makes FORGETTING loud; it does not prove a human. - -Why it is worth the break: the flag was read once and consumed once, to fill a field of the `list_actions` summary. It stopped nothing. That is the failure ADR-0049 retired `tool.requiresConfirmation` for — "a SAFETY flag that is merely accepted is false compliance" — reappearing on the very key the retirement's own ledger entry told authors to move to. The contract this implements landed in `@objectstack/spec` first (#16293). diff --git a/.changeset/admin-create-user-reads-membership-policy.md b/.changeset/admin-create-user-reads-membership-policy.md deleted file mode 100644 index 61414b1a247..00000000000 --- a/.changeset/admin-create-user-reads-membership-policy.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/plugin-auth": minor ---- - -fix(plugin-auth)!: `POST /admin/create-user` reads the deployment's membership policy instead of hard-coding `auto` (#16683) - -**BREAKING** — the membership this published endpoint writes moves for existing inputs on `invite-only` deployments. The route, its request body, its response fields and every exported signature are byte-identical; what changes is what an existing call does on a deployment that declared a non-default policy, stated as a FROM/TO pair below. - -ADR-0093 D1 makes the deployment's `membershipPolicy` the one answer to "does this new account get an organization membership", and enumerates the `invite-only` flows as a closed set — "which endpoint created the user" is explicitly not a determinant. The `user.create.after` reconciler and the D6 backfill both read it through `AuthManager.getMembershipPolicy()`. This endpoint did not: its belt-and-suspenders bind handed the reconciler a literal `'auto'`, so it was the one membership-writing path in the product that ignored the setting. - -FROM: on a deployment declaring `membershipPolicy: 'invite-only'`, an account created through `POST /api/v1/auth/admin/create-user` was bound to the default organization anyway, and the 200 response answered `membershipCreated: true`. The `user.create.after` reconciler had already declined to bind it; this endpoint bound it afterwards. - -TO: the same call creates the account and binds no membership. The response answers `membershipCreated: false` and omits `organizationId`, and the audit row records the same. The account is created and can sign in — `invite-only` withholds the membership, not the login. - -Who is affected: only deployments that set `auth.membership_policy` (or `OS_AUTH_MEMBERSHIP_POLICY`) to `invite-only`. Under the default `auto` posture behaviour is unchanged in every observable respect — response body, `sys_member` write and audit metadata — and that equivalence is pinned by a test rather than asserted here. - -If you relied on admin-created accounts acquiring a membership on an `invite-only` deployment, the supported way to keep it is to bind the membership explicitly (the `add_member` action / `POST /organization/add-member`), which is what `invite-only` means: memberships are granted deliberately, never as a side effect of account creation. Setting the deployment back to `auto` restores the old behaviour for every path at once, including sign-up. - -The direction of the old defect was open, not closed: it GRANTED a membership the operator had configured the platform to withhold, and reported success while doing it. An operator who set `invite-only` specifically to keep a shared organization identity off their users got one anyway. - - diff --git a/.changeset/adr-0112-envelope-refusal-declaration.md b/.changeset/adr-0112-envelope-refusal-declaration.md deleted file mode 100644 index 01b968d9f23..00000000000 --- a/.changeset/adr-0112-envelope-refusal-declaration.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): the ADR-0112 error envelope gains a producer-side `refusal` declaration, so a deliberate 5xx refusal can keep its caller-authored `message` (#16335) - -`ApiErrorSchema` and `EnhancedApiErrorSchema` declare one new optional key, **`refusal: true`** — the producer's declaration that the 5xx it named is a deliberate REFUSAL whose `message` is authored for the caller, so the boundary keeps that message verbatim instead of withholding it. Director ruling, decision batch #58 (2026-09-06, option C): the refusal/fault distinction is a producer-side declaration on the published envelope — not a status heuristic and not a second allow-list. - -The three cases are now documented side by side on the envelope's TSDoc: - -- **undeclared 5xx** (no `status` on the throw) — unchanged: the leak heuristic decides per message. -- **declared fault** (`status >= 500` + `code`, nothing declared here) — unchanged, and still the DEFAULT: `message` is withheld from the body and logged for the operator. -- **declared refusal** (`status >= 500` + `code` + `refusal: true`) — new: `message` is kept verbatim, bounded exactly as a 4xx message is. - -Purely additive: a producer that says nothing here gets exactly the previous behaviour. `true` is the only value — `refusal: false` fails parse instead of becoming a third state consumers would have to interpret. `userMessage` is orthogonal (end-user text; it never replaces `message`) and may ride the same envelope; the TSDoc reconciles this flag with the recorded reason `userMessage` is a text-carrying field rather than "a boolean beside `message`". - -This is the spec half. The relay half — the three withhold arms reading the declaration (two in `@objectstack/rest`: `declaredServerFaultAnswer`, and `resolveErrorResponse`'s own 5xx passthrough arm, which the `/references` door reaches; one at `@objectstack/runtime`'s dispatcher exit, `errorResponseBase`, which `objectstack serve` mounts and which never consults the first), plus retiring the route-local patch from PR #16143 on `/meta/:type/:name/references` — is #16146 for the REST pair and its sub-issue #17153 for the runtime exit; until they land, a declared refusal is still withheld at the wire. diff --git a/.changeset/agent-tools-liveness-row-dead.md b/.changeset/agent-tools-liveness-row-dead.md deleted file mode 100644 index e94375b1191..00000000000 --- a/.changeset/agent-tools-liveness-row-dead.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the `agent.tools` liveness row is `dead` — it claimed `live` on a key the schema tombstoned - -`liveness/agent.json` ships inside this package, and its `tools` row read: - -```json -"tools": { "status": "live", "evidence": "cloud: packages/service-ai/src/agent-runtime.ts", "note": "legacy direct-tool fallback." } -``` - -`agent.tools` was removed in protocol 17 (#3894). `src/ai/agent.zod.ts` declares it -`retiredKey(...)`, which types the key `never` and rejects any authored value with the -upgrade prescription, and the ADR-0087 conversion `agent-tools-to-skills` deletes it from -stored rows and built artifacts when the chain is replayed at rehydration. So nothing can -carry a value for the key and no consumer in any repo can read one — while the ledger's own -vocabulary defines `live` as "Has a runtime consumer". - -The verdict moves `live` -> `dead` with **no key added or removed**: the classified total -stays at 1094 and the accept set is byte-identical, because a liveness row is a claim about -the schema rather than the schema. `dead` is the status the ledger's own convention already -gives this class — of the 40 tombstoned top-level keys across the 36 governed types, 39 -were already `dead` and this was the only outlier — and it is what puts the key on the -ADR-0049 enforce-or-remove worklist it should have been on since protocol 17. `live-elsewhere` -is refused rather than left undeclared: that status needs a genuine foreign enforcer, and a -key nothing can carry a value for has nothing to enforce. - -Nothing changes for authors: writing `agent.tools` failed `tsc` and failed the parse before -this change and fails both after it. What changes is that the ledger, which ships in this -tarball and is the input to the retirement worklist, no longer certifies a consumer that does -not exist. - -Also in this change: the stale `evidence` pointer is deleted rather than repointed (a `dead` -row's pointer lives in its `note` by the gate's own design), the ledger's own `_note` -sentence saying the row was deliberately left unstamped is corrected to record the landed -re-grade, `liveness/state-counts.md` is regenerated, and a contract test pins the class — -a `[REMOVED]` tombstone's ledger row says `dead`, on a measured population of 40. diff --git a/.changeset/alias-citations-two-roles-sweep.md b/.changeset/alias-citations-two-roles-sweep.md deleted file mode 100644 index 5333d090565..00000000000 --- a/.changeset/alias-citations-two-roles-sweep.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/objectql': patch ---- - -Correct six `edit distance cannot reach` citations that are measurably false, and pin the role each alias entry actually plays. - -`aliases` has two jobs, not one: filling a gap the distance fallback leaves empty, and overruling a hit the fallback reaches and gets wrong. The lookup is `aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, budget, 1)[0]` — the table is consulted first and wins outright — and the budget is `Math.max(2, Math.floor(key.length / 3))`. A sentence saying distance "cannot reach" the cited case denies the second job, and in three places the cited case is itself an example of it. - -- **`latitude` → `lat` is an OVERRULE, not a gap** (`data/field-value.zod.ts`, `data/default-value-shape.ts`, `data/field-value.test.ts`, `data/default-value-shape.test.ts`, objectql `validation/record-validator.ts`). `latitude` is 8 characters, so the budget is 2; `lat` is 5 edits away and out of reach, but the declared `altitude` is exactly 2 — so without the curated entry the bare fallback answers `latitude` → `altitude` and points an author who wrote a GPS latitude at the elevation member. Four docblocks cited this pair as proof that aliases exist only where distance reaches nothing. -- **`postal_code` → `postalCode` never involved an alias at all** (`data/default-value-shape.ts`). Scoring folds case and separators on both sides, so it is 1 edit against a budget of 3 — the worked example rendered in that docblock is the fallback's own answer, not the `AddressValueSchema` table's. -- **`uri` → `url` is reachable and agreeing** (`data/driver/turso.zod.ts`). The block was headed "the spellings edit distance cannot reach"; that is true of five of its six rows and false of `uri`, which is 1 edit from `url` against a budget of 2. The row is a pin on an answer the fallback already gets right, not a gap-filler. - -Prose plus new pins. No alias is added or removed, no schema, key list, strictness, suggestion or error message changes: `Clause-②: no`. The three roles are now asserted — `longitude` (gap), `latitude` (overrule, with the negative half), `altitud` (a plain typo still riding the fallback) in `data/field-value.test.ts`, and `dsn` (gap) beside `uri` (reachable) in `data/driver/turso.test.ts`. diff --git a/.changeset/amplifiers-linked-packages.md b/.changeset/amplifiers-linked-packages.md deleted file mode 100644 index 5f4d722872b..00000000000 --- a/.changeset/amplifiers-linked-packages.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/core': patch -'@objectstack/plugin-auth': patch -'@objectstack/organizations': patch ---- - -Build freshness: these three packages now write the repo's build-input content -stamp as the last step of their own build, and are checked for freshness (not -merely existence) by `check:dev-prereqs`. - -What changes for a consumer: each tarball now carries two extra inert metadata -files inside `dist/` — `.build-input-hash` and `.build-input-hash-dts`, the same -pair `@objectstack/spec` has always shipped. Nothing is imported, executed or -resolved from them, no export moves and no runtime behaviour changes. - -Why: a sibling checkout that links these packages by `link:` compiles against -their `dist/`, so a dist built from an older tree surfaces as a type error -naming an import nobody touched, with the symbol present in `src/` the whole -time. A HEAD-versus-pin comparison is silent through that; a content stamp -written by the build itself is not. diff --git a/.changeset/analytics-compareto-kind-refusal.md b/.changeset/analytics-compareto-kind-refusal.md deleted file mode 100644 index a47684f9ae5..00000000000 --- a/.changeset/analytics-compareto-kind-refusal.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -fix(service-analytics): an unrecognised `compareTo.kind` is refused, not answered with a previous-period window under a 200 (#17550) - -`shiftRange` had one branch and a fall-through — `previousYear` was named, and -**everything else** landed in the `previousPeriod` arm. No `default`, no -exhaustiveness check. So `compareTo: { kind: 'previousQuarter' }` came back as a -previous-period comparison under an ordinary **200**, and the caller was told -nothing. The wrong answer is a comparison **window**: a number a dashboard -renders and a person reads as fact, with no status, header or field in the -response to distinguish it from a real answer. - -`DatasetCompareTo.kind` has only ever declared two values -(`'previousPeriod' | 'previousYear'`), but `DatasetSelection` is a TypeScript -interface with no Zod schema anywhere, and `/analytics/dataset/query`'s door -parses only the seven members the selection shares with `AnalyticsQuery` — -`compareTo` is one of the four it projects away before its parse, and the route -forwards the caller's selection to the service untouched. So `kind` was checked -by `tsc` inside this repo and by nothing at all on the wire. - -## FROM → TO - -| Input | Was | Now | -|:--|:--|:--| -| `compareTo: { kind: 'previousPeriod' }` | the equal-length window before | **unchanged** | -| `compareTo: { kind: 'previousYear' }` | the same window one year back | **unchanged** | -| `compareTo: { kind: }` | a previous-period window, **200** | `DATASET_INVALID` / **400**, naming the value received and both legal ones | - -The fix is to name one of the two declared windows, or drop `compareTo` — which -is what the refusal says. No accept set widens, no new error code is minted: the -refusal is the fourth member of the `datasetInvalidError` family -`resolveCompareDimension` already raises three times for the same document, so it -arrives at the route through the envelope that route already classifies on. - -## Why this is a `patch` - -It pulls behaviour back onto the contract the type has always declared, rather -than narrowing past it: every input `DatasetCompareTo` permits returns -byte-identical windows, pinned by a control in the same change. What flips from -200 to 400 is input the declared contract never permitted. The reachable-today -population for that input was measured on the tree — the dashboard authoring path -is already doored (`DashboardWidgetSchema` parses the widget's `kind` as a -`z.enum`, so a third kind cannot arrive through a parsed widget), and no producer -in this repository sends a third value. What is not enumerable from here is a -consumer outside it calling the published `shiftRange` export, or posting a -hand-rolled body to the dataset route; for those, the refusal replaces a wrong -answer with a located one. - -`alignedCompareBucketKey` reads the same two-valued `kind` and deliberately gains -no refusal of its own: it is not on the package's public surface, and its only -caller runs `shiftRange` first — both pinned, so exporting it turns the pin red -rather than silently reopening this defect. diff --git a/.changeset/analytics-dataset-query-selection-door-parse.md b/.changeset/analytics-dataset-query-selection-door-parse.md deleted file mode 100644 index afd9fb60f52..00000000000 --- a/.changeset/analytics-dataset-query-selection-door-parse.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -`POST /analytics/dataset/query` parses its `selection` at the door, the way its two siblings already do - -The route checked one thing about the body it forwards — that -`selection.measures` was a non-empty array — and forwarded everything else -unexamined. `/analytics/query` and `/analytics/sql` Zod-parse their body at -the entry and lift a malformed member to a 400 before the service is reached, -so a client met two postures on one family depending on which door it knocked -on, and a malformed member of `selection` travelled into `dataset-executor` to -be answered by whatever the face behind it happened to do with it. - -⚠️ **A 400 is newly reachable.** Requests that previously slipped through are -now refused. Two shapes: - -- A `timeDimensions[].dateRange` outside the closed preset vocabulary answers - `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` — the same code, status and wording - the sibling door has answered for the identical condition since the - vocabulary closed. Measured on the tree before this change, the literal - string `not a range at all` reached the executor under an ordinary `200`. -- Anything else malformed answers `400 VALIDATION_FAILED` with - `details.fields[]`, each entry naming the member as `selection.`. - -**What is NOT newly refused, deliberately.** `selection` is a -`DatasetSelection`, which is *not* the `AnalyticsQuery` the siblings parse: it -carries no `cube`, and `runtimeFilter`, `dateGranularity`, `compareTo` and -`totals` are members of its own. Reusing the sibling schema would have refused -every real dashboard widget. What the door parses is the projection of the -seven members whose declaration on `DatasetSelection` *is* the `AnalyticsQuery` -member of the same name — `dimensions`, `measures`, `timeDimensions` (declared -there by reference), `order`, `limit`, `offset`, `timezone` — so the refusal -set is exactly what the published interface already declared. The four -dataset-only members are projected away before the parse and keep reaching the -executor untouched. - -Validation-only: the caller's `selection` object is what `queryDataset` -receives, by identity, never a parse output. diff --git a/.changeset/analytics-daterange-driver-alignment.md b/.changeset/analytics-daterange-driver-alignment.md deleted file mode 100644 index 576241cbf8a..00000000000 --- a/.changeset/analytics-daterange-driver-alignment.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/driver-memory": minor -"@objectstack/service-analytics": minor -"@objectstack/spec": patch ---- - -fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) - - - -**BREAKING** for an in-process caller that reaches an analytics face PAST the -schema door with a string the closed vocabulary does not contain: it used to be -answered, and is now refused. Shipped as `minor` under the repo's launch-window -convention. The driver half of #16041, whose spec change closed -`AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen -dashboard preset names; every value affected here was already refused at -`POST /analytics/query` and `/analytics/sql` when that landed. - -## What was wrong - -#16041 closed the contract; the faces behind it never aligned, so the defect it -abolished simply moved onto the newly-blessed vocabulary. Measured on the built -`driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, -2099): - -| input | before | after | -|:--|--:|--:| -| `today` | 1/5 | 1/5 | -| the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | -| `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | - -`driver-memory` recognised exactly `today`: every snake_case preset missed its -`startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose -two bounds were the preset's own NAME, which matched every `Date`-typed row -under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered -the same names — and unrecognised strings, and `today` — to the point window -`created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is -whatever the dialect decides a vocabulary word compares as. So a dashboard -asking for one month got all of history on one backend and a nonsense -comparison on the other, at HTTP 200 on both. - -## What it does now - -- **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / - `resolveAnalyticsDateRangeString` resolve every declared preset to - `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens - handed to the existing macro resolver, so `dateRange: 'this_month'` and a - `{month_start}` filter token cannot answer differently, and the anchoring on - `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) - come from that resolver rather than from each face. -- **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 - envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own - `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door - answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call - it, so "memory and SQL refuse identically" is one function rather than an - agreement. -- **The upper bound keeps #16179's separation.** A window a face RESOLVED is - compared exclusively (`$lt` / `<`) for the ten calendar presets and - inclusively for the three rolling `last_N_days`, whose bound is NOW; an - explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. -- The fifteen `driver-memory` date-range pins #16041 retired are reinstated in - preset form (DST cells re-measured under calendar semantics, not re-spelled), - and one cross-face conformance fixture holds all FOUR faces to the same - windows and the same refusal. -- **The draft-preview evaluator is the fourth face**, and it is in that fixture - for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — - the Live Canvas preview over a pending seed draft) carried the identical - `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, - silently, while the published chart beside it answered a real window — across - a publish boundary the preview exists to make continuous, since publish - materialises the same seed. - -## FROM → TO - -Unchanged from #16041's — the spelling that is refused here is the spelling that -was already refused at the door. - -| you wrote | write instead | -|:--|:--| -| `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | -| `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | -| `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | -| `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | - -The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's -code stays registered under `@objectstack/runtime` (the door that names the wire -vocabulary), and the waiver records that the shared constructor spelling it -lives one package over. diff --git a/.changeset/analytics-inline-dataset-object-read-admission.md b/.changeset/analytics-inline-dataset-object-read-admission.md deleted file mode 100644 index 880074199e7..00000000000 --- a/.changeset/analytics-inline-dataset-object-read-admission.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/plugin-security": minor -"@objectstack/service-analytics": minor -"@objectstack/verify": minor ---- - -fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) - - - -**BREAKING** in the accept-set sense — an accept-set narrowing on a published -route — landing in the launch window as `minor` on all four packages (the -lockstep convention: during the window the bump level is not the carrier, this -banner and the disposition above are). Nothing that was already admitted -becomes refused **except** the requests `GET /data/` refuses today for -the same principal, which is the defect. Nothing that was refused becomes -admitted. - -`POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. - -The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. - -**This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. - -- **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. -- **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. -- **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. -- **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. - -The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. diff --git a/.changeset/analytics-reference-dimension-display-labels.md b/.changeset/analytics-reference-dimension-display-labels.md deleted file mode 100644 index 56fff05f311..00000000000 --- a/.changeset/analytics-reference-dimension-display-labels.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -A dataset dimension over a `user` or `tree` field renders the referenced record's display name, the same way a `lookup` dimension already did. A "by person" chart's axis is people's names, not a column of user ids. - -`packages/spec` declares one reference class — `REFERENCE_VALUE_TYPES` = `lookup`, `master_detail`, `user`, `tree`, "value points at another record … a record-id string in stored form" — and this service already treated it as one class where it annotates measure result types (`measure-result-type.ts` imports that very set). The label resolver, one file away, hand-wrote a two-member subset of it (`lookup`, `master_detail`), so within a single dataset query one axis came back as a name and the other as a raw id, for two fields that differ in one word: - -``` -Field.user({ label: 'Person' }) -> { type: 'user', reference: 'sys_user' } -Field.lookup('sys_business_unit', { … }) -> { type: 'lookup', reference: 'sys_business_unit' } -``` - -- **The subset is gone, not extended.** The resolver now asks `referenceTargetOf` (`@objectstack/spec/data`) — the declared single arbiter of "what does this reference field point at" — at all three sites that classified a dimension: the display pass, the `#3680` sort-key hook's `isLabelBearing`, and its `resolveLabels`. Adding two literals to a private `Set` would have left the next member of the class to be re-reported by the next user. -- **A `user` field authored without `reference` resolves too.** `sys_user` is a constant of the type, which `referenceTargetOf` materializes; requiring an author to restate it is exactly the disagreement between two readers of one field that arbiter exists to end. -- **The label read stays scoped (`#3602`).** Turning a user id into a name is a read of `sys_user`, and it travels the same `LabelScopeResolver` path every other member of the class travels — the referenced object's own RLS is resolved and ANDed into the lookup, and an unresolvable scope still fails closed to the raw id rather than fetching unscoped. This is the half of the change that had to land with it, not after it. -- **Nothing degrades into an error or a blank.** An orphaned or RLS-hidden user id, a `sys_user` with no display field, and a user object unknown to the engine all leave the raw id in place and answer the query, which is the pre-existing contract for an unresolved lookup id. - -No new authorable key and no new export: `DatasetDimensionSchema` is untouched, and a dimension's own declared `type` still does not decide this — the resolver reads the object field's type, as it always has. diff --git a/.changeset/analytics-row-scope-bridge-three-way.md b/.changeset/analytics-row-scope-bridge-three-way.md deleted file mode 100644 index 7fcf9b4e839..00000000000 --- a/.changeset/analytics-row-scope-bridge-three-way.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(service-analytics): the ROW-SCOPE bridge to the `security` service tells the same three resolutions apart as the object-level one — a broken security service refuses the query instead of running it with no row policy (#16918) - -`AnalyticsServicePlugin` bridges to the `security` service twice: once for the OBJECT-level read grant (`admitObjectRead` → `canReadObject`, #16645) and once for the ROW-level read scope (`getReadScope` → `getReadFilter`, ADR-0021 D-C). The object-level bridge tells three resolutions apart — ABSENT admits, THROWING and METHOD-LESS deny at `error`. The row-scope bridge collapsed all three into one: - -```ts -const trySecurity = () => { - try { - const svc = ctx.getService('security'); - return svc && typeof svc.getReadFilter === 'function' ? svc : undefined; - } catch { return undefined; } -}; -getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context); -``` - -A throwing resolver and a registered service without `getReadFilter` both produced `undefined` — the same value an absent security service produces, and the value `ISecurityService.getReadFilter` reserves for one meaning only: *"this caller has no row restriction on this object"*. So on a deployment whose security service was wired but broken (a boot-order fault, a mis-registered plugin, a failing dependency, a provider that is not the contract it claims to be) analytics queries ran with **no row-level policy at all**, and nothing said so. One door of the file failed closed on a throwing resolver and its neighbour failed open — and the neighbour is the one carrying row-level policy. - -**What changes.** The bridge now resolves the same explicit three-way, at the same reporting level: - -- **ABSENT** — no `security` service resolved: **unchanged**. No row-scope provider on this deployment, which is a legitimate configuration (a single-tenant kernel that ships no `plugin-security`, where `/data` carries no row-level policy either) and is already reported loudly at init. ⛔ Deliberately not tightened: refusing here would break every such deployment. -- **THROWING** resolver, or a registered service with **no `getReadFilter`** — the query is **REFUSED**, and the reason is reported at `error` naming the object and which of the two states it was. The refusal is a throw, which `AnalyticsService.resolveReadScopes` — fail-closed since ADR-0021 D-C — already turns into "deny the whole query rather than emit SQL with that object unscoped". A log over an `undefined` would not have been a refusal. - -**This change only NARROWS what analytics serves, and only in a state where the security service is broken.** No deployment with a working `security` service, and no deployment with none, changes behaviour by so much as a byte. Nothing that was refused becomes admitted. - -**No published-surface delta.** No new error code (the refusal rides the seam's existing fail-closed error), no exported symbol, no key on `AnalyticsServicePluginOptions` or any payload, and no documented envelope changes shape. Graded `minor` rather than `patch` because it is a behaviour narrowing on a published package's read path, matching how its object-level sibling was graded in the same lockstep window. - -⚠️ Deliberately **not** answered here: which tenant wall the platform's is (plugin-security's posture-gated Layer 0, or driver-sql's posture-independent auto-scope) — the escalated maintainer decision of triage condition 5. Refusing to serve is neutral between them: it answers *"should we serve at all"*, never *"what shape is the wall"*. diff --git a/.changeset/analytics-row-scope-refusal-envelope.md b/.changeset/analytics-row-scope-refusal-envelope.md deleted file mode 100644 index 0a5439bf467..00000000000 --- a/.changeset/analytics-row-scope-refusal-envelope.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -fix(service-analytics): a fail-closed row-scope refusal can no longer be served as an empty chart (#17130) - -`queryDataset` degrades to `{rows: [], fields: [], totals: []}` when a BARE error looks like a driver reporting an absent table — a deliberate leniency (#5033) so a dashboard widget over an unmounted object renders "no data" instead of failing. The test is a substring match over the message, and three of its six limbs — `not registered`, `unknown object`, `is not a registered object` — are exactly the phrasings a registry or security refusal reaches for. - -Both sites of the row-scope RESOLUTION stage refused with a bare `throw new Error(…)`: the `security` bridge in `AnalyticsServicePlugin`, and `AnalyticsService.resolveReadScopes`. They propagated only because their wording happened to miss all six — so any reword, or any refusal added to that stage later, could silently turn a fail-closed gate into a `200` with no rows. - -Both now declare `READ_SCOPE_COMPILE_FAILED` / `500` — the code the sibling read-scope LOWERING stage has answered with since #5367, so the registered wire vocabulary is unchanged. Two visible consequences for a deployment whose wired `security` service cannot answer a row-level read scope: - -- the refusal reaches the caller as a declared `500` instead of relying on its phrasing to escape the degradation path; -- its message is withheld from the response body by declaration (the operator still gets the full text, at `error`, from the producing site) rather than echoed. - -Every refusal message is byte-unchanged, and #5033's leniency is untouched: a genuine absent source table still degrades to the empty result with its `warn`, and a deployment with NO security service still runs unscoped exactly as before. A guard derived from the source (`refusal-wording-collision.test.ts`) now walks every `throw` in the package and fails if an un-enveloped refusal can be read as a missing source table. diff --git a/.changeset/analytics-sqldialect-declared-vocabulary.md b/.changeset/analytics-sqldialect-declared-vocabulary.md deleted file mode 100644 index 4911b281a36..00000000000 --- a/.changeset/analytics-sqldialect-declared-vocabulary.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(analytics)!: `AnalyticsServiceConfig.sqlDialect` declares its three-name accept set, and a host that answers outside it is told once (#16206) - - - -**BREAKING** for a TypeScript host that declares its `sqlDialect` hook as returning -`string`: the hook's declared return is now the three canonical dialect names or -`undefined`, so such a composition stops compiling until the host's own annotation -says which names it can answer. Shipped as `minor` under the repo's launch-window -convention, in which breaking-ness is carried by this banner and the disposition -above rather than by the bump level. Runtime behaviour for every host is unchanged: -the same three names were the only ones that ever did anything. - -## What was wrong - -`AnalyticsServiceConfig.sqlDialect` — the hook a host answers to say which SQL -dialect backs an object — was typed as free `string`, while `normalizeSqlDialect` -has only ever recognised `sqlite`, `postgres` and `mysql`. Nothing said so, and -nothing told a host that answered otherwise. - -So a host that owns a SQLite datasource and answers the spelling its own stack uses -— knex's canonical `sqlite3`, or `better-sqlite3`, both of which `driver-sql` itself -lists in `SQLITE_EMIT_CLIENTS` — was read as `unknown`. And because `sqlDialectFor` -is tiered "cannot answer, do not block", **a wrong answer and no answer were the -same answer**: the host that tried hardest to help got the residue arm, silently. - -## What it does now - -- **The vocabulary is declared**, on the type and in the docblock, as - `AcceptedSqlDialect` — `sqlite` | `postgres` | `mysql` — so a host reading the - config learns the accept set without running anything. The type and the runtime - membership set are generated from one `const` tuple, so a future widening cannot - land in one and miss the other. -- **A non-empty answer outside the set is diagnosed**: one `warn` naming the object, - the answer and the accepted set. It is emitted **once per distinct unrecognised - spelling** — the failure's identity — so the line count is bounded by the host's - own hook and never grows with query volume. -- **`undefined` stays silent and legal.** The hook is optional and "cannot answer, - do not block" is a supported composition, not a misconfiguration. A pin holds both - halves, because a diagnostic that also shouted at hosts who wired nothing would be - a worse defect than the one being fixed. -- **The accept set is NOT widened.** Teaching this package `driver-sql`'s knex - aliases would be a second copy of that driver's table, and an unrecognised - spelling is sometimes deliberate (`mariadb`, #11756). The answer is still read as - `unknown`; only the silence changed. -- **The plugin bridge translates the driver's own residue.** `SqlDriver.dialectName` - carries a fourth name, `unknown`, meaning "I cannot say"; handed on verbatim it - would have presented a correctly-behaving driver as a host answering out of - contract. It now arrives as `undefined`, this hook's own spelling for the same - thing. The dialect the compilers end up with is unchanged either way. - -## Measured, and worth reading before relying on the residue arm - -Driven on sql.js through a host answering `sqlite3`, against the shared -`FILTER_TEXT_CASES` fixture, with a host answering `sqlite` as the control: **five of -the six case-EXACT cases come back with the wrong rows** — every case that -discriminates on ASCII case. `{ name: { $contains: 'acme' } }` answers `['1','2']` -where the table says `['2']`, and the negated form DROPS a row that belongs in the -result. That is #15684's fold, live on the arm this population lands on, and it is -reported rather than fixed here: closing it is that card's business, not this one's. diff --git a/.changeset/analytics-time-dimension-granularity-buckets.md b/.changeset/analytics-time-dimension-granularity-buckets.md deleted file mode 100644 index 73deae054a1..00000000000 --- a/.changeset/analytics-time-dimension-granularity-buckets.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/objectql": minor -"@objectstack/driver-memory": minor ---- - -fix(driver-memory)!: an analytics time dimension buckets by its declared `granularity`, and refuses a sub-day one instead of ignoring it (#16178) - - - -**BREAKING** in three senses, all on `driver-memory`'s analytics face, landing in -the launch window as `minor` under the lockstep convention this cluster's -siblings already use: - -- an accepted request now answers **differently**: a time dimension carrying a - `granularity` folds its rows into calendar buckets instead of returning one - group per distinct timestamp. Every affected answer was wrong before; -- a **trend query answers rows where it used to answer one total**: a - `granularity` on a member `dimensions` does not also list is now a group - column of its own, so `{measures, timeDimensions: [{dimension, granularity}]}` - — the canonical trend shape — comes back one row per bucket, carrying the - member and a `fields` entry for it, instead of a single ungrouped total with - no such column; -- an accepted request is now **refused**: `granularity: 'second' | 'minute' | - 'hour'` answers `NOT_IMPLEMENTED` / 501 instead of being silently dropped. - -## What was wrong - -`AnalyticsQuery.timeDimensions[].granularity` is declared by the spec and a cube -dimension enumerates the granularities it offers (`granularities: ['day']`). -`memory-analytics.ts` read neither. The `$group` stage keyed on the raw field -path, so a time dimension bucketed **one group per distinct timestamp** — one bar -per row in a "new accounts by month" chart, which is the symptom #3588 -catalogued and repaired for `service-analytics`. - -Measured through the public entry against the built package, two rows on one UTC -calendar day (`2026-09-06T01:00:00Z` and `2026-09-06T23:00:00Z`) under -`granularity: 'day'`: - -| | before | after | -|:--|--:|--:| -| `granularity: 'day'` | **2 groups**, keyed on the raw instants | 1 group, `2026-09-06` | -| no granularity (control) | 2 groups | 2 groups, unchanged | -| `granularity: 'hour'` | **2 groups**, silently | `NOT_IMPLEMENTED` / 501 | -| same, but with no `dimensions` | **`{count: 2}`** — one total, no time column, and no `fields` entry naming it | `{'events.createdAt': '2026-09-06', count: 2}`, `fields` naming both | -| `granularity: 'fortnight'` past the schema door | — | `INVALID_QUERY` / 400 | - -The emitted pipeline was byte-identical across all three, which is the whole -finding: the request was accepted, no warning was emitted, and the key was inert. - -## What it does now - -- **One forward labeller, in `@objectstack/core`.** `bucketDateKey(value, - granularity, timezone)` sits beside the inverse `bucketKeyToCalendarRange` and - the `calendarPartsInTzOrUtc` primitive it builds on, and it is now the only - statement of the rule. `BUCKET_GRANULARITIES` and `isBucketGranularity` name - the five granularities that HAVE a canonical key, so a face that must refuse - the other three quotes the accepted set instead of hand-listing it. -- **`@objectstack/objectql`'s `bucketDateValue` is a delegate**, export name and - signature unchanged, answers unchanged — pinned across granularity, timezone - and input form rather than asserted. A driver that pushes the bucket down into - SQL and this in-memory path must label one instant identically or a drill-down - breaks at the seam, and that is now one function rather than an agreement - between two. -- **A granular time dimension is a group column, listed or not.** `dimensions` - no longer decides alone what `$group` keys on: every `timeDimensions` entry - carrying a `granularity` is grouped, projected and named in `fields`, deduped - against `dimensions` on the resolved member so two spellings of one member - stay one column. This is the rule the SQL/ObjectQL face already records - (`projectedDimensions`, #4033/#5688) — one set feeding grouping, row mapping - and field metadata, because rows carrying a bucket under a `fields` list that - never mentions it is a trend chart with no x-axis. ⛔ An entry carrying only a - `dateRange` is a predicate and is still **not** projected. -- **`driver-memory` folds by granularity before its `$group`.** The pipeline is - cut at that stage: the `$match` half still runs in the driver, the bucket keys - are written onto the selected rows, and the grouping half runs over those. The - key travels under a synthetic field rather than overwriting the row's own, so a - member that is both a group key and a measure's aggregand still ranks instants - in `max()` while grouping on the label. -- **The output vocabulary is the published one** — `2026`, `2026-Q3`, `2026-09`, - `2026-09-06`, `2026-W36`. The week label is `YYYY-Www`, never the Monday's - `YYYY-MM-DD`: `DriverCapabilitiesSchema.queryDateGranularity` calls this an - output contract, and a second spelling is what breaks a drill-down across a - backend seam. -- **Bucketing honours `AnalyticsQuery.timezone`** — the same reference zone - #16042 threaded through the `dateRange` window resolver, so the window that - selects the rows and the bucket that folds them agree on where a calendar day - starts. The same two rows answer one group in UTC, two in `America/New_York` - and two in `Asia/Tokyo`. An absent zone buckets in UTC, the resolver's default. - - ⚠️ That agreement is about the PRESET arm of `dateRange`, which the resolver - reads in the reference zone. An explicit `[start, end]` array is the caller's - own **instant** window and keeps its published reading (#16179), while the - bucket beside it is always a **calendar** label (ADR-0053) — so an array - window and a bucket can still disagree about where a day starts. That - combination is legitimate and is not refused; it is stated here rather than - left to be discovered. -- **`second` / `minute` / `hour` are refused at compile**, in the ADR-0112 - envelope this driver's other capability gaps speak (`NOT_IMPLEMENTED` / 501, - the class `refusePerAggregationFilter` uses for the same reason: the query is - spelled correctly, the spec declares the value, and it is this backend that - compiles nothing for it). The canonical key vocabulary defines no label for a - sub-day bucket, so there is no string another backend's pushed-down SQL would - agree with. Passing it through unbucketed is this card's own defect wearing a - new name. -- **An undeclared granularity is a 400, not a 501.** A 501 says "this backend - cannot", which is only honest about a value the contract declares. - `TimeUpdateInterval` is checked first, so a spelling it never declared — - reachable past the schema door, where `POST /analytics/dataset/query` types - `selection.timeDimensions` without Zod-parsing them — answers `INVALID_QUERY` - / 400 rather than a 501 asserting the spec declared it. The same separation - the `dateRange` half of this face already draws (#16322 / #16041). - -## If a caller is refused - -A stored widget or a request asking for a sub-day granularity was never bucketed -by this backend — it received one group per distinct timestamp under an ordinary -200. Nothing that worked stops working. Ask for `day` or coarser and the answer -is a real bucket; keep the raw timestamps deliberately by dropping the key, which -is the behaviour that key used to produce by accident. diff --git a/.changeset/anonymous-get-session-refusal.md b/.changeset/anonymous-get-session-refusal.md deleted file mode 100644 index e9925550a55..00000000000 --- a/.changeset/anonymous-get-session-refusal.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -**BREAKING** — `GET /api/v1/auth/get-session` answers an anonymous caller with the -declared ADR-0112 failure envelope and HTTP 401, instead of HTTP 200 wrapping a JSON `null`. - -Until now an unauthenticated session read answered: - -``` -HTTP 200 -null -``` - -`ObjectStackClient.auth.me()` declares `Promise`, and -`SessionResponseSchema` requires `data.session` and `data.user` — so no value of that type -means "nobody is signed in", and the most ordinary call a logged-out caller can make -resolved to something outside the method's own declared type. Ruled by the director seat -(decision batch #117 item 4) under the charter rule -「spec 与代码不一致默认改代码,改协议单独立卡非选项」: the implementation is corrected to -the published contract. `SessionResponseSchema` is untouched. - -What changes on the wire: - -- **An anonymous or unresolvable credential ⇒ `401` with `error.code: 'UNAUTHENTICATED'`** - and the message `Sign in first`, the same body a raw `/admin/` mount already answers the - same caller with. No error code is minted: `UNAUTHENTICATED` is an existing - `StandardErrorCode` member, derived from the status through ADR-0112's own map, so - `ERROR_CODE_LEDGER` is unchanged. -- **Unchanged:** a signed-in read still answers `200` with `{ user, session }`, - byte-identical. Every other `/auth/*` route is untouched, and so is the `404` that a - method this route does not serve already answered — this change never invents a route. -- **Also unchanged:** better-auth's JS API. `auth.api.getSession()` still returns `null` for - an anonymous caller, so every internal identity read — execution-context resolution, the - platform-admin gates, the SSO bridges — behaves exactly as before. Only the wire moves. - -**`@objectstack/client`:** `client.auth.me()` now **rejects** for an anonymous caller -instead of resolving with `null` — the SDK throws on every non-2xx before unwrapping. Every -value the method resolves with is now inside its declared `SessionResponse`. Callers that -inspected the resolved value must move to a `catch`: - -```ts -try { - const session = await client.auth.me(); - // …signed in -} catch (err: any) { - if (err.code === 'UNAUTHENTICATED') { - // …signed out; err.httpStatus is 401 - } -} -``` - -A caller that branches on the HTTP status directly reads `401` plus -`error.code: 'UNAUTHENTICATED'` where it used to read `200` plus an empty body. - - diff --git a/.changeset/approval-approvers-manager-rung-may-resolve-empty.md b/.changeset/approval-approvers-manager-rung-may-resolve-empty.md deleted file mode 100644 index e4f6b467f86..00000000000 --- a/.changeset/approval-approvers-manager-rung-may-resolve-empty.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -`approval-approvers-may-resolve-empty` now covers the `manager` rung, not just the group-routed ones. - -The rule exists for the empty-slate dead-end (#3424): an approver slate that resolves to nobody, with `lockRecord` turning that into a stranded record. It reasoned about `position` / `team` / `department` and said nothing about `{ type: 'manager' }` — which has the same failure shape and a strictly worse cause. A `position` rung resolves empty because the position is unstaffed, and an operator can staff it. A `manager` rung resolves empty because `sys_user.manager_id` is unset, and an operator **cannot** set it: the managed-update whitelist for `sys_user` is exactly `{name, image, locale}` (ADR-0092), the auth admin endpoints do not accept the column, and the Console renders no field for it. So the rule warned about the rung an author can rescue and stayed silent on the one they cannot — and `manager` is the canonical first rung of a tiered approval ladder, so the silent case was also the common one. - -- **What fires.** A node whose approver slate is made up ENTIRELY of `{ type: 'manager' }` rungs now draws one `approval-approvers-may-resolve-empty` finding, at the same `info` tier as its `position` sibling. `manager` resolves through `sys_user.manager_id` of the record's owner and yields nobody when that column is unset; when nothing else is on the node, the request waits forever, and under the default `lockRecord` the record stays locked. -- **What it does not claim.** The message states in as many words that this is a static check which cannot read the column, and that it does not assert the slate IS empty — it reports that nothing else on the node can approve if it is. A lint rule must not claim a runtime fact it did not read. -- **The remedy it prescribes, with the routes graded rather than listed.** An exact diagnosis whose prescription cannot be carried out is worse than no prescription, so the hint separates what this platform provides from what it does not. A **seed, or any other system-context write**, populates the column here — both write guards gate on `isUserContextWrite` (`userId && !isSystem`), so a system-context write bypasses the managed-update whitelist by construction. **SCIM provisioning and directory sync** are named too, because a deployment running a real one may well populate the column through it — but named as a path the deployment itself supplies: this repo declares the SCIM Enterprise `manager` attribute without projecting it onto the column, and the admin bulk import does not write it either (`SYS_USER_IMPORT_UPDATE_FIELDS` is `{name, image, locale}` plus `phone_number` and `role`, and `manager_id` is listed there among the admin-surface-only columns). Editing the user in the Console is explicitly ruled out, since it cannot write the column at all. And the escape that depends on none of this stays on offer: add a fallback approver that cannot resolve empty, such as `{ type: 'org_membership_level', value: 'owner' }`. -- **When it stays quiet — and on which surface.** A stack whose own seed data wires `sys_user.manager_id` on any seeded row has shown the linter that it populates the column, and the advisory is suppressed. Seed rows are the only manager-chain evidence a stack can carry, so that is the whole of what this check reads on the question. ⚠️ That suppression is **CLI-side only**. The runtime publish gate hands rules a `RuntimeStackContext` whose collections are fixed — `objects`, `permissions`, `books`, `datasets`, `pages`, and no `data` — so a Studio publish of a manager-only flow carries no seeds to read and draws the advisory however the tenant's users are wired. That is a surface asymmetry, not a broken suppressor: an `info` finding never blocks a publish, it rides the 2xx `advisories`. Noted here so a reader who seeds correctly and still sees it fire on publish does not go looking for a bug in the rule. - -Existing verdicts are unchanged. The new arm is scoped to slates that are entirely `manager` rungs, which keeps it disjoint from the group-routed arm by construction — no node can draw both findings — and leaves every `position` verdict exactly as it was, mixed slates included: a `[position, manager]` node stays silent, as it is pinned to. - -This is a purely additive widening of a published package's public surface — the rule begins covering a case it was silent on — so it is graded `minor`, the floor that act carries regardless of the commit type. - -No severity moved. The finding is `info`, so it lands in the advisory channel on every consumer: `os lint` renders it as a suggestion and its exit code is unchanged (a suggestion does not fail a run even under `--strict`), and the runtime publish gate returns it on the 2xx `advisories` array rather than refusing the write. What changes is the report, not any verdict. diff --git a/.changeset/approvals-terminal-run-status-exhaustive.md b/.changeset/approvals-terminal-run-status-exhaustive.md deleted file mode 100644 index b1b25106fe9..00000000000 --- a/.changeset/approvals-terminal-run-status-exhaustive.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -fix(approvals): the dead-run sweep classifies every `ExecutionStatus` member, so a `refused` run releases its pending approval (#16433) - -`ApprovalService.releaseDeadRunRequests` guarded on a hand-copied four-member subset of `ExecutionStatus` — `completed`, `failed`, `cancelled`, `timed_out` — written when that enum had eight members. #14945 then appended `refused`, documented on the enum as *"Terminal, never resumed"*, and the subset did not grow with it. A run in `refused` was therefore skipped by the sweep, so a still-pending approval on it read as ALIVE, was never released, and kept its record lock forever. - -**Why this is shipped as a fix rather than left alone.** Nothing inside this repo drives a run to `refused` yet — that is #15788 (lane 2 of the #14945 ruling), still open. But `ApprovalService` takes a HOST-supplied automation surface through `attachAutomation`, so a host whose `getRun` already answers with the status the published spec declares sees the corrected behaviour the moment it upgrades, rather than on the day lane 2 lands. That is a real behaviour change in a published package, which is why it carries a bump instead of `skip-changeset`. - -The repair is not "add `refused`" — that yields a five-member hand-copy with the identical trap re-armed for the tenth member — and it is not "derive the terminal set from the enum" either, since `running` and `paused` are plainly not terminal and a wholesale derivation would default every future member to terminal, i.e. to releasing approvals out from under LIVE runs. Instead the file now declares a **total map** over `ExecutionStatus`, classifying each member `terminal` or `live`, from which the terminal set is derived. A tenth member fails to compile until someone classifies it, and fails a test as well. - -No API change: the classification is module-internal and the package barrel is untouched. diff --git a/.changeset/artifact-granted-permissions-load-binding.md b/.changeset/artifact-granted-permissions-load-binding.md deleted file mode 100644 index e39281c242c..00000000000 --- a/.changeset/artifact-granted-permissions-load-binding.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -Bind an environment artifact's install-time GRANTED permission set to the packages that artifact materializes. - -`EnvironmentArtifactSchema.grantedPermissions` — the consented `{ services, hooks, network, fs }` set the control plane compiles onto the artifact at install-consent time (ADR-0025 §3.5 step 2 / F4) — now reaches `PluginPermissionEnforcer.registerGrantedPermissions` at materialize time, one call per consent record, keyed by the plugin manifest `id`. `AppPlugin.init()` performs the binding, so it happens on every path that turns an artifact into a kernel plugin without either caller changing a line, and the enforcer holding the result is readable as `AppPlugin.permissionEnforcer` (with `AppPlugin.grantBinding` recording what bound). - -Absent, `{}` and a consented entry stay three distinct states. An artifact carrying no `grantedPermissions` key allocates no enforcer and registers nothing, so a package with no consent record loads exactly as it did; a per-plugin `{}` is a consent record that consented to nothing and registers a bag that denies every service, hook, host and path. A consent record naming a package the artifact does not carry is reported at `warn` rather than passing in silence. - -Fixed alongside, because without it the binding was unreachable: the `{ schemaVersion, metadata }` envelope unwrap in `loadArtifactBundle` handed the kernel `metadata` alone and dropped every key standing beside it, so an envelope artifact reached the kernel with `grantedPermissions` stripped. The loss was silent and indistinguishable from the legitimate absent reading. The unwrap now carries the key across when the envelope declares it, `{}` included, and never invents one. - -New exports from `@objectstack/runtime`: `registerArtifactGrantedPermissions`, `resolveArtifactGrantBinding`, `carriedPackageIds`, `ArtifactGrantBinding`. - -This is the registration half. Access-time enforcement runs through `SecurePluginContext`, which no production path constructs; that seam is ADR-0025 install-flow work and is unchanged here. diff --git a/.changeset/artifact-scoped-cross-reference.md b/.changeset/artifact-scoped-cross-reference.md deleted file mode 100644 index c00cf9f3afa..00000000000 --- a/.changeset/artifact-scoped-cross-reference.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`defineStack`: a package of a multi-package release artifact can now grant permissions on, and seed data into, an object one of its SIBLING packages owns. - -**FROM** — every `permissions[].objects` key and every `data[].object` had to name an object the same stack declares. In an ADR-0130 artifact this made two accepted records contradict each other: the 2026-09-02 addendum keeps every permission set whole in the `type: app` package, so as soon as that package also owns objects of its own, its sets were refused for granting on its modules' objects (`Permission 'sales_rep' grants on object 'crm_case' which is not defined in objects.`). The only escapes were `strict: false` for the whole package or splitting the sets per package, which contradicts the addendum. - -**TO** — pass the artifact's other object names to `defineStack` and those two reference classes resolve against the artifact instead of the one stack: - -```ts -const service = defineStack(serviceConfig); // owns crm_case -const app = defineStack(appConfig, { // owns crm_account, grants on crm_case - artifactObjects: service.objects?.map((o) => o.name), -}); -export default composeStacks([service, app], { manifest: 'preserve' }); -``` - -Nothing else widens. `hooks[].object` and an app's own `navigation` `objectName` stay refused against the stack's own objects even when the name is listed, because ADR-0130 §1.5 records both refusals as the shape of the package seam. - -The refusal moved rather than disappearing: in a composition of **two or more** packages, `composeStacks` now re-checks those two classes over the composed artifact, so a name `artifactObjects` claims and no package in the artifact defines is refused there, with the same `STACK_CROSS_REFERENCE_INVALID` code, the same `422`, and the same per-finding message. Only the header differs, naming the pass that refused it. `composeStacks` returns a single input untouched, so a one-package composition does not re-check the claim. - -**What that changes about which inputs `composeStacks` accepts.** `defineStack` itself is unchanged for a stack that does not pass `artifactObjects` — every single-package app validates exactly as before. `composeStacks` is not: it applies the two artifact-scoped rules to **every** input carrying objects, not only the ones that opted in. For an input that passed the strict `defineStack` parse **and did not opt in**, that is a no-op, so such an input cannot newly fail — its references were already resolved against its own objects, which are a subset of the composed set. (An input that *did* opt in also passed the strict parse, but it resolved against its own objects plus the names it listed; checking a listed name against the real artifact is what this pass is for, so it can fail here by design.) For an input that **bypassed** the strict parse the no-op argument does not apply at all: `defineStack(config, { strict: false })` returns before cross-reference validation runs, and a hand-built stack object never enters it, so these two rules have never been applied to it. Such an input carrying a dangling `permissions[].objects` key or `data[].object` is now refused at composition where it previously composed with no diagnostic at all — the existing non-array warning covers a malformed collection key, not a dangling reference. If you compose unparsed stacks, that is the one behavioural change to expect, and there is no earlier warning to have noticed it by; a malformed `permissions` / `data` on such an input is still skipped with that non-array warning rather than raising. diff --git a/.changeset/audit-write-failure-cause-keyed-report.md b/.changeset/audit-write-failure-cause-keyed-report.md deleted file mode 100644 index c10aa8edbc7..00000000000 --- a/.changeset/audit-write-failure-cause-keyed-report.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-audit": patch ---- - -A lost audit row is reported once per failure CAUSE, not once per process, and the first line names the cause instead of a fixed remedy. - -`reportAuditWriteFailure` — the best-effort catch around `persistAuditTrailRow` — deduped on a single process-wide boolean. After the first failure of any cause, every later failure of every *other* cause degraded to `debug` for the life of the process, so a long-running server could keep losing compliance rows for hours to a second, unrelated fault with one `error` line at the top of the log describing the first. `persistAuditTrailRow` is registered in the durability-degradation vocabulary precisely because a lost audit row must be reported at `error`. - -The dedupe key is now the failure's identity — the error `code` (or its absence) together with the object being audited. A repeat of an already-reported cause still degrades to `debug`, exactly as before; a new cause reports at `error`, once. The key is built from the `code` and **never** the message: a driver names the offending row in its message, so a message-keyed dedupe would grow one `error` line per failed write. Keyed on the code, the reported-cause set is bounded by the boot-declared object registry and the driver's code vocabulary and does not grow with traffic — measured at 65 lines for 6,500 failed writes and the same 65 for 26,000. - -The first `error` line now leads with the underlying code and message, which were already computed at the call site and passed only into the `debug` payload. The ADR-0057 §3.6 telemetry-datasource guidance is kept — it is the correct remedy for the "no such table" cause it was written for — but is now printed only for that cause, decided by the shared `isMissingTableError` predicate for both tables this writer writes. Previously it was printed unconditionally, so an organization refusal was answered with "check the datasource", sending the operator to inspect something that was working. - -`@objectstack/types` is added as a dependency for that predicate, rather than hand-rolling a second driver-error vocabulary. diff --git a/.changeset/auth-event-audit-cause-keyed-report.md b/.changeset/auth-event-audit-cause-keyed-report.md deleted file mode 100644 index e59dd303ca4..00000000000 --- a/.changeset/auth-event-audit-cause-keyed-report.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-audit": patch ---- - -A lost auth-event row is reported once per failure CAUSE, not once per process, and the first line names the cause instead of a fixed remedy. - -`auth-event-audit.ts` — the writer behind the `login` / `logout` rows in `sys_audit_log` — carried its own, independent copy of both defects the record-level audit writer was fixed for. `reportAuthEventWriteFailure` deduped on a single process-wide boolean, so after the first failure of any cause, every later failure of every *other* cause degraded to `debug` for the life of the process: a long-running server could keep losing sign-in and sign-out rows for hours to a second, unrelated fault, with one `error` line at the top of the log describing the first. `persistAuthEventAuditRow` is registered in the durability-degradation vocabulary precisely because a lost audit row must be reported at `error`. - -The dedupe key is now the failure's identity — the error `code` (or its absence) together with the object the rows are about. A repeat of an already-reported cause still degrades to `debug`, exactly as before; a new cause reports at `error`, once. The key is built from the `code` and **never** the message: a driver names the offending row in its message, so a message-keyed dedupe would grow one `error` line per lost row. Keyed on the code, the reported-cause set is bounded by the driver's code vocabulary and does not grow with traffic — measured at one `error` line for 200 failed sign-ins carrying 200 distinct messages under one code, and the same one line for 200 carrying no code at all. - -The first `error` line now leads with the underlying code and message, which were already computed at the call site and passed only into the `debug` payload. The ADR-0057 §3.6 telemetry-datasource guidance is kept — it is the correct remedy for the "no such table" cause it was written for — but is now printed only for that cause, decided by the shared `isMissingTableError` predicate for the one table this writer writes. Previously it was printed unconditionally, so an organization refusal was answered with "check the datasource", sending the operator to inspect something that was working. - -The cause-key helpers are imported from the record-level writer in this same package rather than re-spelled here: a second copy of that key is how these defects reached this file, so a third spelling would repeat the mistake. No published export is added or changed. diff --git a/.changeset/auth-gate-allowlist-anchored.md b/.changeset/auth-gate-allowlist-anchored.md deleted file mode 100644 index 0e112f61688..00000000000 --- a/.changeset/auth-gate-allowlist-anchored.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/core": patch ---- - -`isAuthGateAllowlisted` matches allow-listed routes at a mount boundary, so an object named `auth` or a record whose id is `health` no longer bypasses the ADR-0069 authentication-policy gate. - -The predicate that decides which paths are exempt from the password-expiry / enforced-MFA gate matched with two UNANCHORED tests: `path.includes('/auth/')` matched at any position, and an `endsWith` test over `['/health', '/ready', '/discovery', '/me/apps', '/me/localization']` matched at any depth. A path segment whose VALUE merely spelled one of those tokens therefore carried the exemption — and object names and record ids are tenant-controlled. Both transport seams hand the predicate a data-plane path directly (`HttpDispatcher.enforceAuthGate` passes `cleanPath`, `RestServer.enforceAuth` passes `req.path`), so these were reachable requests. Measured on the built package before the repair: `/data/auth/123`, `/meta/auth/objects`, `/data/x/health` and `/data/xyz/me/apps` were all exempt, while `/auth/me` (exempt) and `/data/contacts/1` (gated) held as controls. - -- **What replaced them.** The path is read as segments and each test is anchored to a mount base — `/api/v1`, `/api`, or the empty base the dispatcher sees (the hono adapter hands `dispatch()` the app prefix already stripped) — plus at most one environment scope immediately after that base (`/environments/`, or ADR-0006's superseded `/projects/`), because the dispatcher evaluates the gate before its scoped-URL strip. `/auth/…` at that position stays exempt; the five bootstrap reads are EXACT routes there instead of suffixes. The scope is only recognised immediately after a base, which is why `/data/environments/x/health` is not a scoped `/health`. -- **This only ever removes exemptions.** Measured, not asserted: over a generated corpus of 111,152 paths, the number that are newly exempt is **0** and 25,979 stopped being exempt. The check is kept as a test, with the pre-anchoring predicate transcribed beside it, so a later widening cannot arrive quietly. -- **Every genuinely-exempt shape still is**, pinned in both directions: `/auth/sign-out`, `/health`, `/ready`, `/discovery` (dispatcher shapes); `/api/auth/sign-in`, `/api/v1/auth/change-password`, `/api/v1/auth/me/permissions`, `/api/v1/health`, `/api/v1/me/apps`, `/api/v1/me/localization`; and the scoped `/api/v1/environments//auth/sign-out`. - -**If you serve the API from a non-default mount,** an allow-listed route reached as `${basePath}/${version}/…` with `basePath`/`version` moved off `/api` and `v1` is no longer named by the allow-list. That price cannot be avoided: `/rest/v2/health` and `/data/xyz/health` are the same shape, so a rule that accepts an arbitrary base is the defect itself. It costs nothing at either live seam — the dispatcher's path arrives base-stripped, and REST registers its control-plane routes without `enforceAuth` at all — but if you gate a custom mount through this predicate, mount the remediation routes under one of the named bases. diff --git a/.changeset/auth-manager-single-flight-instance.md b/.changeset/auth-manager-single-flight-instance.md deleted file mode 100644 index dfa46ec3804..00000000000 --- a/.changeset/auth-manager-single-flight-instance.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): build ONE better-auth instance per boot, so the RFC 8707 resource row is seeded once (#17176) - -`AuthManager.getOrCreateAuth()` assigned its `this.auth` memo only after `createAuthInstance()` had resolved, and that function awaits a dynamic `import('better-auth')`, the plugin list, the password hasher and finally better-auth's own `$context`. Every caller arriving inside that window read `this.auth === null` and started its own build, so overlapping callers constructed one better-auth instance each — measured: three concurrent `getAuthInstance()` calls returned three distinct instances. - -The boot has such callers. `AuthPlugin` dispatches `registerOidcDiscoveryRoutes()` with `void` from its route-mounting `kernel:ready` hook, which returns while that call is still pending, and a later `kernel:ready` hook reads the instantiated social providers off the instance for the account-issuer backfill. - -Each duplicate instance re-runs every better-auth plugin's `init`, and `@better-auth/oauth-provider` seeds the RFC 8707 `sys_oauth_resource` row from there. Its seed is already check-then-insert — `findOne` by `identifier`, then `create` only on a miss — so on a warm database every instance finds the row and inserts nothing. On a FRESH one all of them miss together, all of them insert, and the unique index refuses all but the first: the `Insert operation failed {object: sys_oauth_resource}` line on the first boot of a fresh project. - -`getOrCreateAuth()` now holds the in-flight build so concurrent callers share it. The seed runs once per process on every driver, because there is only one plugin `init` to run it. Two consequences of the new in-flight slot: `setRuntimeBaseUrl()` now reports "already created" for a build in flight (it silently no-opped before), and `applyConfigPatch()` discards a build composed from the pre-patch configuration instead of letting it install itself. - -No log level changed, in this package or any other. diff --git a/.changeset/auth-sso-boot-report-gate.md b/.changeset/auth-sso-boot-report-gate.md deleted file mode 100644 index 9235c0ecbc9..00000000000 --- a/.changeset/auth-sso-boot-report-gate.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -Gate the `no_sign_in_account_at_boot` boot report on whether the deployment has a delegated sign-in path. - -The report fires on one store shape — human `sys_user` rows, zero `sys_account` rows — and calls it unrecoverable. On a deployment whose sign-in is delegated to an identity provider that shape is the healthy resting state: `ssoOnlyMode` states it in the auth config contract ("managed (IdP-provisioned) users simply hold no local credential") and names cloud-as-IdP. Such a kernel logged the report at `error` on every boot, including boots that had just served a successful SSO sign-in. - -The report now also reads the runtime's sign-in wiring — SSO-only mode declared, a configured social/OIDC provider, or enterprise SSO with at least one registered `sys_sso_provider` — and stays silent at `error` when one of them holds, recording the shape at `debug` under the same grep token with the reason named. - -Unchanged: `probeSignInAccountsPresence` keeps its existence-only predicate, and a deployment with no delegated sign-in path — including one that merely switched the SSO plugin on with no identity provider registered — still reports at `error`. diff --git a/.changeset/automation-run-declaration-truth-residues.md b/.changeset/automation-run-declaration-truth-residues.md deleted file mode 100644 index 58c96957091..00000000000 --- a/.changeset/automation-run-declaration-truth-residues.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -`sys_automation_run.variables_json` states its presence discriminator in ONE direction, and a row-rebuilt snapshot no longer claims its steps are the pause's - -Three corrections to text this package ships. No behaviour changes; every shape -described below is the ruled design, measured as it already is. - -**`variables_json` said `⇔` where only `⇒` holds.** The field description -declared "present on a completed/failed row" and "the row's run had a pause its -resume consumed before a downstream node failed" to be equivalent. The forward -direction holds — nothing but the consumed-suspension path writes that column on -a terminal row. The reverse does not, for one shape: a run that stranded, was -restored and then finished. `recordTerminal` upserts the SAME `run_` row -with all four snapshot columns explicitly `null` — deliberately, so -"restorable" cannot outlive the condition it describes — which leaves that row -equal, across every column the discriminator is read from, to the row of a run -that never paused at all. Absence means "nothing to restore now", never "this -run never had one", and the restore verb already refuses in exactly those terms: -it names the status it observed and declines to say which. The description now -says so. - -**A snapshot rebuilt from a row does not carry the step log as of the pause.** -`deserializeConsumedSuspension`'s docblock said its `steps` are the log "AS OF -THE PAUSE". That is true of the engine's process-local journal copy only, which -slices `run.steps` back to the step count at the pause; the trimmed array is -never persisted. `steps` are the one field the rebuild takes from the row's own -`steps_json`, which is the terminal row's log of the WHOLE run — and both bounds -on that column keep the failure on purpose (history compaction retains every -failure; the byte cap trims the head). A row-rebuilt snapshot therefore carries -steps the pause did not have. It re-arms the same run regardless: the pause is -`nodeId` plus `variables` / `context` / `correlation`, none of which the step log -feeds. - -**`recordTerminal` now names the verb that reads what it writes** — the -restore path in `engine.ts` — and the three properties of the write that are -that verb's inputs rather than local detail. Its summary line also said -"completed / failed" where the terminal vocabulary has had four members since -the fold was removed from both ends of this write. - -Both falsifying shapes are pinned in `suspended-run-store.test.ts`, including the -indistinguishability itself: the restored-then-finished row and a never-paused -row compare equal across those five columns, with the same comparison separating -them while the snapshot is still there. diff --git a/.changeset/basepath-normaliser-consolidation.md b/.changeset/basepath-normaliser-consolidation.md deleted file mode 100644 index b402f663ba0..00000000000 --- a/.changeset/basepath-normaliser-consolidation.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth): one base-path normalisation chain, and an MCP resource identifier that is always a URL - -`AuthManager` derived its base path in three independent places. `getMcpResourceUrl()` -read `this.config.basePath` directly and added no leading slash, so a `basePath` -configured without one produced a value that is not a URL at all: - - basePath 'api/v1/auth' -> http://localhost:3000api/v1/mcp - -`new URL()` throws on that (`3000api` is not a port), so the RFC 9728 path-inserted -well-known route derived from it throws too, and `@better-auth/oauth-provider` 1.7.2 -refuses to seed the `sys_oauth_resource` row from it at plugin init ("resource -identifier ... must be an absolute URI (RFC 8707 §2)"). With -`enforcePerClientResources` at its `true` default, every MCP client was then refused -for want of a link row. That input class could never mint or match a token, so -repairing it re-selects nothing. - -There is now exactly one read of the configured value and one chain above it: - - configuredBasePath() the configured value VERBATIM — what better-auth is handed - └─ rootedBasePath() + a leading slash when absent (better-auth's own rule) - ├─ getAuthIssuer() = origin + this - └─ getBasePath() = this, trailing slashes stripped - └─ getMcpResourceUrl() = origin + this minus `/auth` + `/mcp` - -`getAuthIssuer()` and `getBasePath()` answer byte-identically to before for every -spelling. Only `getMcpResourceUrl()` moves, and only for a non-canonical `basePath`: -a missing leading slash (was not a URL), repeated trailing slashes, or a configured -`/` (was a `//mcp` path no mount serves). A canonical `basePath` is unchanged on all -three getters. diff --git a/.changeset/better-sqlite3-peer-record-remeasured.md b/.changeset/better-sqlite3-peer-record-remeasured.md deleted file mode 100644 index 52ba213f8a1..00000000000 --- a/.changeset/better-sqlite3-peer-record-remeasured.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/cli": patch -"create-objectstack": patch ---- - -fix(cli): re-measure the `better-auth` > `better-sqlite3` peer record, correct what it credits, and pin the declaration it justifies (#16813) - -A tree containing `@objectstack/cli` reports an unmet peer on every fresh -resolve — `better-auth` peers `better-sqlite3@^12.0.0`, the CLI declares -`^13.0.3` — and the reading that decides what to do about it lived only inside -the scaffold generator's prose. No range moves here and no resolution moves: -what changes is the recorded reason, which had two measured errors in it, plus -a gate that now holds the declaration to that reason. - -**The declaration is correct and stays at `^13`.** Three readings, taken rather -than inherited: - -- The peer is `optional`, and it governs exactly one configuration — a raw - better-sqlite3 `Database` passed to better-auth's `database` option. - `AuthManager.createDatabaseConfig()` returns an ObjectQL adapter factory, or - `undefined` for better-auth's in-memory adapter. Never a `Database`. -- better-auth cannot be incompatible with better-sqlite3 13, because it never - touches it: of the 464 files in the published `better-auth@1.7.2` tarball, - exactly one names better-sqlite3 — `package.json`, the peer declaration - itself — and no code file references it (positive control: `kysely` names 9). - It accepts a `Database` the caller constructs; its own sqlite test path uses - node's built-in `node:sqlite`. -- Pinning back to `^12` is not a neutral alternative. Measured on a bare - project depending on `@objectstack/cli@17.3.0`, it clears the report only by - resolving a **second** native better-sqlite3 (12.11.1 beside 13.0.3) that - nothing loads. The scaffold's existing `allowedVersions` entry clears the - same report with the lockfile byte-identical. - -**Two corrections to the record.** It credited `@objectstack/driver-sql` for -the 13.x copy; on the chain that actually reports -(`cli` → `runtime` → `plugin-auth` → `better-auth`) the binding copy is the -CLI's own `optionalDependencies` entry, which pnpm names in the warning itself. -And it was measured on better-auth 1.7.1 while the family has been pinned at -1.7.2 since — re-measured, with the empirical reading replaced by a structural -one. - -The scaffold's rendered `pnpm-workspace.yaml` comment changes wording in both -producers (`objectstack init` and the `create-objectstack` blank template); the -declarations, the widening entry and the resolution are untouched. diff --git a/.changeset/blank-node-condition-refused-at-registration.md b/.changeset/blank-node-condition-refused-at-registration.md deleted file mode 100644 index 73d480ebf8a..00000000000 --- a/.changeset/blank-node-condition-refused-at-registration.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -fix(service-automation)!: a whitespace-only `config.condition` is refused at `registerFlow`, the rule the edge door has carried since #15807 (#17322) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention: `major` is refused by `check-changeset-no-major`, and -breaking-ness is carried by this banner plus the ADR-0087 disposition): a flow -node's `config.condition` — a `decision` node's predicate, and on a `start` node -the **trigger gate** — is now refused at `registerFlow` when its source is blank -after trimming, where it used to register clean and answer a **silent `false`** -at every evaluation. - -Two doors, the same authored value, two fates until now. `FlowEdgeSchema.condition` -composes `EvaluatedExpressionInputSchema` (#15807), so `' '` on an edge is -refused at `FlowSchema.parse`, by name. A node's `config` is an open -`z.record(z.string(), z.unknown())`, so the same value passed through verbatim, -reached `AutomationEngine.evaluateCondition`'s empty-source arm — `exprStr.trim() -=== ''` — and returned `false`, under a comment that names that arm as being for -an **unauthored** condition. `' '` was authored. The branch never ran, forever, -with nothing said at any layer. - -```yaml -nodes: - - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: ' ' } } # the flow was gated shut - - { id: branch, type: decision, config: { condition: { dialect: cel, source: ' ' } } } # the same blank, through the envelope key -``` - -> An expression in an evaluated slot needs a non-blank `source`: the expression -> engine evaluates `source` (the canonical persisted form) and -> cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` -> that is blank after trimming, would validate and register and then fault at -> run time. Write `{ dialect: 'cel', source: '…' }`. - -- **The rule is imported, not re-derived.** `registerFlow`'s structural pass runs - the condition's source through `EvaluatedExpressionInputSchema` itself, so the - node door and the edge door cannot drift into two notions of "blank" or two - sentences for it — the property the #15662 campaign built the shared refusal - for. Nothing is exported from this package to carry it, and no new export was - added. -- **Applied to the SOURCE, not to the whole value**, deliberately: the union - would also refuse an envelope with no `dialect` or with a dialect outside its - enum, and this slot admits both (`structuralConditionRefusal`'s docblock, - #4336). The narrowing is exactly the blank population and nothing else — a - `cron` envelope with a real source still earns its own pre-existing verdict, - and a bare string with a `{…}` brace trap still earns #1491's. -- **`evaluateCondition` is unchanged and still answers `false`.** It is the - shared evaluator and a public method on an exported class, so its throw - behaviour is itself a contract; and a stored flow reaches it whatever the - producer refuses. This change is at the producer only. -- **`structuralConditionRefusal` is unchanged.** A string is still a well-shaped - condition; the new refusal sits behind the shape one and in front of the CEL - one, and answers the evaluated-slot sentence rather than - `STRUCTURAL_CONDITION_SHAPE_REFUSAL`. - -**What an author does with a refused condition.** A whitespace-only condition was -never a predicate — the engine answered `false`, so the branch never fired, and on -a `start` node the flow never triggered. **Remove the `condition` key** if the node -was meant to be unconditional, or **write the expression** if it was meant to -branch. ⚠️ Those two are not interchangeable: a refused condition never fired, -while an absent `condition` on a decision node is an unconditional branch that -always fires and an absent one on a start node is a gate that always opens. -Deleting the key to clear the refusal inverts the node rather than preserving it. -Every condition with a non-blank source is unchanged, and nothing is renamed or -retired. - -**A flow ALREADY STORED in `sys_metadata` stops running entirely — the whole flow, -not just the branch.** Stored flows are deliberately not canonicalized by -`applyConversionsToStoredItem` (`spec/src/conversions/stored.ts`, and the same -skip in `metadata/src/loaders/database-loader.ts`'s `rowToData`); they canonicalize -at `registerFlow`, and each of the three boot paths in -`service-automation/src/plugin.ts` wraps that call in `try`/`catch`, logs one -`warn` naming the flow, and continues. So a node condition that used to answer a -silent `false` while the rest of the flow ran now takes the flow down with it: it -is never registered, its trigger is never armed, and the announcement is that one -warn line — `[Automation] failed to register flow` at boot, `[Automation] -cold-boot flow bind: failed to register flow` at the kernel:ready bind, -`[Automation] flow re-sync: failed to register flow` on a re-sync. The warn line -is also the locator: the refusal names the node and the slot, e.g. `node 'gate' -(start) condition`. A stack authored in config files has a second door, -`objectstack validate` — see the note below for what that door does **not** yet -say. - -**A repo-wide census on this branch found zero authored `config.condition` values -of this shape**, against a lit control: a textual probe over all 8,123 tracked -source files found **461** non-blank `condition:` string literals and **zero** -blank-after-trim ones in any authored flow (the four blank hits are two prose -examples inside #15807's own changeset and two `packages/lint` test fixtures). -There is nothing in this repository to rewrite. - -⚠️ **Two follow-ups this change does not carry, both outside this card's package.** -(1) The ADR-0087 D3 entry named above, -`flow-edge-condition-evaluated-slot-source-required`, registers the decision this -change is a second face of — an evaluated slot requires a non-blank `source` — but -its `surface` and `acceptanceCriteria` name only `edges[].condition`. They need -widening to `config.condition` so a consumer replaying the chain is told to sweep -the node key too; that file is in `packages/spec`. -(2) `@objectstack/lint`'s `validate-expressions` applies only -`structuralConditionRefusal` to a structural condition, so `objectstack validate` -still reports nothing for a blank `config.condition` that `registerFlow` now -refuses — the two doors disagree until that rule is rebound as well. diff --git a/.changeset/build-progress-phase-vocabulary.md b/.changeset/build-progress-phase-vocabulary.md deleted file mode 100644 index 8f98826549d..00000000000 --- a/.changeset/build-progress-phase-vocabulary.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**Declare the build-progress PHASE vocabulary on `@objectstack/spec/ai`.** - -The `data-build-progress` stream frame has shipped as prose only: `AIToolContext.onProgress` -documents the channel and its example carries a `phase`, but nothing ever declared which -phases exist. Consumers filled that gap by guessing, and a guess here is not merely -unlabelled — the objectui chat panel coerces any value it does not recognise to `structure`, -which renders a "still building" spinner, so a build turn that has finished and moved on to -verifying itself keeps claiming to be building. - -New exports (additive; nothing removed or renamed): - -- `BUILD_PROGRESS_PHASES` / `BuildProgressPhaseSchema` / `BuildProgressPhase` — the CLOSED - phase vocabulary: `structure`, `data`, `verify`, `done`, in lifecycle order. An - out-of-vocabulary value is refused, and the refusal names the accepted set. -- `BuildProgressFrameSchema` / `BuildProgressFrame` — the frame's FLOOR: a required `phase` - plus an optional `hop` (which post-apply verification hop) and `tool` (the tool that hop is - running). Deliberately loose, not strict: the presentation fields the chat panel already - reads ride the same frame and belong to it, so a strict schema here would refuse every - frame shipping today. -- `BUILD_PROGRESS_FRAME_TYPE` — `'data-build-progress'`, the one literal both ends select on. - -Producers emit these frames from the agent loop rather than from the applying tool: a tool's -`ctx.onProgress` handle dies when the tool returns, and the verification window opens after -it does. Consumers should compare phases by value and treat every phase as optional — a turn -that seeds no sample data never reports `data`. - -Clause-②: yes (widening) diff --git a/.changeset/chilled-eagles-arrive.md b/.changeset/chilled-eagles-arrive.md deleted file mode 100644 index 45c23504a2b..00000000000 --- a/.changeset/chilled-eagles-arrive.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/client": patch ---- - -`auth.login` and `auth.register` now deliver the `SessionResponse` envelope they declare. - -Both methods annotate their return as `SessionResponse`, whose base `BaseResponseSchema` declares -`success` as a required boolean. Both carried an inline lift that filled `data` and never wrote -`success`, so neither delivered the type it advertises and every consumer keying on the envelope -flag — `ObjectStackClient.unwrapResponse` keys on exactly this — read `undefined` rather than -`true` or `false`. They now run the same lift `auth.me` / `auth.refreshToken` use, so the family -cannot deliver two different envelopes again. - -The credential is unchanged: `data.token` is still the token the route puts in the response body, -byte-identical, and `login` / `register` still arm the client's bearer token from it. - -Known residue, unchanged by this release: `data.session` is still absent from what these two -methods return. `POST /sign-in/email` and `POST /sign-up/email` serve no session object, id or -expiry in the body or in any header, so the member is not obtainable without a second -`GET /get-session` call — read it from `auth.me()`. Nothing is synthesized in its place. diff --git a/.changeset/cli-register-requires-name.md b/.changeset/cli-register-requires-name.md deleted file mode 100644 index 0a72c1b94c4..00000000000 --- a/.changeset/cli-register-requires-name.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os register` requires a name, and the request-side `as any` that hid the mismatch is gone (#16932) - -`os register` prompted **"Name (optional)"**, typed its own payload with `name?`, and guarded `email` and `password` but not `name` — three places agreeing the field was optional. The route it actually posts to does not agree: on a fresh environment (no human user yet, so the audience gate's bootstrap bypass admits the request and the route's own validation is the only judge left), `POST /api/v1/auth/sign-up/email` answers `400 VALIDATION_ERROR` — `[body.name] Invalid input: expected string, received undefined`. The same run with a name supplied answers `200` and creates the account. - -So the first-use path failed on exactly the answer the prompt invited, and `RegisterRequestSchema`'s required `name` was right all along. - -- the prompt now reads `Name: `; -- an empty answer is refused by the CLI itself (`Name is required`), beside the existing `Email is required` / `Password is required` guards, before any request goes out; -- the payload is annotated with the declared `RegisterRequest` instead of a hand-written twin; -- the `as any` at the call site is removed, so the next divergence between this command and the declared request type is a compile error rather than a `400` a user meets on their first command. - -No behaviour change for anyone already passing a name, by flag or at the prompt. diff --git a/.changeset/client-adopts-rotated-session-token.md b/.changeset/client-adopts-rotated-session-token.md deleted file mode 100644 index 73477447488..00000000000 --- a/.changeset/client-adopts-rotated-session-token.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/client": minor ---- - -feat(client): a bearer-mode `ObjectStackClient` keeps the session the server rotates it onto (#16534) - -Three better-auth routes ROTATE the caller's session on success — they mint a new session, install it in `Set-Cookie` (and, through `bearer()`, in the `set-auth-token` response header), and DELETE the row the caller was presenting: - -| route | where the new credential is | -| --- | --- | -| `auth.twoFactor.verifyTotp()` on the enrolment lane | body — `token`, and it is the LIVE one (plugin-auth's `two-factor-rotated-token-echo` repairs the vendor's stale echo) | -| `auth.changePassword({ revokeOtherSessions: true })` | body — `token` | -| `auth.twoFactor.disable()` | **response header only** — the body is `{ status: true }` | - -A browser is carried across all three by its own cookie. A bearer client — this SDK's own mode — kept presenting the DELETED session's token, so its very next call answered `401 UNAUTHORIZED`. Measured against a real `AuthManager` (better-auth 1.7.2) over a real driver, driven through the real `ObjectStackClient`, `login → enable → verifyTotp → disable → deleteUser` could not run to the end without the caller re-seating `client.token` by hand between the steps. - -The three methods now adopt the rotated credential themselves, the way `login()` already adopts the token it is handed. The `token` members stay on the wire and stay declared, so a caller that keeps its own credential store is unaffected; what changes is that it no longer has to. - -**No public surface moves.** No new export, no new option or flag, no new key on any declared request or response type — the SDK stores a token the server already sends and this package already declares. Graded `minor` rather than `patch` because the published runtime behaviour of three methods moves for existing callers. - -## What does NOT change, deliberately - -The adoption is on those three routes only, never in the shared `fetch` wrapper. `set-auth-token` rides **every** response that stages a session cookie — `POST /update-user` stages one to carry the updated user without rotating anything — and it carries the SIGNED `.` spelling while every JSON `token` echo carries the UNSIGNED one. A wrapper-level read would therefore rewrite the stored credential into a different spelling of the SAME session on ordinary traffic. `auth.me()`, `auth.sessions.list()`, `auth.updateUser()` and `auth.twoFactor.verifyBackupCode()` (which does not rotate — the vendor echoes the session it resolved at entry) all leave the stored credential byte-identical, and that is pinned. - -A cookie-only deployment sends no `set-auth-token`; there is then nothing to adopt and `twoFactor.disable()` leaves the stored credential exactly as it was. `changePassword` without `revokeOtherSessions` answers `token: null` and likewise stores nothing. - -The three TSDoc warnings that told bearer callers "this SDK does not store it" are updated in the same change. diff --git a/.changeset/client-environments-delete-purge.md b/.changeset/client-environments-delete-purge.md deleted file mode 100644 index 2799547d29a..00000000000 --- a/.changeset/client-environments-delete-purge.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/client": minor ---- - -feat(client): `environments.delete` gains `purge` and documents the hosted control plane's two-step delete (#17636) - -The hosted control plane's `DELETE /api/v1/cloud/environments/:id` follows cloud ADR-0014: a live environment is **archived**, and only a second call with `?purge=1` on the now-archived environment tears it down. `?force=1` confirms a production environment and is never a purge. The SDK sent `force` only, so an SDK caller could archive an environment but never purge one. - -- `opts.purge?: boolean` sends `?purge=1`. It combines with `force`: a production environment is torn down with `{ force: true }`, then `{ force: true, purge: true }`. Calls that pass no options, or `force` alone, build exactly the URL they built before. -- The return type declares the two answers the route actually sends, discriminated by `deleted`: - - archive: `{ environmentId, deleted: false, archived: true, purgeDeferred, retentionDays, warnings, message }` - - teardown: `{ environmentId, deleted: true, purged: true, warnings }` - - Both members carry every key the old declaration named (`deleted`, `environmentId`, `warnings`), so existing reads still compile. -- The JSDoc no longer describes a one-call cascade delete: a live environment is archived, `purge` acts only on an archived environment, `force` is the production confirmation, and a `failed` environment is torn down in one call. -- `organizations.delete`'s JSDoc no longer claims that server-side hooks tear down the organization's environments. No hook does; delete each environment first. - -Graded `minor`: a purely additive widening of a published method's accepted options and declared answer (the "WHICH LEVEL" rule in `.github/workflows/pr-automation.yml`). Nothing is removed or renamed. diff --git a/.changeset/client-get-active-member-names-the-organisation.md b/.changeset/client-get-active-member-names-the-organisation.md deleted file mode 100644 index 245794df048..00000000000 --- a/.changeset/client-get-active-member-names-the-organisation.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): `organizations.getActiveMember(organizationId)` answers the organisation the caller NAMES, not whichever one the session has active (#16568) - -**BREAKING** — the answer this published method gives moves for existing inputs. The signature, the declared return type and the export are byte-identical; what changes is the response an existing call observes, stated below as a before/after pair per input. - -The method built `GET /organization/get-active-member?organizationId=…`, and better-auth 1.7.2's handler for that path reads `session.session.activeOrganizationId` and never looks at `ctx.query`. The query string was dead on arrival: a client doing a permission check for organisation B while A was active got **A's** membership row back, with a 200 and no diagnostic — the wrong-but-plausible answer, silently. The SDK's own JSDoc promised "the calling user's membership row in the given organisation", so this was a declared capability the runtime did not deliver. - -It now asks the question honestly, in two requests: - -1. `GET /get-session` — the caller's own user id; -2. `GET /organization/list-members?organizationId=…&filterField=userId&filterValue=&limit=1` — the row, unwrapped from the one-entry page. - -`list-members` reads `ctx.query.organizationId`, and its rows carry the identical shape (`OrganizationMemberWithUserWire`, user projection included), so the signature and the declared return type are unchanged and no caller's types move. - -## What an existing call observes, before and after - -Everything here is measured against a real `AuthManager` (better-auth 1.7.2, organization plugin) over a real `SqlDriver`. Each bullet is one input, with the response it drew before and the response it draws now. - -- **An organisation id other than the session's active one.** Before: a 200 carrying the **active** organisation's membership row, whatever id was named. After: a 200 carrying the **named** organisation's row. An input that named the active organisation's own id drew that organisation's row before and draws the same row after — `auth.me()` is where that id is readable, on `session.activeOrganizationId`. -- **An organisation the caller is not a member of.** Before: the named organisation was never consulted, so the answer was about the **active** one — a 200 carrying the active organisation's row, or `400 MEMBER_NOT_FOUND` when the caller had no row there either. After: `403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION`, the server's own refusal, about the organisation that was actually named. -- **Any id, on a session with no active organisation.** Before: `400 NO_ACTIVE_ORGANIZATION`. After: a 200 carrying the caller's row in the named organisation. `setActive` has stopped being a precondition, which is the point of naming the organisation. -- **An empty `organizationId`.** Before: a 200 carrying the **active** organisation's row — better-auth resolves `ctx.query.organizationId || session.activeOrganizationId`, so an empty string fell through to session state and the wrong-but-plausible answer survived on that one input. After: the SDK refuses it before the wire, with a thrown `[ObjectStack] organizations.getActiveMember: organizationId is required`. - -Two things do not move: an anonymous caller still draws `401 UNAUTHORIZED`, thrown by the same session middleware that guarded the old route; and the row's shape is the same on both sides. The method now makes two HTTP requests where it made one. - -Graded `minor` rather than `patch`: the method's published behaviour moves for existing callers, which is the same clause-② judgement this PR declares, and the maintainer's ruling of 2026-09-04 (decision batch #35) holds that a change to a published package's public surface takes at least `minor` — a commit type may raise a bump, never lower it below what the act requires. The banner above carries the breaking-ness that the level cannot, per the ruling recorded on #16568 on 2026-09-08. - -The auth route ledger's `GET /api/v1/auth/organization/get-active-member` row is rebooked from `sdk` to `server-only` in the same change: `sdk` means "expressed by the SDK", and no SDK method builds that URL any more. The `get-session` and `list-members` rows gain the method in their notes, since it now builds both. Ledger-internal, nothing published moves with it. - - diff --git a/.changeset/client-get-session-envelope-and-refresh-read.md b/.changeset/client-get-session-envelope-and-refresh-read.md deleted file mode 100644 index 96de2dfc172..00000000000 --- a/.changeset/client-get-session-envelope-and-refresh-read.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -"@objectstack/client": patch ---- - -fix(client): `auth.me` / `auth.refreshToken` deliver the `SessionResponse` envelope they declare, and `refreshToken` reads the token the route actually serves (#16760) - -Both methods annotate their return as `SessionResponse` — ObjectStack's REST -`{ success, data }` envelope — for `GET /api/v1/auth/get-session`. better-auth -owns those bytes and answers **bare**. Measured against a real `AuthManager` -(better-auth 1.7.2, organization plugin) over a real driver: - -``` -GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}} -GET /api/v1/auth/get-session (anonymous) -> 200 null -``` - -So `(await client.auth.me()).data.user` type-checked and was `undefined` at -runtime, while `.user` — the real payload — did not type-check. The annotation -pointed every caller at the wrong key. - -## What changed - -- The bare answer is now lifted into the declared envelope, the same lift - `auth.login` has always carried for `/sign-in/email`. `SessionResponse` is - **unchanged** and so is each method's published return annotation: the fix is - in what the methods produce, not in what they promise. -- The lift fills `success` as well as `data`. `SessionResponseSchema` is - `BaseResponseSchema.extend(…)` and that base declares `success` as a required - boolean, so a body carrying `data` alone still would not parse as the declared - type. -- The raw `.user` / `.session` keys are **kept** alongside `data`. They are what - callers were pushed onto while the declared shape was unreachable; dropping - them would trade one silent breakage for another. -- `auth.refreshToken` now reads `data.session.token`. It used to read - `data.data?.token` — a field this route does not produce at any nesting, so - the method returned successfully having captured nothing. A bearer-mode client - calling it to refresh kept whatever credential it already had, silently. - -## The read was not a consequence of the envelope - -Worth stating because the reverse is the natural assumption: enveloping the body -does **not** put a token at `data.token`, because the route serves no top-level -`token` to lift. The only credential in the body is `session.token`, and that is -now the read. Fixing the shape alone would have left `refreshToken` exactly as -inert as it was. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `(await client.auth.me()).user` | still works — kept deliberately | -| `(await client.auth.me()).data.user` | now populated (was `undefined`) | -| `(await client.auth.refreshToken(t)).data.token` | `.data.session.token` | - -`refreshToken` stores the **unsigned** session token, which is the spelling -`/get-session` serves; `bearer()` accepts it and the signed -`token.signature` form interchangeably, so a client that held the signed form -stays signed in across the call. - -Three answers sat outside the declared type when this change was written and -are **not** addressed by it. Each has since been answered on its own card, so a -caller reading this entry does not have to code around any of them: - -- the **anonymous** `/get-session` answer, recorded above as `200 null`. It no - longer needs the published return annotation to widen, because the producer - moved instead: since #17881 `plugin-auth`'s `refuseAnonymousSession` converts - better-auth's `200` plus the literal JSON `null` into the declared ADR-0112 - refusal — HTTP `401` with `code: UNAUTHENTICATED` — before it leaves the - process. The SDK's shared `fetch` wrapper throws on any non-2xx, so an - anonymous `auth.me()` **rejects** rather than resolving outside its own type. - Ruled by #17238: the producer moved and `SessionResponseSchema` is untouched. -- `SessionUser.image`, then declared `z.string().optional()` against a route - that serves `null` (#17235). It is now declared `z.string().nullish()`, so - the `"image": null` every `/auth/*` session body carries parses. -- the sibling `auth.login` / `auth.register`, which then normalized into `data` - but set no `success` (#17234). They now run this entry's own lift, which - fills `success` as well as `data`. diff --git a/.changeset/client-invite-role-default-member.md b/.changeset/client-invite-role-default-member.md deleted file mode 100644 index eb6d7ef46f1..00000000000 --- a/.changeset/client-invite-role-default-member.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/client": patch ---- - -fix(client): `organizations.invite` defaults `role` to `'member'`, so the shorter call it declares actually works (#16582) - -`organizations.invite` declares `role?` as **optional** and forwarded the caller's object to better-auth verbatim. better-auth 1.7.2's body schema for `POST /organization/invite-member` makes `role` **required**, so the documented-looking minimal call was refused before it reached any ObjectStack code: - -``` -client.organizations.invite({ email, organizationId }) -> 400 [body.role] Invalid input (VALIDATION_ERROR) -``` - -Omitting `role` now sends `'member'`. **No published type moves** — `role` stays optional, and a caller who names a role still gets exactly that role on the wire (including `role: undefined`, which is treated as omission rather than dropped). - -The default is `'member'` because the sibling `organizations.invitations.resend` has always substituted exactly that over the **same** vendor endpoint. That asymmetry is why the gap stayed invisible: one member of the family papered over the vendor's requirement and the other did not, so only the shorter form ever failed. It is also the least-privileged name in the closed membership vocabulary (ADR-0108 D1 — `orgRoleGrade` floors at `member` and rises only for `owner`/`admin`), and an invitation is a pending row the invitee must still accept, so the implicit choice cannot confer reach the caller did not ask for. - -Measured against a real `AuthManager` (better-auth 1.7.2, organization plugin, `teams: { enabled: true }`) over a real `SqlDriver` (better-sqlite3), before and after: - -``` -before: POST /organization/invite-member -> 400 {"message":"[body.role] Invalid input","code":"VALIDATION_ERROR"} -after: POST /organization/invite-member -> 200 {"role":"member","status":"pending", ...} -``` - -No caller had to change: the census found no in-repo or Console caller using the two-argument form, so this repairs a path that was declared and unreachable rather than one that was in use. diff --git a/.changeset/client-packages-get-single-true-type.md b/.changeset/client-packages-get-single-true-type.md deleted file mode 100644 index c7791d29f78..00000000000 --- a/.changeset/client-packages-get-single-true-type.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): `packages.get` binds the bare `InstalledPackage` row on both the global and the environment-scoped client, replacing a `{ package }` envelope no surface emits (#12034) - -`client.packages.get(id)` and `ScopedEnvironmentClient.packages.get(id)` now resolve to **`InstalledPackage`** — the row itself — instead of an object wrapping it. - -**Migration — read the row directly, not `.package`:** - -```ts -// before -const { package: pkg } = await client.packages.get('com.acme.crm'); -const pkg2 = (await scoped.packages.get('com.acme.crm')).package; - -// after -const pkg = await client.packages.get('com.acme.crm'); -const pkg2 = await scoped.packages.get('com.acme.crm'); -``` - -FROM `{ package: any }` (global) and `{ package: InstalledPackage }` (scoped) TO `InstalledPackage` on both. - -This is a **narrowing**: a `.package` read compiles today and stops compiling after this change. That is the point of the change rather than a side effect of it — the wrapper was never what the wire sent, so every one of those reads was already `undefined` at runtime, and on the global method the `any` member is what kept the falsehood invisible. Nothing about the request or the wire changes; only the declaration moves to match what the server has been sending. - -Why it can be bound now, when #11925 deliberately left it erased: this route used to be served by two implementations that disagreed — the runtime dispatcher sent the bare row, the `@objectstack/rest` registrar sent `{ package }` — so no declaration was true on both. The registrar's read routes were removed in #16628, leaving the dispatcher's `/packages` domain as the single implementation. It builds the detail body with the same expression it maps over every `list` row, which is why this type now agrees with the `InstalledPackage[]` that `packages.list` has already declared, and with `GetInstalledPackageResponseSchema` in `@objectstack/spec`, which has declared `data: InstalledPackageSchema` all along. - -The environment-scoped method is the sharper half of the change: its member was a real `InstalledPackage`, not `any`, so `.package` reads there looked type-safe while returning `undefined` against every surface that has served that path since #16628. diff --git a/.changeset/composestacks-refusal-envelopes.md b/.changeset/composestacks-refusal-envelopes.md deleted file mode 100644 index e2189ac51df..00000000000 --- a/.changeset/composestacks-refusal-envelopes.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): every `composeStacks` conflict refusal carries an ADR-0112 envelope — six new `STACK_COMPOSE_*` codes beside the `defineStack` family - -`composeStacks` refuses six authored-entity conflicts, and until now every one of them threw -`new Error(message)` with `code` and `status` both `undefined`. The `defineStack` family in the -same file has carried the envelope since #15963, so `packages/spec/src/stack.zod.ts` held two -refusal families that are the same thing to an author — a stack refused at authoring time, -through the same callers — and two different things to a consumer branching on `error.code`. - -Every message in the family carries the literal `composeStacks conflict:` prefix, which is how it -is located: FIVE of the six raise inside helper functions 300-800 lines above `composeStacks`' -own body, so reading the function the defect is named after finds one of them. - -| refusal | raised by | code | -| :--- | :--- | :--- | -| a single-valued top-level key declared with different values by two stacks | `composeSingleValue` | `STACK_COMPOSE_KEY_CONFLICT` | -| `functions` authored in the map form by one stack, the array form by another | `composeFunctions` | `STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT` | -| two stacks defining one handler name | `composeFunctions` | `STACK_COMPOSE_FUNCTION_CONFLICT` | -| under `objectConflict: 'merge'`, an object-level collection other than `fields` declared differently | `refuseUnmergeableCollections` | `STACK_COMPOSE_COLLECTION_CONFLICT` | -| the same object name in two stacks under the default `objectConflict: 'error'` | `mergeObjects` | `STACK_COMPOSE_OBJECT_CONFLICT` | -| a cross-stack action key collision | `collectComposedActionKeyCollisions` | `STACK_COMPOSE_ACTION_KEY_COLLISION` | - -Each carries `status: 422` — an unprocessable authored entity, not a server fault — and the -findings the site collected in `issues`, one entry per finding. **Message text is byte-for-byte -unchanged at every site**: this adds the machine-readable half, it rewords no sentence, and the -message pins across the repo read the prose they always did. - -One code per refusal site rather than a shared `STACK_COMPOSE_CONFLICT` catch-all — the -granularity the `defineStack` family landed with, and the granularity the ADR-0112 ledger's -boot-refusal class already had before it. The `STACK_COMPOSE_*` spelling says what the -per-stack family's spellings cannot: the defect is a disagreement BETWEEN stacks, each of which -is legal on its own, so the fix is in the composition rather than in one malformed stack. -`STACK_CROSS_REFERENCE_INVALID` stays the deliberate exception in the other direction — its -per-stack and artifact passes share one code because they are one rule family over two scopes. - -All six are registered in `ERROR_CODE_LEDGER` under `@objectstack/spec`, under the ruling that -every code shipped in `dist` is the published face, door or no door. No wire door raises them: -`composeStacks` runs at authoring and boot time, and the reading was re-measured here — zero -`composeStacks` call sites under `packages/runtime/src` + `packages/rest/src` (7 non-test -occurrences, all doc comments or message prose in one file), with `defineStack` lighting the -same probe 31 times across 8 files as the positive control. - -Not narrowed: `composeStacks` accepts and refuses exactly the inputs it did before, and no export -changes — the error classes stay module-local, as every member of the `defineStack` family is, -because `packages/spec/src/index.ts` re-exports the module with `export *` and the ADR-0112 -contract is the `code` / `status` pair read structurally. - -⛔ The seventh bare `Error` in that file is deliberately untouched: -`composeStacks internal error: no source stack recorded for composed object …` is the code -discovering its own bookkeeping is inconsistent, not an authored entity being refused. Filing it -at 422 would tell an author their stack is invalid when the defect is ours. Whether it takes a -500-class envelope of its own is a separate decision. - -Clause-②: yes diff --git a/.changeset/config-refusal-throws-so-json-faces-emit.md b/.changeset/config-refusal-throws-so-json-faces-emit.md deleted file mode 100644 index 6466423d5eb..00000000000 --- a/.changeset/config-refusal-throws-so-json-faces-emit.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `resolveConfigPath` throws its two refusals so the ten `--json` faces emit their envelopes, and `os verify` gains the catch-all it never had (#15547) - -Every `--json` face in this CLI declares that it answers an error path with a -payload. `resolveConfigPath()` was the one path that bypassed that declaration: -it wrote its refusal and then called `process.exit(1)` **directly**, so nothing -was thrown and the catch-all each command already carries — all of which sit -downstream of a throw — never ran. Ten published faces answered a missing config -file with an empty stdout. - -Measured before this change on the published entry `packages/cli/bin/run.js`, -`NO_COLOR=1`, streams captured separately, exit read before any pipe — ten faces -(`build` · `compile` · `diff` · `i18n check` · `i18n extract` · `info` · `lint` · -`migrate meta` · `validate` · `verify`) across both branches of the helper, 19 -runs: **exit 1, stdout 0 bytes, stderr 296 B (explicit path) / 123 B -(auto-detect)** — and `JSON.parse` on that stdout throws in all 19. After: the -same 19 runs answer **exit 1 with a parseable document on stdout**, stderr -unchanged byte for byte. - -The refusals now throw `ConfigRefusalError`. That is not a new contract — it is -this path being pulled back onto the one its callers had already published, so -it adds **zero** accept-set members and **zero** error codes. - -Three properties hold it in place: - -- **No face becomes a crash dump.** `os verify` had no `try` at all — measured, - a throw through it produced an oclif error line and no payload where every - sibling emitted an envelope — so it gains the catch-all its nine siblings - already had, in this same change rather than after it. -- **The text face does not narrow.** The refusal and both hint lines are still - written by the helper, to stderr, byte-identical: all 19 non-`--json` runs - compare equal before and after on stdout, on stderr and on exit status. The - catch-alls skip re-rendering the sentence a second time on stdout. -- **No error code is minted.** The thrown error carries neither `code` nor - `httpStatus`, so `errorCodeFields()` contributes nothing and each face emits - its own bare `{ error }`. Whether that shape is right is **#15549**'s open - question, and this change deliberately does not answer it. - -The `--json` stdout-purity instrument is widened with the fix rather than after -it: the pre-boot family's discovery moves into a shared module, the pin that -drives it now demands a document (empty stdout no longer passes) and compares -the text face's stderr as a whole string, and `json-stdout-purity.e2e.test.ts` -— whose own discovery is `bootSchemaStack`-based and cannot see a command that -fails above the kernel — reconciles against that population so neither half can -be lost silently. diff --git a/.changeset/console-62597c588072.md b/.changeset/console-62597c588072.md deleted file mode 100644 index 6ef6b2fa834..00000000000 --- a/.changeset/console-62597c588072.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `62597c588072`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 27 releasing of 30 changesets added across 37 non-merge commits; omitted: 3 release-nothing changesets, 7 commits carrying no changeset (they ship no package code). - -- **minor** — fix(plugin-grid): `object-grid`'s `pagination` and `selection` now read their own presence by the SAME rule — presence enables, an explicit off wins (objectui `62597c588`) -- **minor** — A render-time filter refusal now renders a named "this view's filter is malformed" state instead of throwing out of render (objectui#9050). (objectui `804831c2a`) -- **minor** — `record:related_list` `add.picker.filter` is declared as the contract's rule array on the authoring face, not as `unknown` (objectui#9964). (objectui `9a9780072`) -- **minor** — feat(core): `object-tree` joins the curated public tier — the spec declared the block, the roster withheld it (objectui `c131d9e69`) -- **minor** — A signed-in user's language has one source of truth: `sys_user.locale`. (objectui `63f4f928f`) -- **minor** — A record form can no longer be saved while an upload is still in flight (objectui#10166). (objectui `e686f4d9a`) -- **minor** — Nav `visible`: an ancestor's predicate now reaches the whole subtree, and a group no longer outlives its children (objectui#10119) (objectui `73a3c89af`) -- **minor** — A field-backed action param reaches its record picker, and a param whose backing field cannot be read is refused instead of rendered as an empty text box (objectui#10129). (objectui `6cc910b6d`) -- **patch** — fix(react): the refused-`data` dev warning can be reset between tests (objectui `befd40ccd`) -- **patch** — fix(components): a half-typed `between` range in the filter builder shows itself as incomplete instead of being dropped with no signal (objectui#10061) (objectui `777fca22f`) -- **patch** — fix(plugin-detail): a related list resolves `user` columns to names, like every other list (objectui `0d379f571`) -- **patch** — fix(core): a date-only value renders the calendar day it names, in every viewer timezone (objectui `516583b54`) -- **patch** — fix(plugin-detail): the record-grained write verdict is memoised PER PRINCIPAL (objectui#10107). (objectui `b06c3de2a`) -- **patch** — fix(console): a `file` field on a FormView renders the shared upload control, not a text box (objectui `7725c10a0`) -- **patch** — Stop the Studio permission matrix' Delegated Admin Scope editor authoring an `adminScope` the framework spec refuses, and make the section's collapsed badge report a scope it is c… (objectui `2d7fff3b8`) -- **patch** — fix(console): mint a `sys_file` id for files picked in the global action dialogs (objectui `98178b206`) -- **patch** — fix(plugin-detail): `requiredPermissions` on `record:details`, `record:highlights` and `record:related_list` is an ADR-0066 capability set, read fail-closed — it used to pass for… (objectui `c2dabf032`) -- **patch** — A form no longer submits — nor offers — a field the CALLER may read but not edit (objectui#10120). (objectui `80c54122e`) -- **patch** — fix(components): `page:header` resolves a lookup title candidate instead of handing its expanded object to JSX (objectui#10117) (objectui `4c6f549ef`) -- **patch** — `ObjectCalendar`'s user-visible copy now reaches the locale packs. The component was already i18n-aware — it imports and calls both translation hooks — and a set of English senten… (objectui `afb228418`) -- **patch** — `useSettledSchema` no longer republishes an EQUAL definition as a new object, so swapping the adapter on a record-bound view costs ONE record query instead of two. (objectui `162621b6a`) -- **patch** — A form no longer emits the columns the server owns, and a master-detail batch sends only the cells the user changed (objectui#10108). (objectui `e026e15f9`) -- **patch** — fix(plugin-calendar): the month gridcell's accessible name follows the resolved locale (objectui `708f2711b`) -- **patch** — The AI build bar, the Studio workbench and the chat transcript's draft cards report the runtime authoring gate's per-draft advisories (objectui#10039) (objectui `e16f504d9`) -- **patch** — fix(plugin-detail): `record:quick_actions.requiredPermissions` is an ADR-0066 capability set, read fail-closed — it used to pass for every reader of the object (objectui `28b065800`) -- **patch** — fix(plugin-map): the top-level-`style` warning now prescribes a spelling the runtime reads (objectui `2252653d0`) -- **patch** — `record:history` refuses a row cap the contract rejects instead of repairing it (objectui#10005). (objectui `cd2cb4183`) - -**In this console build, declared nowhere** — objectui merged 7 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. - -- _(no changeset)_ docs(agents): 一次派发跑出来的 PR 停在 draft —— 给自行合并条款加第二条例外 (#10213) (objectui `87a0ed8cf`) -- _(no changeset)_ docs(agents): state the attribution-footer append as conditional on absence (#10212) (objectui `2ec310198`) -- _(no changeset)_ docs(skills): the console guide names the resolver `/` actually has (objectui#10140) (#10215) (objectui `8d7915ed2`) -- _(no changeset)_ ci(pm): adopt the board-snapshot archiver — route B, and the five files this repo's own gates demanded (objectui#9387) (#10203) (objectui `0cf2d6644`) -- _(no changeset)_ ci: compile objectui against @objectstack/spec@main as the shape gate (objectui#9860) (#9877) (objectui `6cae9a0b3`) -- _(no changeset)_ perf(console): move the `types` zod validators off the eager budgeted line (#10082) (objectui `64d250c8a`) -- _(no changeset)_ ci(bundle-analysis): retire the exhausted-headroom leg, keep every ceiling (#10151) (objectui `cbda340f0`) - -objectui range: `87af769e9a3e...62597c588072` diff --git a/.changeset/console-87af769e9a3e.md b/.changeset/console-87af769e9a3e.md deleted file mode 100644 index 339bcbadf32..00000000000 --- a/.changeset/console-87af769e9a3e.md +++ /dev/null @@ -1,240 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `87af769e9a3e`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 584 releasing of 891 changesets added across 1156 non-merge commits; omitted: 307 release-nothing changesets, 279 commits carrying no changeset (they ship no package code). - -- **minor** — **BREAKING** — a record id is a **string** wherever metadata names one, and on the last `DataSource` door (objectui#9511). An authored `recordId: 42` / `resourceId: 42` no longer… (objectui `87af769e9`) -- **minor** — Store a typed percent by shifting the decimal, not by dividing a float by 100 (objectui#9810, maintainer ruling batch #161 item 3, letter B). (objectui `1560d4682`) -- **minor** — `object-grid`'s `operations` block is now the CEILING over `rowActions`, instead of one half of a union with it. (objectui `c269ed9e0`) -- **minor** — **Breaking — the published `RootRedirect` is removed; `/` now has exactly one resolver.** (objectui `4274861f4`) -- **minor** — Report display sites follow the display locale instead of the literal tag `en-US` (objectui#10020). (objectui `f7af6372d`) -- **minor** — `ObjectGallerySchema.filter` is typed as the destination its own docblock names — `QueryParams['$filter']` — on both faces, the TS interface in `objectql.ts` and the zod mirror in… (objectui `7cbefa540`) -- **minor** — Render the environment admin's read-rate report from the usage endpoint's `readRate` reading (objectui#9954; maintainer ruling on cloud#2333, batch #164 item 3). (objectui `c698a814a`) -- **minor** — `DetailViewField` declares `dueLike?: boolean` — the published TypeScript twin now accepts the key its own validator already judged and its own renderer already honours (objectui#… (objectui `8b446f5d0`) -- **minor** — feat(console): a "Language" item on the profile page, writing the signed-in user's own `sys_user.locale` (objectui#7501). (objectui `c2f0f4832`) -- **minor** — `RecipientPickerField` gains a picker mode for the `field` sharing recipient (objectui#7613; maintainer ruling objectstack#14103, executor objectstack#15072). (objectui `23b99585f`) -- **minor** — The console's error-recovery exits follow the declared landing (objectui#7373). (objectui `3fa3b3eb6`) -- **minor** — Studio's "publish whole app" reports the runtime authoring gate's per-draft advisories (objectui#6965; server half objectstack#9343). (objectui `ce986aafc`) -- **minor** — Client-evaluated `ConditionBuilder` mounts declare the scope roots their own host binds (objectui#9856) — the declared cost of objectui#9645, now paid. (objectui `030a675b0`) -- **minor** — **BREAKING** — Build the chat runtime's discriminated tool parts at the PRODUCER, and delete the last `as any` on the `useChat` call (objectui#8426; director seat, decision batch #86, 2026-09-08… (objectui `4dbab84d4`) -- **minor** — Drop a saved view's row cap that the contract refuses, at the LOWERING layer every repaired read point sits under — and say so on the one path that has no renderer to say it (obje… (objectui `5eabe8646`) -- **minor** — **BREAKING** — Refuse `operators` on `object-grid` by name, and name the correct spelling (objectui#9739, maintainer ruling 2026-09-18, letter C). (objectui `6ee259a8f`) -- **minor** — Declare the row-click modifier payload on `ObjectDataTableSchema.onRowClick` (objectui#9799), so the object-arm face stops denying a second argument its node already receives. (objectui `3b6d53bc3`) -- **minor** — **BREAKING (authoring surface): `body` is no longer a child-list key. Author `children`.** (objectui `2acd8e109`) -- **minor** — Execute the declarative row-level `operation: 'update'` action (objectui#7551, the objectui half of objectstack#14092; consumes `@objectstack/spec` 17.3.0's `ActionSchema.operatio… (objectui `feac43909`) -- **minor** — `FlowRunner`: a run that ended with `outcome: 'refused'` renders as a Close-only notice (objectui#7707 — lane 3 of the maintainer ruling on objectstack#14945, decision batch #42;… (objectui `98a6bddf9`) -- **minor** — **`resolveRecordSourceConfig`'s `data` parameter now follows the arm it is already told, instead of contradicting it.** (objectui `ab856ed30`) -- **minor** — `object-form`'s two seed keys now merge PER MEMBER. `initialData` is registered as the "alternate spelling of `initialValues` … read FIRST", and every presentation arm implemented… (objectui `63bf47da6`) -- **minor** — The drill `filter[...]` URL dialect can spell IS NOT NULL (objectui#9508). (objectui `6c06f0b50`) -- **minor** — `SchemaRenderer` now applies the objectui#9571 authored-`data` strip to the legacy `props` alias bag as well, so all three authoring spellings of that key lose the React prop seat… (objectui `7649f4364`) -- **minor** — fix(app-shell): `ConditionBuilder`'s subject dropdown stops offering roots the host does not bind (objectui `aced50d2e`) -- **minor** — `record:*` blocks honour the `aria` bag the protocol declares on them, and `RecordComponentAriaProps.ariaLabel` states the contract's inline locale vocabulary instead of narrowing… (objectui `272a53066`) -- **minor** — `object-form.customFields` now MERGES over the metadata-generated field set, as its registered description always promised (objectui#9778, maintainer ruling 2026-09-18, director s… (objectui `5a311a38d`) -- **minor** — **BREAKING** — `useNavigationOverlay` stops reading the retired `navigation.view` key, and stops substituting an authored name for the navigation-MODE token (objectui `0c789a402`) -- **minor** — Stop `FilterConditionField` writing a `between` row into stored criteria until BOTH bounds are filled in (objectui#9914). (objectui `4d7d322aa`) -- **minor** — **BREAKING** — Converge the bare `dashboard` key on `plugin-dashboard`, and retire `view:dashboard` with a by-name tombstone (objectui#9533). (objectui `e356c39ee`) -- **minor** — The percent EDIT WIDGET reads `scale` for its fraction width, not `precision` (objectui#9568). (objectui `9aa2a573b`) -- **minor** — `objectui generate page` scaffolds its child list as `children` (objectui#9847). (objectui `d2f723fd1`) -- **minor** — The VS Code extension no longer ignores a child list spelled `children`, and everything the platform scaffolds now emits that spelling (objectui#7181). (objectui `4b5bb9525`) -- **minor** — Declare `scale` on `PercentFieldMetadata` (objectui#9784). (objectui `e7084269f`) -- **minor** — Give the `file` grid cell a per-file view/download affordance (objectui#9485). (objectui `f0f204677`) -- **minor** — Forward the row-click modifier payload through the three hops that were dropping it (objectui#9462), so Cmd/Ctrl/middle-click on a row reaches a host handler. (objectui `f0f3cd5e0`) -- **minor** — A scatter's two numeric axes now honour the spec `ChartAxis` every other chart family already honours, instead of dropping it. (objectui `1b969aec6`) -- **minor** — **BREAKING** — The schema-driven condition editor now lints in the scope its host declares (objectui#8167, director ruling of 2026-09-17, batch #150 item 5 letter B). (objectui `95121fb99`) -- **minor** — **BREAKING** — `useSpecGesture`'s `onGesture` fallback payload reports the DECLARED spec gesture at every arm, not the recognizer's own name (objectui#9691). (objectui `0ee6e316e`) -- **minor** — Take the percent cell's magnitude from the value, never from the column's name (objectui#9452). (objectui `e05553c46`) -- **minor** — **BREAKING** — `useSpecGesture` fires a swipe on MEMBERSHIP of the declared direction set (objectui#7974, maintainer ruling of decision batch #70). (objectui `1cfdff814`) -- **minor** — Derive the bulk executor's data-source face from `DataSource` and stop erasing the check at the hand-off (objectui#9722). (objectui `20b5e361e`) -- **minor** — Honour `columns[].collapsed` on the registered kanban board (objectui#9628). (objectui `dea17b469`) -- **minor** — **BREAKING if your code hands a numeric primary key to one of these four** — a record id at the `DataSource` boundary is a `string`, as `@objectstack/spec` declares every record d… (objectui `bbba09840`) -- **minor** — Declare `cardTitle` — the canonical card-title spelling — on `ObjectKanbanSchema` (objectui#9606, director seat decision batch #150 item 3 letter 1, maintainer approved 2026-09-17… (objectui `78a9c6744`) -- **minor** — **BREAKING** — Stop `collapsible` from honouring an authored `open`, and retire the declaration on both published faces (objectui#8236, ADR-0049 enforce-or-remove). (objectui `ee70287e4`) -- **minor** — **BREAKING** — Refuse `onNavigate` and `onAddComment` by name on the `detail-view` JSON authoring face (objectui#9447). (objectui `ac716fff4`) -- **minor** — Export `ObjectTreeSchema` from the `@object-ui/types` root barrel (objectui#9550) (objectui `bbe57fdd5`) -- **minor** — **`buttonVariant` becomes authorable on `toast` and `sonner`.** Both registrations now declare it in their registry `inputs`, as `type: 'enum'` over exactly the six values the TS… (objectui `72f55c9ec`) -- **minor** — Build history rows now state their item count in each language's own grammar (objectui#9266). (objectui `15b33aeb4`) -- **minor** — **BREAKING** — `ChatbotSchema` no longer accepts `body`, on either published face (objectui#8572). The chat API's body params are authored as `requestBody`, which is what the rend… (objectui `c42554e94`) -- **minor** — **BREAKING** — the calendar date aliases `dateField` and `endField` are retired at both faces (objectui#8355). They are now **declared refusals**: an authored value is rejected **… (objectui `474797d62`) -- **minor** — **BREAKING** — Declare the one handler key the `'tree-view'` renderer reads (objectui#7804, the `TreeViewSchema` slice). (objectui `604476d97`) -- **minor** — ⚠️ **Behaviour change in the metadata designer: a bare field reference typed into a hook's "Run only when (optional CEL)" is now an ERROR in the editor.** It was accepted. Read th… (objectui `16603b9c9`) -- **minor** — One home for the `datetime` display convention in the readonly field widgets, one face per register (objectui#8209, maintainer ruling batch #142 item 2). (objectui `ac0e39a84`) -- **minor** — `record:details` honours `hideEmpty` on a section again — an all-empty section hides itself, `hideEmpty: false` keeps its heading and skeleton (objectui `542718f45`) -- **minor** — **BREAKING** — `ObjectKanbanSchema` no longer accepts `allowCollapse`, on either published face (objectui#8801). (objectui `d234fa91e`) -- **minor** — **`ObjectView` honours a spec-shaped named list view** (objectui#8254, the renderer half objectui#7928's option A requires — decision batch #70, 2026-09-07 — before `ObjectViewSch… (objectui `5226263ef`) -- **minor** — **BREAKING** — `AIInsightsSchema` and the `ai-insights` node type are RETIRED from the published type face (objectui#8800, ADR-0049 enforce-or-remove). (objectui `1bd1be7e2`) -- **minor** — **BREAKING (shipped as `minor` — see below):** `list` and `timeline` now refuse both content channels by name. Neither renderer reads `body` or `children`, so both keys become `?:… (objectui `53374dc07`) -- **minor** — Two frozen cell-renderer censuses now read the registry instead of a literal (objectui#8734) (objectui `3ecc369bf`) -- **minor** — **BREAKING** (declared `minor` — this repo pins its major to `@objectstack`, so a breaking change ships as a minor with this banner; AGENTS.md §版本号策略): `WidgetInput.label`, `Widge… (objectui `335abea3e`) -- **minor** — `SchemaRenderer` no longer spreads an authored `data` key as a React prop for blocks whose published `data` row is the `ViewData` OBJECT arm (objectui#9571, ruling objectui#8348 Q… (objectui `f0f4d6c8e`) -- **minor** — **BREAKING** — fix(components): Tailwind no longer compiles this package's prose into the published stylesheet (objectui `f7fcc2cdb`) -- **minor** — **BREAKING** — All three percent surfaces read `scale` for their fraction width, not `precision` (objectui#9295). (objectui `4a94c38b0`) -- **minor** — **BREAKING** — Declare the five handler keys the `'list-view'` renderer reads (objectui#7804, the `ListViewSchema` slice). (objectui `f1cd29032`) -- **minor** — Say why a capability-gated action is missing, in the action designer (objectui#7234, maintainer ruling 2026-09-08, option B). (objectui `45889f8e9`) -- **minor** — `record:chatter` / `record:discussion` now read `feed.filterMode` and `feed.enableMentions` (objectui#8968). (objectui `88561fdc4`) -- **minor** — `EventHandlersSchema` is removed from `@object-ui/types` (objectui#6910). (objectui `40f34b4ba`) -- **minor** — `ObjectTreeProps.schema` is the published `object-tree` node instead of `any`, and `getTreeConfig`'s parameter with it (objectui#8655). (objectui `009f92d7a`) -- **minor** — The four plain `objectql.ts` node faces declare the nine handler keys their registered renderers read (objectui#7804, the `objectql.ts` slice): `ObjectFormSchema.onCancel` / `.onE… (objectui `8d50bc2bf`) -- **minor** — **BREAKING (shipped as `minor` — see below):** six component schemas now refuse both content channels by name. `text`, `image`, `icon`, `tabs`, `accordion` and `calendar` (and `ui… (objectui `b7479abc7`) -- **minor** — `UIActionSchema` declares the four keys the two action renderers were reading through `as any` — `disabled`, `recordIdField`, `resultDialog`, `undoable` (objectui#8648, the object… (objectui `f95b1409f`) -- **minor** — `DataTableSchema` declares the seven handler keys its registered renderer reads: `onAddRecord`, `onBatchSave`, `onCellChange`, `onColumnResize`, `onRowActionDef`, `onRowClick` and… (objectui `75fca9669`) -- **minor** — **`NamedListView` declares the 17 members the protocol declares on the same surface, and each one now has a read point** (objectui#8980, director-seat class-one adjudication of 20… (objectui `0e2ddd418`) -- **minor** — `InputShorthandSchema` and `UiCalendarSchema` are now named exports of `@object-ui/types` itself, not only of `@object-ui/types/form` and `@object-ui/types/zod` (objectui#9406). (objectui `bbc9dc34e`) -- **minor** — The ingestion choke point says out loud when it CANNOT fold a retired spelling (objectui#8938) (objectui `84defabb2`) -- **minor** — fix(plugin-calendar): type `ObjectCalendar` at the published `object-calendar` schema, and declare the `calendar` container (objectui `51e144eda`) -- **minor** — A record id is a `string` everywhere in the published types, as `@objectstack/spec` has always declared it. Three published declarations that admitted `number` no longer do. (objectui `72d65875c`) -- **minor** — `AppAction.items` 上的 `shortcut` 由「静默剥掉」改为「具名拒收」 (objectui `c9f9baedf`) -- **minor** — The drill "escape hatch" can spell an empty bucket: the `filter[...]` URL dialect grows an is-null operator on both sides plus a chip for it (objectui#9159). (objectui `136ff4bb3`) -- **minor** — **BREAKING** — Retire 23 measured-dead locale keys from all ten packs — two whole families and eleven individual leaves (objectui#8754; director seat summon #22, 2026-09-12, maintainer verbatim… (objectui `ef5200107`) -- **minor** — Give a read-only `file` field a per-file view/download affordance (objectui#9161). (objectui `6d5db7b17`) -- **minor** — `record:related_list` accepts `relationshipValueField`, and three record renderers stop erasing their own props annotation (objectui#8649). (objectui `541ce4e02`) -- **minor** — `list-view`: retire the legacy `title` alias from the export-filename read, and pin the `rowActionDefs` exemption at both of `ListView`'s read sites (objectui#8653, the objectui#8… (objectui `3a9ab021c`) -- **minor** — The wrong-layer root advisory asks the platform for its verdict instead of keeping a second copy of it (objectui#9318). (objectui `e3cb47624`) -- **minor** — The row/card click props on the view components now declare the modifier payload they have always been invoked with (objectui#9357). (objectui `502eb5880`) -- **minor** — **BREAKING** — Declare the two handler keys the `'detail'` renderer reads (objectui#7804, the `plugin-detail` slice; director seat, decision batch #69, 2026-09-07). (objectui `7ca6ddd4b`) -- **minor** — **BREAKING (scored `minor` per this repo's version-alignment convention)** — `KanbanRenderer` takes `onCardMove` as an explicit React prop, and the `object-kanban` document face t… (objectui `55f39ee90`) -- **minor** — `UseNavigationOverlayOptions.onRowClick` now declares the modifier payload it has always been called with (objectui#9357). (objectui `0ce32d514`) -- **minor** — Give the flow `end` node's inspector a typed control for `config.message`, the key a refused outcome requires (objectui#9336). (objectui `d27dcf2c9`) -- **minor** — A record page shows a discussion panel if and only if it composes one (objectui#7298). (objectui `7aaa89160`) -- **minor** — Take lucide's runtime `icons` record off the console's eager path (objectui#9251, maintainer ruling of 2026-09-13, decision batch #132 item 4). (objectui `67485872e`) -- **minor** — One record-overlay shell: all five list-type renderers honour all four overlay `navigation.mode` values (objectui#9299, director seat decision batch #128 item 1, 2026-09-13). (objectui `7098eed36`) -- **minor** — **BREAKING** — `RecordDetailsComponentProps` no longer declares `layout` (objectui#9040 item 1). (objectui `63fb72c42`) -- **minor** — An unbound map now REFUSES; coordinates are never guessed (objectui#8169, maintainer ruling 2026-09-07, decision batch #67, option B). (objectui `cb725e78f`) -- **minor** — **BREAKING** — The console's "app not available" screen now says what it measured, and the by-name app probe stopped folding four answers into one (objectui#9262). (objectui `2bf34f70c`) -- **minor** — Name two of objectui#8499's arms on the `@object-ui/types/zod` barrel, and record the other two as absent by decision (objectui#9067, director seat, decision batch #121 item 5, ma… (objectui `279e48e8c`) -- **minor** — An `onCardClick` supplied to an `object-kanban` board runs **once** per card click instead of twice, and the published declaration of the key grows the second parameter the surviv… (objectui `a272a4ffe`) -- **minor** — **BREAKING** — Retire the "Tremor/simple format" adapter in `ChartRenderer` — the `index`, `category` and `value` reads (objectui#8650, triage ruling `5619609278` on AGENTS.md #0.1: route to the… (objectui `bb383e83d`) -- …and 484 more releasing changesets in this range (list capped at 100; see the objectui range below). - -⚠️ 98 of these carry a breaking change: 98 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 279 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. - -- _(no changeset)_ ci: one `Test` aggregator becomes the required test context, shards 4 -> 8, dist pins get their own job (#9584) (objectui `eb1c9f9d2`) -- _(no changeset)_ docs(skills,AGENTS): teach `action:button` + `actionType`, retire the `events` bag (#9592) (objectui `7550728a6`) -- _(no changeset)_ docs(census): state the no-changeset-for-tooling ruling in the census header (objectui#9795) (#10077) (objectui `3631937fc`) -- _(no changeset)_ refactor(scripts): the item-carrier disposition is RULED and says it is not a dialect (#10087) (objectui `205b97353`) -- _(no changeset)_ docs(guide): remove the lazy-loading promise nothing keeps from the schema-rendering guide (objectui#9989) (#10086) (objectui `66d870feb`) -- _(no changeset)_ docs(detail-view): teach `dueLike` where a detail-view field is authored (#10075) (objectui `af57dc729`) -- _(no changeset)_ gate(doc-types): walk packages/NAME/README.md, with its ruled DOC_TYPE_EXEMPTIONS entries (#9996) (objectui `6f76bb7ad`) -- _(no changeset)_ docs(skills): move the three published guides off the retired `dataSource` expression root (#9378) (objectui `8ec28d73d`) -- _(no changeset)_ docs(skills): page-builder.md names the channel that publishes expression roots (objectui#9672) (#9997) (objectui `078f2e4f7`) -- _(no changeset)_ docs(skills): gate the usePermissions example on can(), a boolean, and mark its fence (objectui#9671) (#9994) (objectui `3d72fb65f`) -- _(no changeset)_ docs(guide): user-state-persistence taught the rejected user_app_state shape (objectui#5950) (#10011) (objectui `ec1de927d`) -- _(no changeset)_ docs(changeset): four pending bodies cite the retire-vs-remove discriminator instead of restating it (#9970) (objectui `706e09f27`) -- _(no changeset)_ docs(scripts): the $-dialect census keeps its by-name self carve-out, with the reasons pinned (objectui#9891) (#9915) (objectui `2bc9829ea`) -- _(no changeset)_ fix(scripts): type-check-coverage stale-entry messages carry no card-ending keyword (#9898) (objectui `fb7dfedbb`) -- _(no changeset)_ chore(claude): allow-list the two landing REST calls in settings.json (objectui#9862) (#9900) (objectui `99bcde511`) -- _(no changeset)_ feat(scripts): read one changeset against itself, and date the contradiction (#9850) (objectui `5365b4c34`) -- _(no changeset)_ fix(scripts): the polarity census reads the optional marker as a decoration, not as part of the key (objectui#9794) (#9831) (objectui `3ae740c4a`) -- _(no changeset)_ docs(changeset): date the `MEMBER_PIN_EXEMPTION_CEILING` reading in the pending 8171 body (#9823) (objectui `26ac50369`) -- _(no changeset)_ docs(changeset): date the rotted "keeps its own copy" clause in the 5993 note (#9822) (objectui `aed4b4f71`) -- _(no changeset)_ docs(plugin-detail): state the Reference Rail opt-in and the option its count is read from (#9811) (objectui `78a0582ba`) -- _(no changeset)_ fix(scripts): the polarity census reads a key as a NAME, not as any lowercase token (#9793) (objectui `6a0d1a435`) -- _(no changeset)_ docs(changeset): correct the `SpinnerSchema` member attribution in the 5632 pending body (#9763) (objectui `d18322415`) -- _(no changeset)_ fix(scripts): forward-parity stale-entry messages carry no card-ending keyword (#9755) (objectui `2414e3751`) -- _(no changeset)_ test(ci-cd-doc): read the Playwright reporter through the shared comment mask (#9748) (objectui `5e8a31aa1`) -- _(no changeset)_ feat(gate): read BORN-FALSE claims — an address this change's own diff moves (#9744) (objectui `cbb2e45ac`) -- _(no changeset)_ fix(scripts): key the indirect registration bypass by collection, not by file (#9724) (objectui `64deb1603`) -- _(no changeset)_ fix(scripts): refuse a `-t` name filter that cannot match the title it spells (objectui#9660) (#9730) (objectui `10cc93b79`) -- _(no changeset)_ docs(changeset): date the two rotted present-tense declaration claims in the kanban pending entries (#9723) (objectui `2dbb49975`) -- _(no changeset)_ fix(scripts): derive the indirect registrations' namespace from the call, not from the hand-kept table (#9716) (objectui `4cf57b6da`) -- _(no changeset)_ docs(changeset): stop the pending 8499 entry publishing a stale registry size (#9715) (objectui `e3ff936ca`) -- _(no changeset)_ docs(changeset): retire two present-tense "in-flight PR" claims before they publish (objectui#9706) (#9714) (objectui `3172b85fa`) -- _(no changeset)_ fix(ci): the lockfile-dedupe gate reports on pull requests instead of blocking (#9707) (objectui `50e5cafe3`) -- _(no changeset)_ test(ci): widen the live-reading lock to what required jobs RUN, not just the workflows (#9696) (objectui `dd871fc08`) -- _(no changeset)_ docs(changeset): correct the AIInsights paragraph in the 8178 entry (objectui#9625) (#9694) (objectui `a961ad174`) -- _(no changeset)_ test(ci): take the live registry reading out of a REQUIRED context (objectui#9562) (#9690) (objectui `29a8a9526`) -- _(no changeset)_ docs(plugin-calendar): compile the Direct Component Usage block and gate it (#9678) (objectui `bb2d33570`) -- _(no changeset)_ docs(skills): auth-permissions stops teaching `dataSource` as the `data` expression root (objectui#9379) (#9669) (objectui `61b755346`) -- _(no changeset)_ feat(scripts): census every page key a renderer reads against PageSchema (objectui#9438) (#9670) (objectui `53f2b189e`) -- _(no changeset)_ fix(scripts): the vite resolve oracle stops writing its scratch root into the swept repo root (objectui#9468) (#9658) (objectui `e896c3899`) -- _(no changeset)_ docs(tooling): the handler-key gate names the owner its ledger consults, in all three places (objectui#9456) (#9657) (objectui `0fb382eca`) -- _(no changeset)_ fix(scripts): the spec-symbol ratchet detects its own dead anchor (objectui#9537) (#9646) (objectui `0b7be13ad`) -- _(no changeset)_ chore(labeler): delete the inert `designer` rule and the exemption it needed (objectui#7771) (#9644) (objectui `8ad231846`) -- _(no changeset)_ feat(scripts): gate a test source naming a changeset the tree carries (objectui#9583) (#9635) (objectui `dda8f3815`) -- _(no changeset)_ fix(e2e): root the live storage-state write and both storageState configs on their own file (#9636) (objectui `8d1242b58`) -- _(no changeset)_ fix(census): the continuation-scope docblocks stop crediting a guard that cannot fire there (#9632) (objectui `15f01223d`) -- _(no changeset)_ fix(scripts): lint-coverage's stale-entry message stops spelling a closing keyword before its anchor (objectui#9538) (#9595) (objectui `ff29450a9`) -- _(no changeset)_ fix(scripts): make the body-dialect census report the key population it counted over (#9599) (objectui `a5b660f21`) -- _(no changeset)_ docs(plugins): author the `listViews` filter operator in its canonical spelling (objectui#7993) (#9612) (objectui `fdbfe2302`) -- _(no changeset)_ docs(components): document `wrapperClass` and its new refusal on the five pages that omit it (#9614) (objectui `e0a87dbbd`) -- _(no changeset)_ fix(scripts): check:spec-symbols reads EVERY occurrence of a claim phrase, not the first (#9608) (objectui `253c31418`) -- _(no changeset)_ docs(agents): point AGENTS.md at the invocation guard pin test instead of counting its refusals (objectui#9505) (#9587) (objectui `163630bc9`) -- _(no changeset)_ feat(ci): deliver the changeset claim re-read onto the pull request (objectui#9140) (#9581) (objectui `f508000b5`) -- _(no changeset)_ test(scripts): stop pinning a pending changeset filename in the two gate suites (#9582) (objectui `29f4c0582`) -- _(no changeset)_ docs(agents): record the merge_group leg's SECOND refusal predicate (the contract-review carrier) (#9466) (objectui `4b9a0a8f0`) -- _(no changeset)_ fix(scripts): fail on a new bare-name registry collision (objectui#9264) (#9531) (objectui `2904c5c40`) -- _(no changeset)_ build(tsconfig): raise test-program `lib` to ES2022 across 31 packages (#9512) (objectui `4e96becf5`) -- _(no changeset)_ fix(scripts): label an `any` index signature `index signature`, not `return type` (#9510) (objectui `2e1d0f032`) -- _(no changeset)_ fix(devx): refuse an appended path filter that a baked positional already swallows (objectui#7814) (#9504) (objectui `8fa7d69af`) -- _(no changeset)_ docs(gate): name key-refusal as a class check-spec-range-floors deliberately does not judge (objectui#9036) (#9481) (objectui `02d424ab3`) -- _(no changeset)_ fix(tests): register @testing-library/jest-dom in three test programs' types (#9480) (objectui `360300fea`) -- _(no changeset)_ ci(coverage): give the instrumented lane its own per-test budget so the coverage gate can run (#9474) (objectui `511e4024a`) -- _(no changeset)_ test(docs): pin command parity for every ci-cd-pipeline.md section by default (#9467) (objectui `db6aa19a9`) -- _(no changeset)_ docs: finish objectui#9297 — schema-rendering.md stops teaching silence, and the expression sandbox states its real allowlist (#9461) (objectui `75fc9df6e`) -- _(no changeset)_ fix(devx): resolve spec export conditions in the map's key order, and say which arm won (#9455) (objectui `72932dfcd`) -- _(no changeset)_ docs: publish each page's own values through the scope channel on the three remaining teaching surfaces (#9376) (objectui `035d3fac3`) -- _(no changeset)_ docs(fields): blank line before `## Field Schema` on four field pages (#9435) (objectui `63d9ca6f4`) -- _(no changeset)_ refactor(tsconfig): rename tsconfig.base.json to what it is (objectui#9330) (#9426) (objectui `6c7319753`) -- _(no changeset)_ docs(api): stop teaching quickAdd and allowCollapse on the object-kanban table (#9353) (objectui `dab9f96ec`) -- _(no changeset)_ docs(tooling): reserve --rewrite-governed-file by its condition, not by actor (#9383) (objectui `07da32e28`) -- _(no changeset)_ fix(skills): guard the DataSource read in the marked data-integration example (#9352) (objectui `28be0786d`) -- _(no changeset)_ hooks: the three remaining guards name the environment their hatch variable must be set in, never a command prefix (#9300) (objectui `a5921a0f8`) -- _(no changeset)_ fix(ci): put the `scripts/__tests__` markdown population on the shard trigger (#9141) (objectui `4c0dc090a`) -- _(no changeset)_ docs(skills): teach object-nav target exclusivity, not a precedence the spec refuses (#9227) (objectui `bedd7344f`) -- _(no changeset)_ docs(agents): require a runtime reading for inertness claims, and a control for any population-size reading (#9226) (objectui `aebc3a31f`) -- _(no changeset)_ fix(ci): read a locale-catalogue chunk whose content hash contains a hyphen (#9228) (objectui `2102f6125`) -- _(no changeset)_ feat(ci): refuse a merge group whose queued pull request still carries `needs:contract-review` (#9212) (objectui `a94e4d073`) -- _(no changeset)_ docs(scripts): record what a re-baseline absorbs, and that BASELINE.commit cannot be checked from `main` (objectui#7848) (#9208) (objectui `75d34d604`) -- _(no changeset)_ devx(scripts): register check-bash32-floor.mjs in the upstream port pin at its own ref (#9207) (objectui `0f7f8e61c`) -- _(no changeset)_ fix(gate): the expression-carriage blind-spot leg reads the JS object-literal dialect (#9193) (objectui `7696daac0`) -- _(no changeset)_ test(scripts): census why the push-lane coverage gate was red, by cause (#9180) (objectui `87f174c00`) -- _(no changeset)_ fix(gate): make an unrecognised half status LOUD in the eager-closure fold (#9156) (objectui `af674b99a`) -- _(no changeset)_ test(scripts): derive the zero-test workspace members and pin the exclusion so it can expire (objectui#9106) (#9147) (objectui `049f09504`) -- _(no changeset)_ fix(prompts): rule each key-teaching section, and widen check:prompt-keys to read them (#9143) (objectui `44a9b4bd2`) -- _(no changeset)_ docs(changeset): correct six pending changesets whose claims a later merge falsified (#9139) (objectui `36fc71f1c`) -- _(no changeset)_ feat(scripts): derive what each pack-object importer reads off the pack, and how deep (objectui#9046) (#9128) (objectui `c736084bf`) -- _(no changeset)_ fix(docs): stop pricing the eager-closure ruling with a page count nothing derives (#9118) (objectui `567f37019`) -- _(no changeset)_ test(devx): give layout, test-support and console-starter a package-level test entry (#9105) (objectui `58a4fada7`) -- _(no changeset)_ fix(prompts): teach only view keys a real renderer answers, and gate it (#9099) (objectui `d2f0c108c`) -- _(no changeset)_ ci(test): run the shards when a markdown document a test READS changes (#9097) (objectui `a92eef266`) -- _(no changeset)_ fix(scripts): check-side-effects-array walks every published entry point, not just the source barrel (#9084) (objectui `7f3a7ea69`) -- _(no changeset)_ test(ci-docs): pin the Workflow Inventory table, which the inventory test could not see (#9062) (objectui `4ffc333df`) -- _(no changeset)_ docs(changeset): correct two now-false sentences in pending types changesets (#9064) (objectui `a7a818383`) -- _(no changeset)_ fix(scripts): derive the dead-keys pack-object importer population, pin its readings (#9047) (objectui `13372e19d`) -- _(no changeset)_ docs(changeset): drop the stale cardinal from the objectui#8315 changeset (#9023) (objectui `a650bb356`) -- _(no changeset)_ fix(ci): trigger Build Docs on what the site build actually consumes (#9015) (objectui `1e433418b`) -- _(no changeset)_ fix(ci): make pre-install-import-graph.yml point at its population instead of counting it (#8995) (objectui `e8b7b0785`) -- _(no changeset)_ docs(changeset): correct two present-tense claims a later PR falsified (#8994) (objectui `35c6a3453`) -- _(no changeset)_ fix(lint): drop git-ignored build output from ESLint's own walk (#8986) (objectui `403d9efde`) -- _(no changeset)_ docs(setup): point setup.sh's third "Next steps" read at a doc that exists (#8982) (objectui `6112e0dad`) -- _(no changeset)_ test(scripts): census the `$`-dialect lowercase aliases before objectui#8568 is ruled (#8977) (objectui `ca67d42f0`) -- …and 179 more commits with no changeset — this list is capped at 100, the range has 279 in total. Run `node scripts/objectui-range.mjs --from 53ded82bf7a4 --to 87af769e9a3e --all` for the complete list. - - - -objectui range: `53ded82bf7a4...87af769e9a3e` diff --git a/.changeset/console-dd3f7e1be356.md b/.changeset/console-dd3f7e1be356.md deleted file mode 100644 index e8320fde465..00000000000 --- a/.changeset/console-dd3f7e1be356.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `dd3f7e1be356`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 325 releasing of 348 changesets added across 328 non-merge commits; omitted: 23 release-nothing changesets, 25 commits carrying no changeset (they ship no package code). - -- **minor** — Show the environment admin a storage-capacity banner from the tenant runtime's own storage verdict (objectui#10439, the objectui half of cloud#2135). (objectui `4357a278b`) -- **minor** — **BREAKING** — The metadata-admin `SchemaForm` no longer crashes on a form section that references a field group, and the form-section authoring type accepts that shape (objectui#8725). (objectui `8522396c0`) -- **minor** — fix(auth, console): a registration started from an invitation link comes back to the invitation after email verification (objectui `7ea8118f7`) -- **minor** — **BREAKING** — A grouped grid over a data source that declares no `queryGroupHeaders` now **refuses grouping** instead of grouping a page of rows (objectui#10881, maintainer ruling F). Grouping… (objectui `244d516df`) -- **minor** — **BREAKING (shipped as `minor` — see below):** twelve node types now refuse both content channels by name — `object-grid`, `object-form`, `object-kanban`, `object-map`, `object-tr… (objectui `775e0795b`) -- **minor** — `safeValidateSchema` — and so `objectui validate` — accepts twenty of the ADR-0080 public blocks, the ones whose props `@objectstack/spec` declares as a `ComponentPropsMap` row le… (objectui `6a7f24e92`) -- **minor** — **BREAKING** — fix(types,plugin-designer)!: the Studio app wizard saves a document the platform accepts, and an edit keeps the stored `accentColor` (objectui#10867) (objectui `73433769a`) -- **minor** — **BREAKING (authoring)** — the three `@object-ui/plugin-ai` node declarations stop offering members that no runtime honours, and type the handler slots their components call (obje… (objectui `c30c8dd4c`) -- **minor** — **BREAKING (authoring surface and render behaviour, shipped as `minor` per this repo's version policy): an item-level `body` on a `list` item or a `tabs` item is refused at the do… (objectui `412818870`) -- **minor** — **BREAKING** — Grid grouping is now **server-side** (objectui#7189, maintainer ruling A): the set of groups and every number in a group header — the count and any per-group aggregation — come fr… (objectui `e2b38267d`) -- **minor** — `safeValidateSchema` — and so `objectui validate` — accepts the three `@object-ui/plugin-ai` node types, `ai-form-assist`, `ai-recommendations` and `nl-query` (objectui#10859, bat… (objectui `c6678b1bd`) -- **minor** — **BREAKING** — **`ObjectViewSchema.listViews` is the protocol's named-view record, by reference** (objectui#7928: maintainer ruling A, then the director ruling on `options`). (objectui `b93e245f9`) -- **minor** — `ObjectChartSchema` (and its TS twin) accepts the `object-chart` node that the react tier's `` block produces: the spec's `{ name }` series arm, with the chart family… (objectui `b956e693d`) -- **minor** — **BREAKING** — `NamedListView.densityMode` is retired on the TypeScript authoring face (objectui#7924, director-seat ruling **A′**). It is now a `?: never` tombstone: a TypeScript… (objectui `66e8b2ab3`) -- **minor** — **BREAKING** — The Studio's app wizard saves an app the platform accepts, and the app favicon has one spelling, `branding.favicon` (objectui#10842). (objectui `25cb364b2`) -- **minor** — **BREAKING** — fix(types)!: a `filter-builder` `value` is a filter group only, and `defaultValue` is retired (objectui#10825) (objectui `4aebea00a`) -- **minor** — **BREAKING** — Retire `events`, `orientation` and `position` on the timeline node (objectui#6170, ADR-0049 stage 2 — maintainer ruling 2026-08-25: these three go the enforce-or-remove route; wit… (objectui `195052fff`) -- **minor** — An app's `branding.logo` now shows in the console, and it is the only logo spelling (objectui#10827). (objectui `f97677471`) -- **minor** — Retire the published alias `PluginComponentInput` — use `ComponentInput` (objectui#5674). (objectui `fda49e557`) -- **minor** — **BREAKING** — feat(types)!: a timeline item is a declared feed item or gantt row, judged against the shape `variant` selects (objectui `3a5817f07`) -- **minor** — `MaskedCellRenderer`, the mask a `password` / `secret` cell draws, is now exported, so a table that cannot type a column yet draws it WITHHELD instead of as text (objectui#10657,… (objectui `12809a595`) -- **minor** — `object-grid` no longer draws an untyped column as text before its object schema has loaded: the column is WITHHELD until it has, and stays withheld when the read fails; a host ca… (objectui `12809a595`) -- **minor** — feat(components)!: `FilterBuilderCondition` and `FilterGroup` derive from `@object-ui/types` — `operator` narrows to `FilterBuilderOperator`, the group `id` becomes optional (obje… (objectui `fb91ac9b0`) -- **minor** — feat(types)!: a `filter-builder` group is flat — a nested sub-group, `allowGroups` and `maxDepth` are retired and refused by name (objectui#9306) (objectui `fb91ac9b0`) -- **minor** — **BREAKING** — fix(fields): an avatar pick uploads through the `UploadProvider` and submits its `sys_file` id, or is refused by name — never stored as a `data:` URL (objectui `ff94a12d2`) -- **minor** — **BREAKING** — feat(plugin-chatbot)!: `useObjectChat` drops the never-honoured `maxToolRoundtrips` option (objectui#5605) (objectui `95bad1236`) -- **minor** — **BREAKING** — feat(types)!: `maxToolRoundtrips` is retired behind a tombstone on all three chat nodes (objectui#5605) (objectui `95bad1236`) -- **minor** — `@object-ui/core` exports `withoutDeniedFields(record, policy, objectName, extraKeep?)`, the field-read rule for one record: the record as the viewer may read it on `objectName` (… (objectui `9a5f99880`) -- **minor** — **BREAKING** — feat(components): `div` is deprecated on the html tier too — a `kind:'html'` page that authors `
` is refused at compile time, and the error names `box` (objectui `39b8d5102`) -- **minor** — **BREAKING** — **A named view's stray `kanban.groupBy` on an `object-view` document is now refused by name, with the message the `list-view` route already gives.** (objectui `a14fb23b3`) -- **minor** — feat(components): `Calendar` takes a `localeTag`, a BCP-47 tag it reads in place of the display locale (objectui#10747) (objectui `13220af93`) -- **minor** — fix(components): an action container's members evaluate `properties.params` the way a top-level `action:button` does (objectui `6cf599985`) -- **minor** — **BREAKING** — fix(fields): a file or image upload that surfaces no `sys_file` id is refused by name, never submitted as an inline blob (objectui `c907a9c87`) -- **minor** — feat(core,components): the html tier registers and declares `code` — `inline` on a `kind:'html'` page renders its text instead of the `field:code` editor (objectui `29b45f6a0`) -- **minor** — feat(types)!: `FilterUISchema.filters[].operator` is retired on the `filter-ui` node and refused by name (objectui `17cc3a377`) -- **minor** — ⚠️ **BREAKING (scored `minor` per this repository's version-alignment convention) — `@object-ui/types` no longer exports the eight "Phase 3.5" validation types (objectui#10719).** (objectui `97b6c2199`) -- **minor** — feat(app-shell): a package-provided permission set offers "Clone to customize" as its primary action, and names it first (objectui#5987) (objectui `35eb2f0ee`) -- **minor** — feat(types,runner)!: the app node's `actions` array, `AppAction` and `AppActionSchema` are retired; app-level actions are `navigation` items of `type: 'action'` (objectui `25c7d584e`) -- **minor** — feat(core,sdui-parser): the published manifest declares the html tier's registered intrinsic elements, marked `tier: 'html'` — `div` stays out (objectui `baac95a26`) -- **minor** — `DashboardComponentSchema.header` and `.globalFilters` now state `@objectstack/spec`'s `DashboardSchema` member on both faces, the TypeScript interface and the Zod validator (obje… (objectui `1422a920e`) -- **minor** — **BREAKING** — three more record ids that the objectui#9511 ruling's enumeration left out are strings now (objectui#10078). `CommentSearchResult.recordId`, `RecordSubscription.rec… (objectui `7b395d8c5`) -- **minor** — feat(types)!: `ObjectChartSchema.xAxisField` / `yAxisFields` / `aggregation` are retired on the `object-chart` node, each refused by name with the spec spelling as its remedy (objectui `932739704`) -- **minor** — **BREAKING** — `object-data-table` refuses `drillDown.filter`, `.maxRows`, `.report` and `target: 'navigate'`, and `object-pivot` refuses `drillDown.mode` (objectui#10685) (objectui `5ad3b8886`) -- **minor** — **BREAKING (scored `minor` per this repo's version-alignment convention)** — `ColumnWidthConfig` and its Zod mirror `ColumnWidthConfigSchema` are deleted (objectui#10582, ADR-0049… (objectui `c3a26ccda`) -- **minor** — Every data node that sends its own authored `filter` into a query now resolves the spec's context tokens first (objectui#10666). With `filter: [['owner', '=', '{current_user_id}']… (objectui `f9c06ef6a`) -- **minor** — Once the grid has its object schema, a masked grid field's raw value is withheld from copy, tooltip, inline edit, the client export and the mobile card, and a masked field is refu… (objectui `a66e58ea9`) -- **minor** — ⚠️ **BREAKING — `@object-ui/core` no longer exports `ValidationEngine`, `defaultValidationEngine`, `validate` or `validateFields` (objectui#7659).** (objectui `aef97e504`) -- **minor** — `@object-ui/i18n` now publishes `TranslateFn` — i18next's `t` narrowed to `(key: string, options?: Record) => string` — as the one authority for that name (object… (objectui `a695f505f`) -- **minor** — **`@object-ui/types/zod` now accepts a `combobox` without `options` and a `command` without `groups`** (objectui#6033, rulings C7 / C8) (objectui `1e946c96a`) -- **minor** — feat(plugin-chatbot): `ChatbotEnhanced` takes an optional `surfaceContextTitle`, the tooltip of the surface-context chip (objectui `69a6fc1ce`) -- **minor** — **BREAKING** — BREAKING (`@object-ui/components`): `RefreshIndicator`'s `ariaLabel` prop is now required and has no default. It used to default to the English literal "Refreshing", so the progre… (objectui `9fbbb17a2`) -- **minor** — `ObjectChartSchema` (and its TS twin) declares `xAxis` and `yAxis` as `@objectstack/spec`'s axis config: `xAxis` is ONE `ChartAxisSchema` object and `yAxis` a list of them. The Zo… (objectui `fb13e8583`) -- **minor** — **Breaking:** `AppSidebar` is removed from `@object-ui/app-shell` (objectui#5817). The package entry, `dist/index.d.ts` included, no longer exports it. The bump is `minor` only be… (objectui `8e5fba7a0`) -- **minor** — fix(fields)!: the record picker's display column labels a record the way the lookup dropdown does, and `RecordPickerDialog`'s `titleFormat` prop is replaced by `objectSchema` (objectui `8c0e5508b`) -- **minor** — **BREAKING — removes a published export from two packages.** Retire the `PermissionGuardConfig` type (objectui#8024, ADR-0049 enforce-or-remove). The name is deleted from `@object… (objectui `8cd8eb562`) -- **minor** — feat(fields): `isMaskedFieldType()` and `MASKED_FIELD_TYPES` answer "is this field type's cell drawn as a mask?" (objectui `ab7751321`) -- **minor** — **BREAKING** — Framework navigation can now reach the console pages that the retired System Hub card wall used to be the only link to (objectui#10520). (objectui `50e41f738`) -- **minor** — **BREAKING** — `ObjectChartBlock`, the registry shell exported beside `ObjectChart`, declares its props instead of taking `(props: any)` (objectui#8885). (objectui `47ba7903d`) -- **minor** — **BREAKING** — fix(types): a `filter-builder` condition's `value` is judged against its `operator` by the protocol's own rule (objectui `d22b37bd8`) -- **minor** — `ChartRendererProps.schema.series`: the `dataKey` arm now declares `type?: string`, the per-series family override the `name` arm already declares, with the same member type (obje… (objectui `aa6be300b`) -- **minor** — An authored `detail-section` node now takes `hideEmpty`, and it reaches the section. (objectui `9cbe4dbca`) -- **minor** — **BREAKING (scored `minor` per this repo's version-alignment convention)** — `useColumnWidths` is removed from `@object-ui/plugin-kanban`, together with its `UseColumnWidthsOption… (objectui `1434bb434`) -- **minor** — **BREAKING (node type key):** `@object-ui/plugin-map` no longer registers the bare `map` node type key, and its namespaced twin `view:map` goes with it. A node authored `"type": "… (objectui `64563a915`) -- **minor** — **BREAKING** — The `FilterBuilder` dropdown speaks the protocol's operator ids (objectui#9306). (objectui `641fb55b0`) -- **minor** — **BREAKING** — Retire `object-grid`'s `defaultSort` and `object-view`'s `table.defaultSort` (objectui#5861 — ADR-0049 enforce-or-remove, the "C half" of the 2026-08-22 ruling on objectui#4869). (objectui `0cba1b73f`) -- **minor** — `ObjectGantt` refreshes in place when its query really changes, instead of tearing the chart down to the loading placeholder (objectui#7237). (objectui `db3896c4c`) -- **minor** — Retire the widget `dataProvider` key. It was declared and written, but nothing read it (objectui#7353, ADR-0049 remove arm). (objectui `2ceb43a7d`) -- **minor** — feat(app-shell): the Object Field inspector authors `valueDomain` on text fields (objectui#7597) (objectui `51d18f161`) -- **minor** — feat(app-shell): an assignment value can be written as a CEL expression in the flow designer (objectui `82bdd3ac9`) -- **minor** — `ChartSchema` (and its TS twin) declares `xAxis` and `yAxis` as `@objectstack/spec`'s axis config object — `ChartAxisSchema`, referenced by the Zod mirror and typed by the spec's… (objectui `a137d0cc8`) -- **minor** — An authored `detail-section` node now takes `icon`, and draws it. (objectui `80a0ecda9`) -- **minor** — fix(react,app-shell): `usePageAssignment` picks a record page by `type` alone — `pageType` is a key `PageSchema` refuses (objectui `39395455a`) -- **minor** — A `chart` list view bound to a semantic `dataset` takes its scope from the dataset: a view filter on it is now refused at authoring, and its toolbar no longer offers filter contro… (objectui `ae98f1d44`) -- **minor** — **BREAKING: `useClientNotifications` is removed from `@object-ui/react`** (objectui `71a4a5335`) -- **minor** — fix(plugin-form): `customFields` members render inside explicit `sections` on the drawer, modal, tabbed, wizard and split arms (objectui `d5cb2619f`) -- **minor** — **BREAKING:** `@object-ui/core` no longer exports `DataScopeManager` or the row-level filter vocabulary that came with it (objectui#7750). This narrows the package's published sur… (objectui `93fea2ef9`) -- **minor** — feat(types): `FilterOperatorSchema` is the protocol's operator set, and normalises aliases on parse (objectui `1779e8dee`) -- **minor** — fix(fields,components): a single select or radio emptied by a cascade clear is now saved — as `null` (objectui `274e14af4`) -- **minor** — A currency field in `dynamic` mode shows the tenant's currency, not its `currencyConfig.defaultCurrency` (objectui#10422). (objectui `ea9d17fb6`) -- **minor** — **BREAKING** — feat(components)!: an action node's static values ride `properties.params`; `params` is only the input list (objectui `808f33986`) -- **minor** — An incremental edit is no longer read as a whole-app build. An `apply_edit` result says `kind: 'edit'` on its envelope, and its `drafted[]` may list the `app` artifact the edit re… (objectui `e6203d756`) -- **minor** — The form routes `/f/:slug` and `/forms/:name` render every field with the widget the shared field resolver names for it — the same widget the record form renders (objectui#10179,… (objectui `212c45175`) -- **minor** — fix(dashboard,charts): two dashboard surfaces the spec types as translatable now resolve (objectui `061f5e829`) -- **minor** — **BREAKING:** `@object-ui/plugin-detail` no longer exports seven components that nothing registered and nothing mounted (objectui#7192, objectui#7175). This narrows the package's… (objectui `0348bc9f1`) -- **minor** — fix(plugin-list): a map list view requests the fields its markers are drawn from (objectui `974760adb`) -- **minor** — The permission matrix no longer locks a tenant's own permission set as if a code package shipped it (objectui#4526). (objectui `d32824aba`) -- **minor** — **BREAKING** — The non-grid row ceiling's mechanism moves to `@object-ui/core`, its probe row lives in one query helper, and its footnote takes the result and nothing else (objectui#7508, mainta… (objectui `1237ae45a`) -- **minor** — **BREAKING** — fix(types): the spec-derived `ListViewSchema` and `PageNodeSchema` refuse what the spec's publish door refuses — they now run the spec's own object-level checks (objectui#7715) (objectui `309c75ed4`) -- **minor** — The grid's row Delete and bulk Delete now delete the record when `ObjectView` renders the grid itself — the registered `object-view` renderer, with no host list view (objectui#103… (objectui `ff14e29b5`) -- **minor** — New export `recordDelete` — the one record-delete core that list hosts bind to, so a Delete behaves the same wherever it is offered (objectui#10383). (objectui `ff14e29b5`) -- **minor** — fix(core): Undo of an `undoable` update no longer writes `null` over a field the row did not carry (objectui `8acc51b90`) -- **minor** — Retire the undeclared timeline `metaFields` reads in both packages (objectui#10222). (objectui `57a2bc281`) -- **minor** — The line-item grid (`GridField` / `LineItemsField`) no longer gives a currency cell a default of two decimal places or a default `¥` symbol. A currency column without a `scale` no… (objectui `2fc2a2439`) -- **minor** — The Studio flow Runs panel now groups and labels parallel-branch steps by the branch they ran in, and names the loop row as well when the parallel node sits inside a loop (objectu… (objectui `378a4f6ca`) -- **minor** — fix(plugin-dashboard): a metric tile counting rows over a currency field shows a plain number, not money (objectui `6dc82a381`) -- **minor** — Translations that `I18nProvider` loads after mount now reach the readers already on screen (objectui#10382). (objectui `5e67837e8`) -- **minor** — One declaration of which chart families ignore `compareTo` (objectui#7495). (objectui `721d1e008`) -- **minor** — **BREAKING** — fix(types): retire the eight `header-bar` keys the renderer never read (objectui#10387) (objectui `9b281519f`) -- **minor** — A `dataSource` binding's own `limit` that the contract refuses is now treated as **not authored**: the row cap falls through to the named saved view's usable cap, and only when th… (objectui `97abedc98`) -- **minor** — The four declared action renderers (`action:button`, `action:icon`, `action:group`, `action:menu`) now pass an action's `objectName` to the action runner, and `action:button`, `ac… (objectui `778138e20`) -- …and 225 more releasing changesets in this range (list capped at 100; see the objectui range below). - -⚠️ 41 of these carry a breaking change: 41 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 25 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. - -- _(no changeset)_ fix(console): an injected @objectstack/client joins the vendor-objectstack chunk group (objectui#10920) (#10921) (objectui `dd3f7e1be`) -- _(no changeset)_ docs(changeset): date-note six pending entries that PRs #10793, #10802 and #10821 made false (objectui#10877) (#10891) (objectui `733fd5ac6`) -- _(no changeset)_ docs(skills): the plugin guide's tombstone comment lists the serializer key list with `of` (objectui#10337) (#10780) (objectui `4f38f3928`) -- _(no changeset)_ docs(changeset): date-note two pending entries whose option-description and rows refusals spec 17.3.0 lifted (objectui#10801) (#10828) (objectui `610819c40`) -- _(no changeset)_ ci(dependabot-gate): wait for Spec Main Shape Gate now that it is enrolled (objectui#9969) (#10796) (objectui `4785523b0`) -- _(no changeset)_ docs: a lookup option’s `description` is declared by the spec; filter-ui lists `multi-select` (objectui#10769, objectui#10779) (#10794) (objectui `a599517cd`) -- _(no changeset)_ test(scripts): cite the bare-ValidationRule negatives by file, not by line; mark the two retired names synthetic (objectui#10781) (#10791) (objectui `dac022c4e`) -- _(no changeset)_ docs: cite the landing commits where ten objectui issue links answer 404 (objectui#10755) (#10766) (objectui `b5b6f3753`) -- _(no changeset)_ docs(skills): the grid 2xl paragraph names objectui's own BreakpointColumnMap and reads all six keys (#10708) (objectui `f308a655b`) -- _(no changeset)_ docs(skills): the CLI table says what `objectui check` does and points to `objectui validate` for the verdict (objectui#10526) (#10574) (objectui `a2f361b8c`) -- _(no changeset)_ docs(changesets): say what `objectui check` does with a bare document in three pending changesets, and name `objectui validate` for the verdict (objectui#10606) (#10642) (objectui `56b7fb4d5`) -- _(no changeset)_ docs(schema-reference): ObjectGridSchema.sort row reads SortConfig[] only (objectui#10581) (#10621) (objectui `5add18a67`) -- _(no changeset)_ docs(components): annotate refused runtime-slot handler rows (objectui#8235) (#10515) (objectui `0d5d66aee`) -- _(no changeset)_ docs(schema-reference): the kanban mirror sentence says what @object-ui/types declares today (#10532) (objectui `6881e9e47`) -- _(no changeset)_ docs(cli): say what `objectui check` does; point to `objectui validate` for the verdict (#10523) (objectui `d6884d862`) -- _(no changeset)_ docs(fields): grid column examples use only declared column keys (#10505) (objectui `bc4efba73`) -- _(no changeset)_ fix(console): return packages/core to the framework chunk by grouping rule, and pin chunk membership (#9488) (objectui `8cc0e2984`) -- _(no changeset)_ chore(tooling): retire the objectstack tooling port — PM scripts run from objectstack with PM_SWEEP_REPO; hooks become plain copies (objectui#10208) (#10458) (objectui `b274b6103`) -- _(no changeset)_ docs(agents): add issue and pull-request bodies to the cite-by-content commandment (objectui#10048) (#10440) (objectui `606b5c802`) -- _(no changeset)_ docs(agents): a `-t` name filter is a regex, so a pasted title is a false green (objectui#9731) (#10412) (objectui `9d6eb5a80`) -- _(no changeset)_ fix(schema-catalog): author the five stacked-label flex roots as direction "col" (#10414) (objectui `f475557c4`) -- _(no changeset)_ docs(skills): `isContainer` means layout containment, not "accepts children" (objectui#9910 Q2, governed) (#10268) (objectui `2c9d4d370`) -- _(no changeset)_ docs(changeset): the 8415 body states the row-identity property, not a stale read-site count (#10196) (#10385) (objectui `00cdaff1f`) -- _(no changeset)_ fix(console-starter): namespace a spec translation payload the way the console does (#10381) (objectui `170fcb6df`) -- _(no changeset)_ docs(plugin-charts): state the host-page font precondition for axis tick density (#10374) (objectui `923e1b8ab`) - - - -objectui range: `f8a9d0fb0596...dd3f7e1be356` diff --git a/.changeset/console-f8a9d0fb0596.md b/.changeset/console-f8a9d0fb0596.md deleted file mode 100644 index 4f8bc0550a7..00000000000 --- a/.changeset/console-f8a9d0fb0596.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `f8a9d0fb0596`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 86 releasing of 91 changesets added across 92 non-merge commits; omitted: 5 release-nothing changesets, 3 commits carrying no changeset (they ship no package code). - -- **minor** — `aggregate()` reads the analytics answer in ONE spelling: `rows` on the `AnalyticsResult` that `client.analytics.query` resolves to (objectui#7028). The `{ success, data: { rows }… (objectui `512bc9049`) -- **minor** — **BREAKING** — Retire `carousel` from `AIRecommendationsSchema.layout` and from the `ai-recommendations` designer enum (objectui#10330, ADR-0049 enforce-or-remove). (objectui `f98eddf63`) -- **minor** — **BREAKING** — feat(types)!: `DetailViewFieldSchema.options` is the spec's authoring `SelectOptionSchema` (objectui#10296) (objectui `6096f20b3`) -- **minor** — Honour `filter` and the platform row ceiling on a tree's inline (`provider: 'value'`) data (objectui#9136) — the fourth surface of the objectui#8769 repair, after objectui#9061 po… (objectui `9c08dc6b2`) -- **minor** — feat(core): export `declaredNameField`, the one spelling of the ADR-0079 declared name pointer (objectui#9436) (objectui `ba0b61a60`) -- **minor** — fix(plugin-detail): `DetailView`'s header and the `record:details` H1 dedupe rank the declared `nameField` above `titleFormat`, the ADR-0079 order (objectui `ba0b61a60`) -- **minor** — fix(components): the record page H1 ranks the declared `nameField` above `titleFormat`, the ADR-0079 order (objectui `ba0b61a60`) -- **minor** — The grid summary footer and the dashboard metric tile take a currency amount's decimal places from the currency, never from `scale`, and the field designer no longer offers `Scale… (objectui `0651e7ab4`) -- **minor** — fix(react): a data object in a node's `properties` / `props` bag reaches the renderer whole, even when it carries a `source` field (objectui `2b5f509bf`) -- **minor** — `CalendarSchema.defaultValue` / `.value` cross the JSON/TS boundary once (objectui#10293, objectui#7759 ruling D1-(iii)). (objectui `8c10f4f71`) -- **minor** — fix(core): a dashboard `dateRange` that omits `defaultRange` now takes the spec's declared default preset (objectui#10339). (objectui `86982ace0`) -- **minor** — **BREAKING** — feat(types): `TooltipSchema.content` is text only, on both faces (objectui#10295) (objectui `90dac98fa`) -- **minor** — fix(auth,app-shell,console): a browser that changes hands no longer keeps the previous account's UI language (objectui `b57107d46`) -- **minor** — **BREAKING** — `UIEventHandler` and `EventableSchema` are RETIRED from `@object-ui/types`, and `APISchema` loses its `EventableSchema` arm (objectui#6497, ADR-0049 enforce-or-remo… (objectui `cb55718a9`) -- **minor** — **BREAKING** — `record:related_list`'s top-level `filter` is declared as the protocol's rule array on the authoring face, not as `any` (objectui#10199). (objectui `e3ea4f97b`) -- **minor** — On `tree` and `chart` list views, the toolbar's Filter control and the `UserFilters` chips now narrow the view, and on a `gantt` list view the toolbar's Search box now narrows the… (objectui `af243c1fd`) -- **minor** — Refuse the four remaining function-valued mirror keys by name (objectui#7759 group E, the objectui#6124 shape). (objectui `d05fe17f6`) -- **minor** — `ObjectView` opens a Cmd/Ctrl/middle-clicked row in a new browser tab (objectui#9806). (objectui `687353f4e`) -- **minor** — `CurrencyField` takes its fraction digits from the currency, never from the field-level `precision` (objectui#10276). (objectui `31938f01d`) -- **minor** — `object-form`: one rule for section divider rows on the default, modal and drawer layouts, and there a section's own settings apply whether or not it has a heading (objectui#9849… (objectui `8813335bd`) -- **minor** — `CommandItem` and `CommandGroup` are now named exports of `@object-ui/types` itself, not only of `@object-ui/types/form` (objectui#9526). They are the element types of `CommandSch… (objectui `3be720ef8`) -- **minor** — **BREAKING** — BREAKING (`@object-ui/types`, `@object-ui/plugin-chatbot`): the authoring `ChatToolInvocation.state` union sheds the AI SDK's three runtime-only approval states — `approval-reques… (objectui `b46c58f34`) -- **minor** — **BREAKING** — `RecordRelatedListRenderer`'s props type refuses a misspelled key again (objectui#9963). (objectui `905913c0e`) -- **minor** — Fix: a `dependsOn` field is no longer permanently gated when it is edited inline on a record's detail page. (objectui `a33803796`) -- **minor** — fix(core): the shared date path refuses a calendar day that does not exist, with the marker it already renders for an unparsable value (objectui `ad694ac3d`) -- **minor** — fix(plugin-kanban): a kanban lane matches records by its `id` only, never by its `title` (objectui `7a564e004`) -- **minor** — feat(plugin-detail): row caps on `record:activity`, `record:history`, `record:chatter` and `record:discussion` admit only a positive integer number, and a refused one warns (objectui `879ecac78`) -- **minor** — feat(types): `slider` and `tooltip` single-or-list keys follow their read sites (objectui#10280, objectui#7759 group B) (objectui `f3f4e4c9a`) -- **minor** — **BREAKING** — `NamedListView` (one entry of `ObjectViewSchema.listViews`) retires sixteen members on its TypeScript authoring face (objectui#7924). Each is now a `?: never` tombs… (objectui `aa083cd69`) -- **minor** — feat(types): `AppComponentSchema`, `DashboardComponentSchema` and `PageNodeSchema` take the spec by reference, like their zod mirrors (objectui `1bbaa163a`) -- **minor** — **BREAKING: the unimplemented async export-job path is removed from `@object-ui/types` and `@object-ui/components`** (objectui `8b1f06619`) -- **minor** — fix(sdui-parser,components,layout,types): containment is the declared `children` slot, not `isContainer` (objectui#9910) (objectui `5ea623eab`) -- **minor** — feat(react): an action's `params` values are templates, evaluated where `properties` are (objectui `95bf1287a`) -- **minor** — An action param that declares the spec's `carryOver` is shown read-only and submitted verbatim (objectui#6246) (objectui `06b82b8c3`) -- **minor** — A date-only value now renders the calendar day it names, west of UTC, at four more places (objectui#10183). (objectui `4ab4f1ba2`) -- **minor** — `ObjectTreeSchema.filter` is declared on both faces, in the shape objectui#9309 settled for `ObjectGallerySchema.filter`: `QueryParams['$filter']` by indexed access on the TS inte… (objectui `d16d0e977`) -- **minor** — `object-grid`'s `rowActions` now NARROWS the row kebab's generic Edit / Delete inside the `operations` ceiling — the second half of the ruling whose first half ("`operations` is t… (objectui `185079bdf`) -- **minor** — Dates and numbers across the console and the plugins format in the session's display locale instead of the machine's (objectui#9909). (objectui `a78cd378c`) -- **patch** — fix(fields): a lookup's candidate queries expand the reference columns they display (objectui `65f1e8dc6`) -- **patch** — fix(plugin-list): a list view whose every authored column is denied by field-level security still sends a `$select` (objectui `fb7f38bdf`) -- **patch** — fix(plugin-detail): feed diagnostics name the block that carries the bad value (objectui `462bafb9e`) -- **patch** — docs(types): the `WidgetInput` divergence docblock lists the serializer's key list with `of` (objectui#10337) (objectui `a5b08c9ce`) -- **patch** — fix(console): `FormPage`'s required `*` no longer lands in the control's accessible name (objectui `069ce12b4`) -- **patch** — fix(charts): a cartesian chart that declares no series at all now says so instead of drawing an empty frame (objectui#4695) (objectui `f82f85756`) -- **patch** — fix(plugin-list): gallery cards now pass a field's declared `scale` to the shared cell renderer, so a number or percent field declaring `scale` renders padded (`25.00%`) exactly a… (objectui `a883ec21a`) -- **patch** — Gantt: a row that is not an object is now refused instead of drawn as an empty, unlabelled row (objectui#7364). `items: [0]`, `items: ['x']`, `items: [true]` and `items: [[]]` use… (objectui `0b6b295a1`) -- **patch** — fix(plugin-form,plugin-list,plugin-view,react): read `SchemaRendererContext` as declared, not through a cast to `any` (objectui#7209) (objectui `3ed3eec08`) -- **patch** — fix(i18n): a translation bundle with no field label is recognised as a spec payload (objectui#10235) (objectui `dc666f70d`) -- **patch** — An `action:icon` now runs an action it receives with `autoTrigger` set, the same way `action:button` and `action:menu` do (objectui#10274). (objectui `cff4b7754`) -- **patch** — Two display-locale faces now follow the session's display locale (objectui#10232). (objectui `bb5d4eea7`) -- **patch** — fix(app-shell): the metadata form's machine-name chip is judged on the field's untranslated source label, so it shows alike in every locale (objectui#8231) (objectui `78b572f60`) -- **patch** — fix(fields): a failed image upload is reported in `ImageField`, not swallowed (objectui `1f8ef0a89`) -- **patch** — fix(plugin-charts): the legend swatch carries its series colour as a custom property (objectui `a9f34df28`) -- **patch** — Saving a view's config no longer turns the view read-only (objectui#10210). (objectui `baf98cde0`) -- **patch** — fix(plugin-view): a read-only view's menus no longer open empty or on a leading separator (objectui `b07de29d1`) -- **patch** — The two declared display-locale contracts now each name the caller they govern, and each points at the other (objectui#10098). This is documentation only: no module's behaviour mo… (objectui `8cedb0dba`) -- **patch** — **The stray-`groupBy` kanban refusal no longer tells an author their view "never came through the validated path".** (objectui `89bb77a11`) -- **patch** — The package dialog judges a version with the installed `@objectstack/spec`'s own `ManifestSchema` version field instead of a hand-copied regex, so it accepts exactly what the spec… (objectui `c84221daa`) -- **patch** — fix(app-shell): the Studio dataset-filter inspector stores a `between` range as the spec's `$between`, with both bounds required (objectui#10062) (objectui `856bf0f74`) -- **patch** — A flow launched from an action that ends with `outcome: 'refused'` without ever pausing at a screen now shows its refusal instead of reporting success (objectui#9973). (objectui `7616d8935`) -- **patch** — fix(plugin-dashboard): a field's `format` is read as a date pattern only on a `date` / `datetime` field (objectui `e2bd3e400`) -- **patch** — fix(plugin-designer): the Navigation Designer has an entry for the spec's `doc` navigation item type (objectui `1dbb9933c`) -- **patch** — A master-detail form's child grid no longer offers a cell the CALLER may read but not edit (objectui#10163). (objectui `6099dd870`) -- **patch** — **Behaviour change:** an action whose own declared `visible` gate hides it is no longer run by `autoTrigger`, and the refusal is reported instead of swallowed (objectui#4191). (objectui `978507b9a`) -- **patch** — fix(plugin-detail): a related list's row fetch drops a column the principal cannot read once the permission answer has loaded (objectui `5f44cc6f4`) -- **patch** — The published `ViewNavigationConfig` docblock no longer teaches the retired `navigation.view` key (objectui#9938). Its example of `mode` being optional on the authoring side used… (objectui `32bf2d6f6`) -- **patch** — feat(types): `SliderFieldMetadata` declares `step` (objectui `5f00ff491`) -- **patch** — `object-grid`'s `rowActions` now carries a describe, and the `ObjectGridSchema.operations` / `rowActions` docblocks state how the two keys combine: `operations` is the CEILING ove… (objectui `087981282`) -- **patch** — fix(plugin-form): `DrawerForm` no longer paints an editable form before the record it edits has loaded (objectui `88a4ef616`) -- **patch** — fix(plugin-kanban): `object-kanban` honours the binding's `dataSource.sort` (objectui `c9e073ac8`) -- **patch** — fix(plugin-detail,plugin-grid): the record-grained write verdict is forgotten when its record changes, and the row kebab's memo is per principal (objectui#10184). (objectui `fa5fbd9dc`) -- **patch** — fix(plugin-form): `customFields` merges on the drawer and modal arms too (objectui `142fdfd87`) -- **patch** — On a `gantt` list view, the toolbar's Filter control and the `UserFilters` chips now narrow the chart (objectui#10037). (objectui `0427036f5`) -- **patch** — fix(app-shell): the object page no longer re-issues the identical list query on re-renders that change nothing (objectui `5b6d177b2`) -- **patch** — `ui:menubar` now draws an item's authored `icon`, and walks submenus to any depth (objectui#6326). (objectui `d7de5348a`) -- **patch** — The `dateField` alias refusal that objectui#8355 adds to a calendar binding quotes only the first clause of the calendar refusal screen, "Calendar configuration required": the cla… (objectui `6f96fca95`) -- **patch** — `useRecordSearch` no longer keys its search effect on the identity of the caller-supplied `getDisplayName` option (objectui#10044). (objectui `0aacecc08`) -- **patch** — fix(fields): a declared `scale` above 100 no longer crashes the number cell or a grid's computed column (#10071) (objectui `0361d6bd4`) -- **patch** — `record:activity`'s landmark is named after the heading it shows, not "Discussion" (objectui#9998). (objectui `6358a2d59`) -- **patch** — `object-form`: a drawer section that declares `collapsed: true` can be opened again. The drawer now resolves `collapsed` / `collapsible` the way the default layout does (objectui#… (objectui `58d65c50d`) -- **patch** — fix(app-shell): the screen-flow runner draws the app's translated flow copy (objectui#5920) (objectui `5d895c13c`) -- **patch** — A `record:line_items` grid no longer offers a cell the CALLER may read but not edit (objectui#10163). (objectui `b809375ac`) -- **patch** — fix(mobile): `usePullToRefresh` arms on a host that mounts after the first render, and one pull has one owner (objectui#10105) (objectui `6ce001a35`) -- **patch** — A wizard no longer writes a record without a file whose upload was still running when the user pressed Next (objectui#10180). (objectui `a04b06db5`) -- **patch** — `ElementDataSourceGate` now reports a saved view's refused row cap on the renderer path (objectui#10015). (objectui `7b10befe6`) -- **patch** — Full-page search results now read correctly in Russian and Arabic at every count (objectui#10024). (objectui `a507334d2`) - -⚠️ 9 of these carry a breaking change: 9 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 3 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. - -- _(no changeset)_ docs(changeset): the 9242 changeset says the stray-groupBy refusal covers the list-view route only (#10364) (objectui `f8a9d0fb0`) -- _(no changeset)_ docs(changeset): the manifest serializer forwards seven keys per input, not six (#10318) (objectui `c7ab34836`) -- _(no changeset)_ fix(ci): spec-main shape gate re-points the injected spec's declared dependencies (#10238) (objectui `f4f1f4552`) - - - -objectui range: `62597c588072...f8a9d0fb0596` diff --git a/.changeset/cron-typed-positions-retired.md b/.changeset/cron-typed-positions-retired.md deleted file mode 100644 index 56343130613..00000000000 --- a/.changeset/cron-typed-positions-retired.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: delete the seven cron-typed positions nothing evaluated — export schedules, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule`, backup / DR-test schedules (ADR-0049) - - - -**BREAKING** — seven authorable positions across five schemas are DELETED. Executes the -maintainer ruling of 2026-09-06 (director decision batch #56, 「其他同意」 on the per-family -recommendation: option A — retire — per family) under ADR-0049 enforce-or-remove, by the -route the maintainer ruled on 2026-09-10: **直接删** — a bare deletion, with no -`retiredKey()` tombstone, no ADR-0087 D2 conversion and no D3 semantic entry. - -Seven positions declared a `CronExpressionInputSchema` slot that the parse normalized into -the `{ dialect: 'cron', source }` envelope and that NOTHING evaluated — the ADR-0058 D7 -ledger row `cron-declared-unwired` had every one of them `unevaluated`. - -| family | schema | deleted position | reachable from a stack manifest | -|:--|:--|:--|:--| -| export schedules | `ScheduledExport`, `ScheduleExportRequest` (`api/export.zod.ts`) | `schedule.cronExpression` (both) | no — API contract nothing serves | -| flow schedule state | `ScheduleState` (`automation/execution.zod.ts`) | `cronExpression` (was REQUIRED) | no — runtime state | -| connector sync | `DataSyncConfig` (`integration/connector.zod.ts`) | `schedule` | **yes** — `Connector.syncConfig`, `defineStack({ connectors })` | -| cache warmup | `CacheWarmup` (`system/cache.zod.ts`) | `schedule` | no | -| backup / DR testing | `BackupConfig`, `DisasterRecoveryPlan.testing` (`system/disaster-recovery.zod.ts`) | `schedule` (both) | no | - -**What an upgrading author actually observes.** None of the five schemas is `.strict()`, so -a bare deletion means Zod DROPS the key at the PARSE: an existing document still parses and -still loads, and the value is discarded there without a word. There is nothing for -`objectstack migrate meta` to list and nothing for the ADR-0087 chain to replay — the value -was already inert before this change, and it is inert after. - -The parse is not the only channel, and the two that speak are worth stating exactly, -because a reader who stops at "non-strict schema" will conclude the opposite: - -- **`os validate` / `os build` NAME the dropped key**, for the one deleted position a stack - manifest reaches (`connectors[].syncConfig.schedule`). `os validate` exits 0 and reports - `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its - value is dropped at load.` — in the text face and in `--json`'s `warnings`; `os build` - prints the same line under `Undeclared authoring keys — dropped at load (#3786)`. The - channel is `lintUnknownAuthoringKeys`, which walks every stack collection whose entry - schema is strip-mode, and `connectors` is one. **`os validate --strict` treats that warning - as an error and EXITS 1**, so a pipeline running `--strict` over an otherwise-clean stack - refuses the upgraded manifest until the key is deleted. `os migrate meta` still lists - nothing, in either direction. -- **`tsc`**: a TypeScript author annotating with `Connector`, `ScheduledExport`, - `ScheduleState`, `CacheWarmup`, `BackupConfig` or `DisasterRecoveryPlan` gets an - excess-property error at the key and deletes it. - -The other six positions are not reachable from a stack manifest, so no CLI walk visits them: -for those the parse-level strip really is the whole of it. - -**What stays, byte-identical:** every other key of the five schemas and every export — no def -leaves the public surface. `ScheduledExport.schedule` / `ScheduleExportRequest.schedule` keep -their `timezone` (still defaulting to `UTC`); `ScheduleState` keeps `timezone`, `status` and -`nextRunAt`, and a state without `cronExpression` now parses (the requiredness left with the -key); `CacheWarmup.strategy` keeps its `scheduled` member — a value, not a position the -ruling names, and exactly as inert as before. - -**One published TS MEMBER does leave, and "no def leaves" does not cover it.** The required -`cronExpression: string` member is deleted from `ScheduleExportInput` in -`contracts/export-service.ts` — the input type of `IExportService.scheduleExport`, a -published runtime TS interface (both names are in `api-surface/contracts.json`). It follows -the two spec positions it mirrored: with `ScheduledExport.schedule.cronExpression` gone, an -input demanding the key would ask a provider for a cadence it cannot store. The interface, -the method and every other member stay. Measured blast radius: no source outside -`packages/spec` names `ScheduleExportInput` or `IExportService` — 0 hits in this repo -(positive control: a symbol of the same class resolves outside `packages/spec` in the same -sweep) and 0 in `objectui` (control: 1326 files there import `@objectstack/spec`). An -implementor that *does* exist off-tree drops the member from its object literal; a caller -constructing a `ScheduleExportInput` drops it from the literal it passes. - -**Not in scope, deliberately:** `CronSchedule.expression` (`system/job.zod.ts`, read by -`croner` — the ONE cron slot the platform evaluates), `KnowledgeRefreshPolicy.cron` -(experimental by design), `Object.titleFormat`, and the `PromptTemplate` pair (marked, not -retired, on its sibling card). - -## This change states no before/after rewrite, because there is none - -A breaking changeset in this repo normally states the old spelling beside the new one. -This one has no such pair to state: the same document PARSES before and after, the value -was inert in both, and no conversion can be written for it — so a metadata upgrader has no -edit to make and `os migrate meta` has nothing to list. That is a statement about the -migration chain, not about silence: `os validate` / `os build` do name the dropped -connector key and `os validate --strict` refuses on it (above), and `tsc` names the key and -the line for a TypeScript author. What follows is guidance for authoring a cadence going -forward, not a rewrite of an existing document. - -## What to write instead - -There is no replacement on any of the five schemas: no export scheduler, flow-state -scheduler, connector-sync scheduler, cache-warmup engine, backup engine or DR-test runner -exists to declare a cadence to. The one cron slot the platform evaluates is -`Job.schedule.expression` (`system/job.zod.ts`) — work on a cadence is a `job` whose handler -you write: - -```ts -// A connector that used to carry `syncConfig.schedule: '*/15 * * * *'` declares -// the cadence as a job instead; the handler drives the connector. -defineStack({ - connectors: [{ name: 'sap_erp', label: 'SAP ERP', type: 'saas', syncConfig: { strategy: 'incremental' } }], - jobs: [{ name: 'sap_erp_sync', schedule: { expression: '*/15 * * * *' }, handler: 'syncSapErp' }], -}); -``` - -The retirement kit, in the shape the 2026-09-10 ruling prescribes: - -- the key is DELETED at all seven sites (`api/export.zod.ts` ×2, - `automation/execution.zod.ts`, `integration/connector.zod.ts`, `system/cache.zod.ts`, - `system/disaster-recovery.zod.ts` ×2). Each site keeps a source comment recording what - left, why nothing ever read it, and what does work instead -- **no ADR-0087 registration at all** — no `RETIRED_KEYS_BY_MAJOR[18]` entry, no D2 - conversion, no D3 semantic entry, and nothing added to the protocol-18 chain step. That is - the ruling: 「直接删」, taken over the seat's written recommendation to keep the connector - family's D2, on the reading 「我们的客户也不会按照你的设想的版本按顺序升级」 -- the four baseline rows that existed (`automation/ScheduleState:cronExpression`, - `integration/DataSyncConfig:schedule`, `system/BackupConfig:schedule`, - `system/CacheWarmup:schedule`) are deleted from `authorable-surface/` in this same commit, - each carrying the #4650 proof the build computes for itself: the def is not reachable from - the 26 metadata-type roots. The three nested positions never had a row of their own -- no liveness-ledger row: none of the five schemas is an enrolled ledger type -- the ADR-0058 D7 expression-conformance ledger loses its `cron-declared-unwired` row (every - position it covered is gone, so discovery by roster name no longer sees them); the cron - dialect is now exactly the one evaluated slot plus the one experimental-by-design slot -- pin tests (`cron-typed-positions-retirement.test.ts`): per site, the authored value is - accepted and stripped and the enclosing block still parses, on the base schema and through - every nesting carrier (`Connector.syncConfig`, `stack.connectors[]`, the `/meta/connector` - door, `DisasterRecoveryPlan.backup`, `DistributedCacheConfig.warmup`); the `tsc` channel; - and — with lit and dark controls — that no `RETIRED_KEYS_BY_MAJOR` entry, no D2 conversion - and no D3 semantic entry names any of the seven -- generated baselines and docs follow the schema: the five reference pages are regenerated, - the published `objectstack-formula` skill's `cron` row drops the retired carriers and keeps - `Job.schedule.expression`, and `packages/spec/docs/SYNC_ARCHITECTURE.md` stops teaching - `syncConfig.schedule` -- `json-schema.manifest/` and `api-surface/` are unchanged, and correctly so: the first - ratchets def *names* and the second export *existence*; deleting keys removes neither diff --git a/.changeset/dashboard-chartconfig-liveness-row-re-anchored.md b/.changeset/dashboard-chartconfig-liveness-row-re-anchored.md deleted file mode 100644 index 933d4cdf19d..00000000000 --- a/.changeset/dashboard-chartconfig-liveness-row-re-anchored.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): the `dashboard.widgets[].chartConfig` liveness row is re-anchored to the current objectui pin — 12 of 14 keys reach the renderer, not 9 (#17385) - -`packages/spec/liveness/dashboard.json` ships inside this package, so its rows are part of what an author reads. The `widgets.children.chartConfig` row was measured on 2026-08-09 against `objectui @230ffd875` and both halves of that reading are now superseded — re-measured by hand against this checkout's own `.objectui-sha` pin `53ded82bf7a4`. - -**The citation moved repos-internally.** `chartConfigPresentation` was lifted out of `plugin-dashboard` into `@object-ui/core`'s `chart-presentation` module, so the old pointer at `packages/plugin-dashboard/src/DatasetWidget.tsx:380-429` — still byte-exact at the commit it names — lands on the re-export block at that range in the pinned tree, while the nine `if`s it describes are in another package. A foreign path is counted and never resolved by `check:liveness`, deliberately, so nothing mechanical could have caught this: only a hand re-measurement does. - -**The count changed.** `xAxis` / `yAxis` / `series` were recorded as unforwarded on the grounds that they are derived from the dataset selection. They are forwarded today: the dataset keeps series MEMBERSHIP and the column each binding reads (`ChartSeries.name` and `ChartAxis.field`, dropped on the way through) while every other key on those objects merges onto the derived binding with the explicit binding winning. `type` and `aria` remain the two keys that do not reach this face. - -Evidence text only — no verdict moves, no schema key changes, and the row still carries no per-key `children`. The per-key drill, the `type` / `aria` dispositions and the authored-versus-derived precedence the protocol does not yet state stay open on #17385. diff --git a/.changeset/dashboard-item-level-property-names.md b/.changeset/dashboard-item-level-property-names.md deleted file mode 100644 index adc9e3e3492..00000000000 --- a/.changeset/dashboard-item-level-property-names.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/rest": patch -"@objectstack/platform-objects": patch ---- - -feat(spec): a metadata-form repeater's row properties have a name — `DashboardHeaderAction` fields carry a JSON Schema `title`, and `resolveMetadataFormSchemaTitles` overlays a bundle's `metadataForms..fields..label` onto a derived JSON Schema (#16458) - -## What was wrong - -The Studio property panel renders `dashboard.header.actions[]` as a table whose -column headers read `items.properties[k].title ?? k` from the JSON Schema -derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields -(`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback -arm ran for every locale, English included, and the maker saw machine keys. -Nothing could localise them either: the only channel, `resolveMetadataFormLabels`, -decorates the `FormFieldSpec` tree, which the table never reads. And the platform -catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared -no children under the composite, so `os i18n extract` emitted no -`header.showTitle` / `header.showDescription` / `header.actions` key and the -console shipped a private overlay for exactly those three. - -## What changed - -- **`@objectstack/spec`** — `DashboardHeaderActionSchema`'s four fields author - `.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the - derived JSON Schema names each column. New export - `resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in - `@objectstack/spec/system`: every `metadataForms..fields..label` - at any locale of the chain becomes the `title` of the node the path addresses, - stepping through an array's `items` so a repeater ROW property is addressed - as `.` (`header.actions.label`) — the same path the - extractor emits. Pure; returns the input object itself when nothing applies. - `dashboardForm` enumerates the `header` composite's children - (`showTitle`, `showDescription`, `actions` with its four row properties) with - labels equal to the schema titles, pinned equal in `dashboard.test.ts`. - The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx` - → "Metadata authoring forms". -- **`@objectstack/rest`** — `GET /api/v1/meta` localises each entry's derived - `schema` beside its `form`, through that overlay. -- **`@objectstack/platform-objects`** — the four generated `metadata-forms` - catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`, - `ja-JP` and `es-ES`. - -Additive: no key removed, no accept set changed, no parsed output moved. - -`DashboardSchema.columns` deliberately still declares no `.default(12)`, and -the reason is stronger than the one #16458 assumed. The card reasoned that the -renderer already falls back to 12, which would make `.default(12)` -behaviour-preserving. Measured at objectui `origin/main` -(`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a -`columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4` -yields 12 and everything else yields **4** — and the next line switches the -whole layout on that value (`hasExplicitColumns = schema.columns != null || -inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the -default would therefore both retire the inference and flip every auto-flow -dashboard into the positioned grid. A default that silently materialises a key -is expensive to take back, so the round stopped at the declared condition and -left the key alone; see #16458. diff --git a/.changeset/dashboard-stageorder-doc-names-only-funnel.md b/.changeset/dashboard-stageorder-doc-names-only-funnel.md deleted file mode 100644 index 2bffef75413..00000000000 --- a/.changeset/dashboard-stageorder-doc-names-only-funnel.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): `options.stageOrder` no longer documents a chart type that cannot be built, and says plainly that only `funnel` reads it (#17344) - -`DashboardWidgetOptionsSchema.stageOrder` is an ungated member of the open widget `options` bag, so its one sentence of prose is the whole author-time surface: nothing warns, nothing refuses, and a widget carrying the key renders with the authored order simply absent. That sentence said *"Explicit category order for ordered-sequence charts — `funnel` / `pyramid` stages above all"*, and it was wrong twice over. - -- **`pyramid` is not a widget type.** It was removed from `ChartTypeSchema` as a variant that only ever rendered as `funnel`, and `chart.test.ts` pins that refusal alongside its fallback-only siblings — so the headline example in the option's own documentation could not be authored at all. -- **The plural framing promised more than the renderer delivers.** "ordered-sequence charts" and "stages above all" read as a statement about ordered marks generally. It is not one: `funnel` is the only type whose branch consults the forwarded order, measured against this repo's pinned objectui renderer. - -The corrected JSDoc and `.describe()` name `funnel` only, state outright that no other widget type reads the key, and send the other types to `sortBy` / `sortOrder`, which lower into the dataset query itself. The generated reference page (`content/docs/references/ui/dashboard.mdx`) is regenerated from the new `.describe()`. - -No schema shape changes: `stageOrder` still parses exactly as before, on every widget type. Whether the key should be *gated* to the type that honours it is ADR-0049 enforce-or-remove on an accepted key — a published-surface narrowing, and deliberately not this change; it stays open on #17344 together with the locale-dependent order/colour drop, which lives in the objectui renderer rather than here. diff --git a/.changeset/dashboard-stageorder-gated-to-funnel.md b/.changeset/dashboard-stageorder-gated-to-funnel.md deleted file mode 100644 index 72ba949edb2..00000000000 --- a/.changeset/dashboard-stageorder-gated-to-funnel.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: `dashboard.widgets[].options.stageOrder` is refused on every widget type that does not read it (#17344, finding 1) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface. `options.stageOrder` was an ungated member of the widget `options` bag and parsed on every widget `type`; it is now refused at parse on every type except `funnel`. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying `stageOrder` on a non-`funnel` widget now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `dashboard-widget-stage-order-non-funnel-refused`. - -## What was wrong - -The key never failed. It failed to *order*. - -`options` is the open renderer-extras bag, so nothing closed over `stageOrder`: a `horizontal-bar` widget carrying an authored seven-stage contract lifecycle parsed, booted, and forwarded the array to the renderer — which never looked at it, and rendered alphabetically by display label instead. - -Measured at this repo's `.objectui-sha` pin `53ded82b`: the forwarded `categoryOrder` prop has exactly **one** read in the charts plugin — `buildCategoryRank(categoryOrder)` at `AdvancedChartImpl.tsx:1514` — and it sits inside the `chartType === 'funnel'` guard opened at line 1473. The prop's only other occurrences in that file are its declaration (247) and its destructure (850). The producer has no gate either: `DatasetWidget.tsx:1468` builds the explicit order for **any** widget and forwards it whenever non-empty. - -So the authored order was accepted by the metadata layer, carried all the way to the chart, and dropped there with nothing anywhere to say so. A chart rendered in an order the author did not ask for, and did not ask for it *visibly* — it just looked deliberate. That is ADR-0049's enforce-or-remove shape, and a doc sentence saying "only `funnel` reads this" is not enforcement: it is prose the author has to read first. - -## What it does now - -`DashboardWidgetSchema` carries an object-level check that refuses `stageOrder` unless the widget's `type` is `funnel`. - -It has to be object-level: `stageOrder` lives inside `DashboardWidgetOptionsSchema` while the `type` that decides whether it means anything is that object's **sibling one level up**, so a per-field refinement on `stageOrder` cannot see it. The check is a named function chained on with `.superRefine(…)` — the idiom this file already uses for `GlobalFilterSchema`'s date-default rule, rather than a second shape invented for one key. - -The refusal lands at `options.stageOrder` and names three things, because the defect was silence and a bare "unrecognized key" answers silence with a shrug: the key, the `type` this widget carries, and the one `type` that honours it — plus where ordering lives for everything else. - -## FROM → TO - -| you wrote | write instead | -| --- | --- | -| `{ type: 'horizontal-bar', options: { stageOrder: [...] } }` | `{ type: 'horizontal-bar', options: { sortBy: 'contract_count', sortOrder: 'desc' } }` | -| `{ type: 'funnel', options: { stageOrder: [...] } }` | unchanged — this is the one type that reads it | -| `{ options: { stageOrder: [...] } }` (no `type`) | `{ type: 'funnel', options: { stageOrder: [...] } }` if a funnel was meant | - -⚠️ Deleting the key changes nothing about what renders — the widget was already ignoring it. `sortBy` / `sortOrder` are what change it, and unlike a category order they lower into the dataset query as `order: { : 'asc' | 'desc' }` rather than re-sorting what it returned. - -## What the gate does NOT cover - -Stated so the change is not read as complete: - -- ⚠️ **objectui's client-side authoring door.** This refusal is the **publish** door's, not the editor's. `@object-ui/types` builds its own `DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every object-level check — measured here: `z.strictObject(DashboardWidgetSchema.shape)` accepts a `horizontal-bar` carrying `stageOrder` and reports zero checks, while `.extend({})` keeps the refusal. At the pinned `.objectui-sha` that package re-attaches none of this spec's exported checks, so until it imports and chains `checkDashboardWidgetStageOrder` the dashboard editor keeps accepting the key on a `bar`. That mirror also redeclares `type` as optional with no default, so a typeless widget would reach a re-attached check as `undefined` rather than as `metric`; the exported check defaults it itself for exactly that caller, so re-attaching is sufficient. -- **A widget whose `type` is outside `ChartTypeSchema`.** zod treats that `invalid_value` as aborting and skips object-level checks for the input, so `type: 'ziggurat'` plus a `stageOrder` reports the type refusal alone. The author fixes the type, re-parses, and meets this refusal then; the two are never seen together. Pinned. -- **A widget that declares no `type`.** `type` carries `.default('metric')` and zod applies defaults before object-level checks, so an omitted `type` is indistinguishable here from an authored `metric`. The verdict is right either way — `metric` reads the key no more than `horizontal-bar` does — and that one case carries an extra sentence pointing at the missing `type` rather than a wrong one. -- **The array's contents.** Still unconstrained `string | number | boolean` members, unmatched against the dimension's picklist. A `funnel` carrying a misspelled stage parses and renders that stage in the sentinel position; whether a stored value exists is a fact about the dataset, not about the widget. -- **Consumers that derive this schema with `.omit()` / `.pick()` / `.partial()`.** zod 4 throws on all three once an object carries a refinement, so this change converts those three from working to throwing. Latent rather than live — no consumer in either repo derives the widget schema that way today — and `.extend()` is unaffected. - -## The siblings, measured and deliberately not touched - -`stageOrder` was the only member of that bag with this shape. `dateGranularity`, `sortBy`, `sortOrder` and `limit` are read unconditionally at the top of `DatasetWidget` (lines 443–455, outside every type branch) and lower into the `DatasetSelection` the server compiles, so they act on every widget type. - -## The other arm, deliberately not taken - -The card offered either/or: gate the key, **or** teach the ordered marks (`bar` / `column` / `horizontal-bar` / `line` / `area`) to honour it. The second is a renderer change in `objectstack-ai/objectui` and not this repo's to make. The asymmetry also favours gating: a narrowing that is later relaxed costs an author nothing, while an accepted-and-inert key costs them a chart that silently says something they did not author. diff --git a/.changeset/data-migration-flag-columns-moved-at.md b/.changeset/data-migration-flag-columns-moved-at.md deleted file mode 100644 index 81d599537d9..00000000000 --- a/.changeset/data-migration-flag-columns-moved-at.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/platform-objects": minor ---- - -`DataMigrationFlagSchema` gains `columns_moved_at`, and the `sys_migration` platform object gains the matching column: the deployment-level attestation that a migration's COLUMN MOVE ran here — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. - -**What it attests** is a fact the ledger could not previously express. `applied_at` says the backfill ran in apply mode; `verified_at` says the self-check passed. Neither says anything about the physical columns, because the backfill and the column move are separate acts and only the first of them had somewhere to be recorded. A deployment can therefore have applied AND verified a migration and still store the legacy encoding. `columns_moved_at` is that second fact, carried as its own member rather than as a widening of either existing one: folding it into `verified_at` would change what an already-verified row authorises on every deployment that has never heard of a column move. - -**Absence is the contract, not a default.** The member is optional and nullable, and nothing in this change writes it. Null or absent means the columns still hold the legacy encoding — a real, expected steady state on any deployment that has run the backfill but not the move, and never an error state — so every row that exists in the world today, and any consumer that cannot read the member at all, lands on the legacy encoding with no extra logic. A required member, or a default value, would destroy the exact property the mechanism was chosen for. - -**Nothing reads it yet, and the arbiter is untouched.** `isDataMigrationFlagVerified` — documented as the ONE arbiter for the existing consumers (reap gating, the strict value-shape flip) — is unchanged in this diff, and is now pinned to return the same verdict for a row that omits the new member as it returned before the member existed; `authorisesIrreversibleAction`, which composes it, is pinned the same way. The predicate that will require `columns_moved_at` non-null belongs to the driver work this change unblocks, and reads it in addition to the arbiter, never inside it. - -This is an additive widening: `DataMigrationFlag` (`z.input` of the schema) gains one optional member, no existing member changes or moves, and no export is added or removed. diff --git a/.changeset/dataset-measure-aggregate-field-type-refused.md b/.changeset/dataset-measure-aggregate-field-type-refused.md deleted file mode 100644 index 9896df7e07e..00000000000 --- a/.changeset/dataset-measure-aggregate-field-type-refused.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -"@objectstack/service-analytics": minor -"@objectstack/spec": minor ---- - -feat(service-analytics)!: a dataset measure whose `aggregate` its `field`'s declared type cannot carry is refused at compile time with `400 DATASET_INVALID` (#16737, compile leg of #16099) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface. A dataset -measure pairing `aggregate: 'avg'` with a `Field.datetime` used to compile to -`AVG(col)` and reach the backend; it is now refused by `compileDataset` before any -query is built. Shipped as `minor` under the repo's launch-window convention for -accept-set narrowings; the hand-migration prescription is registered under protocol -major 18 as `dataset-measure-aggregate-field-type-refused`. - -The pair is judged against `AGGREGATE_FIELD_TYPE_COMPATIBILITY` — the one table -`@objectstack/spec` declared in #16353 under the director ruling of decision batch -#59 (2026-09-06, "both legs, table in spec"). ⛔ This changeset adds no rows and -restates none: the refusal reads the shipped predicate, so the contract has exactly -one statement. - -## What was wrong - -The answer to `AVG` over a temporal column was decided by the SQL dialect rather -than by the data. Both halves measured on this card: - -``` --- SQLite (better-sqlite3), the canonical UTC-text storage form (#3912) -select typeof(submitted_at), submitted_at from clm_contract limit 1; - text|2026-05-19T00:00:00.000Z -select avg(submitted_at) from clm_contract; - 2025.5 <- text->numeric coercion: the average YEAR - --- PostgreSQL 16.13 -select avg(submitted_at) from t; - ERROR: function avg(timestamp with time zone) does not exist -- SQLSTATE 42883 -``` - -The silent half is the dangerous one, and SQLite is the default dev datasource: -`derived: { op: 'difference', of: [avg_a, avg_b] }` over two such averages returned -`-0.85` and rendered on a tile labelled "average cycle time delta" — a number -indistinguishable from a correct one. Nothing refused it at any layer: not the -schema, not `os validate` / `os lint`, not the analytics service, not the renderer. - -## What it does now - -- `compileDataset` refuses an incompatible `aggregate` × `field` pair with - `DATASET_INVALID` / **400**, naming the measure, the field, its declared type and - the accepted set (read off the table, never restated). Nothing reaches the driver. -- It reads the declared type from the `sourceFieldMeta` a host already wires, via a - new optional `DatasetCompileOptions.declaredFieldType` probe. -- **`derived` is covered by construction.** A derived measure's `of` operands are - base measures of the same dataset, so a dataset carrying a refused base measure - never finishes compiling and no `derived` op can be handed its output — including - when the selection names only the derived measure. -- Tiered "cannot answer, do not block" like every sibling probe: no - `sourceFieldMeta`, an unresolvable field, or a `relationship.field` path (whose - column lives on a joined object) leaves the pair unjudged. - -## ⚠️ Scope: the compile leg executes the TEMPORAL rows only - -> ⚠️ **Superseded within the same release window.** This section was accurate when it was -> written and is kept as the record of where the compile leg stopped. Two later cards -> widened it before any of the three entries shipped, so at the version that compiles this -> entry the scope below is no longer the platform's: **#16099** judged `sum` / `avg` over -> every remaining field class (including `sum` over a `percent`), and **#17560** (director -> ruling, decision batch #127, 2026-09-13) judged `min` / `max` over every class the table -> refuses. ⇒ Three sentences in this section are false at that version and are corrected -> where they stand: the string rows are **not** awaiting a table amendment, `sum` over a -> `percent` does **not** compile as it did before, and `avg` / `sum` over a temporal field -> are **not** the only pairs whose behaviour changes. Read all three entries together. - -The gate judges only a measure whose field is declared `date` / `datetime` / -`time`; a field of any other class is never handed to the predicate. The -verdict for the pairs it does judge is the table's — no row is restated — but -which FIELDS are judged is narrower than the table, on purpose: - -- **String rows** (`min` / `max` over `text`, `select`, `lookup`, - `autonumber`, …) are **not enforced here**. ⚠️ This card recorded them as - 「under #16785, **ruled C** — the table itself is to be amended to accept - them」, because `measureResultType` (#15768) already typed those results as - `'string'` and pinned them end to end, so enforcing them from here would - pre-empt that ruling. **Both halves of that sentence turned out to be - wrong.** `16785` resolves to no issue, and decision batch #127 (#17560, - 2026-09-13) found no ruling C anywhere behind the citation — the one recorded - ruling on this table, decision batch #59, refuses the string rows. ⛔ The - table is **not** amended; #17560 enforces those rows and retires the - `measureResultType` opinion that disagreed with them. -- **Boolean rows** are not a refusal at all any more: #16685 was ruled A and - #16750 added `boolean` / `toggle` to `sum` / `avg` / `min` / `max`, so the - table ACCEPTS them and this gate never judged them. -- The table's `sum` × `percent` row is likewise **not** executed by this leg; - `sum` over a `percent` compiles exactly as it did before. ⚠️ True of this - card only — #16099 executes that row in the same release. - -⇒ The only pairs whose behaviour changes **because of this card** are `avg` / -`sum` over a `date` / `datetime` / `time` field. ⚠️ ⛔ Not a statement about the -release: the full-table leg is #16099's and landed, and the `min` / `max` leg is -#17560's and landed, so at the shipping version every pair the table refuses is -refused at the compile door. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `{ aggregate: 'avg', field: }` | `{ aggregate: 'min' \| 'max', field: }` — a real instant of the field's own type | -| `{ aggregate: 'sum', field: }` | store the duration as a number (a computed "days open" field) and `sum`/`avg` that | -| `derived: { op: 'difference', of: ['avg_a', 'avg_b'] }` over temporal averages | fix the two operand measures; the `derived` spec itself is unchanged | - -⭐ A duration is not recoverable from an aggregate over instants on any backend. -Where an "average cycle time" is wanted, the cycle length has to exist as a number -before it can be averaged. - -## What is deliberately untouched - -`date` / `datetime` used as a **dimension** — grouping, bucketing, date-range -filtering — is unchanged; this is about aggregation only. `avg` over a genuine -numeric measure, `min` / `max` over a temporal one, and `count` / `count_distinct` -over anything all behave exactly as before. - -⚠️ **Two faces stay uncovered, deliberately.** The refusal lives in -`compileDataset` and reads a `declaredFieldType` probe, so it applies only where -a host wires one: `/analytics/query` — the non-dataset face, whose measures a -Cube infers rather than an author declaring them — is NOT covered, and neither -is any other `compileDataset` caller that passes no probe (those stand down -unjudged rather than guessing). Closing those is #16099's, not this card's. - -Alongside the refusal, `service-analytics`' contradictory annotations about what a -SQLite `Field.datetime` column physically holds are reconciled to one statement — -**seven** source sites plus two test narratives, not the four the card quoted. Some -said the column holds an INTEGER epoch and ISO TEXT at once; one said flatly that it -IS an INTEGER epoch. Neither is current: since #3912 the column has ONE -storage form, canonical UTC text, with the epoch surviving only in a database not -yet converged by `backfillCanonicalDatetimes`. The fact is now stated once, on -`AnalyticsServiceConfig.coerceTemporalFilterValue`, and the other sites link to it. -No behaviour changes from that half. diff --git a/.changeset/dataset-panel-en-echoes-decided.md b/.changeset/dataset-panel-en-echoes-decided.md deleted file mode 100644 index a7d0d9f1e0c..00000000000 --- a/.changeset/dataset-panel-en-echoes-decided.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): decide the `dataset` panel en-echoes per leaf, and derive the panel pin (#19403) - -The `dataset` metadata form — the ADR-0021 analytics semantic-layer editor — shipped its English -source in `zh-CN`, `ja-JP` and `es-ES` on 24 string leaves: the type display pair, all four section -headings, and both string leaves of its seven non-repeater fields. An author working in one of those -locales read English on the whole panel while the thirteen repeater row properties inside it, and the -`report` editor next to it, were translated. - -Each leaf was decided on its own evidence, not translated wholesale: the verdicts, their per-leaf -reasons and the `en` source each was judged against are recorded in -`dataset-panel-echo-decisions.test.ts`, which also derives the panel population from the `en` catalog -so a key added to this form is caught rather than missed. - -Two groups of tokens stay **English**, on an authored precedent rather than by habit: - -- the strict-enum values the measures section names — `sum/avg/count/…` (`AggregationFunction`) and - `ratio/sum/difference/product` (`DerivedMeasureOp`), both `z.enum` inside a `strictObject`. Rendering - them as words would tell an author in their own language to write a token the schema refuses. The - precedent is `report.fields.type.helpText`, which keeps `tabular/summary/matrix/joined` verbatim in - all three locales; -- the machine tokens an author types — `lookup` / `master_detail`, `relationship.field`, the worked - example `account.region`, and the `FROM` / `ON` SQL keywords the prose names. The precedent is - `object.fields.fields.reference.helpText`, which keeps `tree` and `lookup` verbatim inside otherwise - translated prose. - -Catalog values only. No key is added, renamed or removed in any bundle (24 insertions / 24 deletions -per translated bundle, a pure value replacement), no schema or export moves, and the three -`*.source-hashes.generated.ts` provenance tables lose exactly the 24 rows per locale that recorded -these leaves as unauthored extractor fills. - -Why `patch` rather than `skip-changeset`: `@objectstack/platform-objects` is not private and ships -`files: ["dist", …]`, and `src/metadata-translations/index.ts` imports all three translated bundles, so -the new leaves are published — measured on the built output rather than assumed. diff --git a/.changeset/dataset-select-dimension-option-i18n.md b/.changeset/dataset-select-dimension-option-i18n.md deleted file mode 100644 index 8f14f660edd..00000000000 --- a/.changeset/dataset-select-dimension-option-i18n.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -**The published `DimensionLabelDeps` type (re-exported from this package's `index.ts`) gains -one new optional key, `translateSelectOptions`** — the surface the level is graded against, -per the same "a new key on a published exported type is the mechanical floor for clause ②" -rule #16778 shipped under. Backward compatible (optional, additive, no removed/renamed key, -no wire-shape change), so `minor` rather than `major`. - -A dataset's `select`-field dimension now renders its option label in the request's locale on -a dataset-backed chart, matching what `GET /meta/object/:name` (and hence the console's list -grid) already renders for the identical field. - -`dimension-labels.ts` resolved a select dimension's category label straight out of field -metadata's authored `options[].label` — always the author's own-language text, since -`SelectOptionSchema.label` is a plain string, never an inline locale map. The dotted -cross-object arm (`field: 'contract.direction'`) was unaffected: a relationship-path field -name never matches a key in the BASE object's own field map, so `resolveDimensionLabels` -skips it via `if (!meta) continue` before either branch runs — this fix changes nothing on -that path, and a regression test now pins that it is never even consulted. - -`DimensionLabelDeps` gains one new optional capability, `translateSelectOptions`, which the -plugin bridge (`plugin.ts`) implements by calling `translateObject` (`@objectstack/spec/system`) -— the SAME translator the object-metadata REST endpoint already uses — against the -deployment's i18n bundle, when an `i18n` service is registered. No new export, no new spec -key, no wire-shape change: `AnalyticsResult` carries the same `rows`/`fields` shape as before, -and a kernel with no i18n service configured (or nothing for the requested locale) falls back -to exactly today's authored-label text. - -A future widening of `LOOKUP_TYPES` (#16390) does **not** automatically inherit this: lookup / -master_detail labels resolve through the separate `fetchRecordLabels` capability (a related -RECORD's display name, not a field's authored `options[]`), which this change does not touch. -It does lower the cost of adding translated lookup-record labels later, though — the i18n -service bridge (`plugin.ts`'s `i18nService()` / `buildTranslationBundle()`) is now already -wired into this package and is a `ctx.getService('i18n')` away from reuse. diff --git a/.changeset/date-range-preset-window-extent.md b/.changeset/date-range-preset-window-extent.md deleted file mode 100644 index 571b7bef760..00000000000 --- a/.changeset/date-range-preset-window-extent.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the date-range preset prescriptions now name a one-day window for the one-day presets (#17014) - -`DATE_RANGE_PRESET_MACRO_WINDOWS` maps each dashboard date-range preset to the `{date-macro}` window a refusal PRESCRIBES to an author who wrote the preset name as a bare filter comparand (`bareDateRangePresetComparandMessage`). Two of its thirteen entries prescribed a window wider than the preset they name — a filter that parses, runs and returns rows over the wrong range, with no second error to correct against. - -- **`yesterday`** was `['{yesterday}', '{today}']` — an end naming the day AFTER the window. The pair is written for `$between`, which is `$gte min` and `$lte max`, and a bare-day upper bound means "through that whole day", compiled half-open to `< nextUtcCalendarDay(max)` (ADR-0053 D-D). So the prescription resolved to `>= yesterday 00:00 AND < tomorrow 00:00`: yesterday **and** today. It is now `['{yesterday}', '{yesterday}']`. -- **`today`** was `['{today}', null]`, the open `$gte`-only arm, so the prescribed filter had no upper bound at all and also selected every day after today on a column carrying future dates. It is now `['{today}', '{today}']`. - -Both entries now name their own last day, matching the convention the other eight closed entries already used and matching both executable mappings — objectui's `PRESET_RANGES` and `@objectstack/core`'s analytics date-range resolver, which independently spell `today` and `yesterday` as one-day windows. - -The convention that decides an end token was nowhere written down, which is what let one table carry two readings. It is now stated as a rule on the table: **`start` names the window's first calendar day and `end` names its last, inclusive — never the day the window stops before**, and `end: null` is the open arm reserved for exactly the three rolling `last_N_days` windows. Tests pin the resolved extent of every window against a frozen reference day and require a stated extent for every declared preset, so a preset added later cannot silently pick the other reading. - -No schema, type or export changes: the refused shapes and the vocabulary are exactly as before, and only the window text a refusal quotes back moves. diff --git a/.changeset/declared-refusal-relay.md b/.changeset/declared-refusal-relay.md deleted file mode 100644 index c8860b58f68..00000000000 --- a/.changeset/declared-refusal-relay.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/types': minor -'@objectstack/rest': patch -'@objectstack/runtime': patch -'@objectstack/metadata-protocol': patch ---- - -A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. - -`ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. - -The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. - -**What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. - -**What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. - -**For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. diff --git a/.changeset/delegated-admin-holdings-organization.md b/.changeset/delegated-admin-holdings-organization.md deleted file mode 100644 index 9a617c560bb..00000000000 --- a/.changeset/delegated-admin-holdings-organization.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -The delegated-admin gate now counts a `sys_user_position` holding only where the runtime grants it. Self-delegation's "you currently hold this position" check (ADR-0091 D3 rule 4) no longer accepts a holding stamped for a different organization — a user can no longer self-delegate a position in an organization where they hold nothing just because they hold a same-named position elsewhere. Organization-less holdings still count, exactly as the runtime authz resolver grants them in every organization. The binding blast-radius check (ADR-0090 D12) likewise counts only the assignments the bound position row reaches — its own organization's plus organization-less ones — so another organization's same-named assignments no longer refuse a binding as outside the subtree or push it over the assignment cap. An organization-less (`single` posture) caller is unchanged. diff --git a/.changeset/deriving-aggregate-nonnumeric-field-refused.md b/.changeset/deriving-aggregate-nonnumeric-field-refused.md deleted file mode 100644 index 97a13913b75..00000000000 --- a/.changeset/deriving-aggregate-nonnumeric-field-refused.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -feat(service-analytics)!: a dataset measure applying `sum` or `avg` to a field whose declared type cannot carry it is refused at compile time, for every field type and not only the temporal class (#16099) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface, continuing the -one #16778 began. A dataset measure pairing `aggregate: 'sum'` with a `text` field (or -`avg` with a `select`, `json`, `lookup`, `formula`, … field) used to compile and reach -the backend; it is now refused by `compileDataset` with `DATASET_INVALID` / **400** -before any query is built. Shipped as `minor` under the repo's launch-window convention -for accept-set narrowings. - -⛔ This changeset adds no rows to any table and restates none. The verdict is -`AGGREGATE_FIELD_TYPE_COMPATIBILITY`'s — the one table `@objectstack/spec` declared in -#16353 under the director ruling of decision batch #59 ("both legs, table in spec") — -read through `isAggregateCompatibleWithFieldType`. - -## What was wrong - -#16778 landed the compile leg SCOPED to temporal source fields, leaving "every other -non-temporal pair the table refuses" as a stated residual that had never been driven. -Driven on this card, through the real service door: - -``` -sum × text the table refuses the pair the compile leg does NOT throw SQL IS emitted -sweep 6 aggregates × 49 field types = 294 pairs; 155 refused by the table; - minus 6 temporal (#16778's) minus 42 `min`/`max` × the string classes; - residual 107 — and 107 of 107 were ACCEPTED by the compile leg -control avg × datetime / date / time → DATASET_INVALID / 400, no SQL emitted -``` - -The control is what makes that a reading of the tree rather than of a blind harness: the -same service, door and `sourceFieldMeta` hook sees the pairs #16778 enforces refused. - -So `sum` over a `text` column reached whichever backend the object is bound to, and the -answer was a property of the dialect rather than of the data — the shape Prime Directive -#12 exists to remove, and the same shape #16778 closed for one field class. - -## What it does now - -- `compileDataset` judges a measure whose aggregate DERIVES a number (`sum` / `avg`) - against the table for **every** declared field type, and refuses an unaccepted pair - with `DATASET_INVALID` / **400** — naming the measure, the field, its declared type - and the accepted set read off the table. Nothing reaches the driver. -- `sum` × `percent` is refused at last: the row `analytics-service.ts` has called - "incoherent" in a comment since before the table existed. `avg` × `percent` is still - ACCEPTED by the same table, which is what makes it a row and not a class. -- The refusal's closing prescription is now chosen by the source field's class: the - temporal sentence #16778 measured is kept verbatim for temporal fields, and a - non-numeric field is pointed at `count` / `count_distinct`, which accept every type - because they read no arithmetic off a value. -- Unchanged: `derived` is covered by construction (a dataset carrying a refused base - measure never finishes compiling), and the three "cannot answer, do not block" tiers — - no `sourceFieldMeta`, an unresolvable field, a `relationship.field` path. - -## ⚠️ Scope: the DERIVING aggregates — and see #17560, which closed the other half - -> ⚠️ **Superseded within the same release window.** This section was accurate when it was -> written and is kept as the record of why this change stopped where it did. #17560 -> (director ruling, decision batch #127, 2026-09-13) then judged `min` / `max` too, so at -> the version that ships this entry **every** pair the table refuses is refused at the -> compile door. Read that entry beside this one. - -`min` / `max` SELECT one of the stored values; `sum` / `avg` DERIVE a number. This is the -line this package already draws — `measureResultType` branches on exactly that pair of -aggregates — and the defect is about a derived number, so the deriving aggregates are its -population. - -The `min` / `max` rows stayed with the table-amendment card (then **#17513**, since closed -as a duplicate of **#17560**, which ruled and landed them), and that is measured rather -than assumed. -Enforcing the residual whole was tried on this card: with `min` / `max` × the string -classes subtracted, **15** cases in `measure-result-type.test.ts` still went red, every -one of them on `min` × `json` — a pair the table refuses, in no ruling's scope, driven -end to end by the same shared fixture as the string rows. One dataset compiles every -measure in that fixture, so one refused pair reds the whole section. ⇒ `min` / `max` is -one question, and it is the table-amendment card's. - -## Upgrading — FROM → TO - -Nothing an author writes is removed or renamed: `DatasetMeasure.aggregate` and -`DatasetMeasure.field` keep their spellings and their types. What narrows is which PAIRS of -values are accepted. The one-line fix, per shape: - -| FROM (compiled before, refused now) | TO | -|---|---| -| `{ aggregate: 'sum', field: }` | `{ aggregate: 'count_distinct', field: }` — counting reads no arithmetic off the value | -| `{ aggregate: 'sum' | 'avg', field: }` | store the quantity you meant as its own numeric field and aggregate that | -| `{ aggregate: 'sum', field: }` | aggregate the formula's numeric INPUT column; a `formula` is virtual in SQL storage, so no arithmetic aggregate can be lowered to it | -| `{ aggregate: 'sum', field: }` | `{ aggregate: 'avg', field: }` — a rate averages, it does not add | -| `{ aggregate: 'sum' | 'avg', field: }` | unchanged from #16778: use `min` / `max` for a real instant, or store a duration as a number and aggregate that | - -`min` / `max` are **not** affected by this change at all, over any field type. - -No shipped dataset in this repository declares a newly-refused pair — every one of the -eleven shipped dataset measures resolves to `number`, `currency`, `summary` or `progress`. -The refusal names the accepted set for the aggregate, read off the table. diff --git a/.changeset/discovery-services-route-follows-mount.md b/.changeset/discovery-services-route-follows-mount.md deleted file mode 100644 index 90c2d96de68..00000000000 --- a/.changeset/discovery-services-route-follows-mount.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): `/discovery` no longer contradicts itself — `services.*.route` follows the mounted paths, like `routes.*` already did (#16674) - -The `/discovery` document states each service's address twice: once in `routes.X` (the flat convenience map) and once in `services.Y.route` (the per-slot entry). The REST discovery handler rewrote only the first half to the paths this server actually mounts, so any deployment that moved a prefix received a document that disagreed with itself — and the `services` half pointed at a path with nothing mounted on it. - -Measured on a boot with `crud: { dataPrefix: '/objects' }`, reading `GET /api/v1/discovery`: - -- before — `routes.data` = `/api/v1/objects` (the mounted path), `services.data.route` = `/api/v1/data` (unmounted) -- after — both answer `/api/v1/objects` - -The same split opened on four keys at once for an `apiPath` deployment: `data`, `metadata`, `ui` and `auth`. All four now follow the mount. `services.*.route` is written as a projection of the finished `routes` map, so the two halves cannot state different answers whatever a future substitution does to `routes`. - -**A default deployment's document does not move by a byte.** With `crud.dataPrefix` at its `/data` default and `metadata.prefix` at `/meta`, the values the correction writes are the values that were already there; only a deployment that had moved a prefix sees a change, and there the old value addressed nothing. Route-less slots (`cache`, `queue`, `job`, and an in-process `realtime` bus) never gain a route, and no advertisement is withdrawn. - -If you have been reading `services.data.route` on a moved-prefix deployment and compensating for it — by re-deriving the path from `routes.data`, or by hard-coding the prefix — that workaround can go: the field now answers the mounted path directly. diff --git a/.changeset/driver-config-registry-off-vocabulary-lookup-guard.md b/.changeset/driver-config-registry-off-vocabulary-lookup-guard.md deleted file mode 100644 index 704dca4544f..00000000000 --- a/.changeset/driver-config-registry-off-vocabulary-lookup-guard.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the driver-config registry refuses an off-vocabulary id instead of answering with a truthy non-schema - -`DRIVER_CONFIG_JSON_SCHEMAS`, `DRIVER_ID_ALIASES` and `DATABASE_DRIVER_ALIASES` -are plain object literals, so all three inherit `Object.prototype`, and every -lookup into them was a bare index. Measured against the built artifact -(`dist/data/index.mjs`) on the repo's Node 22 baseline (v22.22.2), an id that -names an inherited member resolved that member and was handed onward as if it -were a driver: - -| call | before | after | -|:--|:--|:--| -| `getDriverConfigJsonSchemaById('memory')` | the JSON Schema | the JSON Schema — unmoved | -| `getDriverConfigJsonSchemaById('constructor')` | `{}` — an EMPTY JSON Schema that accepts every config | `TypeError` naming the id and the legal vocabulary | -| `getDriverConfigJsonSchemaById('toString')` | `'[object Object]'` — a **string**, where the signature promises an object | `TypeError` | -| `getDriverConfigJsonSchemaById('valueOf')` | the registry object itself | `TypeError` | -| `getDriverConfigJsonSchemaById('__proto__')` | `TypeError: … is not a function` | `TypeError`, now naming the id | -| `getDriverConfigJsonSchemaById('nope')` | `TypeError: … is not a function` | `TypeError`, now naming the id | -| `resolveDriverId('constructor')` | the `Object` **function** — truthy, not a driver id | `undefined` | -| `resolveDriverId('__proto__')` | `Object.prototype` — a truthy object | `undefined` | -| `resolveDatabaseDriverId('constructor')` | the `Object` **function** | `undefined` | -| `driverHasLocalDefault('constructor')` | `undefined`, out of a function declared `boolean` | `true`, as its doc promises for an unknown id | -| `resolveDriverId('pg')` / `resolveDriverId(' PostgreSQL ')` | `'postgres'` | `'postgres'` — unmoved | - -`getDriverConfigJsonSchemaById` handing back `{}` is the worst of these: an -empty JSON Schema validates anything, so a Studio connection form or a -`DriverDefinitionSchema.configSchema` consumer that asked "what shape must this -config have" was told "any shape at all" and reported success. - -The resolvers' half is reachable without a plain-JS consumer. The CLI refuses an -unclaimed operator selection with `if (driverType && !kind)` after calling -`resolveDatabaseDriverId`, so `OS_DATABASE_DRIVER=constructor` produced a truthy -`kind` that is not a driver id and walked past the refusal. - -All three lookups now go through an `Object.prototype.hasOwnProperty.call` check. -This narrows and widens nothing: every legal spelling is an own key of its table, -so no value accepted before is refused now, and only answers that were never -inside the declared return types move. The declared signatures are unchanged — -`getDriverConfigJsonSchemaById` stays `(id: BuiltinDriverId) => Record` -and both resolvers stay `(driver: unknown) => BuiltinDriverId | undefined`. - -A null-prototype table was the other available shape and was measured rather than -assumed: a `__proto__: null` object literal does not type-check against the -`Readonly>` annotation at all (TS2353), and the -`Object.assign(Object.create(null), …)` spelling that does compile silently costs -that annotation — a table missing a driver stopped failing to compile (TS2741). diff --git a/.changeset/driver-sql-aggregate-declared-type.md b/.changeset/driver-sql-aggregate-declared-type.md deleted file mode 100644 index bd8537ca011..00000000000 --- a/.changeset/driver-sql-aggregate-declared-type.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -feat(driver-sql): `aggregate()` publishes its declared return type — the contract's own, not `any` (#17277) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 set for this class of change on `@objectstack/driver-memory`, and PRs #15280 and #15267 followed on this very class). `SqlDriver.aggregate()` carried an EXPLICIT `Promise` over a door `IDataDriver` had already declared narrower: `aggregate?(object, query, options?): Promise[]>`. An explicit `any` satisfies that structurally, so `tsc` said nothing while the emitted `.d.ts` told every consumer that an aggregate row is whatever they like. - -The door is now declared as the contract declares it. A caller that read a cell straight off an aggregate row through the `any` now types what it reads — an aggregate cell arrives as `unknown` — and a caller that indexed the result array, or took `.find()` on it, now narrows the absent arm first. No runtime behaviour changes. - -`aggregate()` is OPTIONAL on the contract (`aggregate?`) where the five doors #15267 moved are required. That governs whether the member EXISTS, not what it returns once it does: a consumer that has already guarded `typeof driver.aggregate === 'function'` — the engine's own dispatch — holds a function whose published return was `any` and is now the contract's record array. The narrowing reaches it either way. - -`@objectstack/driver-sqlite-wasm` does not override this door and re-declares no member of its own, so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides it and carries its own entry. - - diff --git a/.changeset/driver-sql-doors-declared-types.md b/.changeset/driver-sql-doors-declared-types.md deleted file mode 100644 index 58454d13971..00000000000 --- a/.changeset/driver-sql-doors-declared-types.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -feat(driver-sql): the five remaining `IDataDriver` doors publish their honest types — the contract's own, not `any` (#15267) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 set for the same class of change on `@objectstack/driver-memory`, and PR #15280 followed for `update()` on this very class). `SqlDriver` carried an EXPLICIT `Promise` on five doors that `IDataDriver` had already declared narrower: `findOne()` (`Record | null` — it has always answered `results[0] || null`), `create()` (`Record`), `bulkCreate()` (`Record[]`), `execute()` (`unknown`) and `explain()` (`unknown`). An explicit `any` satisfies all five structurally, so `tsc` said nothing while the emitted `.d.ts` told every consumer that `findOne()` never returns `null` and that `create()` returns whatever they like. #15280 un-masked `update()` and filed the census of what was left; this is that remainder. - -Each door is now declared as the contract declares it. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members off `create()` / `bulkCreate()`, or to dereference a raw `execute()` / `explain()` result, now types what it reads. No runtime behaviour changes. - -`@objectstack/driver-sqlite-wasm` overrides none of these five and re-declares no member of its own, so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides four of the five and carries its own entry. - -Out of scope and deliberately unmoved: `analyzeQuery()` (not an `IDataDriver` member) and `aggregate()` keep their annotations. - - diff --git a/.changeset/driver-turso-aggregate-declared-type.md b/.changeset/driver-turso-aggregate-declared-type.md deleted file mode 100644 index d54cbaa394d..00000000000 --- a/.changeset/driver-turso-aggregate-declared-type.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/driver-turso': minor ---- - -feat(driver-turso): the `aggregate()` override publishes its declared return type, not `any` (#17277) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` does not merely inherit this door from `SqlDriver` — it OVERRIDES `aggregate()`, and the override was written out with its own explicit `Promise`. So this package's emitted `.d.ts` re-declared the door as `any` on its own and would NOT have picked up the `@objectstack/driver-sql` narrowing — the same shape PR #15280 had to fix separately for `update()` and PR #15267 for four more doors. - -Both branches already answered the contract's type: the remote branch passes `RemoteTransport.aggregate()`, already declared `Promise[]>`, and the local branch forwards to `SqlDriver.aggregate()`, narrowed alongside (#17277). The override now declares what it has always answered. A caller that read a cell straight off an aggregate row through the `any` now types what it reads. No runtime behaviour changes. - -Out of scope and deliberately unmoved: `upsert()` and `beginTransaction()` keep their annotations. - - diff --git a/.changeset/driver-turso-doors-declared-types.md b/.changeset/driver-turso-doors-declared-types.md deleted file mode 100644 index 921554eee03..00000000000 --- a/.changeset/driver-turso-doors-declared-types.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/driver-turso': minor ---- - -feat(driver-turso): the overridden `IDataDriver` doors publish their honest types, not `any` (#15267) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` does not merely inherit these doors from `SqlDriver` — it OVERRIDES `findOne()`, `create()`, `bulkCreate()` and `execute()`, and each override was written out with its own explicit `Promise`. So this package's emitted `.d.ts` re-declared four of the five doors as `any` on its own and would NOT have picked up the `@objectstack/driver-sql` narrowing — the same shape PR #15280 had to fix separately for `update()`. - -Both branches of every one of the four already answered the contract's type: the local branch forwards to `SqlDriver`'s door (narrowed alongside, #15267) and the remote branch passes `RemoteTransport`'s result — already declared `Record | null`, `Record`, `Record[]` and `unknown` respectively — through the generic `formatRemoteRow` / `formatRemoteRows`. Each override now declares what it has always answered. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first. No runtime behaviour changes. - -`explain()` is not overridden here and reaches these consumers through `@objectstack/driver-sql`. Out of scope and deliberately unmoved: `upsert()`, `aggregate()` and `beginTransaction()` keep their annotations. - - diff --git a/.changeset/driver-turso-remote-declared-indexes.md b/.changeset/driver-turso-remote-declared-indexes.md deleted file mode 100644 index e4fb50a88b0..00000000000 --- a/.changeset/driver-turso-remote-declared-indexes.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/driver-turso': patch ---- - -fix(driver-turso): remote mode materializes every declared object-level index, not only field-level `unique` (#17609) - -## What was wrong - -In remote mode (`libsql://` / `https://`), `TursoDriver` provisions tables through `RemoteTransport`, and the only index DDL that path could emit came from field-level `unique`. An object's declared `indexes: [...]` — unique or not — had no consumer there, so no remote database ever carried one. The local face (`SqlDriver`) created all of them, so nothing failed and no local test noticed: on a remote tenant database `sys_notification_delivery` (five declared indexes) and `sys_job_queue` (three) held only their primary-key autoindex, and the delivery claim query answered every poll with a full table scan (`SCAN sys_notification_delivery` + `USE TEMP B-TREE FOR ORDER BY`). - -## What changes - -- Remote mode now creates **every** declared index: field-level `unique` plus the object's own `indexes`, unique and non-unique, including `unique: 'organization'` with its NULL-safe `COALESCE(, '__global__')` key part. Names and keys come from the same shared normalizers `SqlDriver` and the drift differ use (`uniqueIndexesFromFields`, `normalizeDeclaredIndex`, `buildIndexName`), so both faces land the same index set — pinned by a new local/remote parity suite that compares `sqlite_master` on both. -- New tables get their indexes in the same batch as `CREATE TABLE`. -- **Existing tables are retrofitted on the next schema sync** with `CREATE [UNIQUE] INDEX IF NOT EXISTS`. No row is read-modified or rewritten. -- An index the retrofit cannot create is reported once at `error`, naming the index, the table and the database's own cause. A declared `unique` index over rows that already violate it is **not** forced and no data is repaired: de-duplicate the key's values and re-run schema sync. -- Steady-state cost goes down: a sync now reads the existing index names once (one statement, folded into the column-probe batch it already sends) and issues no index DDL when every declared index exists. Before, every boot re-sent one `CREATE UNIQUE INDEX IF NOT EXISTS` per field-level unique index on an existing table. - -## Upgrading - -Nothing to change in metadata or configuration. The first kernel build after upgrading creates the missing indexes on each existing remote database — on a large table that one build pays the index build time. Watch the boot log for `could not create the declared` lines at `error`: each names an index that is still absent and why. diff --git a/.changeset/email-template-locale-floor.md b/.changeset/email-template-locale-floor.md deleted file mode 100644 index ca24d44dae0..00000000000 --- a/.changeset/email-template-locale-floor.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Email templates: say where the `en-US` fallback floor is, and report a bundle that has none. - -`IEmailService.sendTemplate` matches `(name, locale)` exactly and, for a call that NAMES a -locale, retries exactly one rung — the literal `en-US` — and stops. There is no language-subtag -folding, so a bundle whose English row is tagged `en` is unreachable from `en-US` and from every -other tag it does not itself carry; each such delivery raises `TEMPLATE_NOT_FOUND`, which -classifies permanent, so it dead-letters with no retry. An app declaring -`i18n.defaultLocale: 'en'` and authoring `locale: 'en'` has done the consistent thing throughout -and still shipped a bundle with no floor for those calls — and it validated, built and installed -clean. - -- `EmailTemplateDefinitionSchema.locale`'s `describe` and TSDoc now state the exact match, the - one literal `en-US` rung a call that NAMES a locale gets, the absence of folding, and that the - stack's own declared default locale is the wrong tag whenever it is not spelled `en-US`. -- New exported `EMAIL_TEMPLATE_FLOOR_LOCALE` names that tag once: it is both the schema default - and the rung `sendTemplate` retries for a call that NAMES a locale. The full ladder — including - the lowest-tag rung reachable only by a call that names NO locale — is on - `SendTemplateInput.locale` in `packages/spec/src/contracts/email-service.ts`. -- `defineStack` now reports (advisory `console.warn`, warn-once per bundle) an `emailTemplates` - bundle that carries rows for the stack's own `i18n.supportedLocales` but none tagged `en-US`. - -Advisory only — no accept set moves. The stack still parses and is returned unchanged; the -resolver's ladder is unchanged. diff --git a/.changeset/enableoninstall-optional-preserve.md b/.changeset/enableoninstall-optional-preserve.md deleted file mode 100644 index e25b69e246f..00000000000 --- a/.changeset/enableoninstall-optional-preserve.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `enableOnInstall` becomes `optional()` so absence survives the parse - -The install door was ruled onto three states — 「缺省 = 保持,有旗 = 设置」 — and -implements them: `enableOnInstall: true` enables the row, `false` disables it, -and an **absent** key makes no lifecycle call at all, so a package an operator -disabled stays disabled across an upgrade or a re-install. A fresh id has no -state to keep and lands enabled. - -The published declarations said something else. `z.boolean().default(true)` -resolves absence **at parse time**, so a request that omitted the key came out -of the parse byte-identical to one that set `true` — the third state did not -exist on the published surface, while the door went on acting on it. That is a -declared default the runtime deliberately stops applying, on a contract this -repo does not own both ends of. - -All three declarations now spell `z.boolean().optional()`, with the semantics -written on the field in the `describe` and the docblock: - -- `api/PackageInstallRequest` (`src/api/package-api.zod.ts`) — the authority. -- `kernel/InstallPackageRequest` (`src/kernel/package-registry.zod.ts`) — the - copy restated on the in-process protocol primitive. It is re-exported through - `src/api/protocol.zod.ts`, so it publishes under `api/InstallPackageRequest` - too: one declaration, two published defs. -- `marketplace/MarketplaceInstallRequest` — a different party's key on a - different door, moved with the others so the consistency matrix stays one row - per state. Not a fold. - -**Runtime behaviour is deliberately UNCHANGED**, and nothing in this repo starts -or stops being refused. Nothing parses an install body through these schemas on -the serving path — the door reads the raw body, and `PackageApiContracts` is a -declarative catalog entry rather than a parse. The accept set does not move -either: absent, `true` and `false` are accepted before and after, and a string -or `null` is refused before and after. - -### Migration: FROM → TO - -| FROM | TO | -| :--- | :--- | -| omitting the key and expecting an unconditional enable, because the schema said `default: true` | send `enableOnInstall: true` — the only spelling the door has ever read as "enable" | -| omitting it and expecting the package's current state to be left alone | change nothing; that is what the door already does, and now what is declared | -| sending `enableOnInstall: false` | unchanged in every respect | -| reading `PackageInstallRequestParsed.enableOnInstall` (or the `InstallPackageRequestParsed` / `MarketplaceInstallRequestParsed` copies) after parsing a body without the key | it now yields `undefined` instead of `true` — the third state, and the one the door acts on | -| reading the published JSON Schema's `default` keyword for this key | it is gone; the key is still `type: "boolean"` and still not `required` | - -**Who is actually affected:** a client or SDK outside this repo that validates -its request through the published schema and sends the **parsed** object. It -materialised `enableOnInstall: true` from the declared default and sent it -explicitly — and an explicit `true` is a force-enable, so that caller silently -re-enables a package an operator deliberately disabled, on every upgrade, while -a caller sending the identical body without validating preserves the disable. -Identical request bodies, opposite behaviour, decided by whether the caller -validated before sending. A caller that never parsed its own request body is -unaffected in every direction. - -The four moved published defaults are declared in -`DEFAULT_CHANGES_BY_MAJOR` (`packages/spec/scripts/lib/default-changes.ts`), -each with the consumer prescription above; `check:authorable-surface` prints -them in full on every build that accepts them. diff --git a/.changeset/engine-text-operator-declared-type-door.md b/.changeset/engine-text-operator-declared-type-door.md deleted file mode 100644 index f5962643db8..00000000000 --- a/.changeset/engine-text-operator-declared-type-door.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/spec": patch ---- - -feat(objectql)!: refuse a text operator aimed at a field whose DECLARED type can never store a string — `INVALID_FILTER` 400 at the engine's field-aware door (#15773) - - - -**BREAKING** for a caller that aims `$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike` at a numeric, boolean, temporal or structured-JSON field: the call used to be answered (with `[]`, with every row for `$notContains`, or with a dialect accident) and is now refused with `400 INVALID_FILTER`. Shipped as `minor` under the repo's launch-window convention. Execution lane (2) of the maintainer ruling on #15661 (decision batch #43, option C-deny); lane (1) is the contract it consults, `@objectstack/spec/data`'s `filter-text-operator-declared-type.ts` (#15804). - -## What was wrong - -Measured on `origin/main` `59db8a02cb` with a real `ObjectQL`, the lane-1 fixture registered and a recording driver beneath — the filter reached the driver verbatim every time: - -| filter | before | after | -|:--|:--|:--| -| `{ f_number: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | -| `{ f_summary: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | -| `{ f_json: { $contains: 'a' } }` | driver read, `[]` | `400 INVALID_FILTER` | -| `{ f_date: { $startsWith: '2026' } }` | `400 INVALID_FILTER` — from the #8690 TEMPORAL door, about the COMPARAND | `400 INVALID_FILTER`, naming the field's declared type | -| `{ f_text: { $contains: 'a' } }` | driver read | unchanged — driver read | - -What the driver then answered is #14079's option-A row: no row for a positive operator, EVERY row for `$notContains`. Neither answer is wrong beneath the door — it is the declared answer — and neither carries any signal that the field can never hold a string, which is the cell this closes. - -## What it does now - -- **One door, at the engine's single filter collection point** (`lowerWhereFilterArray`), third in the ladder: comparand shape (#5869) → materializable field (#8296 / #8371) → **declared type (this)** → temporal comparand (#8690). It runs before the temporal gate deliberately: a text operator over a `date` field was already refused there, with the same wire envelope but a message about the comparand, which sends the author to fix a value that could never have made the filter runnable. -- **The refused classes are DERIVED, never re-listed**: the verdict is `@objectstack/spec/data`'s `textOperatorDoorVerdict`, over `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES`. A type added to any of those sets is refused with no change in this package. String-valued classes pass unchanged — `STRING_VALUE_TYPES`, `autonumber`, option codes (single AND multi, so `tags` keeps its substring filter), reference ids and the file classes. -- **No vocabulary is minted.** `INVALID_FILTER` already exists (`StandardErrorCode`) and is this package's filter envelope; the refusal carries `code`, `status` and `httpStatus` per ADR-0112 D5, and names the field, its declared type and the operator. -- **Both filter forms and every verb**: the object form and the `FilterArray` sugar, on `find` / `findOne` / `count` / `aggregate` / `update` / `delete`, plus the per-aggregation `filter` position (#10576's second filter slot on `aggregate`) — a door that spoke on `where` alone would answer one mistake two ways within one verb. -- **Beneath the door nothing moves.** A direct driver call never passes this seam and keeps answering `FILTER_TEXT_CASES`' option-A row (#14079), as does `having` — both pinned. - -## Deliberately unjudged - -- **A dotted key** (`f_address.city`) — `filter-dotted-head`'s subject, whose structured-JSON heads are deliberately unjudged there (#8371). The door steps over it rather than re-closing that carve-out. -- **An unknown filter field** — the engine keeps its registry-less tolerance; this door adds no second opinion about a name. -- **A registry-less host** (`schema.fields` absent) — a door that cannot see the field map invents no verdict, the same early return both neighbours make. -- **`formula`** — judged one door earlier. `assertFilterIsMaterializable` (#8296) refuses every filter over a `formula` field with `INVALID_FIELD` 400, for the broader reason that no driver materialises a column for it, so a formula's declared `returnType` is never the deciding fact at this seam. Not reordered around: that would answer ONE condition with TWO wire codes chosen by `returnType`. The divergence from lane (1)'s formula rows is pinned by name in `engine-text-operator-declared-type-door.test.ts` rather than dropped. - -## The ADR-0087 ledger entry, and why this is `registered` rather than `not-required` - -`@objectstack/spec` carries one new semantic migration entry, `filter-text-operator-declared-type-refused` (protocol 18) — the `patch` bump above is that entry and nothing else; no schema, no export and no published set moved. - -It is a real registration because the refused shape has an AUTHORED, STORED surface, measured on the tree rather than assumed. Nothing rejects a stored filter at load — `FilterConditionSchema` constrains no field type, and `ViewFilterRuleSchema` takes `field: z.string()` with `contains` in its operator enum — so a filter body written before this change still parses, still loads, and answers `400` the next time it is executed. Carriers measured to reach this seam: - -| stored surface | how it reaches the door | -|:--|:--| -| `sys_saved_report.query_json.filter` | `report-service.ts` runs `engine.find(report.object_name, { where: q.filter })` verbatim; every `sys_report_schedule` row reaches the same body through `report_id` | -| `FieldSchema.summaryOperations[].filter` | `summary-aggregate.ts` ANDs it with the parent-FK match and calls `engine.aggregate` | -| `ListView.filter`, tab filters (`ViewFilterRuleSchema`) | `contains` / `not_contains` / `icontains` / `starts_with` / `ends_with` lower to the same operators through `AST_OPERATOR_MAP` | -| dashboard widget / `GlobalFilter`, dataset `filter`, report `runtimeFilter`, `FieldSchema.relatedListFilter` | `FilterConditionSchema` carriers, executed through the same engine seam | - -**Not** on that list, deliberately: an RLS / sharing / tenant predicate. Those are composed onto the AST by the middleware chain AFTER this door, so the door never judges one — a policy filter cannot become a 400 nobody can act on. - -No mechanical rewrite exists, which is exactly what a `semantic` entry is for: `{ amount: { $contains: '5' } }` may have meant `$eq: 5`, a range, or a different column, and `objectstack migrate meta` must not choose. The entry ships the repair procedure and its acceptance criteria instead. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `where: { amount: { $contains: '500' } }` | `where: { amount: { $eq: 500 } }` (or `$gte` / `$lte` for a range) | -| `where: { created_at: { $startsWith: '2026' } }` | `where: { created_at: { $gte: '2026-01-01', $lt: '2027-01-01' } }` | -| `where: { is_open: { $contains: 'true' } }` | `where: { is_open: true }` | -| `where: { address: { $contains: 'Berlin' } }` | filter a stored text field, or `where: { 'address.city': { $contains: 'Berlin' } }` (a dotted path stays unjudged) | -| `where: { tags: { $contains: 'urgent' } }` | unchanged — option codes are strings and still pass | diff --git a/.changeset/engine-verb-result-declarations.md b/.changeset/engine-verb-result-declarations.md deleted file mode 100644 index 300876eb9b4..00000000000 --- a/.changeset/engine-verb-result-declarations.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor -"@objectstack/metadata": minor -"@objectstack/metadata-protocol": minor -"@objectstack/plugin-auth": minor ---- - -feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) - - - -**BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: - -- `findOne` → `Promise | null>` -- `update` → `Promise | number | null>` -- `delete` → `Promise` - -`any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. - -**Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). - -The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. - -**What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. - -**Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. - -**What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: - -- `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. -- `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. -- `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. - -The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. diff --git a/.changeset/engine-write-failure-log-level-warn.md b/.changeset/engine-write-failure-log-level-warn.md deleted file mode 100644 index 0e7f502f7de..00000000000 --- a/.changeset/engine-write-failure-log-level-warn.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a refused write reports at `warn`, not `error` — the caller was already told (#17052) - -`insert`, `update` and `delete` each end their `catch` with `throw e`, then -logged the failure at ERROR one statement earlier. AGENTS.md → *Degradation log -levels* names that exact shape and forbids it: "a failure handed to the CALLER -is not a degradation at all … Do not bolt a `logger.error` onto such a site." - -**This moves published behaviour**, which is why it is a changeset rather than a -`skip-changeset`: the level is what an operator greps, and at least one consumer -reads it structurally. `scripts/publish-smoke.sh` fails a boot on any -error-level line (`SMOKE_ERROR_LOG_PATTERN`), and that is how the defect was -found — `@better-auth/oauth-provider` seeds `sys_oauth_resource` in `insertOnly` -mode and documents its `identifier` UNIQUE constraint AS its race-safety -mechanism, catching the collision and continuing at `debug`. Our line was -emitted before that catch ever ran, so a healthy first boot of every fresh -`create-objectstack` project printed `ERROR Insert operation failed` and red-lit -`publish-smoke / packed-tarballs` for six consecutive runs on a candidate whose -auth and CRUD probes were all green. - -**Nothing else about the entry moved.** Same message, same `object` meta, same -redaction (#8682: the bound statement and its values stay cut from `message` -and `stack`), same subject (#14095: the entry carries the driver's own error — -a `DuplicateRecordError`'s `cause` — never the envelope, so the failing column, -MySQL's index name and the driver's frames survive). The `Logger` contract gives -an `Error` slot to `error`/`fatal` only, so the engine now builds the -`{ error: { message, stack } }` bag that slot used to build; handing the Error -to `warn` as meta would have serialised `{}`, because those two fields are -non-enumerable. The rendered line is byte-identical apart from the level word, -and that equivalence is pinned rather than asserted. - -If you grep your logs for these three messages, keep the message and drop the -level from the pattern. If you alert on error-level lines from `@objectstack/objectql`, -a refused write no longer raises one — the write's exception still does. diff --git a/.changeset/error-code-ledger-boot-refusal-prose.md b/.changeset/error-code-ledger-boot-refusal-prose.md deleted file mode 100644 index 5c9d05e3fa4..00000000000 --- a/.changeset/error-code-ledger-boot-refusal-prose.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The error-code ledger's TSDoc stops naming a retired verdict as a live mechanism, and states the published-face rule it is actually held to. - -`packages/spec` ships `src/**/*.zod.ts`, so `api/error-code-ledger.zod.ts`'s header is published prose — a consumer reads these sentences out of the tarball. Two of them stopped being true when `check-dispatcher-error-vocabulary`'s face refusal widened from `packages/spec/src/**` to every published package's `src/` and the dispatcher vocabulary's `boot-refusal` verdict retired with it (#16649). - -The first said the `boot-refusal` verdict **records** reachability for codes not yet registered, and pointed at the module the verdict was being deleted from. That is a claim about where a live mechanism lives, not about a case that can no longer arise, so a reader following the pointer would have found nothing. It now records the retirement and names what replaced it: a `door: 'none'` code has no resting place short of a row in the ledger. - -The second opened `packages/spec/src/** is held to this mechanically`. True before the widening and an understatement after it — a reader would conclude only the spec tree is guarded, which is the "guarded a part" / "guarded it" confusion this whole class of gate exists to remove. It now states the published face, the stricter spec sub-face where `pending-registration` has no allowance, and the named, dated allowance outside it owed to #8846, with both finding kinds named. - -No schema, accept set, default or refusal moves. `ERROR_CODE_LEDGER` holds the same members before and after, and the generated reference page is regenerated from this prose rather than hand-edited. diff --git a/.changeset/example-caption-fence-assertion.md b/.changeset/example-caption-fence-assertion.md deleted file mode 100644 index c9daf17ef52..00000000000 --- a/.changeset/example-caption-fence-assertion.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The reference-docs renderer now refuses an `@example CAPTION` with no code block beneath it, -instead of publishing an orphaned caption. - -`@example CAPTION` is declared to be *the caption of the fence beneath it*, and the renderer -acts on that reading: it promotes the tag into a bold lead-in on the assumption that a fence -follows. Nothing asserted that one did. When a module header captioned a listing and wrote its -rows as bare prose, the promotion still fired and the rows below collapsed into a single run-on -paragraph — consecutive non-blank lines are one markdown paragraph, and the docs site loads no -`remark-breaks`. Two customer-facing reference pages shipped that way. - -The assumption is now a precondition the generator checks before it emits anything. A module -description whose caption has no block under it fails the docs build with a message naming the -caption and the source-side fix, the way the renderer already refuses a heading it cannot -renumber. Deliberately a refusal in the generator rather than a separate gate: it makes the -wrong page impossible instead of detecting it afterwards, and it is scoped to the population -the renderer actually renders — module doc blocks — rather than to every `@example` line in the -package. - -⛔ The check never asks whether a run of prose is "really" a table. Shape-sniffing is exactly -what this renderer refuses to do, and what an author writes instead of a fence is not knowable -from the text. It asks only the question the contract already states: is there a block beneath -the caption? An author who wants those words as ordinary prose writes them without the tag. - -Both code kinds satisfy it. An indented block reaches the page as a fence — the render loop -re-emits it as one — so a caption above one captions a fence by the time a reader sees it. All -twelve captions in the corpus are fenced today and are unaffected; no schema behavior changes. diff --git a/.changeset/expression-contract-present-tense.md b/.changeset/expression-contract-present-tense.md deleted file mode 100644 index 14b118deaed..00000000000 --- a/.changeset/expression-contract-present-tense.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the Expression contract is stated in the present tense — the M9.1 / M9.2 phase language is dropped (#17849) - -Clause-②: no - -No accept-set change. `ExpressionSchema` still accepts `source` OR `ast`, every -evaluated slot still requires a non-blank `source`, and no key is added, renamed -or retired. What moves is the text six citation sites carried. - -Those docblocks promised a two-phase roadmap — "Phase 1 (M9.1): `source` is the -canonical persisted form … Phase 2 (M9.2+): `ast` becomes required in build -output" — that no ADR ever chartered, and the refusal sentence an author reads -carried the phase id inside it. #17323 ruled the promise removed: `ast` stays an -accepted optional structured value with no promise of becoming required. The -contract is now written as it actually is: - -- `source` is the canonical persisted form — it is what the engine evaluates; -- `ast` is accepted beside it as an optional opaque structured value, and - carries no promise of becoming required; -- a slot whose value the engine RUNS requires `source`, which is what - `EvaluatedExpressionSchema` spells out. - -**The one published string that moves** is `EVALUATED_EXPRESSION_SOURCE_REQUIRED`, -the sentence an author reads when an evaluated slot refuses a non-evaluable -envelope. It loses four words and nothing else: - -> … the expression engine evaluates `source` (the canonical persisted form of -> phase M9.1) and cannot evaluate `ast` alone … - -now reads - -> … the expression engine evaluates `source` (the canonical persisted form) and -> cannot evaluate `ast` alone … - -Nothing parses that sentence for its content: every consumer imports the -constant by name, and the two pending changesets that quote it verbatim -(`flow-edge-condition-evaluated-slot`, -`blank-node-condition-refused-at-registration`) already carry the new wording, -so the quote stays a quote. - -The `packages/formula` half of the same ruling — `cel-engine.ts`'s AST-only arm -and `normalize.ts`'s header — is comment-only and publishes nothing from that -package (`@objectstack/formula` ships `dist` alone), so it is not graded here. diff --git a/.changeset/field-notnull-prescribes-storage-not-required.md b/.changeset/field-notnull-prescribes-storage-not-required.md deleted file mode 100644 index 30bd42c35e4..00000000000 --- a/.changeset/field-notnull-prescribes-storage-not-required.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): `FieldSchema` no longer prescribes `required` for `notNull` / `not_null` — the flattened column-constraint spellings now name `storage: { notNull: true }` (#16867) - -Writing `notNull: true` (or `not_null: true`) on a field was refused — correctly — and then told to write `required` instead, via a rename row in `FieldSchema`'s alias table. `required` is the one key ADR-0113 exists to say is **not** the column constraint. `required`'s own description in the same file states the opposite of what the rename prescribed: *"NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`)"*. - -The failure mode was not the refusal — that fired, loudly, and did its job. It was the **remedy**: an author reaching for a NOT NULL column complied, wrote `required: true`, and received a nullable column plus a write-time gate, with nothing downstream to refuse it. The refusal read as though it had been satisfied. - -All three flattened spellings — `notNull`, `not_null`, and `storageNotNull`, which already carried the correct sentence — now get one prescription naming the real key: - -> physical column constraints live under `storage` — write `storage: { notNull: true }` (ADR-0113). There is no flat spelling of it: post-17 a column is NOT NULL because its author wrote that nested key, and for no other reason. It is NOT `required`, which is the WRITE contract (an insert must provide a value; an update may not null it out) and deliberately does NOT imply the column constraint — `required: true` alone leaves the column nullable. Write whichever of the two you meant, or both. - -Both halves are named on purpose: the defect being repaired is that the author cannot tell which of the two axes they are getting, so a prescription naming only the column half would have fixed the measured direction and opened the mirror-image one. - -**No accepted key moves.** `notNull` and `not_null` were refused before this change and are refused after it — a `guidance` / `guidanceSets` table decorates a rejection and never admits a key. Only the sentence attached to the refusal changed. `storage: { notNull: true }` parsed before and parses now; `isRequired` and `mandatory` are genuine spellings of the write contract, ADR-0113 moved neither, and both still rename onto `required`. - -One mechanical note for anyone repairing a table like this: the entry moved from `aliases` to `guidanceSets`, not to exact `guidance`. `aliases` is indexed by `aliasProbe` (case- and separator-folded, so one row covered `not_null` too) while exact `guidance` is matched case-sensitively on the authored spelling — a lone `guidance.notNull` row would have quietly dropped `not_null` onto the edit-distance fallback. The two spellings are pinned separately for exactly that reason. diff --git a/.changeset/field-type-refused-at-registration-door.md b/.changeset/field-type-refused-at-registration-door.md deleted file mode 100644 index 805fbb00939..00000000000 --- a/.changeset/field-type-refused-at-registration-door.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/metadata-core": minor -"@objectstack/objectql": minor -"@objectstack/metadata-protocol": minor -"@objectstack/driver-sql": minor -"@objectstack/cli": minor ---- - -fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) - - - -**BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. - -**What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. - -## What was wrong - -One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: - -| declaration | driver | `os generate migration --format sql` | `--format ts` | -|:---|:---|:---|:---| -| `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | -| `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | - -`SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. - -## What it does now - -- **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. -- **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. -- **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. - -## Scope, stated rather than left to be inferred - -`SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. - -ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. diff --git a/.changeset/file-family-bare-id-column.md b/.changeset/file-family-bare-id-column.md deleted file mode 100644 index 538b39f1fb6..00000000000 --- a/.changeset/file-family-bare-id-column.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -feat(driver-sql)!: the file family's physical column holds the bare `sys_file` id, per deployment (#15989) - - - -**BREAKING** on the published storage behaviour of `@objectstack/driver-sql`, under the maintainer ruling on #15041 (decision batch #49 item 1), verbatim: 「15041 应该改为实际 id 保存。选A,其他同意」. The physical column for the file family — `file` / `image` / `avatar` / `video` / `audio` — holds the **actual `sys_file` id**, a bare id string in a string column, rather than a JSON-quoted id in a JSON column. The SQL generator already emitted `VARCHAR(2048)` for the family and does not move; the driver is the side that moves. - -Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. - -**The switch is per DEPLOYMENT, and its default is today's encoding.** The ADR-0104 addendum forbids keying it on the `adr-0104-file-references` flag alone: every creation-attested store since 17.0 and every deployment that ran `os migrate files-to-references --apply` before a column step existed holds that flag *and* JSON-quoted ids in a JSON column. The evidence is `sys_migration.columns_moved_at`, which reaches this driver as the new published option `SqlDriverConfig.fileColumnsMoved` — a boolean or an async resolver, resolved once at `initObjects` and memoized. **Every way of not knowing answers "not moved"**: the option omitted, a resolver that throws, a resolver that never runs, a host that never calls `initObjects`. Absence is the JSON arm because every flag row that exists in the world today lacks the field, and a driver that guessed the other way would write bare ids into a JSON column. - -**What an UNMOVED deployment gets** — which is every deployment until something supplies that option — is today's driver, with exactly one answer changed: - -- the column is still `json` / `jsonb` / SQLite `TEXT`, the write still JSON-encodes, and `isJsonField` still answers `true` for the family; -- a media cell whose bytes are a JSON-quoted id **sitting in a character column** now reads back as the id instead of as the id with its quotes. That population is not hypothetical: a database built by `os generate migration --format sql` has a `VARCHAR(2048)` media column, and MEASURED on live PostgreSQL 16.13, the driver wrote `"file_01HXYZ"` into it and handed it back verbatim — every consumer that matches the raw stored form (file resolution, ownership claims) refused it. SQLite never had this defect: its read arm parses the cell and keeps the raw string when the parse fails, which is why the gap was a server-dialect one. - -**What a MOVED deployment gets**: the family leaves `JSON_COLUMN_TYPES`, so `isJsonField` / `formatInput` / `formatOutput` stop treating a single-value media field as JSON; `createColumn` builds `varchar(2048)` — the generator's own width, mirrored by `varcharColumnChars` so the drift detector reads the column the emitter actually builds; and the id on disk is the id. Throughout the window the read path accepts **both** encodings on every dialect, so a cell a column step has not converted still reads correctly. The decode is deliberately narrow — it engages only on a leading `"`, `{` or `[`, none of which can begin a `sys_file` id, a resolver URL or a `data:` URI — because an all-digit id would otherwise parse to a number. - -`multiple: true` media is unaffected on both arms: its value is a list of ids, it is a JSON column on every deployment, and `createColumn` decides `multiple` above its type switch. - -**The drift detector moves with the writer.** `JSON_COLUMN_FIELD_TYPES` no longer names the family, because the family is no longer a constant on either side; `diffManagedTable` takes a `fileColumnsMoved` input instead, and OMITTING it reproduces this module's previous verdicts exactly — an unthreaded caller keeps reporting a `varchar` media column as the corruption it still is on an unmoved deployment. Without this, a deployment that moved its columns would be told by its own tooling to convert them back to `json`, i.e. to undo the ruling. - -**Not shipped here, and named rather than implied:** the column step itself. `os migrate files-to-references --apply` does not yet retype or rewrite media columns, and nothing in this diff moves any deployment's storage. A deployment moves only when it runs that step and its host supplies the arm, and the two must be one act — MEASURED on SQLite: after the columns are converted, a driver still on the JSON arm reads the migrated column correctly but its next write re-quotes. diff --git a/.changeset/filter-operator-schema-projection.md b/.changeset/filter-operator-schema-projection.md deleted file mode 100644 index 015ca262e18..00000000000 --- a/.changeset/filter-operator-schema-projection.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): project a union branch-by-branch, so five filter operators reach a published reference page - -`z.toJSONSchema()` refuses a whole schema the moment ONE node in it has no JSON -form, and `build-schemas.ts` applied that refusal per SCHEMA. `orderingComparandSchema` -is `z.union([z.number(), z.date(), z.string(), FieldReferenceSchema])`, so four -`data/filter.zod.ts` exports emitted nothing at all — and `$gt`, `$gte`, `$lt`, -`$lte` and `$between` reached no reference row. Not a blank Description cell: no -section. The ~2000 characters of `.describe()` on those slots — the #5685 comparand -contract, the #6571 endpoint contract, and the `{ "$gte": "2026-01-01" }` shape the -platform's own date-macro resolver produces — reached no reader. - -The generator now makes a third attempt when both strict directions refuse: it -projects with Zod's `unrepresentable: 'any'`, marks every node that came back with -no structural keyword, and DROPS the marked ones that are direct members of an -`anyOf` / `oneOf`. That is not a narrowing. These artifacts describe JSON -documents, a JSON document cannot carry a `Date` INSTANCE, so the set of JSON -documents that union accepts is unchanged by the drop. - -⛔ A marked node anywhere else — an object property, a record value, an array item -— refuses the projection and the export is skipped with the message Zod threw, so -this cannot change WHY anything is skipped. Five exports leave -`unemitted-schemas.baseline.json` (23 → 18): the four filter exports, plus -`data/Hook`, whose only unprojectable member was the deprecated inline-function -handler branch — that puts 22 `data/Hook:` authorable keys under the key ratchet -for the first time. - -Published artifacts gain `json-schema/data/{ComparisonOperator,FieldOperators, -NormalizedFilter,RangeOperator,Hook}.json`, each carrying an -`x-unprojectable-branches` record naming exactly which branch the projection -dropped and where. diff --git a/.changeset/filter-orthography-binding-and-object-blocks.md b/.changeset/filter-orthography-binding-and-object-blocks.md deleted file mode 100644 index a9f2068f7ac..00000000000 --- a/.changeset/filter-orthography-binding-and-object-blocks.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the binding-level `dataSource.filter` and the four `object-*` `filter` doors converge onto the `ViewFilterRule` array form — one filter orthography platform-wide reaches the family (#15442, #15449; objectui#6206-B, decision batch #55 option A) - - - -**BREAKING** accept-set change at five doors — `ElementDataSourceSchema.filter` -(the `dataSource` binding every data-bound page component carries) and -`ComponentPropsMap['object-grid' | 'object-metric' | 'object-kanban' | -'object-calendar'].filter` — shipped as `minor` under the repo's launch-window -convention for breaking changes; the migration prescription is registered under -protocol major 18 as ONE entry for the family. - -One filter orthography platform-wide (maintainer batch adjudication 2026-08-25, -verbatim 「同意」; reached these two locations on 2026-09-06, decision batch #55, -verbatim 「同意」, option A: converge family-wide). Until this release the -binding alone declared the MongoDB-style record (`FilterConditionSchema`) — so it -refused the array the consumer's own pins author at that key, and -`element:record_picker` carried two orthographies at two keys resolved through -one `??` in the renderer — while the four `object-*` doors declared `z.unknown()` -and took the record, the ObjectQL AST tuple array and the rule array alike, -silently. All five now declare `z.array(ViewFilterRuleSchema)`, the form every -other `filter` door in the map already carried; the `FilterConditionSchema` -import that existed in `page.zod.ts` for this one site leaves with it. - -Sequenced measurement-first, as the family had to be: at the objectui pin -`a472b07` the `object-metric` aggregate path posted an array `where` that -`POST /analytics/query` refused (400 on every array form, #15828), so the -converge was parked behind the pin bump #16626. At the pin this repo builds -against (`53ded82b`, objectui#7754) the adapter lowers an authored array through -`translateFilterArray` and the spec's own `parseFilterAST` sink before the -wire; `ObjectGrid` lowers a rule array through `toFilterNode`; `ObjectKanban` / -`ObjectCalendar` hand it verbatim to `$filter`, where `convertQueryParams` -lowers it; the binding's composition seam AND-combines it with the named view's -rules through `mergeFilterNodes`. Nothing on those paths parses the value -against the installed spec. - -**Migration** (`element-data-source-and-object-block-filter-rule-array` — -listed by `os migrate meta --from 17` once the protocol major is 18): a -record-form `filter: { status: 'active' }` becomes -`filter: [{ field: 'status', operator: 'equals', value: 'active' }]`; an -operator object `{ status: { $ne: 'done' } }` becomes -`[{ field: 'status', operator: 'not_equals', value: 'done' }]`; several keys -become several rules (they AND); an AST tuple array -`[['owner_id', '=', '{current_user_id}']]` becomes -`[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` — -placeholders and date macros are unchanged. The record form is refused at -`filter` (`invalid_type`, expected array); the tuple array is refused at -`filter.0` (expected object). The dashboard widget `filter` -(`dashboard.zod.ts`) is a different family and is unchanged by this release -(#15829); `object-grid.defaultFilters` is a different key, not named by the -ruling, and is unchanged. - -In-repo authors migrated in the same change: four spec test fixtures at the -binding, five showcase authors (`my-work.page.ts`, `index.ts`) and three lint -fixtures. Type aliases: `ElementDataSourceParsed`, `ObjectMetricPropsParsed`, -`ObjectKanbanPropsParsed` and `ObjectCalendarPropsParsed` are declared (ADR-0122: -`operator` normalizes on parse, so input ≠ infer at these five schemas now). diff --git a/.changeset/flow-edge-condition-evaluated-slot.md b/.changeset/flow-edge-condition-evaluated-slot.md deleted file mode 100644 index 9b93572b236..00000000000 --- a/.changeset/flow-edge-condition-evaluated-slot.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `FlowEdgeSchema.condition` is an evaluated slot — it composes the new `EvaluatedExpressionInputSchema`, and `structuralConditionRefusal` no longer admits an `ast`-only envelope (#15807) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention: `major` is refused by `check-changeset-no-major`, and -breaking-ness is carried by this banner plus the ADR-0087 disposition): the -edge condition of a flow — `FlowEdgeSchema.condition`, the branch predicate -`AutomationEngine.evaluateCondition` runs at every traversal — now refuses at -authoring an envelope the engine cannot evaluate, where it used to parse, -register, pass `objectstack validate`, and then answer a **silent `false`**: a -branch that quietly never fired. - -Two spellings of one seam, refused by ONE rule with one sentence -(`EVALUATED_EXPRESSION_SOURCE_REQUIRED`, the rule #15430 introduced for the -`assignment` value envelope): - -```yaml -edges: - - { id: e1, source: check, target: approve, condition: { dialect: cel, ast: { kind: const, value: true } } } # `ast` only — the engine never reads it - - { id: e2, source: check, target: reject, condition: { dialect: cel, source: ' ' } } # blank after trimming - - { id: e3, source: check, target: escalate, condition: ' ' } # the shorthand for the same blank source -``` - -> An expression in an evaluated slot needs a non-blank `source`: the expression -> engine evaluates `source` (the canonical persisted form) and -> cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` -> that is blank after trimming, would validate and register and then fault at -> run time. Write `{ dialect: 'cel', source: '…' }`. - -- **New export `EvaluatedExpressionInputSchema`** (type `EvaluatedExpressionInput`), - the sibling of `ExpressionInputSchema` for an evaluated slot: the bare-string - shorthand still normalizes to `{ dialect: 'cel', source }`, but the string - must be non-blank after trimming, and the envelope arm composes - `EvaluatedExpressionSchema` (`source` required and non-blank) instead of - `ExpressionSchema`. `FlowEdgeSchema.condition` is the first slot to compose - it. An `ast`-only envelope and a blank bare string surface as one - `invalid_union` issue at the slot carrying the sentence above; a blank - `source` inside an envelope surfaces as one `custom` issue at `source`. -- **`ExpressionSchema` / `ExpressionInputSchema` are NOT narrowed.** They remain - the persistence contract (`source` OR `ast`), where `ast` is accepted as an - optional opaque structured value and carries no promise of becoming required. - If AST-only evaluation is ever chartered, `EvaluatedExpressionSchema` is the - one place to relax, and every evaluated slot follows. -- **`structuralConditionRefusal` no longer admits an `ast`-only envelope** on - either structural condition slot (`config.condition` on a node, - `edge.condition`). #15662's refusal admitted it on purpose through a - `rec.ast !== undefined` clause, because the spec still admitted the shape at - `edge.condition` and refusing it from the consumer side would have decided - #15430's question there; with the edge schema closed, that admission kept the - refusal deliberately holed for a shape the engine cannot run on either slot. - `STRUCTURAL_CONDITION_SHAPE_REFUSAL` now reads "an expression envelope - carrying a string `source`" and says why. Consequence on `config.condition` - (a start node's trigger gate, a decision node's predicate — an open record - with no schema in front of it): an `ast`-only envelope there is refused at - `registerFlow`, reported as a located `error` by `objectstack validate`, and - refused by `evaluateCondition` with the same sentence, instead of answering a - silent `false`. An `ast` BESIDE a string `source` is still admitted - everywhere. The whitespace-only STRING ruling on `config.condition` (#15662: - consistent `false` on both sides) is untouched by this change, but it does not - survive the release that carries it: two sibling notes in that release refuse - the value, at `registerFlow` (#17322, `@objectstack/service-automation`) and - at `objectstack validate` (#17495, `@objectstack/lint`). -- **Three doors agree, through the spec.** `registerFlow` refuses the flow at - `FlowSchema.parse` (edge) or at its structural pass (`config.condition`); - `objectstack validate` refuses it at its `ObjectStackDefinitionSchema` parse - (edge) or reports the structural refusal (`config.condition`); - `evaluateCondition` refuses the shape a stored flow or a direct caller hands - it. None of them grew a rule of its own. - -**What an author does with a refused edge condition.** An edge condition that -carried only `ast` has no evaluable form: author its `source`. A -whitespace-only condition — envelope or bare string — was never a predicate -(the engine answered `false`, so that edge never fired): remove the -`condition` key if the edge was meant to be unconditional, or write the -expression if it was meant to branch. Every edge condition with a -non-blank `source` is unchanged, and nothing is renamed, retired or rewritten — -the refusal itself carries the prescription. - -**A flow ALREADY STORED in `sys_metadata` stops running entirely — the whole -flow, not just the edge.** The paragraph above is the author's remedy, at -`objectstack validate` / `POST /api/v1/automation`; a stored row has no author in front of -it. Stored flows are deliberately NOT canonicalized by -`applyConversionsToStoredItem` (`spec/src/conversions/stored.ts`, and the same -skip in `metadata/src/loaders/database-loader.ts`'s `rowToData`) — flow-node -conversions need the automation engine's live executor registry, so flows -canonicalize at `registerFlow` instead, which parses through -`canonicalizeStoredFlow` → `FlowSchema.parse`. Each of the three boot paths in -`service-automation/src/plugin.ts` wraps that call in `try`/`catch`, logs one -`warn` naming the flow, and continues. So an edge that used to answer a silent -`false` while the rest of the flow ran now takes the flow down with it: it is -never registered, its trigger is never armed, and the only announcement is that -one warn line — `[Automation] failed to register flow` at boot, -`[Automation] cold-boot flow bind: failed to register flow` at the kernel:ready -bind, `[Automation] flow re-sync: failed to register flow` on a re-sync. That -warn line is also the locator: its `issues[].path` names the offending edge — -`edges[N].condition` — beside the sentence above, so nothing has to be exported -to find it. Author the `source` — or remove the key, if the edge was meant to -be unconditional — and republish. A stack authored in config files has a second -door, `objectstack validate`, which locates the same edge at -`flows.N.edges.N.condition`. Registered as the ADR-0087 D3 semantic entry -`flow-edge-condition-evaluated-slot-source-required`, which carries the same -judgment for a consumer replaying the chain. - -Not touched here: `start.config.condition` has no Zod schema to narrow (the -start node's `config` is an open record). Its producer-side gate is the -structural pass at `registerFlow` and `objectstack validate`: the shape refusal -above, which this change tightens but does not type, and after it a blank-source -check that runs this change's `EvaluatedExpressionInputSchema` on the -condition's `source` (added by #17322 at `registerFlow` and by #17495 at -`objectstack validate`). diff --git a/.changeset/flow-template-variable-roots.md b/.changeset/flow-template-variable-roots.md deleted file mode 100644 index 03ecf58f59c..00000000000 --- a/.changeset/flow-template-variable-roots.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -fix(lint): `validate-flow-template-paths` resolves flow-variable template roots, and gates the record trigger on the `record` root alone (#17305) - -The build-time guardrail against a template token that renders a silent empty -string could resolve exactly one root — `record` — and skipped any flow that was -not record-triggered. Both limits hid the failure it exists to catch, and both -are resolvable from the authored metadata alone: - -- A `get_record` node declares `objectName` **and** `outputVariable` in one - config, so the name it binds holds a record of a known object. `limit > 1` - switches the executor to a multi-record read, so that name holds an array and - is tracked as a list rather than a record root. -- A `loop` declares `collection` **and** `iteratorVariable`, so when the - collection names one of those lists, each element is a record of that object. - -`{caseRecord.owner_id.manager}` (a `get_record` output) and -`{currentCase.owner_id.manager}` (a `loop` iterator) are now judged by the same -two rules `{record..}` already was — `flow-template-unknown-field` -and `flow-template-lookup-traversal` — at the same position-based severity: an -`error` inside a filter-guarded CRUD node's `filter` (the node refuses to run, -framework#3810), a `warning` everywhere else. - -The record-trigger gate now applies to the `record` root alone. A `schedule` -flow's `get_record` output is as statically typed as a record-change flow's, so -such a flow is no longer skipped whole; `{record.…}` on it stays unjudged -exactly as before. - -**Newly reported, not newly refused by anything else.** No authorable key -changes, no export is added or removed, and no shape that parsed stops parsing. -What changes is that a flow whose template reaches through a variable can now -produce a finding. A root resolves only when nothing else in the flow can bind -that name — an assignment target, another node's `outputVariable`, an -`indexVariable` / `errorVariable`, a node id, or a trigger field flattened to -top level all make it ambiguous, and ambiguous stays silent. A `flow.variables` -declaration is deliberately **not** a second binder: it declares the slot the -node then fills, which is the shape `examples/app-todo`'s sweep flows ship. diff --git a/.changeset/fold-admission-tenancy-posture-classification.md b/.changeset/fold-admission-tenancy-posture-classification.md deleted file mode 100644 index 8a3dbb01c4e..00000000000 --- a/.changeset/fold-admission-tenancy-posture-classification.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/rest': patch -'@objectstack/cloud-connection': patch -'@objectstack/plugin-sharing': patch -'@objectstack/service-datasource': patch -'@objectstack/service-settings': patch -'@objectstack/service-storage': patch ---- - -refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) - -Six admission doors each hand-wrote the same try/catch on the `tenancy` read that -feeds `resolveAuthzContext`: the registry's branded "never registered" rejection -(`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the -supported no-tenancy composition, where no posture-conditional refusal runs at -all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` -(ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization -INPUT and admission was therefore never DECIDED. That is #13906 decision 1 -option A, and it is the part nobody may get wrong: a quiet `catch` at any one of -the six re-opens the defect, where a failure reads as "this check does not apply" -and an ex-member's org-stamped API key is admitted. - -Nothing is broken today — every copy was correct — so this removes a standing -hazard rather than fixing a defect. **No admission verdict changes**, on any -wiring: the classification is byte-for-byte the decision the six copies made, -now made once. - -- **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the - `TenancyServiceResolver` type), exported from the package index beside - `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. - The thunk is not a style choice: the REJECTION is what gets classified, so the - resolution has to happen inside the helper's `try` — a caller that awaited the - service first would need a `catch` of its own, which is the thing being - deleted. -- **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on - kernel-vs-provider, and asking twice would let a provider bound to the local - kernel answer for a request that resolved to another environment; four seams - read `ctx.getKernel()`; `service-storage` reads an already-normalised gate - registry; and each seam's reason why a MISSING async accessor must stay quiet - is its own argument (the storage door's is its declared degrade-to-ungated - contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper - that also owned how the service is reached would be wrong for one of them or - grow a flag per seam — the copies again, with an extra step. Every one of - those reasons stays written at its seam. -- **Folded**: `packages/rest/src/rest-server.ts` (both wirings), - `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, - `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, - `packages/services/service-datasource/src/admin-routes.ts`, - `packages/services/service-settings/src/settings-service-plugin.ts`, - `packages/services/service-storage/src/storage-service-plugin.ts`. -- **Pinned where the decision now lives**: - `packages/core/src/security/admission-tenancy-posture.test.ts` drives both - rejections at the production seam — a real `ObjectKernel` that never - registered `tenancy`, and one whose `tenancy` factory throws — each beside the - brand predicate's own answer on that same rejection, so "the outage throws" is - distinguishable from a helper that throws at everything. It also holds the - constraint mechanically: the helper's source may not name an accessor, a - kernel or a plugin context, and it takes exactly one parameter. diff --git a/.changeset/generate-migration-emits-declared-unique-index.md b/.changeset/generate-migration-emits-declared-unique-index.md deleted file mode 100644 index 07676f0bb3b..00000000000 --- a/.changeset/generate-migration-emits-declared-unique-index.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os generate migration` emits the field-level unique index the driver creates (#16317) - -## What was wrong - -Both migration formats emitted the table and none of the object's declared -uniqueness. Measured on live PostgreSQL 16.13 — one object driven through all -three producers into three schemas, `pg_indexes` read back per schema: - -```ts -{ name: 'probe', fields: { keyed_unique: { type: 'text', unique: true, maxLength: 100 } } } -``` - -| producer | before | after | -|:--|:--|:--| -| `driver-sql` via `initObjects` | `probe_pkey`, `uniq_probe_keyed_unique` | unchanged | -| `--format sql` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** | -| `--format ts` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** | - -Two rows with the same `keyed_unique` value were refused by the platform's table -(`23505 ... violates unique constraint "uniq_probe_keyed_unique"`) and accepted -by both generated ones, with nothing reporting it: a scaffold that creates the -table for an object silently dropped a uniqueness guarantee the object declares. -After the change the duplicate is refused by all three, each naming the same -constraint. - -The key set was not missing — it was already computed here to size the keyed -text family's columns; only the index it implies was never emitted. - -## What it does now - -- **`--format sql`** emits an inline `CONSTRAINT "" UNIQUE ()`. - That is what knex's `table.unique(columns, { indexName })` — the driver's own - call — compiles to on PostgreSQL, so a generated table and a platform-created - one agree in `pg_constraint` as well as in `pg_indexes`; and it stays inside - the statement's `IF NOT EXISTS`, which a following `ALTER TABLE ... ADD - CONSTRAINT` has no spelling for. -- **`--format ts`** emits that knex call itself, `indexName` included — which is - what makes the driver recognise the constraint as already present on its first - boot against a generated table, instead of adding a second one under its own - name and then reporting the generated one as an orphan to drop. -- Names come from a transcription of `driver-sql`'s `buildIndexName`, pinned - against the driver's own export (a CLI production module may not statically - value-import a driver package). - -## What it deliberately still does not emit — and now says so - -Both formats print a `NOT EMITTED:` line naming the index, its key parts and the -reason, instead of dropping it silently: - -- the **organization-scoped composite** (`unique: true` / `'organization'` on an - object with an organization column), whose key part is - `COALESCE(, '__global__')`. Emitting the bare composite - instead would be worse than emitting nothing: under SQL's NULL-distinct - `UNIQUE` it constrains no row that has no organization, which on a - single-tenant deployment is every row. -- an index over a column no field materialises (a virtual `formula` field) — - the same skip the driver performs, where the driver logs a warning. - -Object-level `indexes[]` remains unemitted by both formats; it is normalized by -a different driver-side rule and is not covered by this change. diff --git a/.changeset/generate-name-charset-gate.md b/.changeset/generate-name-charset-gate.md deleted file mode 100644 index 025e61854bb..00000000000 --- a/.changeset/generate-name-charset-gate.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli)!: `os generate` refuses a metadata name outside the charset `packages/spec` declares for an object `name`, before it derives anything from it (#16726) - -Maintainer ruling, decision batch #82 (2026-09-08), option A — **a gate, not a sanitiser**. `os generate ` used to accept any name at all; since #16724 it has refused names whose emitted TypeScript does not parse. It now also refuses, ahead of that check and ahead of every derivation, any name the object-`name` declaration in `@objectstack/spec` rejects. The refusal names the value and quotes the schema's own rule, and writes nothing. - -⛔ Nothing is rewritten. The rejected alternative was to derive a legal identifier the way `os create` does, which decouples the name the author wrote from the name that gets emitted with nothing announcing it — the failure mode that multiplies silently when metadata is written in bulk. So the name you author and the name that lands in the file are always the same string. - -**What this narrows:** kebab-case (`order-line`), uppercase (`Order`), dotted (`foo.bar`) and digit-initial (`2fast`) names were accepted before and are refused now — `order-line` used to generate `order_line.object.ts` binding `orderLine`. Write the snake_case name directly (`os g object order_line`). ⛔ No new charset was minted and no flag bypasses the gate; #16724's parse check is unchanged and stays as the backstop behind it (`class` passes the charset and is still refused for `object`, because `const class:` is not a declaration). - - diff --git a/.changeset/generator-declared-column-default.md b/.changeset/generator-declared-column-default.md deleted file mode 100644 index 831508a53d3..00000000000 --- a/.changeset/generator-declared-column-default.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): a generated migration carries the column DEFAULT `driver-sql` puts on the same field (#16294) - -## What was wrong - -Neither `os generate migration` format read a field's `defaultValue`, so a table -created from a generated migration had no column DEFAULT where the platform's -own table has one. A row inserted out of band — by a database client, a seed -script, anything that does not go through the engine — got NULL where the -declared value belonged. - -Driven on live PostgreSQL 16.13: one object, three schemas, one producer each -(`driver-sql` through `initObjects`, `--format sql` through `db.raw`, -`--format ts` by importing the emitted module and calling `up(db)`), with -`information_schema.columns` read back per schema. - -``` -field driver sqlgen verdict -f_default null=YES default='hello'::text null=YES default=- DIVERGED -f_default_required null=YES default='hello'::text null=YES default=- DIVERGED -``` - -After: `diverged: 0 of 6` on the card's probe, and 22 of 23 on a wider one -covering every `defaultValue` shape. - -## What changed - -Both formats now render one shared verdict, taken from -`SqlDriver.applyDeclaredColumnDefault` — the single place a `defaultValue` -becomes DDL on the platform side: - -- a **literal** is emitted, quoted the way knex binds it (`DEFAULT '42'`, not - `DEFAULT 42` — PostgreSQL keeps those two textually apart forever in - `column_default`, and the driver's column carries the quoted form); -- **`'NOW()'`** becomes the driver's own translation, which is type-branched: - `CURRENT_TIMESTAMP` on a timestamp column, and a UTC-pinned expression on - `date` / `time`, because a bare `CURRENT_TIMESTAMP` resolves those in the - server's timezone; -- **any other runtime token** (`current_user`), an **Expression envelope** and - an **option-level `default: true`** emit nothing, each because the driver - emits nothing — the engine owns those, and a column DEFAULT would override a - decision it makes deliberately; -- a **`multiple: true`** field gets neither, because `createColumn` returns - before both questions. - -No authorable key, export or accepted-input set changes: `defaultValue` was -already declared, already parsed and already honoured by the driver. The -generators simply now read it. diff --git a/.changeset/great-jars-sleep.md b/.changeset/great-jars-sleep.md deleted file mode 100644 index aa1ecfa67bd..00000000000 --- a/.changeset/great-jars-sleep.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/service-messaging': patch ---- - -fix(service-messaging): the durable fan-out refuses a channel nobody registered instead of writing a delivery row for it - -`MessagingService.emit()` on the reliable-delivery (outbox) path wrote one -`sys_notification_delivery` row per recipient for a channel the composition had -never registered, and the dispatcher dead-lettered every one of them on attempt -one. The inline path had always refused this case; only the durable path wrote -the rows, so a deployment whose flows notify on `['inbox','email']` without an -email plugin accumulated guaranteed-dead rows in the hot delivery table. - -The durable path now reports the same failed delivery outcome the inline path -reports — `ok: false`, `error: "channel '' not registered"`, counted in -`EmitResult.failed` — and writes no row. The refusal is logged once per channel -per emit with the number of rows it refused, not once per recipient. - -The refusal is deliberately **not** recorded in -`sys_notification.suppressed_channels`: that key answers "why can this tenant not -send on this channel", and an unregistered channel is a composition fact, -identical for every tenant in the process. The event row's column set is -unchanged. diff --git a/.changeset/great-pugs-attack.md b/.changeset/great-pugs-attack.md deleted file mode 100644 index 477e0651529..00000000000 --- a/.changeset/great-pugs-attack.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -Re-run the seed-ownership claim when the seed settles, and report whether each pass was final. - -`claimSeedOwnership` was reached exactly once per database lifetime, on the pass that promotes the first platform admin, while the platform's own seeder was still writing in the background — an app bundle that overruns `OS_INLINE_SEED_BUDGET_MS` (default 8 s) continues past kernel start rather than block it. Registry order and seed order are unrelated, so every object whose rows landed after that walk stayed `owner_id IS NULL` permanently: nothing re-ran the claim. Ownerless rows are invisible to every `readScope: 'own'` grant, and under `public_read` they read fine and answer 403 on every write at `modifyAllRecords: false` — a granted permission that can never be exercised. - -The claim now also runs on `app:seeded`, the published settle signal for that background continuation, against the same admin and with the same predicates — so it moves ownership for exactly the rows the promotion-time pass missed, and never for a row a human already owns. - -Two additive keys support it, both optional: `bootstrapPlatformAdmin` reports `adminUserId` on the promotion path and on the `already_have_admin` short-circuit (so the re-run reads the one existing holder scan instead of a second copy of it), and both `bootstrapPlatformAdmin` and `claimSeedOwnership` accept a `seedSettlement` snapshot read through the `seed-settlement` contract. No existing key, argument or return shape changed. - -Every claim pass now logs one line whether or not it claimed anything, and says whether its reading was final: a pass taken while a seed source is still writing is reported at `warn` as PROVISIONAL. Previously a pass that matched nothing logged nothing at all, so a boot that permanently orphaned rows and a boot with nothing to do produced identical evidence. diff --git a/.changeset/group-scheduled-work-per-record-ownership.md b/.changeset/group-scheduled-work-per-record-ownership.md deleted file mode 100644 index bb376732d5b..00000000000 --- a/.changeset/group-scheduled-work-per-record-ownership.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -"@objectstack/types": minor -"@objectstack/spec": minor -"@objectstack/trigger-schedule": minor -"@objectstack/metadata-core": minor -"@objectstack/cli": patch ---- - -feat(spec,types,triggers)!: `group` runs package-authored scheduled work without a declaration, owning each run's writes per record (#18378) - - - -`Clause-②: yes (widening)` - -**ADR-0087 disposition — `not-required (already-registered)`, not `registered`.** -The ledger entry this change belongs to already exists -(`schedule-flow-acting-organization-required`, entry 18) and predates this diff -at the merge base, so `registered` would assert a registration this PR did not -make. The entry's `surface`, `replacement`, `reason` and `acceptanceCriteria` -each gained their `group` row here, the rejected bootstrap-organization arm -included — recorded because it is the one a later reader will re-propose. - -**Marked breaking (`!`) for the behaviour change, not for a narrowing.** Nothing -that worked stops working and nothing that was admitted becomes refused — the -accept set WIDENS in one cell. What earns the banner is the other direction: on a -`group` deployment with the switch already on, flows that were refused at bind -now arm and run, so clock-driven work appears where an operator had none. That is -worth reading before upgrading even though no consumer has to change anything. - -## What changes - -With `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` on and tenancy posture `group`, a -time-triggered flow that declares no `config.organization` now **binds and -runs**, where it was previously refused at bind. The organization its writes -carry follows the record: - -| posture | declaration | a bound run's writes act as | -|---|---|---| -| `single` | not read | nothing — the install's one organization resolves beneath each write | -| `group` | **optional** | declared ⇒ the declaration; undeclared ⇒ **the swept record's own organization** | -| `isolated` | **required** | the declaration; undeclared ⇒ not armed, unchanged | - -A `timeRelative` sweep under `group` reads group-wide — inherent to the posture -(ADR-0105 D1) — and stamps each run it launches with that record's organization: -sweep contracts across four plants and each plant's contract yields a run acting -as that plant, whose notifications reach that plant's inboxes. - -## Why this is not a fallback that guesses - -It is the order `sys_automation_run` was **already** ruled to use. -`ObjectStoreSuspendedRunStore` resolves a run's organization as -`organizationOf() ?? ctx.tenantId` — subject first, acting -context as the fallback and never the primary. Before this change those two -halves disagreed under `group`: the history row was stamped from the record while -the inbox and delivery rows followed an acting context that could not exist -there, so they were refused while the tick summarised itself as healthy. - -⚠️ With one stated exception, because the two halves ask different questions: -the history row is STAMPED (`tenancy.organizationField` wins there) while the -run's acting organization is a WALL reading that never consults that key. They -agree on every object where the two coincide — which is every ordinary object, -since a declared stamp column is what makes them differ and one shipped object -declares one (`sys_api_key`, deliberately unwalled). Sweeping that object under -`group` stamps its history row while the run itself acts as nothing: the correct -pair of answers, not a residue of the old disagreement, and recorded rather than -smoothed over. - -⛔ A record-less run under `group` that declared nothing still resolves -**nothing** and is refused at its first tenant-scoped write (`walled-posture`, -ADR-0112), loudly and by name. The rejected alternative was a fallback to the -bootstrap organization (`slug='default'`): under a wall that organization is -minted admin-keyed by the enterprise organizations runtime and may not exist at -all, and where it does it is whichever organization the platform owner -registered under — plausibly one plant of many, not the group's head office. - -## Upgrading - -**Most deployments: nothing to do.** The switch this depends on is OFF by default -and ships unreleased alongside this change, so the `group`-is-walled behaviour -being amended has never appeared in a published version — no released consumer -can be relying on it. - -If you run posture `group` **and** turn the switch on, read your boot log: each -time-triggered flow's bind line now names which of the three shapes it bound as -("as organization '…'", "with per-record acting organization", or "with NO -acting organization"). Two things to check: - -- A flow you expected to act as ONE organization but which binds per-record is - missing its `config.organization`. Add it — declaring still narrows, bounding - the sweep's query as well as its identity. -- A plain `schedule` cron flow that binds "with NO acting organization" has no - record to derive one from. If it writes notifications, inbox messages or any - other per-organization row, declare `organization` on its start node; the bind - line says so, and so does the refusal at the first tick. - -## Which organization a record belongs to — the WALL question, not the stamp one - -`@objectstack/metadata-core` gains a second face on the record→organization -resolver, and the split is the point: `resolveRecordOrganizationField` / -`createRecordOrganizationResolver` answer **"who is this row ABOUT"** (the STAMP -question, whose `tenancy.organizationField` limb stays pinned to the three -sanctioned platform-row writers), while the new -`resolveRecordWallOrganizationField` / `createRecordWallOrganizationResolver` -answer **"what is this row WALLED by"** — `tenancy.enabled: false` ⇒ nothing, -then a declared `tenancy.tenantField`, then the kernel's `organization_id`. - -The sweep uses the WALL face, because "which organization does this run act as" -is a question about the wall. ⛔ It never reads `tenancy.organizationField`: that -key is declared on exactly one shipped object (`sys_api_key`, deliberately -unwalled, #8287), and reading it here would turn "the audit trail should follow -this row's own organization even though nothing walls it" into an acting -identity. A sweep over such an object resolves **nothing** and takes the -`walled-posture` refusal at its first tenant-scoped write, which is the honest -answer. Limbs 1 to 4 are one implementation shared by both faces, pinned as -such, so the half they agree on cannot drift apart. - -**API:** `ScheduledWorkPolicy` gains `runOwnership: 'unscoped' | 'per-record' | -'declared'`, and `requiresActingOrganization` narrows from "any walled posture" -to `isolated` only. The two are deliberately separate axes: the boolean decides -whether BIND refuses, `runOwnership` decides what a run that DID bind carries. -Inside `@objectstack/trigger-schedule`, both triggers share one bind-line -vocabulary (`describeScheduleRunOwnership`) so they cannot describe one -deployment differently. ⚠️ That helper is module-level, NOT a package export: it -is not re-exported from the package barrel, whose own note says an export whose -only consumers live inside its own package belongs in a non-barrel module. The -new PUBLIC surface in this change is `ScheduledRunOwnership` and the -`runOwnership` key on `@objectstack/types`, plus -`resolveRecordWallOrganizationField` and -`createRecordWallOrganizationResolver` on `@objectstack/metadata-core` — and -those four are what put `Clause-②` at `yes`. Nothing existing is renamed or -re-typed: both stamp-face exports keep their names, their signatures and their -answers, limb 0 included. diff --git a/.changeset/grouping-field-non-padded.md b/.changeset/grouping-field-non-padded.md deleted file mode 100644 index e23b4597558..00000000000 --- a/.changeset/grouping-field-non-padded.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: `grouping.fields[].field` refuses a padded field name instead of handing three renderers a lookup that always misses (#17360, ruling C on objectui#7347) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface. `GroupingFieldSchema.field` was a bare `z.string()`, so `' business_unit '` was valid authored metadata; it is now refused at parse. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying a padded grouping name now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `ui-list-view-grouping-field-padded-refused`. - -## What was wrong - -The padded name never failed anywhere. It failed to *group*. - -Measured on objectui (M1–M11, with live controls): the projection harvester `collectGroupingFieldRefs` **trims** the name when it builds `$select`, while **three** renderers bucket rows by the **raw** name — plugin-grid `usableGroupingFields`, plugin-list `ObjectGallery.groupedItems`, plugin-kanban `effectiveSwimlaneField`. So the server answers under `business_unit`, every per-row lookup asks for `' business_unit '`, reads `undefined`, and the view collapses into one `(empty)` group (grid, gallery) or one `Uncategorized` lane (kanban) holding every record. - -That is a silent wrong answer that reads as a true statement about the data: a user looking at one giant `(empty)` group has no way to tell it apart from a dataset where the field genuinely is empty. Nothing weaker than a parse refusal is honest about it. - -## What it does now - -`grouping.fields[].field` carries a **non-padded** pattern — no leading and no trailing whitespace. The refusal lands at `grouping.fields[N].field` (the offending element's own key, not the view or the array) and names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message, together with the trimmed name to write instead. - -⛔ **Not a `.trim()`.** A trimming schema makes `' a '` and `'a'` silently equivalent, which is the consumer-tolerance direction AGENTS.md #0.1 refuses: the padded spelling is a mistake the author should be told about, not a dialect the producer quietly normalises away. objectui's harvester trim stays as defence-in-depth; nothing is removed there. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `grouping: { fields: [{ field: ' business_unit ' }] }` | `grouping: { fields: [{ field: 'business_unit' }] }` | -| `grouping: { fields: [{ field: 'status\n' }] }` | `grouping: { fields: [{ field: 'status' }] }` | - -The remedy is always the same: write the field name exactly as the object declares it and the server answers under. If a view has been silently showing one `(empty)` group, re-authoring the name is also the fix for that. - -## Scope — what is deliberately NOT narrowed - -- **The blank name is unchanged.** It is already refused loudly one layer down by `compileListViewGroupQuery`'s `grouping_field_blank` (`400`, path `['grouping','fields',N,'field']`). This narrowing exists for the **silent** case; the empty string still parses here exactly as before. -- **This is not the snake_case machine-name grammar.** `packages/spec` spells `/^[a-z_][a-z0-9_]*$/` inline for object, field and tool **names**, and this key deliberately does not take it: a grouping level is authored as a field **reference**, and a dotted relationship path (`owner.name`) is an in-tree spelling of one. The ruling asked for a non-padded pattern and this is exactly that — nothing wider, nothing narrower. -- **The sibling `groupByField` axis** (kanban / gantt / timeline) is symmetric and is **not** touched by this change. - -## Who is affected, measured - -Every `grouping.fields[].field` spelling in this repo parses unchanged: 50 literal occurrences under a `grouping:` key across 19 files, harvested with the TypeScript parser and cross-checked against a deliberately over-approximating second pass over 906 shape-exact `{ field, order?, collapsed? }` literals in `packages/**`. The single harvested spelling this refuses is `' '` in `view-grouping-query.test.ts` — a **negative** fixture handed straight to `compileListViewGroupQuery` with no parse on its path, pinning that same `grouping_field_blank` refusal. Nothing in the tree reddens. - -Outside the repo, only metadata that was already grouping wrongly is affected: a padded name has never produced a correct grouped view on any renderer. - -## Consumer - -**objectui#7347 unblocks on the INSTALLABLE RELEASE of this package, not on merge.** Its side of the work — a pin bump plus a regression test that a padded name is refused before it reaches any renderer — needs a published `@objectstack/spec` to depend on, so it stays `pm:blocked` until this ships in a release a consumer can install. The gallery and kanban sites are covered by this one producer fix and get no cards of their own. diff --git a/.changeset/hono-adapter-declared-envelope-render.md b/.changeset/hono-adapter-declared-envelope-render.md deleted file mode 100644 index f6927fc8a51..00000000000 --- a/.changeset/hono-adapter-declared-envelope-render.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@objectstack/plugin-hono-server': minor ---- - -fix(plugin-hono-server): an escaped throw that declares an ADR-0112 envelope is answered as that envelope, not as a bare `500 INTERNAL_ERROR "No response from handler"` (#16545) - -`HonoHttpServer.wrap()` is the seam **every direct-mount route passes** — `get` / -`post` / `put` / `delete` / `patch` each register `this.wrap(handler)`, and -`IHttpServer` is how `service-datasource`, `packages/rest` and the dispatcher -bridge all mount. Until now a throw that escaped a route handler was answered -there as `500 { code: 'INTERNAL_ERROR', message: 'No response from handler' }`, -with the thrown value discarded — so a producer that had *declared* its refusal -lost both halves of the declaration on the way to the caller. - -The measured case: `service-datasource`'s `requireDatasourceAdmin` re-raises -`AuthzStoreUnavailableError` (declared `status: 503`, declared `code: -SERVICE_UNAVAILABLE`) when the authorization store cannot be read — deliberately, -per the #13279 ruling that an unreadable store licenses no verdict. The operator's -outage reached the caller as a generic fault naming the wrong component: the -declared code never arrived, and the message said "No response from handler". - -**What changed.** An escaped throw carrying **both** a declared ADR-0112 status -(a key of `HttpStatusErrorCodeMap`) **and** a code registered in `ErrorCode` -(`StandardErrorCode` ∪ `ERROR_CODE_LEDGER`) is now rendered as that envelope, -with the producer's `details` and `userMessage` channels forwarded. The status -and code are read through `resolveThrownHttpError` — the one rule the REST -registrar and the dispatcher already share — so this seam agrees with the other -doors by construction rather than by a second ladder. - -**What did NOT change**, pinned in the same PR: - -- an escaped throw that is **not** such an envelope answers exactly the bytes it - answered before — 500, no cause in the body. A partial declaration (status but - no code, code but no status), an unregistered code, and a status ADR-0112 does - not declare all take that arm; -- a handler that simply wrote nothing is untouched; -- a handler that **wrote and then threw** keeps what it wrote; -- the `notFound` fallback seam still answers `Fallback handler failed` — a - fallback that threw is a broken consumer, not a refusal it declared; -- ⛔ no error code is minted and no ledger row is added. A code on this path that - is not registered is a ledger gap under the #16404 ruling, and takes the - unchanged 500 arm rather than being registered in passing. - -The 5xx disclosure filter every door emitting a thrown message already runs -(`looksLikeInternalErrorLeak`, #3867 / #8086) is applied here from this seam's -first day: a driver dump on a declared 5xx is withheld, where the old bare 500 -disclosed nothing at all. The escaped-throw diagnosis (#5848) still fires exactly -once at `error`, and now names the answer that was really sent instead of -claiming an opaque 500. - -⚠️ **Known-unreached door, stated rather than left silent.** A route mounted -through `getRawApp()` funnels through neither `wrap()` nor any registrar wrapper, -so it is **not** repaired by this change and still answers a non-envelope -`text/plain` 500. That is out of this card's scope by the `domain:cli` seat's -ruling and is filed separately. diff --git a/.changeset/hook-input-is-the-persist-image.md b/.changeset/hook-input-is-the-persist-image.md deleted file mode 100644 index 52bcdb6727a..00000000000 --- a/.changeset/hook-input-is-the-persist-image.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor -"@objectstack/plugin-auth": patch ---- - -fix(objectql)!: `beforeUpdate` receives the record the engine intends to persist, and the caller's submission travels on `ctx.submitted` (#16344) - - - -**BREAKING** — what a `beforeUpdate` handler reads on `ctx.input.data` changes. A `readonly` field the caller supplied a value for is no longer there. The hidden set is the update strip's own subject set: author-declared `readonly: true` **and** the types whose value the runtime owns end to end (`autonumber`, implicitly read-only since #5503). `readonlyWhen` locks are deliberately not hidden. - -## The defect - -On update, a value sent for a field declared `readonly: true` was correctly **not persisted** — and was still handed to the object's `beforeUpdate` hook. A hook deriving columns from the incoming record therefore derived them from a value the row would never contain, and **those derived writes persisted**, because they are the hook's own. - -Measured on a real app (17.2.0, sqlite, dev runtime) and reproduced in `packages/objectql/src/engine-readonly-hook-input.test.ts`. One `PATCH { actual_value: 380, target_value: 1, weight: 1 }` against a `readonly` `target_value`: - -``` -read back: target_value 400 weight 10 ← the strip worked - score 1.2 calc_trace "实际 380 / 目标 1 … 权重 1%" -``` - -The row's own audit trail cites values the row does not hold. No error, no warning, 200, and `droppedFields` correctly reporting the strip the whole time — every channel said the write was fine, because by every channel's own lights it was. The only way for an application to be safe was for every hook to re-read its read-only columns and ignore the incoming record, which defeats declaring them read-only at all. - -## What changed - -**`ctx.input.data` on `beforeUpdate` is now the record the engine intends to persist.** Caller-supplied values for `readonly` fields are taken out of the hooks' view before the before phase is dispatched, and handed back at the engine's post-hook confluence — so the payload every engine-owned consumer below reads is byte-for-byte what it read before. `onFieldsDropped` reports the same fields with the same `readonly` reason, the read-only WARN says the same sentence, and `strictReadonlyWrites` refuses exactly the same writes. - -**The caller's submission travels on a new `HookContext` member, `ctx.submitted`** (`@objectstack/spec`, `HookContextSchema`) — the payload as sent, snapshotted at engine entry before any middleware or hook stamp, frozen, and documented as *diagnostics only, never the persist image*. It is bound on the update verb, both phases, and every per-row dispatch of one caller write. - -Two things deliberately did **not** move: - -- **The enforcement pass is still after the hooks.** It is the only point that can tell a hook's stamp from a caller's forgery (`hookWrittenKeys`), so a `beforeUpdate` that stamps a read-only column still lands — including when the caller echoed the same key back, which is the whole subject of #5591 / #14088. -- **`beforeInsert` is untouched.** The create side's strip position is settled post-hook by ruling C (#14147, "one semantics, one enforcement point"), and `readonlyWhen`-locked fields stay hook-writable per #9107. - -`@objectstack/plugin-auth`'s ADR-0092 identity write guard is migrated onto the new member in the same change, which is why nothing degrades: its 403 and its security warn still name the non-whitelisted field the caller sent. Without that migration the identical request answers `None of the submitted fields (—) are editable` — as strong a refusal, saying nothing about what was refused. Both readings are pinned side by side in `identity-write-guard.test.ts`. - -Ruled 2026-09-08 (maintainer, verbatim 「批 #87 同意」, director seat, decision batch #87). The refused primary was the same strip move **without** the new member: the ADR-0092 diagnostic degrades and every third-party `beforeUpdate` guard reading `ctx.input.data` degrades with it, silently. The refused alternative on the other side was documenting that hooks must read read-only columns from `ctx.previous` — which outsources the invariant to every application, the exact shape triage had already rejected. - -## Who is affected - -A `beforeUpdate` handler that **reads a `readonly` field (declared, or runtime-owned) out of `ctx.input.data`**, on a non-`isSystem` write. Three shapes, and the fix is one line each: - -- **deriving a value from it** — this is the defect; the handler now derives from `ctx.previous`, or from `ctx.input.data` with the payload's absence meaning "unchanged", which is what it always meant for a field the caller never sent. -- **reporting on what the caller sent** (a guard naming the offending key) — read `ctx.submitted`. -- **a self-assignment** (`data.x = data.x`) on such a field — this used to promote the caller's forged value to hook-owned and commit it. It is now a **no-op**: the key the hook reads is gone, so the line re-creates it holding `undefined`, and the engine treats set-to-undefined of a hidden read-only key as the no-op it is — deleting the key, dropping it from the hook-write record, and letting the ordinary hand-back put the caller's value back for the strip to judge. **The stored value stands**, and the write reports exactly as it would with no hook at all (stripped, `onFieldsDropped`, the WARN, `strictReadonlyWrites` refusing). Persisting the `undefined` instead would erase the stored value on the memory driver and hand knex an undefined binding on a SQL one — neither is the record the engine intends to persist. That laundering route closing is intended, and it is re-pinned in both directions rather than removed. - -⚠️ **The sharpest edge is a sandboxed `body` hook, and it is a refusal rather than a quiet change.** A body that reaches *through* such a key — `ctx.input.locked_meta.who = 'hook'` — now dereferences `undefined` and throws, and a `body`'s default `onError` is `abort`, so the caller's **whole write is rejected** where it used to succeed. What that body used to do was persist a value derived from the caller's forgery, so refusing is the correct direction; but the message the author sees is a raw `TypeError` from their own dereference and names nothing actionable. Measured end to end through a real QuickJS sandbox and pinned in `packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts`. - -A body hook cannot read `ctx.submitted`: it is deliberately not marshalled onto the sandbox face, for the reason `dispatch.scope` is not — that face is assembled key by key, and a key added there is a second published contract with its own compatibility story. A body deriving a column from a read-only field reads **`ctx.previous`**, the stored row, which is the correct source either way. - -⚠️ **One ADR-0092 boundary changes a status code, and no in-repo object hits it today.** On an object whose UPDATE whitelist admits a field that is ALSO declared `readonly`, a whitelist-only payload now answers **403** where it used to answer **200 having written nothing**. The identity write guard composes its refused list from what the engine left it, and a whitelisted key is excluded from that list by design, so the refusal reads `None of the submitted fields (—) are editable` — naming nothing. The write was already being dropped by the read-only strip before this change; what moves is that the caller is now told, and told imprecisely. `sys_user`'s three writable fields are not read-only, so nothing in this repository is on that boundary; an application that puts a `readonly` field in an UPDATE whitelist should take it out, which is what the whitelist meant either way. - -An `isSystem` caller sees no change at all: the strip has never applied to one, and neither does the hide. diff --git a/.changeset/hook-previous-row-invariant-rewrite.md b/.changeset/hook-previous-row-invariant-rewrite.md deleted file mode 100644 index 8b354b95ab9..00000000000 --- a/.changeset/hook-previous-row-invariant-rewrite.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `HookContext` admits a row-invariant-in-effect rewrite by per-row `previous` on a predicate write, kept safe by the engine's key-divergence refusal (#16074) - -The `hook.zod.ts` contract said that on a predicate (`multi: true`) write the per-row `previous` is supplied *so a guard can REFUSE (throw), not so a rewrite can be aimed*. Three shipped `beforeUpdate` provenance stamps (`sys_email_template`, `sys_sharing_rule`, `sys_webhook`) read `ctx.previous` per row and write `customized: true` conditioned on it — inside the letter of what the engine allows, outside the stated purpose of the input they use. Maintainer ruling (recorded by the director seat, decision batch #59, 2026-09-06), option 1: **the contract admits the shape.** - -The amended D3 clause (`HookContextSchema.input` TSDoc, mirrored in `bulk-write-hook-conformance.ts`) now states: - -- Per-row `previous` is supplied so a guard can REFUSE, **and** so a `before*` hook can make a **row-invariant-in-effect rewrite** — one whose written KEY SET is the same on every matched row **and is assigned in place** (`ctx.input.data.customized = true`, not a wholesale replacement of `ctx.input.data`). -- What makes that shape safe is the engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099): the dispatch records, per row, the payload keys that row's hook chain assigned **in place**, and if any two rows disagree the whole batch is refused **before any write**. In place is the condition the refusal rests on: a hook that REPLACES `ctx.input.data` leaves the dispatch unable to attribute keys, so the comparison is skipped and the batch is not judged at all. -- What an operator sees when it fires: an ADR-0112 envelope with `status: 400`, `code: 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE'`, `keys` (the sorted keys some rows' hooks wrote and others did not, e.g. `['customized']`), `rows` (how many rows the predicate matched), `object`, and a message that says "Nothing was written" before naming the remedy. A bulk edit over rows that already disagree on the stamp's condition is refused whole rather than half-stamped; that is the engine working, not the hooks misbehaving, and the remedy is the caller's — write those rows by id, or from inside the handler through `ctx.api`. -- Three shapes the rule does **not** admit: a rewrite whose written key set differs across rows (that is the refusal itself); the same key written with a per-row VALUE — the engine judges key sets, never values, so that shape clears the check and applies the last dispatch's value to every row; and a row-conditioned REPLACEMENT of `ctx.input.data`, which silences the recording above so that shape is judged by nothing at all. All three stay out of contract. - -Purely additive at the contract: no schema key, type or accept set of `HookContextSchema` itself changes, and the engine's behaviour is unchanged — the three stamps become conforming by amendment, and the rule for the next hook author is written down where the contract lives. Option 2 (change the hooks to stop aiming by `previous`) was not adopted: #15302 measured that declining on a predicate write leaves unstamped exactly the rows the next boot overwrites, turning a visible 400 into silent loss of an admin edit. diff --git a/.changeset/hook-register-undispatched-lifecycle-event-refused.md b/.changeset/hook-register-undispatched-lifecycle-event-refused.md deleted file mode 100644 index 2db4150a33f..00000000000 --- a/.changeset/hook-register-undispatched-lifecycle-event-refused.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/spec': minor ---- - -**BREAKING** `engine.registerHook` refuses an engine lifecycle event the engine never dispatches (#17713) - -`registerHook(event, handler)` took `event: string`. For a name outside the dispatched set it logged a warning and then **registered the handler anyway**, so the declaration succeeded and the handler never ran — ADR-0078's prohibited fourth state (parsed, unmarked, silently inert) on an authorable seam. - -The measured cost is a data-visibility one. A consumer registered **read filters** on `beforeFindOne` and `beforeCount`, expecting them to scope single-record reads and list totals. They sat inert through every boot behind ~40 warning lines: `findOne` was still filtered (`beforeFind` covers it, so the mistake gave no signal), `count` was not — a `limit`ed list answered a `total` counting rows the caller could not see — and `aggregate` was not either, so a `groupBy` was not narrowed at all. - -Six event names now throw at registration instead of registering inert. They are the engine's own lifecycle namespace — `before`/`after` × `OperationContext['operation']` — minus the eight the engine dispatches, derived in code rather than typed out. - -FROM → TO: - -| was | now | fix | -| --- | --- | --- | -| `registerHook('beforeFindOne', h)` | throws | register on `'beforeFind'` — it already fires for `findOne` | -| `registerHook('afterFindOne', h)` | throws | register on `'afterFind'` — same reason | -| `registerHook('beforeCount', h)` | throws | `count()` dispatches no hook; use `engine.registerMiddleware(fn)` and read `ctx.operation === 'count'` | -| `registerHook('afterCount', h)` | throws | same as `beforeCount` | -| `registerHook('beforeAggregate', h)` | throws | `aggregate()` dispatches no hook; use `engine.registerMiddleware(fn)` and read `ctx.operation === 'aggregate'` | -| `registerHook('afterAggregate', h)` | throws | same as `beforeAggregate` | - -One-line fix for a read filter that was on `beforeCount` or `beforeAggregate`: move it into `engine.registerMiddleware(async (ctx, next) => { if (ctx.operation === 'count' || ctx.operation === 'aggregate') ctx.ast.where = ctx.ast.where ? { $and: [ctx.ast.where, scope] } : scope; await next(); })` — the same seam RLS and sharing already use, so the predicate reaches the driver call. - -What is **not** affected: an event name outside the engine's lifecycle namespace (`'myPlugin:flush'`) still warns and still registers, so a plugin that dispatches its own events through `triggerHooks` keeps working. Metadata-authored hooks were never exposed — `HookSchema.events` is `z.array(HookEvent)` and `HookEvent` enumerates exactly the eight dispatched names, so the gap only ever existed on the code door. - - diff --git a/.changeset/hook-withheld-readonly-key-diagnostic.md b/.changeset/hook-withheld-readonly-key-diagnostic.md deleted file mode 100644 index bd3a4ae0122..00000000000 --- a/.changeset/hook-withheld-readonly-key-diagnostic.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a hook that faults reaching through a withheld read-only key now names the key, says the platform withheld it, and points at `ctx.previous` (#17219) - -Since #16344 the update path hides a caller-supplied static `readonly` value from `before*` hooks. A hook body that reaches **through** such a key — `ctx.input.locked_meta.who = 'hook'`, where `locked_meta` is a caller-supplied read-only `json` column — therefore dereferences `undefined` and throws, and a `body` hook's default `onError: abort` refuses the caller's whole write. - -**The refusal is correct and is unchanged.** What it replaced is a write that succeeded while persisting a value derived from the caller's forgery, and #16344 exists to close exactly that route. What this fixes is the diagnostic. Measured before this change, at both doors: - -``` -direct SandboxError: hook 'guard_task_body' threw: - TypeError: cannot set property 'who' of undefined -REST 500 {"error":"Internal server error","code":"INTERNAL_ERROR"} -``` - -The REST reading is the one that matters, and it is the worse of the two: a leading `TypeError:` is correctly classified as a script fault and sanitised (#7543), so an author was told nothing at all — not which key, not that the platform had taken it away, not what to read instead. - -### Who is affected - -Anyone whose `beforeUpdate` hook reads a read-only field that the caller may also send. The write was already being refused; only the message changes. A hook that needs the stored value reads it from **`ctx.previous.`** — the same remedy PR #17195's changeset documents. - -### What the message says now - -``` -A `beforeUpdate` hook faulted while `locked_meta` was withheld from it. That field is -`readonly: true`, and the engine withholds a caller-supplied value for a read-only field -from `beforeUpdate` hooks, so `ctx.input.locked_meta` reads `undefined` — withheld by the -platform, not missing by accident. Read the stored value from `ctx.previous.locked_meta` -instead. Original fault: TypeError: cannot set property 'who' of undefined -``` - -The error declares **HTTP 400**, which is what carries it past the script-fault sanitiser onto the same "message verbatim" channel a body's own authored refusal already rides; REST callers who previously saw `500 INTERNAL_ERROR` for this case now see 400 with the text above. The original fault is carried inside the message rather than replaced. - -### Deliberate limits - -No new error code is registered and no key is added to any published payload — a dedicated `ERROR_CODE_LEDGER` entry for this refusal is a separate decision. The explanation claims only what is knowable at the seam: *faulted while these keys were withheld*, never a proven cause. An **authored** refusal (`throw new Error('…')`) is never rewritten, and a crash on an operation where nothing was withheld passes through untouched. diff --git a/.changeset/hook-write-set-finding-path-lowered-handler.md b/.changeset/hook-write-set-finding-path-lowered-handler.md deleted file mode 100644 index 6b483879bb6..00000000000 --- a/.changeset/hook-write-set-finding-path-lowered-handler.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/lint": patch -"@objectstack/cli": patch ---- - -fix(lint): a hook write-set finding on a handler-authored hook reports `path: hooks[i].handler` — a key the author actually wrote — instead of the lowered `hooks[i].body.source` (#16546) - -`hook-api-update-readonly-field` / `hook-api-update-readonly-when-field` -(`validate-readonly-hook-writes.ts`) and `hook-body-write-unknown-field` / -`hook-body-write-unprovisioned-anchor` / `hook-body-source-unparseable` -(`validate-hook-body-writes.ts`) all report their `path` against `hook.body`, -because that is the shape they parse. For a hook authored as an inline -`handler: async (ctx) => { … }` (39 of 39 hooks in the reference app), -`hooks[i].body` is not something the author wrote at all — `lowerCallables` -mints it from the handler before `os build` / `os lint` hand the stack to -these rules (#16095). The reported `path` therefore named a key that does not -exist in the author's own source file; grepping for `body.source` there finds -nothing. - -**What changed.** `lowerCallables` now records, per `lowerCallables()` call, -which `hooks[*].handler` ref strings got their `body` minted this way (as -opposed to a `body` the author wrote directly). The CLI's four lowering doors -(`os build`, `os lint`, `os validate`, `os init`/`dev`'s scaffold validation) -pass that set through `runAuthoringRules`'s `ctx.loweredHookRefs`, and the two -hook write-set rules use it to redirect a finding on a lowered hook to -`path: hooks[i].handler` — the key that replaced the function the author -wrote — with a message suffix ("judged on the metadata body lowered from the -inline handler") explaining why. A hook whose `body` the author wrote directly -is unaffected: `path` stays `hooks[i].body.source`, unchanged. - -**No verdict changed.** Which hooks are flagged, at what severity, and why is -untouched — #13653 and #4271 are unmoved by a word. Only the location a -finding points at, and the wording explaining it, are different. `os build` -and `os lint` continue to report the identical `path` and message for the -same hook (#16095's "one implementation, both commands agree" — now including -this). - -No `--json` field was added or removed: `path` and `message` keep their -existing shape (string), and this is a within-type value correction for the -one subclass whose old value could never be resolved against the author's -source in the first place. diff --git a/.changeset/host-importer-location-install-diagnostic.md b/.changeset/host-importer-location-install-diagnostic.md deleted file mode 100644 index e5c358a01d2..00000000000 --- a/.changeset/host-importer-location-install-diagnostic.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/types": patch ---- - -`createHostImporter` stops prescribing an install repair for a `link:` / `file:` install that is already correct. The refusal is unchanged; only its wording is. - -A host app declaring `{"foo": "link:../bar"}` links `node_modules/foo` to a directory whose manifest may be named anything. `link:`, `file:` and git or tarball URLs name a LOCATION or a remote artefact, never a package, so the specifier carries no name for the ESM-only fallback finder to expect and the KEY stays the expectation — kept deliberately, because widening it would accept any directory sitting at the key and trade a wrong REMEDY for a wrong LOAD. When the linked manifest names something else the finder therefore refuses, and it was reporting that refusal with the `declared-unresolvable` INSTALL wording: run `pnpm install`, check a production prune did not drop it, check the dist was built. Driven on a real symlinked install, all three are measurably false — the finder had just read the manifest at `node_modules/foo`, so the package is on disk, was not pruned, and its `import` target exists. The operator reinstalls, nothing changes, and they go looking for a build that is not broken. - -That sub-case now states what was actually measured: the directory it consulted, the name the manifest there carries, the name it expected, and why a location specifier leaves it with only the key. It says outright that this is neither an install nor a declaration problem, and closes with the remedy that does work — make the two names agree, by declaring the linked package under its own name or by renaming the linked manifest to the key. Both ends are pinned as loading. - -Unchanged: the refusal itself, its `declared-unresolvable` kind, its `MODULE_NOT_FOUND` code and every consumer branch that reads them; the finder's accept set, which is byte-for-byte what it was — a `link:` install whose manifest matches the key still loads silently, and a plain range or an `npm:` alias whose directory holds a different package still gets the INSTALL wording, because there the install really is the fault. The second verification axis that would make these installs LOAD (comparing `realpath(node_modules/)` against the declared location) is deliberately not built here. diff --git a/.changeset/host-resolution-control-fixture-name.md b/.changeset/host-resolution-control-fixture-name.md deleted file mode 100644 index 29e8e6ef948..00000000000 --- a/.changeset/host-resolution-control-fixture-name.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/verify': minor ---- - -verify: let `bootStack` be told which package `multiTenant: true` resolves, so the -`declared-unresolvable` control can name a subject the workspace can never supply - -`BootOptions` gains an optional `organizationsPackage`. It defaults to -`@objectstack/organizations` and production callers never pass it — the -operator-facing error still names that package literally, because in every -production boot it is the subject. Only the specifier moves. - -Why it exists: a fixture whose whole content is "this host root DECLARED the -package and does not have it" cannot state the second half with a name the -workspace owns. Since ADR-0132 the multi-org runtime is a tracked workspace -package, pnpm's hoisted store carries it, and a `pnpm exec`-launched runner -exports a `NODE_PATH` that reaches that store — so such a fixture resolved the -package out of the ambient workspace the moment it had been built, and its -verdict became a function of an unrelated package's build state rather than of -its own directory. The harness's own host-resolution control now hands in a -`@fixture/*` name and proves the absence instead of assuming it, the repair -already landed for `packages/qa/dogfood` and `packages/types`. diff --git a/.changeset/hungry-doors-invent.md b/.changeset/hungry-doors-invent.md deleted file mode 100644 index 13203976b2b..00000000000 --- a/.changeset/hungry-doors-invent.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/rest': patch -'@objectstack/runtime': patch ---- - -Attach four TSDoc blocks to the declarations they describe. - -TSDoc binds a block by position, so a block can end up describing a declaration -it does not document, or none at all. Four had: three in -`packages/rest/src/rest-server.ts` (the `resolveProtocol` paragraph stacked above -`resolveHostnameCached`'s own block, the exported `RestServer` class overview -orphaned by the `RestEnvRegistry` block, and the `registerSharingEndpoints` route -table orphaned by the analytics block) and one in -`packages/runtime/src/http-dispatcher.ts`, where the block above -`resolveActiveOrganizationId` still described `resolveCallerUserId`, a sibling -deleted with the multi-tenant `/cloud` control plane. - -No runtime behaviour changes and no API surface moves. This is a `patch` rather -than `skip-changeset` because the block text was measured to ship: each of the -four appears in the published `dist/index.d.ts` and `dist/index.d.cts` of its -package, both of which are inside `files: ["dist", ...]`. Anyone reading -`@objectstack/rest` or `@objectstack/runtime` declarations in an editor was being -shown a description of the wrong function. - -Clause-②: no diff --git a/.changeset/hungry-pugs-shave.md b/.changeset/hungry-pugs-shave.md deleted file mode 100644 index 95f54034a37..00000000000 --- a/.changeset/hungry-pugs-shave.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/plugin-security': patch -'@objectstack/metadata-protocol': patch ---- - -`DELETE /api/v1/data/sys_permission_set/{id}` stops reporting a deletion it did not perform. - -A package-declared permission set cannot be deleted from an environment: its delete is an -ADR-0005 RESET — the overlay tombstones and the record re-projects to the declared body. -That behaviour is unchanged and deliberate. What was wrong is the answer: the door replied -`200 {"object":…,"id":…,"success":true}`, byte-identical to a real deletion, so a caller -that meant to revoke a permission set was told it was gone while it was still enforced, and -a UI fired a success toast and showed the row again on refresh. - -The write-through's delete leg now reports how many of the addressed records actually went, -and `deleteData` maps that onto the already declared `success` key instead of hard-coding -`true`. No key is added to `DeleteDataResponseSchema`. - -On the wire: - -- packaged set — `200 {"success":false}`, the record still present with the same id (was - `success: true`); -- environment-authored set — `200 {"success":true}`, the record really gone (unchanged); -- unknown id — `404 RECORD_NOT_FOUND` (unchanged: zero-removed is deliberately not read as - not-found, because the record is still there to GET). - -The read-back that decides this is fail-closed: a read that cannot answer reports the record -as NOT deleted and warns on the durability channel, because "the read failed" and "the row is -gone" are opposite facts and only the second may claim a deletion. - -Clause-②: no diff --git a/.changeset/i18n-check-platform-bucket-and-app-gating.md b/.changeset/i18n-check-platform-bucket-and-app-gating.md deleted file mode 100644 index bd1f096b9cc..00000000000 --- a/.changeset/i18n-check-platform-bucket-and-app-gating.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): `os i18n check` counts the coverage an app actually owns, so `--strict` / `--threshold` can gate an app package (#16681) - -## What was wrong - -`collectExpectedEntries` walks the Studio metadata-form registries -unconditionally — identically for every config, an empty one included — so -every stack's expected set carries ~773 `metadataForms.*` keys that -`@objectstack/platform-objects` translates and the runtime already serves. - -Two of the three commands that see that family already knew it is not the -author's. `os lint` hides it and says so ("platform built-ins: 773 i18n -issue(s) hidden — rerun with `--include-platform`"); `os i18n extract` has -`--no-metadata-forms`. `os i18n check` is the one command that publishes a -**percentage**, and it carried the baseline in its denominator: - -``` -Coverage by locale - en ████████████████████████ 100.0% (1265/1265, missing 0) - zh-CN █████████░░░░░░░░░░░░░░░ 38.9% (492/1265, missing 773) -``` - -That is an application with every key it owns translated. `--strict` and -`--threshold` — the two flags whose entire purpose is CI gating — therefore -could not gate an app package at all, and the only way to move the number was -to ship a copy of the platform's bundle, which would *override* the platform's -own and go stale at the next upgrade. The workaround was worse than the defect. - -## What it does now - -**Ownership is observed, not assumed.** The baseline counts toward coverage -when the stack under examination ships those translations itself, and does not -when it does not — read from the config's own `translations` bundles, requiring -a non-empty string leaf so an `--fill=empty` scaffold is not mistaken for a -claim of ownership. An app gets a number about its own surface with no flag; -`platform-objects`, which does ship the family, stays gated on it with no flag -either. An unconditional exclusion would have turned the app side green by -deleting the platform's own gate, and is what the negative-control tests forbid. - -**The flag is `os lint`'s, spelling and all.** `--include-platform` forces the -baseline in; `--no-include-platform` forces it out, for a package that ships a -partial baseline and does not intend to own the rest. Absent, the decision is -the observed one — three states, not two. - -**Both output faces carry the decision.** `--json` gains -`platformMetadataForms: { mode, excludedKeys }`, and the console prints -`platform built-ins: N key(s) not counted — rerun with --include-platform to -gate them here` under the coverage table, rendered from those same two numbers. - -`os lint` is unchanged. The shared `computeI18nCoverage` seam still counts the -baseline by default, because lint folds it away one seam later and counts what -it folded for its own hint line. - -## Compatibility - -Additive on the command surface; an invocation that was refused is now -accepted, and no flag is removed or renamed. The behaviour that changes is the -**default coverage number for a stack that ships no `metadataForms` bundle** — -it stops reporting a debt that stack must not pay. A run that wants the old -numbers back asks for them with `--include-platform`, on the same argv. diff --git a/.changeset/i18n-inline-locale-map-population-count.md b/.changeset/i18n-inline-locale-map-population-count.md deleted file mode 100644 index a625886cf4b..00000000000 --- a/.changeset/i18n-inline-locale-map-population-count.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`i18n.zod.ts` stops asserting a stale size for the inline-locale-map population. - -Two docblocks in this file each stated that the repo authors 31 inline locale maps — the -`INLINE_LOCALE_KEY` rationale ("Every inline map authored in this repo (31 of them, across -three platform pages) uses `en` / `zh-CN` / `ja-JP` / `es-ES`, so the constraint costs no real -authoring surface") and the `I18nLabelSchema` form-2 note ("Three published platform pages -author 31 of these"). The measured population is 45: 33 in `sys-user.page.ts`, 6 in -`sys-organization.page.ts`, 6 in `sys-position.page.ts`. - -The number is **dropped** at both sites rather than corrected to 45. Neither sentence's -argument needs a magnitude. The first turns on the universal — *every* authored map uses those -four tags — so the accept set is what makes the constraint free, not the size of the set. The -second turns on the map being authored on published platform pages *and* resolved by -`pickLocalized`; one authored-and-resolved map already refutes "a convention the runtime -ignores", so the count was never load-bearing there either. Writing 45 would buy one release of -accuracy in prose that is cited as evidence for a schema constraint, and the figure has already -drifted once with nothing noticing; deriving it would mean a permanent gate whose only job is -keeping a number in a comment true. - -The measured half survives untouched at both sites: three platform pages author these maps, and -that is still exactly three. No schema arm, bound, default, `.describe()` string or export -changes; nothing an author can write is affected. diff --git a/.changeset/i18n-slotted-pages-and-global-filters.md b/.changeset/i18n-slotted-pages-and-global-filters.md deleted file mode 100644 index 261148b5c3f..00000000000 --- a/.changeset/i18n-slotted-pages-and-global-filters.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/cli": minor -"@objectstack/platform-objects": minor ---- - -Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). - -**BREAKING** (return shape) — `walkAddressedPageComponents` is a published export of `@objectstack/spec` and its return value is now the rebuilt roots pair `{ regions?, slots? }` where it used to be the regions array alone. A caller that only enumerates components through the visitor and ignores the return value is unaffected. A caller that reads the return value binds `const { regions } = walkAddressedPageComponents(doc, visit)` and reads `regions` exactly as it did before; `slots` is the other half of the same rebuild and is present exactly when the input page authors slots. The bump stays `minor` because the launch-window convention `scripts/check-changeset-no-major.mjs` enforces refuses a `major` while the fixed group is in lockstep — during that window the version number carries nothing about breaking-ness, so this banner and the disposition below are the carriers. - -**`walkAddressedPageComponents` widens in both dimensions.** The shared page walk behind `translatePage` and the CLI extractor (`os i18n extract` / `os i18n coverage`) rooted at `regions[].components[]` only and descended `properties.children` only. A slotted record page authors `regions: []` and puts everything under `slots.`, so the walk visited nothing on it and `pages.` carried exactly two addressable keys however many components the page authored; a `page:tabs` / `page:accordion` keeps its panels' components under `properties.items[].children`, one level deeper than the descended slot, so a related list inside a tab was unreachable on any page kind. The walk now roots at `regions[].components[]` **and** `slots.` (one component or an array per slot, regions first, then slots in authored order — both root level for the collision arbitration and for the page-name `page:header` route, so a slotted page's `slots.header` is translated as the page's header), and descends `properties.children` **and** `properties.items[].children` (matched by shape, so a custom container speaking the same vocabulary is walked too; `body` / `footer` remain undescended — a renderer back-compat fallback, not an authorable spelling). The depth cap, the cycle guard and the ruled id arbitration are unchanged. - -- Signature: the parameter is `AddressedPageRoots` (= `Pick`) instead of `Pick`, and the walk returns the rebuilt roots pair `{ regions?, slots? }` (each key present exactly when present on the input) instead of the regions array alone. `PageLike` gains `slots`. An enumeration-only consumer that ignores the return value needs no change; a consumer reading the returned regions destructures `{ regions }`. -- `translatePage` carries the rebuilt `slots` back onto the document. - -**`dashboards..globalFilters.` is a new bundle group.** A dashboard's filter bar draws directly above the widget titles the bundle has always translated, and neither a filter's label nor its static option labels had a key. The group is keyed by the filter's `name` (`GlobalFilterSchema.name`, declared as defaulting to `field` — a filter that authors no `name` is keyed by its `field`) and carries `label` and an `options.` map keyed by the option `value` spelled as a string. `translateDashboard` overlays it on the served document, which is what objectui's filter bar already reads; the exported `globalFilterKey()` is the one key derivation both the resolver and the extractor use. `optionsFrom` options are fetched rows and are deliberately not addressable. - -**`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. - -**`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards..globalFilters.created_at.label` entry for the system-overview dashboard's date-range filter, which authors no `name` and is therefore keyed by its `field`. - -**Why no ADR-0087 ledger entry.** Nothing an author writes moves. The authorable side is purely additive — `dashboards..globalFilters.` is a new optional group and every bundle that was valid before is valid unchanged — no spec key is retired, no stored `sys_metadata` shape changes, and no conversion or migration id is touched, so `objectstack migrate meta` has nothing to act on. The one incompatible surface is a published function's TypeScript return type, which reaches every affected consumer through the compiler. - - diff --git a/.changeset/id-field-retirement-declared.md b/.changeset/id-field-retirement-declared.md deleted file mode 100644 index 503f9c965e6..00000000000 --- a/.changeset/id-field-retirement-declared.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`id_field` now gets a named answer instead of a bare refusal: `FIELD_KEY_GUIDANCE` declares it a retirement with **no successor**, which is the spec-side fact objectui's ingestion choke point needs before it can canonicalise the key (objectui#7650 ruling A — retired spellings are folded once, at ingestion, never at the consumer). - -The direction was a factual finding, not a preference, and it went the way the cheaper branch happens to point — so here is the evidence rather than the verdict alone. A lookup stores the referenced record's id, and which field holds that value is not an authored per-field choice: the picker resolves record identity itself. Nothing on `FieldSchema` names it, nothing in `objectql` / `runtime` / `metadata-protocol` reads a per-field id key, and the two places the platform does let a reference be stored by something other than an id are declared elsewhere — `APPROVER_VALUE_BINDINGS.valueField` (per approver type, e.g. `position` routing by `sys_position.name`) and a seed dataset's `externalId`, the channel lookup references already resolve through. So there is no member to fold onto, and the prescription says what to reach for instead: `displayField` for the candidate's label, a dataset `externalId` for a portable natural key. - -**The entry is keyed `id_field`, in snake_case, and that is deliberate.** The two channels this table feeds disagree about the key face. A `to` becomes a `strictObject` alias, matched through `aliasProbe` — case folded, separators stripped — so one camelCase row covers every spelling. A `why` becomes strict guidance, matched exactly and case-sensitively on the authored spelling. A camelCase row would therefore never be reached by the key authors write, and every existing test in the file would still pass, because none of them asks whether an entry is ever consulted. - -That gap is closed too. Three assertions read the channel that actually answers an authored field key — `FieldSchema.safeParse`, since the schema is strict and the authoring-key walker stays silent on a strict surface by its own posture rule — and pin that the refusal carries this table's sentence verbatim, that a retirement suppresses the rename channel, and that the same-named `idField` on the `inlineColumns` GridColumn mirror is a different schema that stays live. diff --git a/.changeset/import-protocol-implementor-typed.md b/.changeset/import-protocol-implementor-typed.md deleted file mode 100644 index 4cb5cf8fe0b..00000000000 --- a/.changeset/import-protocol-implementor-typed.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): let `ImportProtocolLike` type the admin import protocol's members (#17422) - -`admin-import-users.ts` is the only hand-written in-repo implementor of the runner's `ImportProtocolLike`, and it annotated all three required members `args: any`. An explicit parameter annotation wins over the contextual type, so #16952's newly declared request dialect held every implementor except this one — the one with a demonstrated history: before #16950 this file read `args?.query?.$filter ?? {}`, the runner moved to the canonical spelling, the read went `undefined`, and the `?? {}` default degraded the import's duplicate probe into match-everything, so `POST /api/v1/auth/admin/import-users` updated the wrong users without a sound. - -The three annotations are deleted, so `findData` / `createData` / `updateData` are typed by the contract they implement. Measured: with the annotations gone, reading a retired wire alias (`args.query?.$filter`) is `TS2339 Property '$filter' does not exist on type 'QueryInput'`; with `args: any` restored the identical probe type-checks at exit 0. - -`FindDataRequest` declares `query` optional, so `findData` now states its refusal in code — a thrown `Error` carrying the already-registered `INVALID_REQUEST` code — instead of relying on an incidental `TypeError` from a property read on `undefined`. No `??` fallback and no optional chaining were added: both spell match-everything, which is the defect this closes. - -No API, request body, response shape or exported signature changes. A caller that reaches `findData` through `runImport` always supplies `query`, so no supported call moves; only a protocol call that was already failing now fails with a code attached. diff --git a/.changeset/import-protocol-typed-args.md b/.changeset/import-protocol-typed-args.md deleted file mode 100644 index ba2c88d924f..00000000000 --- a/.changeset/import-protocol-typed-args.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -refactor(rest)!: `ImportProtocolLike` declares the request each of its three required members receives, instead of `args: any` (#16952) - -The exported extension point `runImport` accepts a protocol through now states its own contract. - -**FROM** — every required member erased its parameter, so the interface declared nothing about the request it would hand an implementor: - -```ts -export interface ImportProtocolLike { - findData(args: any): Promise; - createData(args: any): Promise; - updateData(args: any): Promise; -} -``` - -**TO** — each member names the declared spec request, wrapped in the server-scoped envelope the runner adds (`ImportProtocolRequest`, exported alongside): - -```ts -export type ImportProtocolRequest = R & { context?: any; environmentId?: string }; - -export interface ImportProtocolLike { - findData(args: ImportProtocolRequest): Promise; - createData(args: ImportProtocolRequest): Promise; - updateData(args: ImportProtocolRequest): Promise; -} -``` - -**Why this is breaking-ish, and released as `minor`.** This is a narrowing of a published surface: an implementor that compiles today may stop compiling. Nothing about the values the runner sends changes — the request objects are byte-for-byte the ones #16638 already made canonical — so no runtime behaviour moves. What changes is that the compiler now holds an implementor to the same `QuerySchema` the runner is held to: `where` / `limit` / `offset` / `fields` / `orderBy` / `expand` are declared, and the wire spellings `$filter` / `$top` are not. - -**Migration for implementors.** If your `findData` / `createData` / `updateData` reads a wire alias, it will now fail to compile — that diagnostic is the point of this change, and the fix is to read the canonical key: - -```ts -// before — compiles, and silently degrades to match-everything when `$filter` is absent -async findData(args: any) { - const where = args?.query?.$filter ?? {}; - const limit = args?.query?.$top ?? 2; -} - -// after — drop your own annotation and let the declaration type the parameter -async findData(args) { - const where = args.query!.where; - const limit = args.query!.limit; -} -``` - -⛔ An implementor that keeps an explicit `args: any` annotation of its own opts back out: the annotation wins over the contextual type, and the contract reaches nothing. Leave the parameter unannotated, or name `ImportProtocolRequest` explicitly. - -⚠️ The `?? {}` shape in the "before" is the mechanism that made a dialect mismatch silent rather than loud: an unrecognised query does not throw, it degrades into a filter that constrains nothing, so a duplicate probe stops discriminating and an upsert updates the wrong record. Prefer a read that throws. - - diff --git a/.changeset/import-runner-canonical-query-ast.md b/.changeset/import-runner-canonical-query-ast.md deleted file mode 100644 index 06b036abfd1..00000000000 --- a/.changeset/import-runner-canonical-query-ast.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -`import-runner.ts` builds its three server-side `findData` requests in the CANONICAL QueryAST, and the helper that carried them is typed against the declared contract instead of `any`. - -`FindDataRequestSchema` declares `query: QuerySchema.optional()`, and `QuerySchema` declares `where` / `limit` / `offset` / `fields` / `orderBy` / `expand` — it declares neither `$filter` nor `$top`. The normalizer's own table calls those two "the wire-only spellings no schema declares". The reference resolver, the duplicate probe and the id recheck each built a literal in that undeclared dialect, and nothing reddened because the helper they went through took `query: any`: the literals were type-checked by nothing at all, so the undeclared keys cost no diagnostic. Reverting one of them to `$filter` now costs `TS2353 … '$filter' does not exist in type 'QueryInput'`; on the pre-change file the identical revert cost zero errors. - -- **The three literals.** `$filter` → `where`, `$top` → `limit`, plus the `object` the declared query requires. No behaviour change on the two `rest-server.ts` call paths (`POST /data/:object/import` and the async import-job worker), which hand `runImport` the real `ObjectStackProtocolImplementation`: that normalizer folds `$filter` onto `where` and `$top` onto `limit` by the spec's own `RPC_QUERY_ALIAS_SLOTS`, moving the value verbatim, so both dialects reach `engine.find` as the same option bag. -- **The erasure vehicle.** `findArgsBase` now takes a `FindDataRequest` rather than a bare `any` query, so the request-level `object` is compiled too and the `object: ''` placeholder every caller had to override is gone. This is the durable half: rewriting the literals while leaving the parameter `any` would leave the next author in this file with no diagnostic at all. -- **The pin.** `rest-server-canonical-query-ast.test.ts` censuses the PACKAGE rather than one file. `import-runner.ts` has no HTTP door — every query in it is server-built — so its census rejects a wire spelling anywhere in the file, not only inside a `query:` slot. That whole-file rule is the one that finds this class: these three literals were arguments to a helper and were never in a `query:` slot to begin with. - -⚠️ Implementor-visible: `ImportProtocolLike` is exported, its `findData(args: any)` never declared which dialect the runner sends, and the runner now sends the canonical one. An implementation that reads `args.query.$filter` / `args.query.$top` directly — rather than through the protocol normalizer — receives `undefined` and must be updated to read `where` / `limit`. diff --git a/.changeset/insert-check-post-image.md b/.changeset/insert-check-post-image.md deleted file mode 100644 index 1bc422f7b84..00000000000 --- a/.changeset/insert-check-post-image.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/objectql": minor ---- - -fix(plugin-security)!: the insert-side RLS `check` is evaluated on the row that will be STORED — after `beforeInsert` — instead of on the caller's raw payload (#16608) - - - -**BREAKING** — an accept-set narrowing on the write gate's refusal behaviour. An insert that is admitted today can be refused after this change. - -`check` validates the row a write produces — the PostgreSQL `WITH CHECK` analog. `update` reached that row by merging the caller's pre-image with the change set. `insert` could not: it has no pre-image, and the security middleware runs BEFORE the engine's operation, so its post-image was `opCtx.data` — the caller's payload as it arrived, ahead of `applyFieldDefaults` and ahead of every `beforeInsert` hook. - -A denormalised scoping field is exactly what an RLS predicate compares (ADR-0055: a predicate cannot traverse a lookup) and exactly what an app stamps server-side so a caller cannot choose it. Judging the raw payload therefore inverted the policy in both directions, measured on 17.3.0 with a real engine, a real `SecurityPlugin` and both drivers: - -- **the derived value was not on the image**, so the only way to pass a `check` over it was for the caller to SEND the value the hook exists to make un-sendable. Same identity, same object, same second: the payload carrying the stamped field returned 201, the identical payload leaving it to the hook returned 403 — and the stored row was identical either way. -- **the sent value WAS on the image and was then overwritten**, so an insert naming an in-scope organization while pointing at a parent in ANOTHER organization PASSED the check and stored the parent's organization. That is a row whose stored scope the caller does not hold, and it is why this is a narrowing rather than a widening: today it is admitted, after this change it is refused with nothing stored. - -Ruled 2026-09-07 (maintainer, verbatim 「同意」, director seat, summon #17, decision batch #3). The refused alternative — keep the order and write the contract that a checked field must arrive from the caller, plus an `os validate` rule to police it — institutionalises the contradiction and needs a permanent lint to hold it in place. - -**What changed, mechanically.** `OperationContext` gains `postHookWriteImageCheck` (`@objectstack/objectql`), an optional judgement an enforcement layer installs and `ObjectQL.insert` runs once the `beforeInsert` chain has produced the row — after the post-hook declared-field door, after the two value-changing strips (`stripRuntimeOwnedFields` and the static-`readonly` strip with its `defaultValue` re-default, both moved ahead of it), and before every producer with a side effect (the secret channel, the autonumber, validation, the statement), so a refusal still costs nothing. `@objectstack/plugin-security` installs its compiled `check` filter there for `insert` instead of matching it against `opCtx.data`; this entry leaves `update` unchanged, and the predicate and by-id updates move onto the same seam in their own entries (#19950, #19989). The compiled filter is still built in the middleware, where the caller's permission sets, the ADR-0090 D10 delegator's, the staged membership and the request context are all resolved — only the IMAGE is deferred. A middleware that installed the judgement and finds the seam was never run refuses the write and logs at ERROR: an unjudged write is not an allowed one. - -**Who is affected.** Only objects governed by a permission set that EXPLICITLY declares `check`, on single-row inserts by a non-system caller — the gate's existing scope, unchanged. Two behaviour changes to expect, and they are the two halves of the same correction: an insert that left a hook-stamped field off the payload now succeeds where it used to be refused, and an insert whose hook-stamped field lands outside the caller's scope is now refused where it used to be admitted. Callers that were duplicating the stamp to get past the gate keep working and may stop. - -**Two further behaviour changes the reorder produces, measured on both legs** (the reviewed order and this one), because moving the strips ahead of the seam also moves them ahead of the credential channel: - -- a caller-forged value on an author-declared `readonly` **`secret`** field is now stripped. Before, `encryptSecretFields` ran first and replaced the row's value with a `sys_secret` reference, so the strip's `Object.is` value test compared that reference against the caller's plaintext, read the difference as a hook's write, and KEPT the forgery — measured on 17.3.0's order as stored `token: "secret:sec_1"` with a `sys_secret` row minted. This is a narrowing, and it closes a hole that predates this card. -- an empty string on a `readonly` **`password`** field is stripped instead of answering `VALIDATION_ERROR`. `""` reaches the store on neither order, so the 2026-08-13 empty-credential ruling's guarantee is unchanged; only which refusal a caller sees moves, on a payload a caller was never allowed to send. ⚠️ This is the one direction of the reorder that is not a narrowing, and it is recorded rather than left to be discovered. - -**The invariant this buys, stated to its real edge.** A stored row satisfies the insert `check` on every field the CALLER can steer, whatever the caller sent. Nothing offered any such guarantee before: the check read the payload, and the payload was entirely the caller's. - -⚠️ It is deliberately not "on every field", and the difference is a boundary rather than a hedge. Four engine-owned passes still run between the judgement and the driver, and each substitutes a platform value for whatever stands on the row: the tenant fill of an ABSENT organization column (`resolveSystemInsertOrganization` plus the driver's `injectTenantOnInsert`), `encryptSecretFields` replacing a `secret` field's plaintext with a `sys_secret` reference, `applyAutonumbers` issuing a record number, and `normalizeMultiValueFields` coercing a declared multi-value field to its stored shape. A policy whose `check` names an autonumber, a `secret` or the tenant column is therefore judging a value the platform is about to replace. None of those four is caller-steerable — which is exactly why the two passes that WERE (`stripRuntimeOwnedFields` and the static-`readonly` strip) moved above the seam instead of being explained away. diff --git a/.changeset/iso-from-valid-date-family-collapse.md b/.changeset/iso-from-valid-date-family-collapse.md deleted file mode 100644 index e5ecf7c10a5..00000000000 --- a/.changeset/iso-from-valid-date-family-collapse.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -"@objectstack/metadata": minor -"@objectstack/metadata-protocol": patch ---- - -fix(metadata): four `isoFromValidDate` call sites collapse onto the shared canonical-ISO spelling; `MetadataHistoryRecord.recordedAt` gets the terminal value it never had (#16422) - -## What was wrong - -`#14037`/`#14038` landed a narrow per-site helper, `isoFromValidDate`, beside -the shared `canonicalIsoInstant` spelling. It rewrote exactly one shape — a -valid JS `Date` becomes ISO text — and handed **every other input back -untouched**. Four adapter boundaries used it, and each fed a field declared -`z.string()` or `z.string().datetime()`: - -| site | declared as | -|:--|:--| -| `SysMetadataRepository.rowToEvent` → `MetadataEvent.ts` | `z.string()` | -| `DatabaseLoader.rowToRecord` → `MetadataRecord.createdAt` / `.updatedAt` | `z.string().datetime().optional()` | -| `DatabaseLoader.getHistoryRecord` → `MetadataHistoryRecord.recordedAt` | `z.string().datetime()` — **required** | -| `DatabaseLoader.queryHistory` → the same field, the other door | `z.string().datetime()` — **required** | - -So a `null`, a `number`, an opaque column and an Invalid `Date` all arrived at a -field declared `string`, each wearing an `as string` / `as string | undefined` -cast that asserted the opposite. Measured over the seven inputs that -distinguish the two helpers, the declared schemas refused **21 of 35** produced -values. - -`recordedAt` was the sharp end: a REQUIRED `z.string().datetime()` for which -none of the three available answers was legal — the visible text -`"Invalid Date"` fails the refinement, `undefined` fails the required field, and -the pass-through fed it the `Date` object, which fails both. - -## What it does now - -Those four sites read `canonicalIsoInstant`, whose return type **is** -`string | undefined`, so all four casts are deleted rather than restated. Both -sibling definitions of `isoFromValidDate` are gone. The terminal value is chosen -per site, from the site's own declared schema: - -- `MetadataRecord.createdAt` / `.updatedAt` are `.optional()` → `undefined`, the - branch an absent column already took. ⛔ No default is invented for a field the - schema lets be absent. -- `MetadataHistoryRecord.recordedAt` is required → the **epoch**, via a named - `recordedAtFallback()` shared by both history doors. ⛔ Not `new Date()`: a - `now` stamp is a plausible-looking recording instant nobody measured, and it - sorts a version recorded years ago to the top of a newest-first timeline. The - epoch invents no fact and sorts to the oldest end. It is also the answer the - sibling reader of this same `sys_metadata_history.recorded_at` column already - gives (`rowToEvent` and `history()`, both `?? new Date(0).toISOString()`). - -Schema refusals over the same seven inputs: **21 → 8**. The eight that remain -are a `number` and an opaque object at four sites — shapes no driver is measured -to materialise for these columns. They now arrive as the declared *type* (a -string) that simply is not a valid datetime, so the producer's bug stays visible -instead of being papered over. - -## One behaviour change worth reading twice — and it is why this is `minor` - -`DatabaseLoader.stat()` computes `record.updatedAt ?? record.createdAt`. An -Invalid `updated_at` used to WIN that `??` — a `Date` is truthy and not nullish — -so a row with an unreadable `updated_at` and a good `created_at` published -`new Date()` as its `mtime`. It now folds to `undefined` one step earlier and -loses the `??`, so the row publishes its `created_at`: a stored instant in place -of a fabricated one, and exactly the "same `?? DEFAULT` chain an absent column -takes" that `#14078`'s own ruling text prescribes for the shape. - -⚠️ **The old answer was LEGAL.** `new Date().toISOString()` satisfies -`MetadataStats.mtime`'s `z.string().datetime()` perfectly well, and the -pre-existing pin asserted exactly that. So this one site is **not** the repair of -a violation — it is one legal published answer replaced by a different legal -published answer on a published read verb. Nothing was refused before and is -permitted now; a consumer simply receives a different instant. - -## Why the two levels differ - -- **`@objectstack/metadata` — `minor`.** Its four repaired sites, on their own, - are the "repairing an implementation that silently violated its own already - published declared type" case: the values that changed there are ones - `MetadataRecordSchema` / `MetadataHistoryRecordSchema` already refused, and - nothing a consumer legitimately received has moved. But this package also - carries `stat()`, and that site changes a **legal** published answer, which the - paragraph above measures. The level is per package, so the four repaired sites - ride along at `minor`. -- **`@objectstack/metadata-protocol` — `patch`.** Neither of its two sites moves - a legal published answer. `rowToEvent` only stops emitting values - `MetadataEventSchema` refused (a `Date`, a `number`, an opaque object in a - field declared `z.string()`), and `listCommits` is byte-identical on all seven - probe inputs. - -⛔ No declared type narrowed, no export was added or removed (neither helper was -ever exported), and no envelope or accept set moved — so this is `minor` by the -changed-answer row, not a breaking change, and it carries no ADR-0087 -disposition. - -## What deliberately did NOT collapse - -`listCommits` in `@objectstack/metadata-protocol` keeps its copy. Its docblock -promises callers the RAW value back for a non-`Date`, and the shared spelling -rewrites the whole domain: swapping it in would ERASE an Invalid `Date` from the -response (`undefined` — the one answer ADR-0053 D-F3 refuses, because it silently -drops a value that is on disk) and hand a `number` or an opaque object to the -commit-timeline sort as `String(value)` rather than verbatim. Measured, that site -is byte-identical on all seven inputs before and after this change. - -`SqlDriver`'s same-named helper is not part of this family at all: it takes -`Date` (not `unknown`), both its call sites narrow with `instanceof Date` first, -and it is the PRODUCER-side fold ADR-0053 D-F3 governs. It is untouched. diff --git a/.changeset/issue-17400-text-door-formula-prose.md b/.changeset/issue-17400-text-door-formula-prose.md deleted file mode 100644 index 25a7e578069..00000000000 --- a/.changeset/issue-17400-text-door-formula-prose.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Scope the text-operator declared-type door's `formula` prose to the judgement it -actually states. The module declared that a `formula` with a readable -`returnType` is judged as the field type its return type names, but at the -door's only consumer — the engine's field-aware seam — a filter over a formula -field never arrives: the earlier materializability door refuses every one of -them with `INVALID_FIELD` 400, whatever the `returnType`. The verdict function, -its sets, the class table and every case are unchanged; only the prose now says -the formula rows are a contract answer no consumer currently reaches, and why -they are kept rather than retired. diff --git a/.changeset/issue-17461-manifest-version-example.md b/.changeset/issue-17461-manifest-version-example.md deleted file mode 100644 index 10b4cf99939..00000000000 --- a/.changeset/issue-17461-manifest-version-example.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`ManifestSchema.version`'s TSDoc no longer documents an `@example` its own regex refuses - -The key documented two examples and accepted only one: - -``` -@example "1.0.0" -> /^\d+\.\d+\.\d+$/ accepts -@example "2.1.0-beta.1" -> /^\d+\.\d+\.\d+$/ REFUSES -``` - -An author who copied the second example verbatim got a `ZodError` out of -`ManifestSchema.parse`. The prerelease example is corrected to `"2.1.0"`, a -value the regex accepts. - -**Nothing published moves except the comment.** The regex, the -`.describe('Package version (semantic versioning)')` string and the prose -`(major.minor.patch)` are byte-identical; no accept set, authorable key or -runtime behaviour changes. `@objectstack/spec` ships `src/**/*.zod.ts` in its -`files[]`, so this TSDoc line is itself published — which is why it carries a -changeset rather than `skip-changeset`. - -**The refusal was already the settled reading, which is why this is a comment -fix and not a schema change.** Three artifacts agreed before this change and -still agree: the regex, the prose `(major.minor.patch)`, and -`manifest.test.ts`, which pins `'1.0.0-beta'` in `invalidVersions` on purpose. -Only the `@example` line dissented, so it was the artifact in error. Widening -the accept set to admit prerelease or build metadata would contradict that pin -and is deliberately NOT done here. - -`PluginSchema.version` accepts a different grammar today; the two keys are -deliberately different and are not reconciled by this change. diff --git a/.changeset/issue-17574-search-fields-docblock.md b/.changeset/issue-17574-search-fields-docblock.md deleted file mode 100644 index a9beb15817c..00000000000 --- a/.changeset/issue-17574-search-fields-docblock.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Correct the `search-fields.ts` module docblock's ENGINE bullet: the `$search` expansion is not closed over the resolved set, and its clauses are not all `$icontains`. - -The bullet claimed `expandSearchToFilter` expands a `$search` term into a `$or` of `$icontains` clauses "over exactly this set". Since the pinyin-recall companion column landed, an object whose deployment provisioned the hidden `__search` companion gets one additional clause per latin term on that companion — a field `resolveSearchFields` never returns and no `$searchFields` override can name, so it sits outside the set the sentence called exact. That one clause is `$contains`, deliberately: the companion is already lowercase on both sides, so a case-sensitive operator over two folded values is exact rather than a case bug, and the engine carries an explicit instruction at the site not to align the two operators. The docblock now states both facts and cites that instruction, so a reader does not "repair" the deliberate split. - -Documentation only — no behaviour, schema or exported surface changes. diff --git a/.changeset/issue-17595-retired-component-type-report.md b/.changeset/issue-17595-retired-component-type-report.md deleted file mode 100644 index c64aded76db..00000000000 --- a/.changeset/issue-17595-retired-component-type-report.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -fix(lint): `component-type-unknown` reports an EXACT retired component type, relaying the spec's own prescription - -A retired component type is `isKnownComponentType` on purpose — its -`ComponentPropsMap` row is kept so the props door can dispatch the retirement -prescription — and this rule read that as "accepted". So a caller linting a -**raw stack** got silence on a name `PageComponentSchema.type` refuses at the -parse: the author's earliest feedback channel was the one that stayed quiet, -and the refusal landed later, at the parse door, or in front of an end user. - -``` -FROM validateComponentTypes({ pages: [{ … components: [{ type: 'element:filter' }] }] }) - -> [] // silence, on a name the parser refuses - -TO -> [{ rule: 'component-type-unknown', severity: 'error', - path: 'pages[0].regions[0].components[0].type', - message: '`element:filter` was removed in @objectstack/spec 17 (ADR-0049) …' }] -``` - -**No new prose.** The finding's `message` is the `RETIRED_PAGE_COMPONENT_TYPES` -entry **verbatim** — the same string the enum error map and the kept props row -already carry — pinned by byte equality in the rule's test, so the three doors -cannot drift and a type retired tomorrow arrives reported on the day it lands. - -Two things deliberately unchanged: `isKnownComponentType` still answers `true` -for a retired type (flipping it would MOVE the refusal out of the props door -rather than add a report), and the typo suggester still never proposes a retired -name. - -The new arm is judged **before** the reserved-namespace guard, because a -retirement can take its namespace with it: `user:profile` was the `user:` -namespace's only member, so `hasReservedComponentNamespace('user:profile')` is -`false` and a check placed after that guard would have stayed silent on the -member that has been refused longest. - -Measured before landing: **zero** authored instances of any retirement-map -member across the in-repo page sources, with live component types as the lit -control in the same query — so no existing authored stack turns red. diff --git a/.changeset/job-example-retired-id.md b/.changeset/job-example-retired-id.md deleted file mode 100644 index 4fbe9461d19..00000000000 --- a/.changeset/job-example-retired-id.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): `JobSchema`'s own `@example` no longer opens with `id`, the key retired in 17.0.0 (#19184) - -The TSDoc `@example` on `JobSchema` opened with `id: "job_sync_meta"`. `id` was removed in -17.0.0 (#4667, ADR-0049) and is tombstoned a dozen lines below the block that wrote it, so the -example the schema publishes was refused **by that schema** on a verbatim copy: - -``` -JobSchema.safeParse() - → success: false - → unrecognized_keys: ["id"] - → "Unrecognized key(s) on this job: `id`. • `job.id` was removed in @objectstack/spec 17.0.0 …" -``` - -The same object with the line deleted parses, so the one deleted line is the whole fix. An -`@example` is read by whoever copies it before they read the key table — an author, and every -agent writing job metadata from this schema — which is why a key the same file declares dead is -the one thing it must not open with. - -Nothing about what a job may be written as changes here: the accept set, the key table, the -tombstone and its prescription are all untouched, and the generated authorable-surface and -JSON-Schema artifacts are byte-identical across the change. What ships is the corrected example -itself — `src/**/*.zod.ts` is part of this package's published `files`, so the block travels in -the tarball an upgrading consumer reads. - -Scope, stated because the adjacent block invites it: the file's second `@example` (on -`defineJob`) carries no retired key and is untouched. The package-wide `@example` sweep is its -own card. diff --git a/.changeset/lazy-messaging-channel-mounts.md b/.changeset/lazy-messaging-channel-mounts.md deleted file mode 100644 index b246c1d6127..00000000000 --- a/.changeset/lazy-messaging-channel-mounts.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -Mount the email and SMS channels per lookup instead of deciding once at `kernel:ready` - -The messaging plugin registered its email and SMS channels behind `if (getEmail())` / -`if (getSms())` inside a `kernel:ready` hook. That guard ran exactly once, so a transport -service that registered later in the same boot — from a plugin ordered after this one, from -`kernel:bootstrapped` / `kernel:listening`, or at runtime — never got its channel, and every -`notify` naming that channel was refused as "not registered" for the life of the process. - -New public surface (which is why this grades `minor` and not `patch`, per the 2026-09-04 ruling -that a purely additive widening of a published surface takes at least a minor): -`MessagingService.registerChannelProvider(id, resolve)` mounts a channel that is resolved on -every lookup, and the plugin now mounts both channels through it: the mount tracks the -transport instead of recording a verdict about it, and the dispatcher — which has always -looked channels up dynamically — picks up a late transport without a restart. A composition -that never registers the transport is unchanged: the channel is not mounted, fan-out refuses -it, no delivery row is written, and nothing is recorded in -`sys_notification.suppressed_channels`. diff --git a/.changeset/lifecycle-panel-ja-es-renderings.md b/.changeset/lifecycle-panel-ja-es-renderings.md deleted file mode 100644 index 8a6e39a27cc..00000000000 --- a/.changeset/lifecycle-panel-ja-es-renderings.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -Author the object form's data-lifecycle panel (ADR-0057) and the email-template -variables sample in `ja-JP` and `es-ES`. - -Thirty-three `en` leaves — sixteen labels and sixteen helpTexts under -`object.fields.lifecycle.*`, plus `email_template.fields.variables.helpText` — -shipped their English source in both locales while `zh-CN` had all thirty-three -authored. An author working in Japanese or Spanish read the whole retention / -TTL / rotation / archive panel in English. Each leaf is now decided per locale -with its reason and the `en` source it was judged against, recorded in -`object-lifecycle-panel-echo-decisions.test.ts` and pinned to the live bundle, -to the `en` source and to the form declaration that manufactures it. - -No keys are added or removed: values were authored by hand and the structure -regenerated with `pnpm i18n:extract`. - -Clause-②: no diff --git a/.changeset/link-finder-declared-location-axis.md b/.changeset/link-finder-declared-location-axis.md deleted file mode 100644 index adf64377a06..00000000000 --- a/.changeset/link-finder-declared-location-axis.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/types': minor ---- - -Host importer: a `link:` / `file:` install is now verified by the LOCATION the app declared, so a correctly linked package loads instead of being refused. - -The ESM fallback finder (`createHostImporter`) verifies the one directory it consults — `/node_modules/` — against what the host's own `package.json` declares. Until now it could only do that by NAME, and a `link:` / `file:` value promises no name, so the KEY stood in for one: a package linked exactly as the app asked, whose own manifest happens to be named something else, was refused with `declared-unresolvable` / `MODULE_NOT_FOUND`. Nothing was broken, and the only way out was to stop using a supported linking mode. - -Such a declaration does name something checkable — a directory — so the finder now checks that too: `realpath(node_modules/)` against `realpath(resolve(hostRoot, ))`, both sides canonicalised, compared exactly (no basename matching, no case folding). If they are the same directory, the host declared it and it loads. - -This is a second verification axis, not a looser first one. A directory the app declared neither by name nor by path is refused exactly as before, and the finder stays strictly tighter than the CommonJS resolution it backs up, which asks neither question. Unchanged: a plain version range licenses no path; an `npm:` alias is still checked by name; `github:` / tarball URLs and the bare `owner/repo` shorthand name no on-disk location, so they gain nothing; a package that publishes a `require` condition never reaches this fallback at all, so no load that succeeds today changes. - -Measured on pnpm 10.33: `link:` symlinks the key at the declared directory and verifies; a `file:` directory install routes through pnpm's virtual store (a copy), so it does not, and keeps today's refusal. The refusal's text now states what the location check compared instead of asserting a limit the finder no longer has. diff --git a/.changeset/lint-changelog-export-claim.md b/.changeset/lint-changelog-export-claim.md deleted file mode 100644 index cd266b702bb..00000000000 --- a/.changeset/lint-changelog-export-claim.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -**Docs:** the 17.3.0 entry for #13935 no longer claims `FIELD_RULE_AMBIENT_ROOTS` and `FIELD_RULE_JUDGED_ROOTS` are exported — `src/index.ts` exports neither (#18169). - -`CHANGELOG.md` is in this package's `files[]`, so that sentence ships inside the npm tarball and is the text an upgrading agent greps. Measured on the published `@objectstack/lint@17.4.0` tarball (read 2026-09-16T12:25Z): the export block of `dist/index.js` names `FIELD_RULE_BOUND_ROOTS` and neither of the other two, and the export clause of `dist/index.d.ts` is the same — `FIELD_RULE_JUDGED_ROOTS` occurs in that file only inside two `{@link}` docblocks, and `FIELD_RULE_AMBIENT_ROOTS` not at all. A consumer who wrote `import { FIELD_RULE_AMBIENT_ROOTS } from '@objectstack/lint'` on the strength of the entry got a resolution failure. - -Per AGENTS.md, a factual error in a released entry is amended **in place**, in a dedicated docs-only PR, never by an erratum in a later entry — the reader greps the symbol and lands on the old entry, so a correction anywhere else is one they never reach. The correction therefore lives in the 17.3.0 entry itself, which now states what `src/index.ts` actually exports, verified at the export statement. This changeset is not that correction; it exists so the corrected text reaches the registry at all. Published tarballs are immutable, so the amendment becomes published text on the next publish of this package and not before. - -No code, no export, and no behaviour moves. diff --git a/.changeset/lint-dataset-measure-aggregate-field-type.md b/.changeset/lint-dataset-measure-aggregate-field-type.md deleted file mode 100644 index c727e6d1e3a..00000000000 --- a/.changeset/lint-dataset-measure-aggregate-field-type.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -Refuse a dataset measure whose `aggregate` the field's declared type cannot carry, at authoring time - -A dataset measure pairs an `aggregate` with a `field`, and -`AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec`) declares which of those pairs every -backend answers the same way. Nothing in the authoring path read that table, so `avg` over a -`datetime` field validated clean and shipped: one SQL family coerces the column's canonical UTC -text and returns a plausible number (the average *year*), another has no such function and fails at -query time — the answer decided by the deployment rather than by the document. The analytics service -refuses the pair when a query is built (`400 DATASET_INVALID`); this is the same verdict, from the -same table, at the door the author is standing in front of. - -New rule `measure-aggregate-field-type-refused`, gating (`error`), on `os validate` / `os build` / -`os lint`. It resolves the field's declared type on the object graph lint already indexes — including -a dotted `relationship.field` path, whose leaf type the compile leg cannot see — and refuses the -pair when `isAggregateCompatibleWithFieldType` says no. The message names the aggregate, the field, -its declared type and the accepted set, and the hint names the aggregates that type *does* accept, -both computed from the table rather than restated. It stays silent wherever the type cannot be -resolved (an object this stack does not define, a field path that resolves to nothing, an untyped -field, an aggregate outside the closed `AggregationFunction` vocabulary) rather than guessing. - -**BREAKING**: metadata that passed `os validate` / `os build` / `os lint` before can now fail. Every -pair this refuses is one the analytics service already refuses at query time, so nothing that -*worked* stops working — but a build that did not fail now does. - -Migration, per refused pair — FROM the aggregate the field's type cannot carry, TO one it accepts: - -- `avg` / `sum` over a `date` / `datetime` / `time` field → `min` / `max`, which return a real - instant of the field's own type, or `count` / `count_distinct`. A DURATION is not recoverable from - an aggregate over instants: store it as a number (a computed "days open" field) and aggregate that. -- `sum` over a `percent` field → `avg`. A rate does not add; the total routinely exceeds 100%. -- `min` / `max` over the string, option, reference, file, structured-JSON or `formula` classes → - `count` / `count_distinct` for "how many distinct values", or a SORT on the record list for "the - first / last record". String order is collation-dependent, so two backends answer two different - "smallest" values for one document. -- Any other refused pair → read the row for your aggregate in - `AGGREGATE_FIELD_TYPE_COMPATIBILITY`; the refusal message prints it. - -A `derived` measure whose `of` names a refused measure is fixed by fixing that measure, not the -`derived` one. A `date` / `datetime` / `text` field used as a DIMENSION — grouping, bucketing, -filtering — is untouched: this is about aggregation only. - -Clause-②: yes (narrowing) - - diff --git a/.changeset/lint-injected-temporal-column-types.md b/.changeset/lint-injected-temporal-column-types.md deleted file mode 100644 index 3176ab8d5b1..00000000000 --- a/.changeset/lint-injected-temporal-column-types.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -fix(lint): a field-typed rule reads the registry's own type for an injected column, so `created_at` / `updated_at` stop escaping the preset-comparand refusal (#16340) - -`@objectstack/lint`'s object graph recorded the registry-injected system columns by NAME only. A path resolving to one came back `{ kind: 'ok', injected: true }` with no `meta`, so every rule asking a SECOND question about the leaf — "is it temporal?" — had to treat it as unanswerable and stay silent. That silence landed on the two most-filtered columns in the platform. - -Measured on `origin/main` `d57611dfd3`, one dashboard widget over one object declaring `close_date: date` and authoring no `created_at`: - -| authored filter | before | after | -|:--|:--|:--| -| `close_date: 'last_30_days'` (authored `date`) | refused | refused | -| `created_at: { $gte: 'last_30_days' }` (ordering — arm 1) | refused | refused | -| `created_at: 'last_30_days'` | **silent** | refused | -| `created_at: { $eq: 'last_30_days' }` | **silent** | refused | -| `updated_at: { $in: ['last_30_days'] }` | **silent** | refused | -| `stage: 'this_quarter'` (a `select` column) | silent | silent | - -The engine already refused all three of those at query time (`INVALID_FILTER` / 400, the registry's field map in hand), so the gap was purely author-time: `objectstack lint` and the runtime publish gate passed a filter the runtime then refused with a 400 on first render — and an AI author's correction loop only sees what fails the build. - -## What changed - -`GraphObject.injected` is now a `ReadonlyMap` rather than a `ReadonlySet`: each injected column carries the registry's own definition. Both halves are DERIVED from one plan — membership from `resolveInjectedSystemColumns`, the slice from `injectedSystemColumnDefs` (`@objectstack/spec/data`, the same tables `applySystemFields` spreads at registration) — so lint never hand-copies "`created_at` is a datetime" and cannot drift from the runtime that provisions it. `resolveFieldPath` populates `meta` for an injected leaf accordingly, and `filter-preset-comparand`'s field-type oracle lost its `verdict.injected` bail: the marker says WHO wrote the column, and the ruling turns on what the column IS. - -`id` is the one addressable column with no definition behind it — the DRIVER provisions the primary key — so its slice is empty and a second question about it is still unanswered, truthfully and only there. The `select`-column reading arm 2 exists to protect is untouched: no injected column is a picklist. - -**Behaviour change for authors**: a stack that filtered an injected `date` / `datetime` column against one of the thirteen dashboard date-range preset names in an equality or membership position now fails `objectstack lint` and the runtime publish gate where it previously passed. Every such filter was already refused by the engine at query time; the error simply moves to where the filter is written. Write the `{date-macro}` window the message names, or an ISO date. - -**Type change for direct consumers of the seam**: `GraphObject.injected` changed from `ReadonlySet` to `ReadonlyMap`. `.has(name)` answers exactly as before; code that iterated the set or spread it into one needs `.keys()`. Shipped as `minor` under the repo's launch-window convention. - -## Two more rules inherit it, in the same edit - -The type reaches every rule that asks a second question about a resolved leaf, which is the whole reason it was fixed at the seam rather than inside `filter-preset-comparand`: - -- **`list-view-field-dotted`** now refuses a dotted list-view filter key whose head is an injected column, on the same axis as an authored one. `created_at.x` reads as the `datetime` scalar it is (nothing beneath it for a path to reach) and `owner_id.name` as the `lookup` it is (it stores an id, not an embedded document). `assertFilterIsMaterializable` and the REST ingress have always answered `400 INVALID_FIELD` for both — the linter was silent only because the type was missing here. -- **`dataset-include-unknown`** now judges an `include[]` entry naming an injected column instead of bailing on the marker: `include: ['owner_id']` joins (it is the registry's `lookup`), `include: ['created_at']` is refused (a `datetime` derives no join, so every dimension written against that prefix addresses nothing). - -`id` falls through the untyped branch of all three rules — the DRIVER provisions the primary key and no definition table describes it, so an unreadable head is what the door sees too, and none of them invents a refusal there. - -A relationship HOP through an injected column stays a skip (`unknowable` / `injected-hop`), deliberately: the slice now carries `reference`, and traversing it would newly judge every path through a platform anchor wherever `sys_user` is compiled into the stack — a widening with its own findings to measure. diff --git a/.changeset/lint-per-package-namespace-prefix.md b/.changeset/lint-per-package-namespace-prefix.md deleted file mode 100644 index cfd5264d0f9..00000000000 --- a/.changeset/lint-per-package-namespace-prefix.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os lint`: evaluate the `naming/namespace-prefix` duplicate advisory per package. - -The advisory read one flattened array per collection key with no package boundary, so on a -composed multi-package project two packages that each legitimately declare the same bare name -(e.g. `home`) were reported as one package declaring it twice — prescribing a rename of a name -that was already correct, with the OTHER package's namespace as the suggested prefix, under a -closing sentence saying distinct packages may reuse a name freely. Both ADR-0130 D4 stack shapes -were affected (flattened-plus-`packages[]`, and `packages[]`-only). - -ADR-0130 D4/D5 registers artifacts per package, so the advisory now runs once per package — -the same shape `os build` has used for the author-time rule table — and a genuine duplicate -inside one package still warns, with the suggestion taken from that package's own namespace and -a path written whole (`packages[1].manifest.apps[1].name`) so it resolves in either shape. A -single-package project is judged exactly as before. diff --git a/.changeset/list-view-tabs-retired.md b/.changeset/list-view-tabs-retired.md deleted file mode 100644 index 17f5f95d2a3..00000000000 --- a/.changeset/list-view-tabs-retired.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: retire the list view's own `tabs` key — parsed, stored, and drawn by nothing; named presets are `listViews` entries - -**BREAKING** — `tabs` is removed from the list view (`ListViewSchema`, -`ObjectListViewSchema` — a `defineView` container's `list` / `listViews`, an -object's `listViews` — a view item record's list `config`, and the flattened -list overlay the `PUT /api/v1/meta/view` door accepts). ADR-0049 -enforce-or-remove; triage verdict RETIRE, on the rule that a capability the -mainstream has and this platform already delivers keeps ONE spelling. - -The key parsed at every list-view door and was stored, and no renderer ever -drew it. Measured before removal, each reading beside a lit control: a list -view's own `tabs` has no reader, and objectui's `TabBar` — the one component -that would draw it — has zero production mounts at the objectui commit this -repo pins (every occurrence is in its own two test files), while the saved-view -switcher (`ViewTabBar`) mounts in the object view and is fed from the object's -`listViews`. That switcher IS the tab strip above an object's records: one tab -per named list view. `userFilters.tabs` is a different key with the same -element type: it is read and rendered as a page list's preset bar, and it -stays. Zero list views in this repo's examples or platform sources authored the -key; the one published skill example that taught it is corrected here. - -### FROM → TO - -| removed | what to write instead | -| --- | --- | -| a list view's `tabs: [{ name, label, filter, … }]` | one named list view per tab, under the object's `listViews`: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too). A tab whose `view` already named a list view needs nothing more. | -| the tab keys `icon`, `order`, `pinned`, `isDefault`, `visible` | nothing — none of them ever had an effect. | - -**The one-line fix: delete `tabs:` from every list view, and add a `listViews` -entry for each tab you want users to switch to.** `os migrate meta --from 17` -lists the mechanical edits for existing sources; apply them by hand. - -```ts -// before — parsed clean, drew no tab bar -defineView({ - object: 'crm_ticket', - list: { - type: 'grid', columns: ['subject', 'status'], - tabs: [{ name: 'open', label: 'Open', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }], - }, -}); -// after — the switcher above the records shows "Open" beside the default view -defineView({ - object: 'crm_ticket', - list: { type: 'grid', columns: ['subject', 'status'] }, - listViews: { - open: { - type: 'grid', label: 'Open', columns: ['subject', 'status'], - filter: [{ field: 'status', operator: 'equals', value: 'open' }], - }, - }, -}); -``` - -⛔ **Untouched: the page-only preset bar.** `userFilters: { element: 'tabs', -tabs: [...] }` on a page list is a different key, it renders, and -`ViewTabSchema` stays for it. - -### The retirement kit - -- **A `retiredKey()` tombstone on the list-view shape**, beside the `pageName` - tombstone on the same strict shape. Every door built from it refuses: `tsc` - types the key `never`, and the parse raises the prescription (which names the - move to `listViews`) instead of a bare unknown-key report. -- **D2 conversion `view-list-tabs-removed`** (protocol 18, retired from the load - path): strips `tabs` from every list payload in `stack.views[]`, in all three - persisted spellings, as a lossless delete — nothing ever drew the tabs — so a - stored `view` row replays clean through the rehydration seam. An object's own - `listViews` is reached by no conversion, so such an object is refused at its - door until edited by hand. -- **D3 entry `list-view-tabs-retired`** beside it, carrying the part no - conversion can decide: which tabs deserve a `listViews` entry. -- **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ListView:tabs`, `ui/ObjectListView:tabs`; - both `authorable-surface/ui.json` rows become `[RETIRED]`. -- **The metadata form's `tabs` repeater** leaves with the key, and the - extracted form-label bundles are regenerated. -- **The liveness row stays `dead`**, re-verified, with a REMOVED note — the - tombstone keeps the key in the walked shape. -- **The published `objectstack-ui` skill** no longer teaches the key: its - list-view rules example and the "tabs win over dropdowns" rule (which - described a tab bar that never rendered) are replaced by the `listViews` - pointer. -- **Pins** (`ui/view-list-tabs-retirement.test.ts`): the refusal, its issue - code, path and prescription at seven doors, each with a lit control; the tsc - channel; the `userFilters.tabs` boundary; the conversion's reach, boundary and - idempotence; the D2/D3 registration; and a tree-scoped absence walk over the - declared radius. -- **No deprecation window**, per the project's startup-stage posture. - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` -is published, so this is breaking for consumers no telemetry was consulted for. - -Clause-②: no (narrowing) - - diff --git a/.changeset/listview-calendar-type-axis-scope-16577.md b/.changeset/listview-calendar-type-axis-scope-16577.md deleted file mode 100644 index d36781eb61d..00000000000 --- a/.changeset/listview-calendar-type-axis-scope-16577.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): record which axis the list-view calendar guard gates — and which it does not (#16577) - -`checkListViewCalendarVisualization` gates ONE way of asking for a calendar: `appearance.allowedVisualizations` includes `'calendar'`. A view can also ask for one by BEING one — `type: 'calendar'` — and that axis parses CLEAN at all three doors (`ListViewSchema`, `ObjectListViewSchema`, `VIEW_METADATA_MEMBERS.listOverlay`). The disposition was correct but undocumented, so it read as an oversight rather than a decision. - -**No behaviour changes.** Every parse verdict at every door is byte-identical before and after; the diff is a TSDoc block on the exported check (which ships in `dist/*.d.ts` and in `src/**/*.zod.ts`) plus pins in `view.test.ts`. - -What the docblock now records, all of it measured rather than inferred: - -- The `type:` axis is **not unwatched**. It is carried by `checkViewCompleteness`'s `VIEW_BINDING_BLOCKS` (`kernel/functional-completeness.ts`) at **warning** severity, under the same ADR-0078 §1 rubric this file's `page` note already cites — refuse what renders NOTHING, warn what degrades. The two doors have complementary coverage: the completeness check reads `type` only and is blind to `allowedVisualizations`; this check reads `allowedVisualizations` only and is blind to `type`. -- `viewType` is **not** a second spelling of `type`. The two authoring doors refuse it as an unknown key; the `.strip()`ed overlay write door (`PUT /api/v1/meta/view`) DROPS it, so the view parses as the defaulted `type: 'grid'` — an author who spells it reaches a grid, never a calendar. - -⛔ Escalating the `type:` axis to a parse refusal is deliberately NOT done here: it would refuse a shape 17.3.0 accepts, which is a published-surface narrowing and belongs to a ruling — the same disposition the `timeline` scope pin has stated since #13817. diff --git a/.changeset/lookup-picker-reader-prose-remeasured.md b/.changeset/lookup-picker-reader-prose-remeasured.md deleted file mode 100644 index f8f6252451f..00000000000 --- a/.changeset/lookup-picker-reader-prose-remeasured.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The lookup-picker "who reads this" claims in `packages/spec` are re-measured against objectui and dated to the commit they were measured on. No schema, accept set, default or refusal moves — this is evidence prose, and every verdict it sits under is unchanged. - -Three claims had gone false, all in the same direction: they credited objectui's picker with reading a `snake_case` alias that objectui no longer reads. A stale *tolerance* claim fails in the dangerous direction — it tells an author a spelling is accepted downstream when it is not, so a value that will silently arrive as nothing looks supported by the spec's own prose. - -- **`liveness/field.json`, both `displayField` notes.** `/props/displayField` claimed the record picker "reads displayField || display_field"; `/props/inlineColumns/children/displayField` named the `snake_case` spelling flatly as *the* key the grid's lookup cells pass. objectui deleted that twin from `LookupFieldMetadata` with no deprecation window and no dual read. Both notes now name the read chain they actually have — `LookupField.tsx`'s `fieldMeta?.displayField || fieldMeta?.reference_field || 'name'`, and `GridField.tsx` handing the column's camelCase `displayField` straight through at all three lookup-cell call sites. Both entries stay `status: "live"`: `displayField` is live, and more exclusively so than the notes claimed. -- **`src/data/field.zod.ts`, the LOOKUP PICKER (forward) docblock.** It told authors that objectui's `LookupField` / `RecordPickerDialog` / `deriveLookupColumns` read "both these camelCase keys and their snake_case aliases" — a blanket claim over all seven keys declared beneath it. Measured, it holds for three: `lookupColumns`, `lookupPageSize` and `allowCreate` are each read as ` ?? `. The other four — `displayField`, `descriptionField`, `lookupFilters` and `dependsOn` — are read camelCase-only. The docblock now states that per key, keeps saying the truth for the three aliases that survive, and records that those three are objectui's own back-compat rather than a spelling this schema declares. -- **`liveness/field.json`, the `valueDomain` `evidence` string.** It described the shared membership predicate as one "the write path **will** call" while its own first clause already quotes the landed call site that calls it. Tense corrected; the pointer is unchanged. - -Each rewritten claim now names the objectui commit it is dated to, so a later reader can tell how old the evidence is instead of assuming it is current. That dating is prose by design: a gate over a pinned foreign tree would go stale at every pin bump and need its own anti-vacuity self-test, which is a worse trade than a dated sentence. diff --git a/.changeset/lookup-picker-reference-only.md b/.changeset/lookup-picker-reference-only.md deleted file mode 100644 index a290848a68f..00000000000 --- a/.changeset/lookup-picker-reference-only.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -**BREAKING (runtime behaviour on a published route).** The public-form lookup-picker route -`GET /forms/:slug/lookup/:field` resolves its target object from the canonical field key -`reference` alone. The three tolerant fallback arms it used to read after it — the -`referenceTo`, `target` and `options.objectName` spellings — are deleted. - -Effect on the wire: a stored object-metadata row whose lookup field carries one of those -three spellings and no `reference` used to answer `200` with rows from the aliased object; it -now answers `500 LOOKUP_TARGET_MISSING`, and the data engine is never called. A field -carrying `reference` is unaffected, including a partially-migrated row carrying a legacy -spelling beside it. `publicPicker.object` on the form is still the explicit override and is -still read first. - -No migration is prescribed, and none is owed. `FieldSchema` is a `strictObject` that refuses -`relatedTo`, `referenceTo`, `target`, `targetObject` and `lookupObject` by name, answering -with a rename hint naming the canonical key, so no authoring path can produce such a row; a -census across both trees found no producer and no relation field carrying any of them, with -positive controls; and the maintainer ruled on 2026-09-09 that no deployment holds rows to -preserve. The spec spelling is the contract, and a stored row spelling the target the old way -is a producer defect rather than a dialect this route accommodates. - - diff --git a/.changeset/lookup-reference-target-gate.md b/.changeset/lookup-reference-target-gate.md deleted file mode 100644 index 53ef53a1bdb..00000000000 --- a/.changeset/lookup-reference-target-gate.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/lint': minor -'@objectstack/cli': patch ---- - -`object-reference-unknown` now judges a field's `reference` — the target of `Field.lookup()` / `Field.masterDetail()` / `Field.user()` — with the same four-rung ladder it applies to every other object-name site, and `os build`'s per-package run resolves those names across the artifact's `packages[]` - -`FieldSchema.reference` is `z.string()`: the schema holds it present and non-empty on `lookup` / `master_detail`, and nothing anywhere asked whether the name resolved. So `os validate`, `os lint` and `os build` all exited 0 — no diagnostic of any severity — on `Field.lookup('zzz_object_that_does_not_exist')` (measured on 17.3.0), and the miss surfaced only at runtime: the record picker asking the REST layer for an object that is not registered (404 `OBJECT_NOT_FOUND`), `$expand` failing on the field, the form rendering a control that can never resolve a value. - -The site joins `validateObjectReferences` and rides its existing ladder, so the three commands judge it identically: - -1. resolves in the stack's own objects, or in the objects an entry of this artifact's `packages[]` provides → ok; -2. resolves in `PLATFORM_PROVIDED_OBJECT_NAMES` (`sys_user`, the target `Field.user()` writes) → ok; -3. unresolved and not platform-prefixed → **`error`** — `os validate` / `os build` / `os lint` exit 1; -4. unresolved, platform-prefixed, registered by nothing (`sys_approval_process`) → the existing `object-reference-unregistered-platform` advisory. - -Judged: `lookup`, `master_detail`, `user`. Not judged, on purpose: `tree` (the object schema already refuses any target but the own name), a `reference` on a non-relationship type (inert), and `objectExtensions[].fields` (an extension targets an object another package owns, routinely one this artifact does not carry). - -## Migration - -**A build that used to pass can now fail.** Rung 3 is a new `error`-level refusal on a published accept set. Point the field at one of the stack's own objects, at an object another package of the same artifact ships, or at a platform object by its full name (`sys_user`, not `user`); the finding names the objects that resolve and suggests the nearest one. - -**A reference into a sibling package of the same release artifact resolves — it needs no annotation.** ADR-0130 makes the release artifact the co-ownership boundary, so `os build`'s per-package leg now hands each package's stack the artifact's `packages[]` as resolution context (`compile.ts`). A module's `crm_order.account` → its App package's `crm_account` is an ordinary rung-1 resolution on all three commands. This changes what a rule can resolve, never what it judges: the collections judged per package are still that package's own, and a name no entry of `packages[]` provides still errors on the per-package run exactly as it does on the union one. - -**A reference into another RELEASE ARTIFACT still has no rung** — an app naming an object a separate product ships (HotCLM's `clm_contract.crm_contract` → HotCRM). It is unresolved and unprefixed, so rung 3 refuses it. The declared escape for that case resolves against declared manifest dependencies and is its own change; ⛔ it is deliberately not an authored per-field marker, which would be a one-line switch that silences the gate. diff --git a/.changeset/lucky-pugs-repeat.md b/.changeset/lucky-pugs-repeat.md deleted file mode 100644 index b6d733200cc..00000000000 --- a/.changeset/lucky-pugs-repeat.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -Translate the report and dataset form panel leaves that shipped their English source in every locale - -Four metadata-form keys — `report.fields.dataset`, `report.fields.values`, `report.fields.rows` and `dataset.fields.measures` — carried labels byte-identical to their `en` source in `zh-CN`, `ja-JP` and `es-ES`, so an author working in a translated locale read English on those two panels while everything around them was translated. Twelve label leaves and nine `helpText` leaves at the same four keys are now translated; each was judged individually, and the verdicts with their reasons are pinned in `report-dataset-panel-echo-decisions.test.ts`. No key was added, removed or renamed — the bundles' shape is unchanged. diff --git a/.changeset/manifest-namespace-leading-letter-refusal.md b/.changeset/manifest-namespace-leading-letter-refusal.md deleted file mode 100644 index 231a48e03f4..00000000000 --- a/.changeset/manifest-namespace-leading-letter-refusal.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the `manifest.namespace` refusal now names the leading-letter rule its pattern enforces - -Clause-②: no - -`manifest.namespace` is enforced by `^[a-z][a-z0-9_]{1,19}$`, so its FIRST character must be a lowercase letter. Its refusal sentence and its TSDoc `Rules:` line stated only the length and the charset, so `1leave` and `_leave` satisfied every clause an author was shown and were still refused, by a sentence that could not say why. - -- **The refusal sentence** is now `Namespace must be 2-20 chars, start with a lowercase letter, and contain only lowercase letters, digits and underscores`. It was `Namespace must be 2-20 chars, lowercase alphanumeric + underscore`. -- **The same sentence on the publish payload.** `PackageSchema.namespace` and `CreatePackageRequestSchema.namespace` (`marketplace/package.zod.ts`, and through them the scaffold-only `TemplateManifestSchema.namespace`) carried a byte-identical copy of the old sentence and carry the new one. A new pin holds all four fields to `manifest.namespace`'s sentence, not only to its verdicts. -- **The TSDoc `Rules:` line** now reads `2-20 characters, starting with a lowercase letter; lowercase letters, digits, and underscores only`. -- **Doors that surface the sentence verbatim** carry the new text with no change of their own. For example, `duplicatePackage`'s refusal of an explicit `targetNamespace` reads the declaration's message, so its rule clause now names the leading letter too. - -The accept set is unchanged: the pattern is byte-identical, and every value that parsed before still parses. Only the text shown on a refusal changes. A consumer that matched the old sentence verbatim needs to match the new one. diff --git a/.changeset/mcp-readme-custom-connector-reaches-from-anthropic.md b/.changeset/mcp-readme-custom-connector-reaches-from-anthropic.md deleted file mode 100644 index cb387743261..00000000000 --- a/.changeset/mcp-readme-custom-connector-reaches-from-anthropic.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/mcp": patch ---- - -docs(mcp): the README no longer promises that Claude Desktop reaches intranet deployments — *Add custom connector* is the claude.ai connector system and dials from Anthropic's servers (#16882) - -`packages/mcp/README.md` grouped the clients by **where the client application runs**: "Local clients (Claude Code / Desktop) can reach intranet deployments; claude.ai web connectors additionally need the endpoint publicly reachable." That grouping is wrong for Claude Desktop. Its *Settings → Connectors → Add custom connector* flow is the same claude.ai connector system, and the connection to the MCP server is made **from Anthropic's servers** — Anthropic's custom-connector documentation requires the server to be reachable over the public internet from Anthropic's IP ranges and states that a server on a private corporate network, behind a VPN, or blocked by a firewall will not connect. An operator following the old sentence pointed Claude Desktop at an intranet address and the failure surfaced inside a third-party client, with nothing to connect it back to our instructions. - -The README now groups by **where the connection is made from**, which is the mechanism and does not go stale when a client's dialog is redesigned: - -- **Claude Code** (`claude mcp add`, or the plugin) dials the endpoint from your own machine, so `localhost` and intranet-only deployments work — this is the door that genuinely reaches a private deployment, and the README now names it as such. -- **claude.ai (web) and Claude Desktop** go through the one claude.ai custom-connector system and need public HTTPS; a locally trusted certificate does not make a private address reachable. - -Documentation only — no exported symbol, endpoint, schema or runtime behaviour changes. The `patch` bump is because `README.md` is in this package's published `files[]`, so the corrected text ships to the npm page. diff --git a/.changeset/mcp-refuse-undeclared-tool-arguments.md b/.changeset/mcp-refuse-undeclared-tool-arguments.md deleted file mode 100644 index 7e82ab200d7..00000000000 --- a/.changeset/mcp-refuse-undeclared-tool-arguments.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -fix(mcp): refuse undeclared argument keys on every MCP tool instead of stripping them - -`query_records` answered `{"objectName":"crm_opportunity","sort":"-amount","limit":3}` with `200` -and rows in seed order, and `{"objectName":"crm_opportunity","filters":[["name","contains","Meridian"]]}` -with `200` and the full unfiltered set. Neither key is declared, and zod's strip default — reached -through the MCP SDK's raw-shape wrap — deleted both before the handler ran, so the handler could not -report what it never received. Nothing in either payload distinguished it from a real answer, and the -consumer of these tools is an AI agent: it reads a successful response and reports the wrong answer -confidently. A dropped sort key answers a differently ORDERED set; a dropped filter key answers a -WIDER one. - -All eleven tools held that posture; none refused. Each tool's `inputSchema` is now a built strict -object, so an undeclared key is refused before dispatch, the data bridge is never reached, and -`tools/list` advertises `additionalProperties: false` — the closed set is readable off the schema -rather than discoverable only by being refused. The refusal names the offending key and, where the -spelling is recognisable, the declared one to send instead. - -Spellings that used to be accepted-and-ignored, and what to send now. Every one of them was already -inert: it was dropped, and the call proceeded exactly as if it had never been sent. - -| previously sent and ignored | send instead | on | -| :-- | :-- | :-- | -| `sort`, `sortBy`, `order`, `order_by` | `orderBy` | `query_records` | -| `filters`, `filter`, `conditions`, `criteria` | `where` | `query_records` | -| `select`, `columns`, `projection` | `fields` | `query_records` | -| `pageSize`, `top`, `take` | `limit` | `query_records` | -| `skip`, `start` | `offset` | `query_records` | -| `filters`, `filter`, `conditions` | `where` | `aggregate_records` | -| `metrics`, `aggregates`, `aggs` | `aggregations` | `aggregate_records` | -| `group_by` | `groupBy` | `aggregate_records` | -| `tz`, `timeZone` | `timezone` | `aggregate_records` | -| `object`, `table` | `objectName` | every object-scoped tool | -| `id`, `record_id` | `recordId` | `get_record`, `update_record`, `delete_record`, `run_action` | -| `record`, `values`, `fields` | `data` | `create_record`, `update_record` | -| `action`, `name`, `action_name` | `actionName` | `run_action` | -| `args`, `input`, `arguments`, `parameters` | `params` | `run_action` | -| `formula`, `expr`, `cel` | `expression` | `validate_expression` | - -A key outside this table is refused with its name echoed back and a closest-declared-key suggestion -when one is within a length-relative edit distance. diff --git a/.changeset/mcp-token-human-principal.md b/.changeset/mcp-token-human-principal.md deleted file mode 100644 index aa4ce217705..00000000000 --- a/.changeset/mcp-token-human-principal.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -MCP OAuth: refuse a `client_credentials` (machine-to-machine) access token - -`AuthManager.verifyMcpAccessToken` resolved an M2M access token to a -principal — a machine ran as an authenticated member, stamping a user id that -belongs to no user into `created_by` / `updated_by` and owner columns — while -the method's own contract declared such tokens rejected. The contract's -premise was that they carry no `sub`; the OAuth provider stamps -`sub = user?.id ?? client.clientId`, so the premise was never true and the -rejection it described could never fire. - -The subject and the client identity are now read as a pair, the way RFC 9068 -defines them for a JWT access token: `client_id` is REQUIRED (§2.2), and `sub` -is the resource owner for a grant that had one or an identifier for the client -application for a grant that did not (§2.2.3.1). A token whose `sub` equals its -own `client_id` / `azp` therefore assembles no principal, and the MCP HTTP door -answers `401`. A token carrying neither client claim is refused as well: the -check has no input, and a check that cannot run must not silently pass. - -Unchanged: interactive OAuth clients (authorization code + PKCE) resolve -exactly as before, and the headless track is untouched — `x-api-key` / -`Bearer osk_…` over HTTP and `OS_MCP_STDIO_API_KEY` over stdio are a separate -chain with a separate credential shape, and remain the supported way for a -machine to call this platform. diff --git a/.changeset/memory-driver-tenant-scope-refusal.md b/.changeset/memory-driver-tenant-scope-refusal.md deleted file mode 100644 index f3374541d06..00000000000 --- a/.changeset/memory-driver-tenant-scope-refusal.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/driver-memory": minor ---- - -fix(driver-memory): refuse a call the engine tenant-scoped, instead of silently answering with every organization's rows (#16589) - -**BREAKING** for a `driver-memory` deployment that holds more than one organization's rows: an operation the engine tenant-scoped now refuses loudly instead of answering. Shipped as `minor` under the launch-window convention, the same grading the driver's `update()`/`upsert()` type-surface narrowing used. - -Two predicates decided "is this object tenant-scoped", and they disagreed on the default case. The engine scopes an object **unless** it opts out (`buildDriverOptions`: `execCtx?.tenantId !== undefined && !isTenancyDisabled(objectSchema) && !isFederated`), while this driver's boot guard refused only an explicit opt-**in** (`declaresTenantScope`: `tenancy.enabled === true`). An object that **omits the `tenancy` block entirely** — the common case — therefore fell between them: the engine scoped it, the guard never saw it, the deployment posture really was `single` so the posture check passed, and the driver then discarded the scope and returned every organization's rows. A SQL driver refuses the same read. - -This driver still implements **no row-level tenant isolation**, and deliberately does not gain any: it declines to answer rather than answering correctly. `assertCallNotTenantScoped` is a third seam beside the two boot seams, and it judges the scope the engine actually handed over (`DriverOptions.tenantId` / `tenantIds`) rather than re-deriving the engine's predicate from object metadata — a driver that re-derived it would drift from the engine the first time that reasoning changed, and drift here is silent exposure. It runs first in every driver door that accepts a `DriverOptions`, so a refusal leaves the store exactly as it found it. - -**⚠️ Every isolation measurement previously taken on the memory driver is void and must be re-taken.** A suite asserting "tenant A cannot see tenant B's rows" passed here trivially — not because isolation worked, but because both tenants' rows came back to every caller and the assertion was written against a single tenant's fixture. An app that proved out its isolation model on this driver measured nothing. - -What is unaffected, and why: an object declaring `tenancy: { enabled: false }` is never scoped by the engine (ADR-0066), so the driver never sees a scope for it and serves it unchanged; a caller with no organization context is never scoped either, which is the ordinary dev, example-app and single-organization path. Only a call that actually arrives carrying a tenant scope is refused. A deployment that needs organization-scoped reads in development uses `@objectstack/driver-sql`, whose `:memory:` connection is the closest in-process replacement; a deployment whose data genuinely is platform-global can say so with the ADR-0066 posture, which stops the engine scoping it at all. - -The refusal reuses the existing `MemoryMultiTenantUnsupportedError` and its `MEMORY_MULTI_TENANT_UNSUPPORTED` code rather than introducing a second error family: the cause is identical, so a host that already recognises the boot refusal recognises this one with no new code and no second code to learn. - -Also corrects `declaresTenantScope`'s docstring, which closed on a false sentence — "every object in a single-tenant deployment omits the block". A `single` posture constrains the **wall**, not the number of organizations: a `single`-posture run was measured holding 13 `sys_organization` rows, with each row carrying whichever `organization_id` it was written with. The sentence is recorded as superseded rather than deleted, because it is what justified the predicate being an opt-in test. - - diff --git a/.changeset/memory-matcher-scalar-comparand-array-value.md b/.changeset/memory-matcher-scalar-comparand-array-value.md deleted file mode 100644 index dd0926535c5..00000000000 --- a/.changeset/memory-matcher-scalar-comparand-array-value.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/driver-memory": minor ---- - -fix(driver-memory): a scalar comparand against a stored ARRAY is read as membership on both filter faces, so a filter written to narrow stops returning rows it never selected (#16838) - -`memory-matcher.ts`'s equality arm ended in `value == condition`. Loose `==` converts a stored ARRAY to a primitive — `['a','b']` becomes the string `"a,b"` — so this package's reference matcher and its live query path (`InMemoryDriver.find`, through mingo) answered the same filter two different ways, in both directions at once: - -| filter | stored value | reference matcher, before | live query path | -|---|---|---|---| -| `{ tags: 'a' }` | `['a','b']` | no row | the row | -| `{ tags: 'a,b' }` | `['a','b']` | the row | no row | -| `{ tags: 'a' }` | `['a']` | the row | the row | - -The second row is the sharper one: a **false positive**, a filter written to narrow returning a row it should not, which on a read scope is a permission concern rather than a degraded filter. The first is fail-open in the other direction and just as silent — `if (!rows.length)` cannot tell "genuinely none" from "the predicate asked the wrong question". - -**What changes.** A stored array is now read as its elements, and each is asked the question the arm asks of a scalar: the answer for a row storing an array is the OR of the answers for the rows storing its elements. That is MongoDB's array semantics and therefore mingo's, so the reference face converges on the path this package's users actually run rather than on a third reading nobody wrote. One level only — a nested array is not descended into, matching mingo. `$eq` and `$ne` take the same equality as the implicit spelling, so `$ne` stays the exact complement. - -**What does not change.** An array in the **comparand** position is still refused (`INVALID_FILTER` / 400) by the shape gate every face of this package runs; this is the VALUE side, which that door does not judge. The live query path is untouched — it already answered membership — so a caller who only ever used `find()` sees no difference. Callers who compared results against the reference matcher, or who ran it directly as a driver double, will see a stored array select on membership instead of on its joined string. diff --git a/.changeset/memory-unique-sticky-tenancy-opt-out.md b/.changeset/memory-unique-sticky-tenancy-opt-out.md deleted file mode 100644 index d877f97e206..00000000000 --- a/.changeset/memory-unique-sticky-tenancy-opt-out.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -"@objectstack/driver-memory": minor -"@objectstack/driver-sql": patch -"@objectstack/objectql": patch ---- - -fix(driver-memory,driver-sql): an explicit `tenancy.enabled: false` opt-out is sticky, so a partial `syncSchema` re-registration no longer flips a platform-global object's UNIQUE partition (#16729) - -## What was wrong - -`InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever -schema THAT call happened to carry. A second registration without a `tenancy` -block — the `{ name, fields }` shape — fell through to the implicit -`organization_id` heuristic, so a `unique` field moved from **one row per -install** (`scopeField: null`, which is what `tenancy.enabled: false` declares) -to **one row per organization**. A duplicate the declaration refuses then -landed. Measured at the driver door on `origin/main` `d61139f1ba`: - -| sequence | second `key: 'K'`, different organization | -|:--|:--| -| register with `tenancy.enabled: false` | `REFUSED` — `UNIQUE_VIOLATION` / 409 | -| …then re-register with `{ name, fields }` | **`LANDED`** | - -`SqlDriver` running the same sequence refuses in **both** cases: it has kept a -sticky `tenantOptOutByTable` since #3249. `driver-memory` had mirrored the inner -`computeTenantField` and not the wrapper that consults the record, so "mirrors -`computeTenantField` arm for arm" stayed literally true while the pair diverged. - -It is silent in both directions — nothing logs the flip, and the refusal names -the field, never the partition. That is the declared-vs-enforced shape Prime -Directive #10 forbids, reached by a state change rather than by a missing check. - -## What it does now - -- **`@objectstack/driver-memory`** gains `computeAndRecordTenantField`, the - sticky resolver, and the `TenantOptOutRecord` type for the per-instance record - a driver owns. `InMemoryDriver` holds one and resolves through it, handing - BOTH declaration surfaces — field-level `unique` and declared `indexes[]` — - the same resolved column. `uniqueConstraintsFromFields` and - `uniqueConstraintsFromDeclaredIndexes` accept that column as an optional - second argument; called with one argument they answer exactly as before. - `tenantFieldOf` is unchanged and still a pure function of its argument. -- **`@objectstack/driver-sql`**: the shard leaf resolved its tenant column with - the BARE `computeTenantField`, so a `rotateShards` sweep carrying no `tenancy` - block gave a shard an organization key part the base table's index does not - have — one object, two partitions, decided by which physical table a row - landed in. It now resolves through the record, keyed by the base table. -- **`@objectstack/objectql`**: `LifecycleObjectLike` declares `tenancy`. The - Archiver hands that object straight to `cold.syncSchema`, and the published - type refused the key while the driver below read it — so an author writing a - fresh literal was pushed into producing exactly the partial re-registration - above. Same correction #16711 made where the shard leaf narrowed the key off - the object it was handed. - -The record is deliberately narrow. Only the explicit OPT-OUT is sticky: a -declared `tenancy.tenantField` is not recorded, matching `SqlDriver`. An object -that never declared the opt-out never enters the record, so a genuinely -org-scoped object keeps its `organization_id` partition across a partial -re-registration — an implementation answering `null` more often would not be -stickier, it would be tenant isolation switched off. A carried `tenancy` block -stays authoritative in both directions and CLEARS a recorded opt-out. - -`@objectstack/driver-memory` is `minor` for the two new public-entry exports. -The behaviour repairs themselves are `patch`: each restores an implementation to -the `tenancy.enabled: false` contract (`isTenancyDisabled`, ADR-0066) it was -already declaring, rather than replacing one legal published answer with -another. The `objectql` entry is a published type WIDENING — a key the interface -refused is now accepted, and nothing that compiled before stops compiling. diff --git a/.changeset/meta-state-route-engine-outage-distinguishable.md b/.changeset/meta-state-route-engine-outage-distinguishable.md deleted file mode 100644 index af9945d70d1..00000000000 --- a/.changeset/meta-state-route-engine-outage-distinguishable.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): `GET /meta/object/:name/state/:field` tells a wired-and-failing engine apart from an absent one (#15405) - -`objectQLProvider` has two consumers in `rest-server.ts`. #13476 repaired one of them — the `computeExecCtx` authorization-input seam — by reaching the provider through `wiredEngineOrLoud`, which keeps "no engine is wired" and "the engine was wired and could not be resolved" as two facts instead of one `undefined`. This route, the slot's second consumer, reached it through `.catch(() => undefined)` and converted every rejection straight back into the `undefined` a never-registered engine produces, three lines before the answer is chosen. So a wired-and-failing engine and a never-registered one both answered `404 NOT_FOUND · "Object not found"` — a diagnostic route lying about the cause during exactly the incident it would be consulted in. - -That line was newly load-bearing rather than long-broken: before #13904 the shipped provider was `try { … } catch { return undefined; }` and could not reject at all, so the `.catch` was dead code. #13904 made the provider re-raise precisely so a consumer could see the outage, and this consumer caught it back. - -**What moves.** On this route only, an engine that is wired and fails to resolve now answers `503 SERVICE_UNAVAILABLE` instead of `404 NOT_FOUND` — the same answer its sibling seam and the package door (#13476) already give for the same fault. No accept set widens and no new wire code is minted: `SERVICE_UNAVAILABLE` is an existing `StandardErrorCode` member, reached through the existing `AuthzStoreUnavailableError`. - -**What does not move.** An engine that was never wired, and a provider that resolves `undefined` (the seam contract declaring absence rather than failing), both keep the `404 NOT_FOUND` they answered before — that is the supported no-data-plane composition. A healthy engine asked about an object that genuinely does not exist still answers `404 NOT_FOUND`; a healthy engine asked about an object that exists is still served. - -**Reachability, stated rather than implied.** Every `/meta` route sits behind the anonymous-deny gate, and that gate resolves the same engine first. Where it takes its provider branch (a single-kernel boot such as `pnpm dev:crm`) a broken engine already raised there, before this route's line ran — so nothing changes for those deployments. The collapse was reachable where a resolvable kernel supplies auth and the separately-wired `objectQLProvider` is broken, which is the multi-kernel wiring, and that is where the new answer lands. - -`POST /email/send` carried the other retired `.catch(() => undefined)` in the same file and moves to `seamOrUndefined`. Its answer is deliberately unchanged at `501 NOT_IMPLEMENTED`; what changes is that a host wiring a **non-`async`** provider — which the seam's declared type cannot prevent — now reaches that same 501 instead of throwing past a `.catch` that did not exist yet and landing in the handler's own `500 EMAIL_SEND_FAILED`. Not reachable from the shipped wiring, where both providers are declared `async`; repaired because it is the same spelling at an embedder-reachable seam. diff --git a/.changeset/meta-types-action-schema-no-longer-empty.md b/.changeset/meta-types-action-schema-no-longer-empty.md deleted file mode 100644 index 4b3b3784400..00000000000 --- a/.changeset/meta-types-action-schema-no-longer-empty.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Fix `GET /meta/types` serving an empty JSON Schema for `action` - -`ActionSchema` is a `ZodPipe`, and the `output` derivation of a pipe carries no -properties, so `/meta/types` advertised `action` as -`{"$schema": "https://json-schema.org/draft/2020-12/schema"}` — a document that -reads as "this type declares no constraints" for a type that accepts 47 keys. -The hand-crafted fallback declared for this case never fired, because the -conversion did not throw: it succeeded and returned a truthy husk, which -short-circuits the `??` that was supposed to reach the fallback. - -A derivation that comes back with no properties, no union arms, no `$ref` and no -`additionalProperties` object is now treated as a non-answer. It is retried in -the authoring shape (`io: 'input'`), and if that degenerates too the type is -named in a one-shot warning and the hand-crafted fallback decides. - -Only `action` changes. The `output` derivation remains the served default on -purpose: deriving every type with `io: 'input'` was measured across the whole -served surface and would move 24 of the 26 types that carry a Zod schema, in the -direction of a weaker contract (`required` entries 1132 to 867, -`additionalProperties: false` 663 to 637). Gating the retry on degeneracy keeps -the change to the one type that was actually broken. - -Consumers reading `schema` for `action` from `/meta/types` or `/api/v1/meta` now -receive its real 47 properties instead of an empty object. No other type's -served payload moves, and a type that resolves no Zod schema at all continues to -be served with no schema — absence is not the same failure as a derivation that -came back empty. diff --git a/.changeset/migrate-meta-default-range-terminus.md b/.changeset/migrate-meta-default-range-terminus.md deleted file mode 100644 index 2ef119d6d70..00000000000 --- a/.changeset/migrate-meta-default-range-terminus.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): `os migrate meta --from N` — the invocation every tombstone prescribes — lists the conversions it was sent to list, and an empty range stops reading as success (#17134) - -`--to` defaulted to `PROTOCOL_MAJOR`, the major the runtime implements. But retirements land throughout a major's line, and their ADR-0087 conversions are registered under the NEXT one: `@objectstack/spec@17.4.0` tombstones `dashboard.refreshInterval` while the conversion that renames it is `toMajor: 18`. The `retiredKey()` house sentence names the major the source was **authored** against — `Run \`os migrate meta --from 17\` …` — so the prescribed invocation composed the range `17 → 17`, which `composeMigrationChain` selects **no step** for, and the command answered: - -``` -✓ Nothing to migrate — the metadata is already canonical for this range. -``` - -exit 0, printed immediately under the five refusals that named that exact command. **29 shipped tombstones across 15 source files prescribe it.** - -Two changes, both in `packages/cli`: - -- **`--to` now defaults to the highest major this build of `@objectstack/spec` carries a migration step for** (`Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS)`), so the tombstone template's presumption holds in every window rather than only after the next major has shipped. Nothing is migrated "past" the runtime: every registered conversion maps a shape the installed schemas already **refuse** onto the one they accept, which is why the terminus is the only target for which the command's own `schemaValid` verdict is reachable. `Math.max` keeps the runtime's major as the floor for the reverse case. -- **A range holding no step is answered as one.** `already canonical` was a green verdict on a check that never ran, so the empty-range case now says so, names the range that would list the conversions (`--to N`), and no longer returns past the schema verdict that contradicted it — the same run used to report `schemaValid: false` in `--json` while the human output claimed the metadata was canonical and stopped. - -**What changes for you.** `os migrate meta --from ` with no `--to` now replays one hop further than it did, so a cross-major run prints that hop's semantic TODOs as well — the same wall a `--from N-1` run has always printed, one major on. The mechanical rewrite list is still first. `--to` is unchanged when you pass it, `--stored` is untouched, exit codes are unchanged (this command reports findings, it does not exit on them), and a range that holds real steps and rewrote nothing still answers `Nothing to migrate`. diff --git a/.changeset/migrate-meta-protocol-version-key.md b/.changeset/migrate-meta-protocol-version-key.md deleted file mode 100644 index a2fa5612490..00000000000 --- a/.changeset/migrate-meta-protocol-version-key.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -"@objectstack/cli": minor -"@objectstack/metadata-core": minor ---- - - - -feat(cli,metadata-core)!: the protocol version is emitted under `protocolVersion`, never under a `runtime`-shaped name (#15585) - -**BREAKING** — two published machine surfaces change a key name. There is **no alias -and no dual-key transition window**: one axis, one name. - -| Surface | Was | Now | -|:--|:--|:--| -| `os migrate meta --json` payload | `runtime` | `protocolVersion` | -| `OS_PROTOCOL_INCOMPATIBLE` diagnostic (`ProtocolIncompatibleError.diagnostic`) | `runtimeVersion` | `protocolVersion` | -| `checkProtocolCompat()` / `assertProtocolCompat()` 2nd parameter | `runtimeVersion` | `protocolVersion` | - -The **value** is unchanged on every one of them: it is `PROTOCOL_VERSION`, the protocol -major padded to a semver (`'17.0.0'`), exactly as before. Nothing else on either payload -moves — no other key is added, removed or reshaped, and both text faces are byte-identical. -The parameter rename is positional, so no call site changes. - -## Why the name had to move - -`PROTOCOL_VERSION` is the protocol major padded to a semver and never tracks the installed -`@objectstack/cli` or runtime package version. Printed or emitted under the word *runtime* -it read as one: on a 17.3.0 install `runtime: "17.0.0"` reads as an apparent downgrade or -a stale install, next to the real package versions of the same upgrade session. - -The human line was repaired first and now reads -`Chain: protocol 17 → 17 (this runtime implements protocol 17)`. The machine face is the -worse half and was left standing, because a key on a published payload is a contract -change: an agent scripting an upgrade has no prose to disambiguate at all, and the -diagnostic's own `message` — which *is* unambiguous — is the one part a machine consumer -does not parse. - -## What a consumer should do - -Read the new key. The old one is absent, so a consumer that does not move reads -`undefined` rather than a wrong value. - -```diff -- const v = payload.runtime; // os migrate meta --json -+ const v = payload.protocolVersion; - -- const v = err.diagnostic.runtimeVersion; // OS_PROTOCOL_INCOMPATIBLE -+ const v = err.diagnostic.protocolVersion; -``` - -The diagnostic surfaces through every package that re-emits it — `@objectstack/runtime` -spreads it into `ArtifactReferenceError.detail`, `@objectstack/metadata-protocol` throws it -from the package install boundary, and `@objectstack/services-package` reads it during -hydration — so a consumer reading it from any of those reads the new name too. - -`runtimeMajor` on the same diagnostic is deliberately **unchanged**: it is an integer -protocol major, not a semver in a version position, and it does not carry the ambiguity -this rename closes. - -The breaking surface was measured before the rename and is closed inside this repository: -the only reader of the `--json` key was this repo's own e2e pin and the only reader of the -diagnostic member was `metadata-core`'s own unit test, both of which move in this same -change; the published `skills/objectstack-upgrade/SKILL.md` documents `--json` without ever -naming the field. **Zero external consumers were found.** Graded `minor` rather than -`major` for the launch window; the banner above carries the breaking-ness the level cannot. diff --git a/.changeset/nested-strand-chain-restore.md b/.changeset/nested-strand-chain-restore.md deleted file mode 100644 index 7da61e9c180..00000000000 --- a/.changeset/nested-strand-chain-restore.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -'@objectstack/service-automation': minor -'@objectstack/plugin-approvals': patch ---- - -`restoreConsumedSuspension` reaches a NESTED run: the ancestors a stranded descendant cascade-failed are journalled too, and the chain is re-armed as one unit - -`resumeInternal`'s catch arm journalled the consumed suspension of the run that -threw, and nothing else. For a nested run the ancestors were handled on both -paths with no journal at all: up-bubble (`failAncestors` walks `$parentRunId` -and calls `failSuspendedRun` on each suspended ancestor) and delegation (the -parent frame sees a failed child with no retryable code and calls -`failSuspendedRun` on itself). `failSuspendedRun` was `forgetSuspendedRun(run, -'failed')` plus a `failed` log record — it journalled nothing. - -So the leaf was restorable while every ancestor was recorded `failed` with its -pause consumed and no snapshot (`restoreConsumedSuspension(PARENT)` answered -`NO_CONSUMED_SUSPENSION`), and restoring the leaf completed it into a parent -that never continues: `bubbleToParent` found no parent suspension and logged. -The operator ended up worse off than before using the exit. - -`failSuspendedRun` now journals the pause it consumes whenever the descendant -whose failure consumed it is itself repairable — from the same single producer -and onto the same durable terminal row as the strand's own snapshot, so the -chain is repairable from any replica and after a restart, not only from the -process that stranded it. `restoreConsumedSuspension` then repairs the chain as -one unit: it walks down to the stranded descendant and up through the ancestors -it cascaded into, and re-arms every member DEEPEST FIRST, so an ancestor becomes -resumable only after the run it is parked awaiting is parked again. The entry -point does not matter — naming any member of the chain repairs all of it — and -the continuation is then re-issued once, on the run that was named. - -Additive on the wire and in the type: the result's existing fields still -describe the run the caller named, and the new `chain` key is present only when -the repair was a chain repair. `ChainRestoreEntry` is exported for it. The -narrower `IAutomationService.restoreConsumedSuspension` contract in -`@objectstack/spec` is unchanged and the HTTP door's payload is unchanged — the -door answers `{ runId, restored, reason }` as it always did. - -Every member goes through the same per-run call as a flat restore — its own -in-process claim, its own strict live-suspension read, its own two-witness read, -its own durable park — so idempotence and the #14333 advance claim hold per run -in the chain: a second restore finds every member parked and answers -`RUN_SUSPENDED` without minting a second pause anywhere. - -⛔ No ancestor is stamped `'stranded'`. That word is the resume result of a run -that consumed its OWN pause and then threw downstream, and nothing re-arms an -ancestor by resuming it; stamping it would send an operator to retry a recovery -that cannot succeed. The parent frame's delegation result still carries no -status at all, and an ancestor's repairability is carried by the journal and by -this verb's answer. - -Journalling is EARNED, not applied to every cascade: an ancestor whose -descendant is beyond repair is still consumed without a snapshot, because -re-arming it would promise a chain repair that could not be completed. - -**`@objectstack/plugin-approvals`** reports the consequence rather than causing -it: `inspectStrandedRequests` asks the engine per run, so a cascade-failed -ancestor whose descendant is repairable now comes back `runState: -'repairable'` instead of `'unrepairable'`, and restoring either row repairs the -pair. `'unrepairable'` keeps its other causes — a run that never paused, a -snapshot no longer held, and a cascade whose descendant was itself beyond -repair. No plugin logic changed; the docblocks that documented the old -limitation did. diff --git a/.changeset/notify-zero-delivery-is-distinguishable.md b/.changeset/notify-zero-delivery-is-distinguishable.md deleted file mode 100644 index dfd6d00a27f..00000000000 --- a/.changeset/notify-zero-delivery-is-distinguishable.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -`notify` now reports the recipients it addressed, so a run that notified nobody stops reading like a run that had nobody to notify - -A `notify` node whose delivery count came back zero contributed `acted: 0` and nothing else to the run summary. A flow whose only effect-bearing node is that one then folded to `selected: 0, acted: 0, unmeasured: 0` — byte for byte the summary of a run that had nothing to notify about, and of a run whose `notify` node never executed. The run read healthy, and the only trace was a log line. - -`emit()` returns `delivered: 0, enqueued: 0` on several paths, each after logging and nothing else: an audience that resolved to no recipient, a preference filter that suppressed every (recipient × channel) pair, a dedup hit, every enqueue failing. A stack with no messaging service installed lands in the same place. All of them were silent in the summary, so this is not one cause being fixed — it is the whole class becoming visible. - -The node now reports `selected` — the recipient entries it addressed — on every path that reaches a recipient list, alongside the `acted` / `unmeasuredEffect` rules it already had. Those two are unchanged, so a delivering run keeps its existing `acted` (inline) or `unmeasured` (outbox) reading and stays outside the broken-sweep filter; a zero-delivery run now reports `selected: N, acted: 0` with no `unmeasured`, which is the platform's declared "matched N, acted on none, and that zero is trustworthy" signature and puts the run **inside** `selected > 0 AND acted = 0 AND unmeasured = 0` — the filter that exists for exactly this, and whose first clause the old reading could never satisfy. - -The zero is deliberately NOT reported as `unmeasuredEffect`. That flag means the count is unknown; this count is known and it is zero, and claiming otherwise would take the run out of the very filter it belongs in. - -`selected` counts audience entries, not resolved users: the entry (`role:manager`, a bare id) is what the node has, since expansion happens inside the messaging service and is not reported back. diff --git a/.changeset/nowhere-bound-app-root.md b/.changeset/nowhere-bound-app-root.md deleted file mode 100644 index 465263e81bb..00000000000 --- a/.changeset/nowhere-bound-app-root.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -**Fix:** a field-level `*When` predicate reading `app` no longer tells the author the root is mounted by the renderer — decision batch #67 ruled that away, and the diagnostic now says `app` binds nowhere at all. - -`FIELD_RULE_AMBIENT_ROOTS` is renamed `FIELD_RULE_NOWHERE_BOUND_ROOTS` and keeps its single member. The name and the docblock were the false part: #13935 added `app` on the premise that objectui's app-shell bound it at the renderer, so the honest verdict was "bound somewhere, just not here". Batch #67 (2026-09-07) ruled the engine's `SCOPE_ROOTS` to be the contract and ObjectUI aligned to it, so nothing binds `app` any more — and the constant's cited source of truth, the page-component schema's ambient-roots section, no longer names `app` either. - -What an author reads changes; what lints clean does not. Before and after, both `app` spellings earn exactly one `error`. - -- **Before:** `` `app` is NOT declared platform-wide — it is an AMBIENT root, mounted only by the renderer (…) So it resolves in a form VIEW's own field predicate and on no server path at all … `` and, at the end, an offer to *"leave the `app`-dependent decision on the view's own field predicate where `app` IS bound"* — a destination that no longer binds it. -- **After:** `` `app` is NOT declared platform-wide, and no evaluation site binds it — not this one, and not any other … The predicate therefore faults wherever it is written, and there is no surface to move it to. `` - -The ``⛔ Do NOT write `record.app` `` refusal is kept verbatim, and that is the point of the repair. Emptying the constant — the obvious reading of "nothing is ambient any more" — drops the root through to `@objectstack/formula`'s generic bare-reference check, whose prescription is to rewrite the root as a member of the record; following that earns ``unknown field `app` `` one pass later. That is the exact two-step wrong correction #13935 existed to remove, so the membership stays and only its grounds move. Four pins now assert, on both the bare and the dotted spelling, that no path produces that prescription. - -No published export moves: `FIELD_RULE_AMBIENT_ROOTS` was never re-exported from this package's entry (only `validateStackExpressions`, `fieldRuleRootIssue` and `FIELD_RULE_BOUND_ROOTS` are), so the rename is internal and no import breaks. diff --git a/.changeset/numeric-column-representation.md b/.changeset/numeric-column-representation.md deleted file mode 100644 index 719018cda5d..00000000000 --- a/.changeset/numeric-column-representation.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/driver-sql': minor -'@objectstack/cli': minor ---- - -One physical representation for the NUMERIC column family, read by every producer of DDL - -`packages/spec` now states, per field type, what column a numeric field gets, and all three -producers read it: `SqlDriver.createColumn`, `os generate migration --format sql` and -`os generate migration --format typescript`. Measured on live PostgreSQL 16.13, one object -through all three producers, before and after: - -``` - BEFORE AFTER - driver sql gen ts gen all three -number real numeric(18,2) numeric(8,2) numeric(65,30) -currency real numeric(18,2) numeric(8,2) numeric(65,30) -percent real numeric(5,2) numeric(8,2) numeric(65,30) -slider real numeric(18,2) numeric(8,2) numeric(65,30) -summary real numeric(18,2) numeric(8,2) numeric(65,30) -progress real numeric(5,2) numeric(8,2) numeric(65,30) -rating real integer integer integer -``` - -7 of 7 columns diverged before, 0 of 7 after. Every arm of the old split lost data in its own -direction: `real` is IEEE-754 binary32, so a `currency` of `1234567.89` read back `1234567.9`; -`numeric(5,2)` and `numeric(18,2)` silently ROUND a legitimate `33.333` to `33.33` (round -half-up — executed, not inferred); `numeric(8,2)` refused `1234567.89` outright. `65,30` is -MySQL's documented `DECIMAL` maximum and therefore the portable one, and it is the only -candidate measured to lose nothing on a nine-value corpus. - -Both migration formats also take the physical `NOT NULL` from `storage.notNull` and never from -`required`, which is where `SqlDriver.createColumn` has taken it since ADR-0113: `required` is -the write-time contract the record validator enforces, and binding the DDL to it made every -post-deploy tightening a destructive migration. - -**BREAKING** — new columns only; no existing column is retyped, no migration is planned, and no -backfill runs. Four consequences to know before creating new tables: - -- `rating` is an INTEGER column, and the two server dialects dispose of a fractional star count - DIFFERENTLY — do not read one answer for both. PostgreSQL REFUSES `4.5` outright, where a - `real` column accepted it. MySQL does NOT refuse: it ROUNDS, and `4.5` becomes `5` with no - error, which is a silent alteration and the reason to declare a `slider` (in the exact-decimal - set) for anything that wants fractional values. SQLite refuses nothing either: it stores `4.5` - as a REAL in an INTEGER-affinity column, unchanged from today. -- An exact-decimal column is bounded where a float is not, in BOTH directions. It keeps 30 - fractional digits: a magnitude whose significant digits run past the 30th decimal place loses - the tail silently — `1.2345678901234567e-15` stores as `0.000000000000001234567890123457`, so - the loss begins around |x| < 1e-13 and is total below 1e-30 — and magnitudes at or above 1e35 - are REFUSED, where `real` kept about seven significant digits out to ~1e38. A refusal is loud; - the rounding it replaces was not. -- Reads are bounded by the wire contract, not by the column. `find()` hands back a JS number - (`z.number().finite()`), so a value that was never a JS double does not survive the round trip - exactly — `1234567890123456.123` reads back `1234567890123456`, and 2^53+1 reads back 2^53. - The fidelity this buys is an exact COLUMN read through a double: values written by this - platform round-trip exactly, and SQL-side writers, `summary` roll-ups computed in SQL and any - magnitude at or above 2^53 are bounded by the read seam. Widening that is a wire-contract - change and is not in this release. -- A generated migration no longer emits `NOT NULL` for a field marked only `required: true`. - Declare `storage: { notNull: true }` for a physical constraint — which is what the platform's - own table has always done since ADR-0113, and what `os migrate meta` deliberately does NOT - supply on your behalf (the conversion that stamped it was withdrawn by maintainer ruling on - 2026-09-08). A source author who wants the column they had must write that block themselves; - `required: true` keeps its own meaning, the write-time contract the record validator enforces. - -SQLite emits byte-identical DDL for the six exact-decimal members: knex compiles both -`table.decimal(name, p, s)` and `table.float(name)` to the same `float` column there. - - diff --git a/.changeset/oauth-agent-runs-as-the-user.md b/.changeset/oauth-agent-runs-as-the-user.md deleted file mode 100644 index 4c945367bb8..00000000000 --- a/.changeset/oauth-agent-runs-as-the-user.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/spec": minor -"@objectstack/mcp": minor -"@objectstack/runtime": minor ---- - -fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) - -Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** - -**The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: - -| identity path | `crm_account` | `crm_opportunity` | `crm_task` | -|:--|--:|--:|--:| -| API key, `principalKind: human` | 9 | 23 | 45 | -| OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | - -The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. - -**The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. - -**(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. - -⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. - -**(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. - -**(3)** The Setup page's promise is untouched — it is now true rather than rewritten. - -Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. - -`DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: - -| direction, after release | consumer cost | -|:--|:--| -| ship optional fields, later tighten them to required | a compile break | -| ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | - -The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. diff --git a/.changeset/oauth-register-declares-only-honoured-members.md b/.changeset/oauth-register-declares-only-honoured-members.md deleted file mode 100644 index cf48a99420d..00000000000 --- a/.changeset/oauth-register-declares-only-honoured-members.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): `oauth.applications.register` declares only the members `/oauth2/create-client` accepts — `name`, `scopes` and `metadata` are removed (#15447) - -**BREAKING** — three members leave a published request type. A caller who sets one compiles today and gets a type error after this release. That is the point: the route never honoured any of them, so what the compiler now refuses is code that was already having its value thrown away. - -## What a caller passing these members should do instead - -| you were passing | pass instead | why | -|---|---|---| -| `name: 'My App'` | `client_name: 'My App'` | same `string`, and `client_name` is the member the route reads | -| `scopes: ['openid', 'profile']` | `scope: ['openid', 'profile'].join(' ')` | ⚠️ **not** a rename — `scope` is one space-delimited string; posting an array is refused with `400 [body.scope] Invalid input: expected string, received array` | -| `metadata: { tenant: 'acme' }` | nothing — delete the member | no door this SDK can reach accepts it (see below) | - -## ⚠️ These were the vendor's RECORD vocabulary, not typos - -`client_name` writes the DB column literally named **`name`**; `scope` writes the DB column literally named **`scopes`**, as a JSON array. The removed members were the *column* names offered next to the *wire* names in the same declared type — an author picking the adjacent one of two got a success receipt and no value. Treating them as misspellings would be the wrong reading of what they were; the prescription above is still the wire member either way. - -## Why they had to go rather than be honoured here - -`POST /api/v1/auth/oauth2/create-client` is mounted verbatim from `@better-auth/oauth-provider@1.7.2`. Its body schema declares 21 members and sets no `catchall`, so it is zod's default **strip**: an unknown key is dropped, not refused, and the caller gets **HTTP 201 and a client that quietly does not have the value**. Driven end to end against a real `betterAuth` + `oauthProvider` over a real ObjectQL engine on a real socket, through this client: each of the three came back absent from the response, absent from `oauth.applications.get`, absent from `oauth.applications.list`, and `null` in the `sys_oauth_application` row. - -A second, independent barrier stands behind that strip — the handler funnels the parsed remainder into the opaque-metadata envelope, and all three names sit in `OPAQUE_METADATA_RESERVED_FIELDS` — so no amount of loosening on the SDK side could ever have made them arrive. `metadata` in particular is honoured only by `PATCH /admin/oauth2/update-client`, which is `SERVER_ONLY` and therefore not an HTTP route at all: over the wire it answers 404 with a zero-byte body. - -Nothing else on the method moves. The two members the route does honour, `client_name` and `scope`, are declared exactly as before and still reach the server byte for byte; the method's return type, its URL and its request-building step are unchanged. - -Graded `minor` rather than `patch` because a published package's public surface moves, per the maintainer's ruling of 2026-09-04 (decision batch #35) that such a change takes at least `minor`; the banner above carries the breaking-ness the level cannot. - - diff --git a/.changeset/object-block-sort-item-array.md b/.changeset/object-block-sort-item-array.md deleted file mode 100644 index 4e09034f601..00000000000 --- a/.changeset/object-block-sort-item-array.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `object-grid` and `object-calendar` constrain the `sort` VALUE to the `SortItem` array — one sort orthography platform-wide reaches the last two unconstrained doors (#16553; objectui#8221, decision batch #77 option B) - - - -**BREAKING** accept-set change at two doors — `ComponentPropsMap['object-grid'].sort` -and `ComponentPropsMap['object-calendar'].sort` — shipped as `minor` under the -repo's launch-window convention for breaking changes; the migration prescription -is registered under protocol major 18 as `object-block-sort-item-array`. - -One `sort` spelling platform-wide, the array (objectui#8221, decision batch #77, -2026-09-07, maintainer verbatim 「其他同意」, option B; the consumer half is -objectui PR #8758, which drops the legacy string arm from -`convertSortToQueryParams`). Item 4 of that ruling is this release's subject: -「`ComponentPropsMap` for `object-calendar` and `object-grid` constrains the -`sort` value to the array shape (today it accepts anything), so the spec, the -registrations and the helper agree; that is a pull-back to the declared contract, -ordinary tier」. - -Until this release both doors declared `z.unknown()` — no orthography at all. -Measured on `@objectstack/spec` 17.2.0 and re-measured on this tree before the -change: an array, the legacy string clause and a bare NUMBER all returned -`success: true`, while `bogusProp` was refused by name on the same call. So key -checking was live and only the VALUE was unheld, and an author following -objectui's own registrations (`plugin-grid/src/index.tsx:222` has published -`type: 'array'` all along) and an author following the legacy string each got a -silent success receipt for a different shape — while objectui's html tier -answered `type-mismatch` on the second one. Both doors now declare -`z.array(SortItemSchema)`, the array `ElementDataSourceSchema.sort`, -`ListPageSchema.sort` and `element:record_picker`'s flat `sort` shorthand already -carry: one shared schema, not a third copy. - -Sequenced measurement-first, as this family has to be. At the objectui pin this -repo builds against (`53ded82b`) the string is still lowered — -`ObjectGrid.tsx:1844-1851` carries an explicit `typeof === 'string'` arm onto -`$orderby` beside the array arm, and `ObjectCalendar.tsx:431` hands `schema.sort` -to `convertSortToQueryParams`, whose string arm is still present at -`sort-query.ts:66-70`. This declaration therefore lands ahead of the pinned -consumer, which the ruling permits explicitly — either order, since the -registrations already declare the array — and the next pin bump carries the -retirement in. - -**Migration** (`object-block-sort-item-array`): `sort: 'created_at desc'` becomes -`sort: [{ field: 'created_at', order: 'desc' }]`; a bare field name -`sort: 'created_at'` meant ascending and becomes -`sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required in -`SortItemSchema`, so it is written out rather than omitted; a comma-separated -clause becomes one array entry per key, in the same order. The string is refused -at `sort` (`invalid_type`, expected array), as is a bare number; a misspelled or -absent direction is refused at `sort.0.order`. Metadata AT REST is not rewritten -and this disposition adds no D2 conversion — a stored page carrying a string -`sort` keeps loading and still renders at the pinned `.objectui-sha`; what -changes is that RE-SAVING it is refused at the `sort` door. - -**Not moved by this release.** `record:related_list.sort` keeps its declared -string arm: that string is the `'field'` / `'-field'` dialect read by -`RelatedList.normalizeSortSpec`, it never reaches `convertSortToQueryParams`, and -retiring it was not ruled — objectui#8221's own implementing round narrowed it, -established the dialect and reverted the narrowing byte-identically. -`object-grid.defaultSort` is a different key, already retired by #11805. Zero -authored `sort` values on either block exist in this repo (the two showcase pages -that author `object-grid` declare none), so nothing in-tree was converted. - -Type aliases are unchanged: `SortItemSchema`'s input equals its infer, so neither -block's parsed state moves for this key, and both already take the -`…PropsParsed` route for `filter` (ADR-0122). diff --git a/.changeset/objectql-aggregate-inmemory-rows-ast.md b/.changeset/objectql-aggregate-inmemory-rows-ast.md deleted file mode 100644 index 0075d356acc..00000000000 --- a/.changeset/objectql-aggregate-inmemory-rows-ast.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): `engine.aggregate`'s in-memory lowering asks the driver for ROWS, so a per-aggregation `filter` stops being refused by the driver it was lowered for (#16642) - -`engine.aggregate` forks: a driver with a native `aggregate()` gets the pushdown, and anything the pushdown cannot express — a per-aggregation `filter` (#10576), a date granularity the driver does not advertise, a non-UTC reference timezone — falls back to `driver.find()` plus `applyInMemoryAggregation`. That fallback handed `find()` the whole aggregate AST, **aggregation keys included**. - -`find()`'s contract says nothing about `groupBy` / `aggregations`, and the drivers disagree about them. `driver-sql` and `driver-rest` ignore both and return rows — which is the only reason this path ever worked. `driver-memory` **honours** them (`find()` → `performAggregation`, the same method its `aggregate(AST)` door funnels through), which is the shape measured here; `driver-mongodb` and `driver-turso` carry the same refusal on their own aggregation faces, so a driver that ever routes `find()` into one lands in the same place. Against a driver of the second kind the one seam answered two different wrong things: - -- the per-aggregation `filter` that **routed the call here** was refused `NOT_IMPLEMENTED`/501 by the driver's own #10413 guard — a guard aimed at a caller reaching the driver's aggregation face directly, whose remedy text is *"route the query through the engine"*. The engine's own lowering was being told to use the engine. Downstream, `service-analytics`'s ObjectQL strategy lowers a dataset measure `filter` into exactly this key, so on the memory driver a measure `filter` (and the `derived: { op: 'ratio' }` that needs two differently-filtered counts) answered **501** while sqlite answered the number; -- a date-bucketed `groupBy` came back **already grouped**, on the raw timestamp — `dateGranularity` is an engine concept no driver face reads — and `applyInMemoryAggregation` then aggregated those group rows a second time. That half does not refuse: it reports a count of *buckets* under the author's own measure name. - -The fix is one seam: on the in-memory path the AST sent to `find()` carries no `groupBy`, no `aggregations` and no `having` — the three things this path is about to evaluate itself. `where` is untouched, so the middleware-injected read scope (RLS / tenancy) still travels with the call. - -`patch`: no signature moves and no key is added or retired. The pushdown fork is unchanged (an aggregation with no filter still goes to `drv.aggregate`), and on `driver-sql` — which ignored the stripped keys — the emitted statement and every number are unchanged. What changes is that two shapes that used to answer a refusal or a wrong number now answer the number the contract already promised: `driver-memory`'s `refusePerAggregationFilter` and `driver-sql`'s `unsupportedAggregationFilterError` both document themselves as *unreachable through `engine.aggregate`, which lowers in memory for every driver* — this is the line that makes that true. diff --git a/.changeset/objectql-scoped-repository-declared-returns.md b/.changeset/objectql-scoped-repository-declared-returns.md deleted file mode 100644 index 0cd2a59e90c..00000000000 --- a/.changeset/objectql-scoped-repository-declared-returns.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -feat(engine): `ObjectRepository.findOne` / `.update` publish their honest types — the contract's shapes, not `any` (#16786) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #15280 used for `SqlDriver.update()` and the `TursoDriver.update()` override, and PR #14434 before it on `@objectstack/driver-memory`). - -`ObjectRepository.findOne()` and `.update()` were written out with an explicit `Promise` while they have always answered what the contract declares — each one forwards, one line down, to an `IDataEngine` door that already declares the shape: - -- `findOne` → `Promise | null>` -- `update` → `Promise | number | null>` - -`IScopedObjectRepository` — the contract this class carries an `implements` clause for — declares both, and has since ruling A on #16231 landed (PR #16783). An explicit `any` satisfies that structurally, because a **wider** declared return always satisfies a narrower one: `class ObjectRepository implements IScopedObjectRepository` compiled green the whole time while the emitted `.d.ts` read `Promise`, so no caller holding an `ObjectRepository` — or reaching one through `ScopedContext` or `ObjectQL.createContext()`, both exported from this package's index — was ever asked to narrow. They are now declared as the contract declares them. No runtime behaviour changes. - -A caller that read fields off `findOne()`'s result through the `any` now narrows the `null` arm first; a caller that read `update()`'s result now separates the by-id record from the predicate-form count. The in-repo census for this change was one file, repaired alongside. - -`updateById` is deliberately untouched: `IScopedObjectRepository.updateById` itself declares `Promise`, so the class already matches its contract and there is no drift to repair on this side. That half stays open on #16786. - - diff --git a/.changeset/objectui-pin-describe-repoint.md b/.changeset/objectui-pin-describe-repoint.md deleted file mode 100644 index ed85cfd6077..00000000000 --- a/.changeset/objectui-pin-describe-repoint.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -One published `describe` sentence that dates itself to the `.objectui-sha` pin is re-pointed to the pin this release builds against, objectui `dd3f7e1be356`, after being re-read there. - -Clause-②: no - -- `FormField.span`: the `'auto'` clause says that at the pin this repo builds against, only textarea, markdown, html, richtext and repeater resolve to the full column count. It named `f8a9d0fb0596`. Re-read at `dd3f7e1be356`, the claim still holds. `plugin-form`'s `autoLayout.ts` `WIDE_FIELD_TYPES` (`:58-69`) and `resolveColSpan` (`:154`) are byte-identical; its only change is one docblock line. `form.tsx`'s `spanLadderFor` (`:204-231`) is byte-identical, so `'full'` is still the whole row at every multi-column tier. Only the pin the sentence names moves. - -No key, default, enum member or export moves: the same authored metadata is accepted and refused as before, and `content/docs/references/ui/view.mdx` is regenerated from the sentence. diff --git a/.changeset/objectui-pin-trash-icon.md b/.changeset/objectui-pin-trash-icon.md deleted file mode 100644 index d564d380867..00000000000 --- a/.changeset/objectui-pin-trash-icon.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/platform-objects': patch -'@objectstack/spec': patch ---- - -fix(platform-objects,spec): the delete actions and the flow builder's Delete Record node name the `trash` icon, which still renders after the console's lucide 1.43 upgrade - -Clause-②: no - -The console build at the new objectui pin ships `lucide-react` 1.43, whose runtime `icons` record dropped one key, `Trash2`. The console resolves an authored icon name through that record, so `icon: 'trash-2'` now resolves to nothing and the button draws no glyph. `trash` draws the identical glyph, which objectui measured node for node when it made the same repair in its own tree. - -Four delete actions in `@objectstack/platform-objects` (OAuth application, organization, SSO provider, team) and the `delete_record` entry of the flow builder's default node palette in `@objectstack/spec` now say `trash`. The `BulkAction.icon` description's example names `trash` too. No key, default shape or export moves. An author's own `icon: 'trash-2'` keeps validating as before, but draws no glyph in the console; write `icon: 'trash'` to get the same glyph back. diff --git a/.changeset/odata-example-programmatic-use-dollar-prefixes.md b/.changeset/odata-example-programmatic-use-dollar-prefixes.md deleted file mode 100644 index e3b216158de..00000000000 --- a/.changeset/odata-example-programmatic-use-dollar-prefixes.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): the OData `@example Programmatic Use` bag is spelled with the `$` prefixes the schema actually declares (#19028) - -The file-level docblock of `src/api/odata.zod.ts` carried an `@example Programmatic Use` block that wrote every `ODataQuery` key unprefixed — `select`, `filter`, `orderby`, `top`, `skip`, `expand`, `count` — while every key the schema declares carries a `$`. Measured with `safeParse` on that bag verbatim: - -| bag | result | -|:---|:---| -| the documented bag, verbatim | `success: true`, `data: {}` — all seven keys stripped | -| the same bag with `$` prefixes | `success: true`, all seven keys retained | -| a bag holding one fabricated key | `success: true`, `data: {}` | - -So the documented bag and a bag of pure nonsense parsed identically: accepted, silently emptied, no error and no warning. An author who copied it got a query that asked for nothing — no projection, no filter, no ordering, no paging — with nothing anywhere to say so. - -The correct spelling was already ten lines above it in the same docblock: the `@example OData Query` block spells the URL conventions `$select=`, `$filter=`, `$orderby=`, `$top=`, `$skip=`, `$expand=`, `$count=`. Only the second example contradicted the schema, and only the second example moves here. - -**What reaches a consumer.** `@objectstack/spec` ships `src/**/*.zod.ts` in its `files[]`, so this docblock is in the installed tarball as well as on the generated reference page `content/docs/references/api/odata.mdx`, which the same docblock feeds. Both now show the seven prefixed keys. - -**What does not move.** Example prose only. `ODataQuerySchema` is untouched — same accept set, same optionality, same unknown-key behaviour: a key it did not declare is still accepted and stripped rather than refused, exactly as before. No export, no type, no runtime path changes, and no test assertion needed editing. Whether that stripping should instead be a refusal is a separate question, deliberately not answered here. diff --git a/.changeset/olive-buttons-shave.md b/.changeset/olive-buttons-shave.md deleted file mode 100644 index 076a524f3c0..00000000000 --- a/.changeset/olive-buttons-shave.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -`POST /api/v1/data/{object}/deleteMany` stops reporting a deletion it did not perform. - -Each row of the batch answered `success: true` for every engine result that was not the -driver contract's `false`. The `false` arm — "no row matched" — has reported honestly since -#4435; the other ending had not: a row that MATCHED and was deliberately NOT removed was -counted in `succeeded` and reported as deleted, byte-identical to a real deletion. - -`sys_permission_set` is the shipped case. Deleting a package-declared set is an ADR-0005 -RESET: the overlay tombstones and the record re-projects to the declared body instead of -vanishing. That behaviour is unchanged and deliberate — what was wrong is the answer, and on -a security-configuration write it told an operator a permission set was gone while it was -still being enforced. - -`IDataEngine.delete` declares `Promise` — the driver boolean for a by-id -write, a count of rows removed otherwise — so a numeric zero is the one value that positively -means the record is still there. The per-row `success` now reads that count instead of being -a literal. - -On the wire, for one row of a `deleteMany`: - -- removed — `success: true`, counted in `succeeded` (unchanged); -- matched but not removed — `success: false`, counted in `failed`, and no `errors[]` entry: - a surviving record is an outcome, not a fault, and this envelope's two per-row codes - (`ROLLED_BACK`, `NOT_ATTEMPTED`) both describe a row that never ran; -- unknown id — `success: false` with `errors[0].code: RECORD_NOT_FOUND` (unchanged). - -Because `succeeded` and `failed` partition `results`, a surviving row also makes the -request-level `success` false, and an `atomic` batch containing one now rolls back rather -than committing under a response that called every row deleted. A non-atomic batch is not -stopped by it: nothing was thrown, so the `continueOnError` stop does not apply and the -remaining ids are still attempted. - -Clause-②: no diff --git a/.changeset/olive-donuts-invent.md b/.changeset/olive-donuts-invent.md deleted file mode 100644 index 4db59c46d13..00000000000 --- a/.changeset/olive-donuts-invent.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -**`AppPlugin` now names the manifest-stage `permissions` value its ADR-0057 security registrar cannot read, instead of dropping it in silence.** - -The registrar flattens the manifest under the stack's own collections (`{ ...manifest, ...collections }`), so `manifest.permissions` is read whenever the stack declares no `permissions` collection of its own. That key is the ADR-0025 §3.2 capability grant a package *requests* — a flat list of permission strings, or `{ services, hooks, network, fs }` — while the registrar wants ADR-0090 `PermissionSet[]`. Both arms were skipped with nothing logged: the structured arm is not an array, so the whole value never entered the loop; every member of the flat list carries no `name`, so all of them were dropped. An author who wrote `manifest: { permissions: ['sales_rep'] }` meaning a permission set got no set registered, no `sys_audience_binding_suggestion`, and no line anywhere saying why — the "absence must be loud" rule in AGENTS.md → Route & surface ownership §3. - -It now warns once per boot, naming the field, how many entries were lost, both readings of the key, and where permission sets belong (`defineStack({ permissions: [ … ] })`). The report is written per `SECURITY_FIELDS` entry, so a hand-built bundle carrying `positions` / `capabilities` / `sharingRules` on its manifest is named too. - -**Nothing else moves.** Which items register is byte-for-byte unchanged — the registrar is deliberately *not* made tolerant of the grant reading (widening the key was rejected by name, #14242 road C, maintainer 2026-09-02). The line is `warn`, not `error`: nothing here claimed to persist anything. It stays silent on every shape where nothing was lost — a stack declaring its own `permissions` collection, a manifest with no such key, a manifest whose entries the registrar really can read, and the `securityMetadataRegistrar: 'artifact-door'` composition that owns the route. diff --git a/.changeset/olive-moons-tickle.md b/.changeset/olive-moons-tickle.md deleted file mode 100644 index e225d5eab2f..00000000000 --- a/.changeset/olive-moons-tickle.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -Translate the object field-editor panel leaves that shipped their English source in every locale - -Fourteen metadata-form keys under `object.fields['fields.*']` — the field editor on the object form (`placeholder`, `valueDomain`, `rows`, `lookupFilters`, `deleteBehavior`, `expression`, the four `summaryOperations` entries, `autonumberFormat`, `visibleWhen`, `readonlyWhen`, `requiredWhen`) — carried both their `label` and their `helpText` byte-identical to the `en` source in `zh-CN`, `ja-JP` and `es-ES`, so an author working in a translated locale read English on the most trafficked authoring panel in Studio while everything around them was translated. All 28 leaves are now translated in each locale; each was judged individually, and the 84 verdicts with their reasons are pinned in `object-field-editor-panel-echo-decisions.test.ts`, which also derives the panel's population so a re-fill or a newly added field is red on the day it lands. No key was added, removed or renamed — the bundles' shape is unchanged. diff --git a/.changeset/one-app-rule-adr-0019-citation.md b/.changeset/one-app-rule-adr-0019-citation.md deleted file mode 100644 index 233a4f248b9..00000000000 --- a/.changeset/one-app-rule-adr-0019-citation.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the one-app-per-package refusal cites the record it means, `ADR-0019 (app-as-consumer-unit) D3` - -`ADR-0019` names **two** records in this repository — `0019-app-as-consumer-unit` (D3 = a `type: 'app'` package defines at most one app) and `0019-approval-as-flow-node` (D3 = deprecating `ApprovalProcessSchema`). Both have a D3, and `stack.zod.ts` cited the bare number for both, so an author following the refusal's own citation was as likely to reach the wrong decision record as the right one. - -The three citations of the app-cap rule now name the record: - -- the `STACK_SINGLE_APP_VIOLATION` message — the only one an app author ever sees; -- the `validateSingleApp` docblock; -- the `StackSingleAppViolationError` docblock. - -Only the message tail changed: `An 'app' package must define at most one app, but found N (…)` is untouched, so any consumer matching on that prefix is unaffected. The rule, the refusal's condition and `defineStack`'s behaviour are unchanged. - -The approvals-side citations are deliberately left bare — repo-wide ADR-number disambiguation is tracked separately. diff --git a/.changeset/operator-facing-raw-exec-cause-text.md b/.changeset/operator-facing-raw-exec-cause-text.md deleted file mode 100644 index b563b540510..00000000000 --- a/.changeset/operator-facing-raw-exec-cause-text.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -'@objectstack/types': minor -'@objectstack/metadata-protocol': patch -'@objectstack/metadata': patch -'@objectstack/cli': patch -'@objectstack/driver-sql': patch ---- - -fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal - -Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer -lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a -COMPOSED message that discloses neither the statement nor the diagnostic, and carries -the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and -is unchanged here. - -What changed underneath it is what every consumer STORED. Each migration probe, backfill -and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded -`error.message` into an operator-facing record, so those records began reading - - the database refused to run a raw statement - -where they used to read - - no such column: foo - -For a live console that costs nothing — the driver prints the statement and the dialect -text to its warn sink one line earlier. For a record read later it costs everything: -whoever opens a customer install's backfill result a week on never had that line, and the -dialect's words were unrecoverable for them. - -`@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk -of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen -stored-record sites plus `os db clean`'s console line read through it: - -- `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; -- `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the - three per-object warnings; -- `partial-index-probe` — the `detail` both callers report (and its two module comments, - which stated the opposite of what happened); -- `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, - `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; -- `os db clean` — the `VACUUM failed` line. - -Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned -on its own message channel, its `cause` never walked, and a declared envelope that is not -the raw-path one — the typed read exits' terminal, which composes a different sentence — -is left exactly as it arrived. - -That message channel is deliberately NOT byte-identical to what the replaced expressions -computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as -`messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the -string itself when a string was thrown, and `String(error)` when neither yields text. Every -difference from the replaced expressions follows from that rule, so read the rule and not a -list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, -which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a -thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read -`undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no -record was written at all and the operation aborted; an object carrying a NON-EMPTY string -`message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` -(one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads -`''`, so this channel is neither always prose nor never empty. - -## The levels, and why they are not uniform - -`@objectstack/types` takes **`minor`**: it is the one package here that grows a published -surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the -export list. A purely additive widening takes at least `minor`. - -The other four take **`patch`**, because none of them widens anything: they are a bug fix in a -released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named -because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper -against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this -PR: no entry point reaches a test file, and `files` packs `dist` only. - -**Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: -what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name -and never a type. The change these sites were made for is the declared raw-path fault, where -the record gains the dialect's words in place of the driver's composed placeholder. Every -other throw now reaches these records through the rule above rather than through the -expression each site spelled out, so its text can move too — a consequence of the rule, not a -bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, -and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an -`Error` whose `name` and `message` are both empty are the ones measured. The fourteenth was -`seed-tenancy-backfill`'s organization probe, which kept a `|| 'unknown error'` fallback on -top of the rule, so those same three shapes recorded `'unknown error'` there rather than `''`; -that fallback was load-bearing — the site read an empty value as "the probe did not fail" — -and #17167 removed it in this same release, so all fourteen sites now record the channel as -is and that site carries its failure fact structurally. The sentence being replaced is not a value any -consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these -records gets the dialect's words back where it had been getting a placeholder. diff --git a/.changeset/organizations-open-core-prose.md b/.changeset/organizations-open-core-prose.md deleted file mode 100644 index 55844b53e84..00000000000 --- a/.changeset/organizations-open-core-prose.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/plugin-dev': patch ---- - -Operator-facing text no longer tells an open-source install that multi-organization -operation requires a subscription. - -ADR-0132 moved the `org-scoping` registrar into open core — `@objectstack/organizations` -is Apache-2.0, carries no licence check, and declares both walled postures (`group` and -`isolated`) as its own constant. The messages an operator actually reads had not followed: - -- `os serve`'s install remedy for a walled posture ended "this runtime is closed-source and - is NOT on the public npm registry ... Without one this bullet is not followable" — it now - says the runtime is Apache-2.0 and on the public registry, and notes that a commercial - deployment resolves the same package name to its own private, licence-gated build. -- The `isolated` posture hint rendered by `os serve` and `os doctor` no longer calls the - runtime "enterprise". -- `os verify`'s `--org-scoped` flag description drops the same word. -- The dev stack's degraded-tenancy warning and its stage-2 mount refusal no longer describe - the package as the enterprise runtime. - -Text only — no control flow, no identifiers, no behaviour change. diff --git a/.changeset/osv-advisory-bumps-2026-09-29.md b/.changeset/osv-advisory-bumps-2026-09-29.md deleted file mode 100644 index 39ae356e001..00000000000 --- a/.changeset/osv-advisory-bumps-2026-09-29.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/plugin-email": patch ---- - -`@objectstack/plugin-email` declares `nodemailer` `^10.0.2` (was `^9.1.1`), clearing GHSA-6vj9-mwq6-2f5v (5.9): nodemailer's process-global DNS cache kept the TLS `servername` per host, so a second SMTPS transport to the same host could inherit the first transport's SNI and certificate identity and send its credentials to the wrong TLS virtual host. Every release from 5.0.0 through 10.0.1 is affected and the fix ships only in 10.0.2, so the 9.x line has no patched release and the major is taken. - -Clause-②: no - -No exported symbol, option key, payload key or accept/reject verdict of ours moves; the published surface is unchanged and grades `patch`. What an operator can see is nodemailer's own behaviour inside the 10.x line this range admits: - -- **Node.js floor.** nodemailer 10 declares `node >= 20`. `@objectstack/core`, which this package depends on, already declares `node >= 22`, so no install that could load the plugin is excluded. -- **Module shape.** nodemailer 10 is a TypeScript rewrite shipping both ESM and CommonJS builds with bundled declarations. `SmtpTransport` loads it lazily and reads `createTransport` off the namespace or its `default`; measured on 10.0.2, 10.0.10, 10.0.11 and 10.0.12, both entry points expose it both ways, so the loader is unchanged. -- **Contradictory TLS flags in `transportOptions`.** From nodemailer 10.0.12 (the version a fresh install resolves today), `requireTLS` wins over `ignoreTLS` / `opportunisticTLS`. `SmtpTransport` sets `requireTLS` itself whenever TLS is on and the port is not 465. On such a port, a `transportOptions: { ignoreTLS: true }` escape-hatch override ran a cleartext session under nodemailer 9. It now performs the required STARTTLS upgrade or fails the send, which is the behaviour `secure: true` already documents. To connect in the clear on purpose, set `secure: false`. - -The devDependency on `@types/nodemailer` is dropped: nodemailer 10 ships its own declarations, and TypeScript resolves `nodemailer` to them (`dist/esm/nodemailer.d.ts`) before any `@types` package. - -The same sweep also moves two transitive packages. Neither release changes anything, and they are listed here so all seven findings can be read in one place. Both are workspace overrides in `pnpm-workspace.yaml`, and each dependent's declared range already admits the fixed version: - -- `ip-address` 10.4.0 and 10.5.0 → one copy on `^10.5.1`, for GHSA-2vr4-cq9g-pvrc (6.9) and GHSA-rpw4-54j3-4h4q (6.3). It reaches the tree through `@modelcontextprotocol/sdk` → `express-rate-limit` and through `mongodb` → `socks`. -- `undici` 7.29.0 → `^7.29.1` (a target-only lift of the existing pin) and 8.9.0 → `^8.10.2` (a new 8.x selector), for GHSA-3wwx-pv8p-q78v (5.9). Both copies are dev-only, through `ai` and `jsdom`. - -`osv-scanner.toml` keeps zero exemptions and is untouched. diff --git a/.changeset/osv-advisory-bumps-2026-09.md b/.changeset/osv-advisory-bumps-2026-09.md deleted file mode 100644 index af74c7d9a49..00000000000 --- a/.changeset/osv-advisory-bumps-2026-09.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/plugin-email": patch -"@objectstack/plugin-hono-server": patch ---- - -Take the fix for the fifteen OSV advisories that turned `Validate Package Dependencies` red on every PR. - -The advisory database moved; the lockfile did not. `origin/main`'s `pnpm-lock.yaml` is byte-identical to the tree that scanned GREEN the day before and RED the day after, so this is a repo-wide condition rather than any PR's regression, and every one of the fifteen names a published fix version — the take-the-fix path `osv-scanner.toml`'s header describes, not the exemption path. That ledger keeps its zero entries and is untouched here, as is `.github/workflows/validate-deps.yml`. - -Two published packages change what a downstream install resolves, which is what this changeset grades: - -- **`@objectstack/plugin-email`** declares `nodemailer` `^9.1.1` (was `^9.0.5`), clearing GHSA-2x7j-588g-ccc2 (7.5), GHSA-cc9r-2j5m-2m83 (6.5), GHSA-wmmp-3585-3rmp (6.5) — all fixed in 9.1.0 — and GHSA-8m3c-c648-2xjj (5.9), fixed in 9.1.1. The range takes the higher of the two fix lines so one floor covers all four. The 10.x major is deliberately not taken. -- **`@objectstack/plugin-hono-server`** declares `hono` `^4.13.5` (was `^4.13.2`), clearing GHSA-crvj-82cr-hjcx (5.9), GHSA-g6gw-c38x-mqfc (5.3) and GHSA-gqvv-2mrq-wpjv (6.5). - -No exported symbol, payload key or accept/reject behaviour of ours moves — the published surface is unchanged and both grade `patch`. - -The rest of the sweep releases nothing and is named here only so the set is readable in one place: the `sharp` override target lifts to `^0.35.4` (GHSA-rgj7-g3m4-5g8c, 8.9) and the `hono` override target to `^4.13.5`, both target-only lifts whose selectors already sit at the compatibility boundary; the private docs app takes `next` 16.3.3 (GHSA-2xp9-vwfh-vxw4 9.5 and GHSA-p293-qw3h-jr36 9.0, the two Criticals); and the `vitest` devDependency line takes 4.1.11 across the workspace, with `@vitest/coverage-v8` moved in lockstep because its peer on `vitest` is exact (GHSA-82fw-gwwq-j7x9, 5.9, which flagged both `vitest` and `@vitest/mocker`). - -`hono` was flagged at TWO resolved versions and both are gone: the override lift is what collapses them. The transitive copy `@modelcontextprotocol/sdk` pulled sat exactly on the old `^4.12.34` floor and so was never re-resolved, while our own three declarations floated up to 4.13.2; `^4.13.5` excludes the floor, both edges re-resolve, and the tree now holds one `hono`. A bump that moved only our declarations would have left the transitive copy flagged and the gate red. diff --git a/.changeset/page-guidance-stops-prescribing-assignedprofiles.md b/.changeset/page-guidance-stops-prescribing-assignedprofiles.md deleted file mode 100644 index a7bec10c9c1..00000000000 --- a/.changeset/page-guidance-stops-prescribing-assignedprofiles.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec): `PageSchema`'s rejection guidance stops prescribing `assignedProfiles` as a page gate (#16929) - -The two wrong-layer prescriptions `PageSchema` hands an author at parse time both ended by pointing at `assignedProfiles`: the `visibleWhen` pointer said "or gate the page with `assignedProfiles`", and the `permissions` pointer said "reach it through `assignedProfiles`". Neither is true. `assignedProfiles` gates nothing. - -Measured 2026-09-10 on `origin/main` `e1eee43beb` and objectui `3fbdd4a2d`: `assignedProfiles` has **zero readers** in this repo — every one of its 25 matching files is a declaration, a generated artifact, prose, a `CHANGELOG`, the liveness ledger, or this schema's own round-trip test — and **zero readers** in objectui, whose three hits are a docs table row and two type/zod declarations. Lit controls in the same sweeps (`visibleWhen` 308 files, `PageSchema` 94 files in objectui; `visibleWhen` 168 files here) prove the instrument fired; a fabricated dark control read 0 in both. The key is also named for the concept **ADR-0090 D2** removed, which `security/permission.zod.ts` states to authors three times over. - -Prescribing it was Prime Directive #10's exact prohibition — advertising a capability the runtime does not deliver — delivered to the author in the error that is supposed to be teaching them the correct spelling. Both prescriptions now say only what the platform actually does: put `visibleWhen` on the component inside a region, and gate the DATA a page shows with the object's permission sets. - -**Nothing about what `PageSchema` accepts changes.** `assignedProfiles` remains an authorable key with its declaration untouched, and the `profiles:` / `assignedTo:` alias entries are untouched. Both channels edited here fire only from the `unrecognized_keys` path, so every key involved is rejected before this change and rejected after it, with identical `issue.code` and identical `path` — only the human-readable text moves. The key's own disposition (keep, rename, or remove) needs a ruling and stays open on #16929. diff --git a/.changeset/permissions-alias-hosts-justification.md b/.changeset/permissions-alias-hosts-justification.md deleted file mode 100644 index 83235a96ae2..00000000000 --- a/.changeset/permissions-alias-hosts-justification.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Correct the `permissions` alias table's justification for `hosts`, and pin the two aliases nothing measured. - -`PluginPermissionsSchema` (`kernel/manifest.zod.ts`) curates three aliases — `filesystem` and `paths` point at `fs`, `hosts` points at `network`. The block's only comment said edit distance cannot reach any of them, and it sat directly above all three. That is true of the two `fs` entries and false of `hosts`. - -The fallback budget is `Math.max(2, Math.floor(key.length / 3))` (`shared/suggestions.zod.ts`), so a 5-character key gets 2, and `hosts` differs from the declared `hooks` by exactly 2. Measured against the real `findClosestMatches` with the alias table out of the picture: `filesystem` and `paths` return nothing, `hosts` returns `hooks`. So without the alias an author writing `hosts` is answered ``Did you mean `hosts` → `hooks`?`` — pointed at lifecycle hooks on the one block that also grants network access. - -The alias is therefore better justified than the comment claimed: it overrules a confident wrong suggestion rather than filling a silent gap. Only the justification moves — the alias stays, the declared keys, the strictness and the union are untouched, and no message an author reads changes. - -`hosts` is also the only one of the three whose absence would be invisible, since it is the only one that changes a live suggestion, so `manifest-unknown-keys.test.ts` now pins both it and `paths` alongside the `filesystem` pin that was already there, asserting the offending key and the rename — and, for `hosts`, that `hooks` is not what comes back. diff --git a/.changeset/persist-terminal-run-status-distinction.md b/.changeset/persist-terminal-run-status-distinction.md deleted file mode 100644 index f45dc36989b..00000000000 --- a/.changeset/persist-terminal-run-status-distinction.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -A run's durable history row records the terminal status the run actually reached — `completed`, `failed`, `cancelled` or `timed_out` — instead of folding all four into two. A restart no longer changes a run's answer. - -`RunRecord.status` declared two members (`'completed' | 'failed'`) while `AutomationEngine.recordLog`'s own terminal predicate admitted four and `ExecutionStatus` (`@objectstack/spec`) has declared them all along. Both ends of the store folded to match the narrower declaration: the write mapped everything that was not `completed` to `failed`, and the read mapped everything that was not `failed` back to `completed`. The distinction was therefore not hidden — it was **destroyed at write time**, so no later change could recover it for a row already stored. The cost was that one run answered differently depending on where you read it: `getRun` prefers the in-memory ring entry and said `cancelled`, while after a restart or a ring-buffer eviction the durable row answered, and it said `failed`. - -- **The write side.** `recordLog` writes the status its own terminal predicate admitted, resolved once into a `const` that also decides whether a row is written at all. The predicate is now the single declared vocabulary, `TERMINAL_RUN_STATUSES` (`engine.ts`) — three sites had a copy of that list and only one of them was ever going to be updated together with the writer. -- **The read side.** `ObjectStoreSuspendedRunStore` resolves the row's status once in the gate that already decided whether the row is terminal at all and hands the member to `deserializeTerminal`, which no longer re-reads or folds it. `listHistory`'s filter was the second copy of the two-member list — left alone it would have replaced a wrong status with a *missing row*, dropping cancelled runs out of the Runs list entirely. -- **The stored column.** `sys_automation_run.status` accepts the two added members, and the retention scope (`lifecycle.retention.onlyWhen`) counts them as terminal — a widened writer over a two-member sweep scope would have left `cancelled` and `timed_out` history rows never ageing out, on a table whose whole retention posture (ADR-0057) is that history is telemetry. `refused` is deliberately not added: `ExecutionStatus` declares it (#14945) but no engine path produces it, and an option nothing can write is declared-but-inert metadata (ADR-0078). -- **Rows already stored keep reading `failed`.** The information they lost is not recoverable and this change does not pretend otherwise — there is no backfill, because there is nothing to backfill *from*. Rows written from this release forward carry the distinction. -- **`TerminalRunStatus`** is exported for the same reason `ConsumedSuspensionDropNotice` is: `RunRecord` is barrel-reachable, and a host store implementing `recordTerminal` / `loadTerminal` has to be able to name the field it round-trips. - -Not a breaking change, and deliberately carries no breaking-change banner: the published contract (`IAutomationService.getRun` / `listRuns` return `ExecutionLog`, whose `status` is `ExecutionStatus`) has declared all four members since before this row existed. What changes is that the implementation stops under-reporting one the contract already promised — a consumer written against the declared contract is unaffected. Also no ADR-0087 migration entry: that ADR governs authorable metadata shapes on `sys_metadata`, and this is an engine-owned system data table whose existing values stay valid under the widened option set. diff --git a/.changeset/plain-donkeys-repeat.md b/.changeset/plain-donkeys-repeat.md deleted file mode 100644 index 4dbc1a977a4..00000000000 --- a/.changeset/plain-donkeys-repeat.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Declare the ASSEMBLED manifest stage on the installed-package read API. - -**BREAKING** — a TYPE-level break on two PUBLISHED response types. It ships -`minor` under the pre-GA launch-window convention (ADR-0087, *Ratified: the -pre-launch launch-window exemption*), where the npm level is deliberately not the -carrier of breaking-ness; this banner and the ADR-0087 disposition at the bottom -are. Runtime is untouched and stays additive — every payload that parsed before -still parses — so the affected party is a TypeScript consumer and the channel is -the compiler at their own call site. Reading a manifest field off -`ListInstalledPackagesResponseSchema` or `GetInstalledPackageResponseSchema` can -stop compiling, and assigning a malformed manifest to either can start compiling -where the old annotation refused it. Both directions are measured against the -built `.d.ts` under *The STATIC gain is one-sided* below, which is also where the -point-of-use reading lives. - -`GET /api/v1/packages` and `GET /api/v1/packages/:packageId` serve whatever a -package was installed with, and two stages reach that table through declared -doors: `POST /api/v1/packages` installs an authoring manifest (`manifest.objects` -= glob patterns), while a `defineStack()` host installs the assembled body -(`manifest.objects` = object definitions). Both response schemas typed every row -at the authoring stage alone, so the shipped `defineStack()` path served a -payload its own declared contract refused. - -Following the #14242 ruling — declare the assembled stage rather than widen the -authoring one — `@objectstack/spec/api` gains two exports: -`AssembledInstalledPackageSchema` (the assembled-stage counterpart of -`InstalledPackageSchema`) and `InstalledPackageAtEitherStageSchema`, a union -over the two whole closed stage declarations. `ListInstalledPackagesResponseSchema` -and `GetInstalledPackageResponseSchema` are bound to the union. - -This is additive at runtime, and the runtime parse is where the gain is: every -payload that parsed before still parses, payloads that were refused for their -manifest stage now parse, and a row belonging to neither stage — an `objects` -array mixing globs with definitions — is still refused. `ManifestSchema` is -unchanged. - -The STATIC gain is one-sided, and smaller than a union normally implies. -`AssembledPackageBodySchema` is annotated `z.ZodType, …>` -in `stack.zod.ts` — deliberately, for the declaration-size reasons recorded -there, and untouched by this change — so the assembled branch carries no field -typing. Measured against the built `.d.ts`: a plain `.manifest.version` read off -one of these two response types now yields `unknown` where it used to yield -`string`; narrowing toward the AUTHORING branch restores the whole of -`ManifestSchema` (`version: string`, `objects: string[]`), while narrowing away -from it yields `Record` — every manifest field `unknown`. In the -assignment direction the assembled branch admits any object at `manifest`, so a -garbage manifest and the mixed-stage row named above both typecheck clean even -though the runtime union refuses both. So: narrow at the point of use for the -authoring stage, and treat an assembled manifest as a record the runtime — not -the compiler — has checked. - -`@objectstack/spec/api` also gains a `browser` export condition. Declaring the -assembled stage makes this entry's module graph reach the datasource -declaration and with it the driver-config validators, whose postgres URL -refinement links `pg-connection-string` — a package whose `parse` statically -resolves `require('fs')`, so a browser bundler that reaches it fails on -`Can't resolve 'fs'`. The entry now resolves, for browser consumers only, to a -build with the pg-grammar arm swapped for its dependency-free twin: exactly the -boundary the four entries that already carry the condition use. Node resolution -and the Node bundles are unchanged, byte for byte. For browser consumers the -postgres `url` refinement degrades to the shape-only checks it already performs -before `parse` — the unix-socket short-circuit and the refusal of the -filesystem-reading `?sslcert=` / `?sslkey=` / `?sslrootcert=` query parameters -are kept; the "is this a URL `pg` can open" arm answers "no findings". Datasource -publish is a server-side act, so that arm never legitimately ran in a browser. - - diff --git a/.changeset/platform-admin-existing-holder-scan.md b/.changeset/platform-admin-existing-holder-scan.md deleted file mode 100644 index 18e204a7021..00000000000 --- a/.changeset/platform-admin-existing-holder-scan.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -The first-boot `already_have_admin` short-circuit now FINDS an existing platform admin instead of sampling for one, so a tenant's organization-admin count can no longer decide whether a second unscoped `admin_full_access` grant is minted. - -Before this change the holders read was `sys_user_permission_set` with **no `orderBy` and a cap of 50**, and the predicate that actually decides — `!organization_id` — was applied **client-side to whatever 50 rows the driver returned first**. `admin_full_access` is not only the platform-admin set: every *organization-scoped* grant of it writes a row carrying the same `permission_set_id`, so this population grows with the number of **org** admins, not platform admins. A tenant with fifty-odd of them filled the window with rows that all fail the filter, the short-circuit did not fire, a **second** unscoped grant was minted, and `claimSeedOwnership` re-owned the seeded business records to the newly promoted user — silently, because the boot logs a successful promotion exactly as on a genuinely fresh install. Measured on the real better-sqlite3 driver: with 60 organization-scoped grants plus one unscoped human grant, the unordered 50-row window contained 50 organization-scoped rows and not the one that decides. - -That is the guarantee #14348 case D pins — 「Moving an already-granted platform admin is reserved to the maintainer.」 — failing open by row count. - -- **The read asks the driver the narrow question first.** `{ permission_set_id, organization_id: null }`, ordered and bounded. Because it is narrowed server-side, no number of organization-scoped grants can crowd the answer out of a window. -- **A second, ordered and bounded leg still applies the exact predicate.** It runs only when the narrow leg found nobody. This is deliberate rather than redundant: `organization_id: ''` is storable and reads back as `''` on both SQL families, which `!organization_id` counts as **unscoped** and `where: { organization_id: null }` does **not** return — so replacing the client-side predicate with the narrowed read alone would have made this guard fire *less* often and mint the very grant this fixes. Both legs are strictly additive to what the old read could see, so the guard can only fire more often than before, never less. -- **The bound is never silent.** The scan pages 200 rows at a time up to a 5000-row ceiling, and reaching that ceiling without finding an unscoped human holder now WARNS — naming the ceiling, the number of rows examined, and the consequence (promoting from here would mint a second unscoped grant and re-own the seeded records). -- **The answer says how many rows it examined.** `bootstrapPlatformAdmin`'s returned report gains an optional `adminGrantRowsExamined`, counted by row identity across both legs, on every return the guard reaches. A guard that had seen the whole population and one that had seen a truncated slice of it previously returned byte-identical payloads. -- **The ordering is stated to the driver, and it is measured, not assumed.** `tryFind` answers `[]` when a query is refused, and on this guard `[]` reads as "no platform admin exists yet" — which promotes. An order this object could not serve would therefore be a silent relaxation, so `id` ascending was measured honoured through ObjectQL on both SQL driver families against the real declarations. - -Unchanged: an unscoped grant held by the seed identity `usr_system` still never counts, so a database where it was wrongly promoted stays self-healing on restart; the walled postures still mint no grant row and still point a legacy unscoped holder at the config path; and a genuinely fresh install still promotes exactly as before. diff --git a/.changeset/platform-admin-promotion-selection.md b/.changeset/platform-admin-promotion-selection.md deleted file mode 100644 index 8095c9e08a9..00000000000 --- a/.changeset/platform-admin-promotion-selection.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -First-boot platform-admin promotion under the `single` posture now CHOOSES its target instead of sampling one: the candidate read is ordered by the database, and an operator who declared an owner gets that owner — and only once that owner has verified the address. - -Before this change the selection read `sys_user` with **no `orderBy` and a cap of 50** and then sorted that array client-side, so "the oldest authenticable user" actually meant *the oldest authenticable user among whatever 50 rows the driver produced first*. Measured on 113 seeded users with the intended owner inserted first, holding the oldest `created_at` and an id that collates last: the in-memory driver returned it in row 1 and promoted it, while the default sqlite driver returned rows in id order, never saw it at all, and handed the unscoped `admin_full_access` grant — plus, through `claimSeedOwnership`, ownership of every seeded business record — to a seeded job-seeker persona. Same code, same config, same data; the answer changed with the storage driver. - -- **The read is ordered where the driver can see it.** `created_at` ascending with `id` as the tie-breaker (seeded populations routinely share one timestamp). There is deliberately no client-side re-sort left behind: one would re-rank the returned page and keep the guard passing if the ordering were ever lost again. -- **The declared owner is asked first, and must be a VERIFIED holder.** `OS_PLATFORM_OWNER_EMAIL` was imported into this file and read only on the walled branch, so a deployment that had said who its owner is could still have someone else promoted. Under `single` the target is now a row that holds a declared address, is human, can authenticate, and has `email_verified === true` — all four. Requiring verification rather than merely preferring it answers the one direction in which honouring the declaration would otherwise have been a widening: because `sys_user.email` is UNIQUE on the SQL family, an attacker who registers the declared address before the operator does would have been promoted with no way for the real owner to coexist, so an unverified holder is refused instead. -- **A declared owner who cannot sign in, or has not verified, REFUSES.** No silent fall-back to whoever happens to be oldest — that is the outcome this fixes. The pass warns, naming the variable, the address and which of the two is missing (`declared_owner_not_authenticable` / `declared_owner_not_verified`), and promotes nobody. **Accepted cost, stated rather than discovered:** a `single` deployment whose declared owner has not verified their email gets no platform admin at first boot until they do, loudly. Because the pass replays per sign-up while no admin exists, that warning re-emits on each replay until the owner is promotable; it is deliberately not latched, so the condition stays visible in the log a fresh operator is actually reading. -- **Verification landing is a replay trigger again.** `shouldReplayBootstrapFor` admits a `sys_user` update touching `email` / `email_verified` under `single` — but only while an owner is declared, which is the only configuration where such a write can change the answer. With none declared, the trigger set stays exactly as narrow as it was. -- **The cap is replaced, and never silent again.** A 200-row page with a 5000-row scan ceiling, walked oldest-first. Because the page is ordered it holds the rows the age rule actually wants, so truncation can only bite when every one of the oldest 5000 humans is non-authenticable — and reaching the ceiling now WARNS, naming the number examined. -- **The grant's log line records WHY and FROM HOW MANY.** `[security] first user promoted to platform admin: ` keeps its prefix and gains the basis (`declared-owner` / `oldest-authenticable`) and the candidate-pool size, repeated as `basis` / `candidatePoolSize` fields for structured sinks. The returned report carries `basis` too. - -Unchanged: no declaration still means first-user promotion by age (`single` keeps Choice 4A), and that leg has no verification requirement; a user nobody can authenticate as is still never promoted; an existing unscoped grant still short-circuits before any selection runs, so no deployment that already has an administrator can be re-pointed by this. diff --git a/.changeset/plugin-auth-admin-import-canonical-query.md b/.changeset/plugin-auth-admin-import-canonical-query.md deleted file mode 100644 index ad0377e53d7..00000000000 --- a/.changeset/plugin-auth-admin-import-canonical-query.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -`runAdminImportUsers`'s hand-written `ImportProtocolLike` reads the CANONICAL QueryAST (`where` / `limit`) — the payload `@objectstack/rest`'s import runner sends as of this same release — instead of the wire-only `$filter` / `$top`. - -`POST /api/v1/auth/admin/import-users` reuses the shared import runner but swaps in an identity-specific protocol, because an identity write is `auth.api.createUser` and not an engine insert. That protocol is hand-written, so it never passes through `ObjectStackProtocolImplementation` — the normalizer that folds `$filter` onto `where` and `$top` onto `limit` for a caller arriving off the HTTP door. It has to read the canonical keys itself. - -- **A mismatch here does not produce a missing filter, it produces an unbounded one.** `const where = args?.query?.$filter ?? {}` turns an unread key into an empty filter, and an empty filter constrains nothing: the upsert duplicate probe stops discriminating, `findExisting` matches rows it was given no key for, and an admin import updates the WRONG user. Both halves are measured in `admin-import-users.test.ts` — the email-match case reported `updated: 2` where one of the two rows was new, and the phone-match case sent a probe carrying no `where` at all. -- **One dialect, and no default behind it.** The two reads are now `args.query.where` and `args.query.limit`, with no `??`. A default here would not be tolerance for an older caller — this handle is fed by the runner, never off the wire — it is precisely the lenient fallback that converts a spelling mismatch into a silent match-everything. A request that arrives without a `query` now costs a loud `TypeError` instead. - -⚠️ No published version shipped the mismatch. The runner's rewrite and this adapter land in the same release, and `@objectstack/plugin-auth` depends on `@objectstack/rest` at an exact workspace version, so the two cannot be installed apart. What this entry records is why they move together — and what the same mismatch costs any OTHER hand-written `ImportProtocolLike`, which the `@objectstack/rest` entry calls out for implementors. diff --git a/.changeset/plugin-security-read-fault-vs-empty.md b/.changeset/plugin-security-read-fault-vs-empty.md deleted file mode 100644 index 8654b2d30f4..00000000000 --- a/.changeset/plugin-security-read-fault-vs-empty.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -Tell a read that DID NOT ANSWER apart from a read that answered NOTHING at two boot-reconciler seams, so a transient storage fault can no longer withdraw a standing org-admin grant or report an unreadable catalog as an already-canonical one (#15840). - -`reconcileOrgAdminGrant`'s `sys_member` read swallowed a fault into `[]`, and `[]` is what that function reads as "this user is not an admin of this organization" — the input to a DELETE. One transient read fault therefore revoked a sitting admin's standing grant, and the store kept it withdrawn after the fault cleared; only a `debug` line separated that run from a healthy one. That read now reports at `error` and returns `{ action: 'skipped', reason: 'membership_unreadable' }`, performing no write at all for the pair: nothing is granted, so nothing widens, and nothing standing is destroyed. The next `sys_member` write and the `kernel:ready` backfill ask again. - -`normalizeManagedByVocab` swallowed a catalog read fault into `[]` too, so an unreadable catalog and an already-canonical one were byte-identical on both channels — the same `{ positions: 0, permissionSets: 0 }` and zero log lines at any level — while the row that needed healing stayed legacy. A read that does not answer now reports at `error` and refuses the pass instead of attesting counts it could not read. The refusal aborts at the first un-answered read, so it is one line per refused boot rather than the four the report-and-continue shape measured. Its only production consumer already declared the handling: the `kernel:ready` bootstrap catches it, reports it at `warn` as non-fatal, and boot proceeds. - -⭐ Per-site, not a sweep. A genuine EMPTY read keeps today's behaviour EXACTLY at both seams — a demotion with no membership row still revokes, a membership still grants, an already-canonical catalog still answers `{ positions: 0, permissionSets: 0 }` in silence. `claim-seed-ownership.ts` is untouched: its fault already propagates to a per-predicate handler that reports at `warn` and names the consequence, which is the right disposition already. The plugin's other reads keep their existing best-effort contract, where an unanswered read costs a grant that is not created rather than one that is destroyed. - -No exported symbol, published payload key or spec path changes: `action: 'skipped'` is already in the returned union, `reason` is already free text, and the two logger option types gain an optional `error` method a caller may omit. Healthy-path behaviour is byte-identical; only the fault path moves. diff --git a/.changeset/plugin-version-honest-grammar-claim.md b/.changeset/plugin-version-honest-grammar-claim.md deleted file mode 100644 index 3f7e059ef25..00000000000 --- a/.changeset/plugin-version-honest-grammar-claim.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`PluginSchema.version` now describes the grammar it actually enforces instead of calling itself `"Semantic Version"`. - -The key's regex accepts **every** SemVer 2.0.0-valid string and, additionally, eight strings SemVer 2.0.0 forbids: - -| SemVer 2.0.0 rule | Strings this key accepts anyway | -|---|---| -| §2 — numeric identifiers MUST NOT include leading zeroes | `01.1.1`, `1.01.1`, `1.1.01` | -| §9 — prerelease identifiers MUST NOT be empty or carry leading zeroes | `1.0.0-0123`, `1.0.0-alpha..1`, `1.0.0-alpha..`, `1.0.0-.` | -| §10 — build-metadata identifiers MUST NOT be empty | `1.0.0+.` | - -**No accepted value moved, in either direction.** The regex is byte-for-byte what it was; the `describe()` string is what changed. The leading-zero half is older than the recent widening — the original `/^\d+\.\d+\.\d+$/` admitted `01.1.1` too, because `\d+` always has — so tightening the key to the official SemVer regex would refuse plugin objects that load today, which the ruling on this key forbids. With the accept set frozen, the only side of the declared/enforced pair still free to move is the claim, and the bare `"Semantic Version"` was the false half: it named a standard this key does not implement. - -The replacement states the shape an author can predict a verdict from — `major.minor.patch` with an optional `-prerelease` and an optional `+build` suffix — and disclaims the standard it exceeds rather than merely dropping the word. This follows `ManifestSchema.version`, which already spells `(major.minor.patch)` explicitly rather than leaning on "SemVer". - -**What consumers see.** The `description` on `version` in the shipped `json-schema/` tree and on the generated `kernel/plugin` reference page. No `pattern`, no `type`, no accepted or rejected value changes, so a tool that validates against this schema behaves identically. - -All eight forms are now pinned as **accepted** — in `packages/spec` (`plugin.test.ts`) and in `packages/core` (`plugin-loader.test.ts`, `plugin-contract-enforcement.test.ts`) — so the honesty is enforced rather than narrated, and a future edit that "corrects" the grammar to be standards-compliant fails those pins on purpose. - -`@objectstack/core` is deliberately **not** listed above. Its `PluginLoader` predicate was renamed `isValidSemanticVersion` to `isSemverShapedVersion` in the same change, for the same reason, but the symbol is `private` and package-internal: measured against the built `dist/index.d.ts`, `import { isValidSemanticVersion } from '@objectstack/core'` is TS2305 (no exported member) and `loader.isValidSemanticVersion` is TS2341 (private), while a public member on the same class compiles. Nothing published moves. diff --git a/.changeset/plugin-version-semver-grammar.md b/.changeset/plugin-version-semver-grammar.md deleted file mode 100644 index 4cd3a9306a8..00000000000 --- a/.changeset/plugin-version-semver-grammar.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor ---- - -`PluginSchema.version` now accepts the whole of the SemVer 2.0.0 grammar, and `version` becomes the ninth declared key `kernel.use()` enforces. - -Two declarations in this repository disagreed about what a plugin `version` is, and the disagreement became load-bearing the moment the boot path started running the schema: - -| Declaration | Grammar | Accepted `1.0.0-alpha.1` / `1.0.0+20230101` | -|---|---|---| -| `PluginSchema.version` (`@objectstack/spec`, `kernel/plugin.zod.ts`), described `"Semantic Version"` | `/^\d+\.\d+\.\d+$/` | **no** | -| `PluginLoader.isValidSemanticVersion` (`@objectstack/core`), the check the boot path has always run | `/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/` | **yes** | - -SemVer 2.0.0 defines prerelease and build metadata as **parts of** a semantic version, so the key's own `describe()` — `"Semantic Version"`, no qualifier — claimed the wide grammar while its regex implemented a subset of it. The spec key was the one that was wrong, and it is the one that moved. - -**The spec adopts the loader's grammar character for character**, deliberately, rather than a third spelling: that is the check the boot path has always run, so the two declarations now converge exactly and nothing that loaded before is refused now. - -**`@objectstack/spec` — a WIDENING of a published contract.** `Plugin.json`'s `pattern` in the shipped `json-schema/` tree changes from `^\d+\.\d+\.\d+$` to `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`. This is a strict superset — same three-segment core, two **optional** suffix groups — so every string that validated before still validates. A tool that mirrors this schema to validate plugin manifests should widen with it; one that does not will merely keep refusing prerelease versions the platform accepts. - -**`@objectstack/core` — `version` joins the enforced set, which NARROWS `LiteKernel`.** **BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A plugin object `LiteKernel` accepted before can be refused now.** `assertPluginContract` filtered `version` issues out while the two spellings disagreed; that stopgap is gone. The full enforced set is now **NINE** keys, each refused with the offending key named in the message: - -- **`id`** — a non-string, or the empty string. -- **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`. -- **`staticPath`** — a non-string. -- **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`. -- **`default`** — a non-boolean. -- **`version`** — a non-string, or a string outside the SemVer grammar above. **New in this release.** -- **`description`** — a non-string. -- **`author`** — a non-string. -- **`homepage`** — a non-string, or a string that is not a URL. - -**`null` is refused on every one of the nine**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is still refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope. - -⚠️ **This supersedes the eight-key enumeration published in `@objectstack/core@17.4.0`.** Both of that release's entries — the `kernel.use()` and the `LiteKernel.use()` enforcement notes — say the enforced set is eight keys and that `version` is excluded, and both point at reconciling the two `version` spellings as separate spec work. This is that work. Those entries stay as written, because they describe what 17.4.0 did; **nine is the current set**, and `version` is no longer excluded from anything. - -**What actually changes behaviour, stated narrowly.** On **`ObjectKernel`** nothing moves: `PluginLoader.validatePluginStructure` already judged `version` with this exact grammar and still runs first, so a malformed `version` is still refused as `Invalid semantic version`, never as `PLUGIN_CONTRACT_VIOLATION`. On **`LiteKernel`** a plugin object with a malformed `version` — `version: 'v1.0.0'`, say — was **registered** before and is **refused** now, with `PLUGIN_CONTRACT_VIOLATION` at `'version'`. `LiteKernel` has never run the loader's structural checks, so `version` was the one declared key it did not judge at all: such a plugin was green in vitest and refused by `ObjectKernel` at production boot. That is exactly the split the `LiteKernel` convergence closed for the other eight keys, closed now for the ninth. - -**What is unchanged.** `1.0.0-alpha.1`, `1.0.0+20230101` and `0.0.0-fixture` load on **both** kernels, as they did before — measured, not assumed, and pinned per kernel. A version-less plugin still loads; `version` is `.optional()`. Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the object that was passed in). A class-based plugin keeps its identity, prototype and prototype methods. - -⚠️ **The accepted grammar is wider than SemVer 2.0.0 itself**, and this release neither introduced nor widened that fringe: leading zeroes in the numeric core (`01.1.1`) were accepted by **both** spellings before this change and are accepted by both after it, and the loader's prerelease/build classes admit degenerate identifiers SemVer forbids (`1.0.0-alpha..1`, `1.0.0-0123`, `1.0.0+.`). Tightening to the official SemVer regex would have **narrowed** this key rather than widening it, so it is deliberately not done here. - -**Migration.** Nothing to rename, and nothing to do if your plugin's `version` is a real semantic version. If you register plugins on `LiteKernel` with a `version` string that is not one — a leading `v`, a two-segment `1.0` — spell it `MAJOR.MINOR.PATCH` with optional `-prerelease` and `+build`, or drop the key. The refusal names the plugin and the key. - - diff --git a/.changeset/preview-avg-empty-group-null.md b/.changeset/preview-avg-empty-group-null.md deleted file mode 100644 index c9c989a2d49..00000000000 --- a/.changeset/preview-avg-empty-group-null.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -Draft-preview analytics: `avg` answers the mean of the NON-NULL operands, and `null` when there are none — matching every live face - -A dataset measure `{ aggregate: 'avg', field: 'amount' }` compiles to the cube -metric `{ type: 'avg', sql: 'amount' }`, and the draft-preview evaluator built -its operand list with `rows.map((r) => Number(r[field]))`. `Number(null)` is `0` -and `Number.isFinite` accepts it, so every NULL entered the average as a zero -OPERAND and was counted in the divisor. `AVG(col)` is defined over non-null -values in every SQL dialect, so a drafted chart showed a different number than -the published one, silently — and where a group's column was NULL in every row -the number it showed was `0`: a plausible-looking average that a reader cannot -tell from one somebody measured. - -Measured on one dataset, one row set, two `AnalyticsService` instances differing -only in `draftRowsResolver` (the live half being `NativeSQLStrategy`'s generated -SQL on a real SQLite). Rows `{meals, null}` and `{meals, null}` answered -`avg_amount` null live and `0` on preview; rows `{travel, 10}`, `{travel, 20}`, -`{travel, null}` answered 15 live and 10 on preview. Both cells now answer the -live number. - -The empty answer is READ from the platform's own ruling rather than restated -here: `emptyGroupValueFor` (`@objectstack/spec/data`) returns the identity `0` -where counting or summing nothing is a measured fact and `undefined` — spelled -`null` on this wire — where there is nothing to answer. It is the same function -`fillEmptyGroups`, `sql-driver` and `driver-turso` read, and the one #16203 cited -when it moved `min`/`max` off the same idiom in this function. - -Unchanged, and pinned by the same differential: `sum` over a group with no values -still answers the ruled identity `0`, `count` over one still answers `0` -(#16218), `min`/`max` still answer `null` (#16203), and `avg` over a group that -has values still answers its mean. `sum` and the numeric `default` arm keep their -existing operand list — `0` is the additive identity, so the coercion never moved -`sum`'s answer, and the `default` arm serves the custom-SQL metric types, which -have no live standard to be moved towards. - -The `null` fires on an EMPTY group and never on an incoherent one. "No numeric -operand" is two different situations: no row carried a value at all — the empty -group the policy rules on — or rows carried values that do not read as numbers, -such as a `date` column under `avg`. The second is an incoherent -aggregate/field-type pair that #16099 owns and no layer refuses yet; it keeps the -numeric identity it has always had, since the live face answers a different -number again (SQLite's numeric affinity over a TEXT column) and a `null` there -would invent a third answer. That boundary is pinned from both sides — by -`preview-aggregate-operand-type.test.ts` (#16203) and by a control in the new -differential. - -The live path is unchanged. - -Bumped `patch` rather than `minor`, on the same reasoning the sibling #16218 -shipped under: the package's published surface is byte-unchanged — `src/index.ts` -is not in this diff and does not re-export `preview-evaluator.ts` at all, and -`aggregate()` is module-private — and the only user-visible effect is a drafted -chart's number moving to the number the published chart already showed. A value -correcting toward the live standard is a fix, not the backwards-compatible -feature addition `minor` denotes. It is a real value change for a consumer -reading the preview response (`0` becomes blank), which is why the card was filed -separately rather than ridden along with #16203 — but the `0` it replaces was -never a number the platform promised. diff --git a/.changeset/preview-count-over-field-non-null.md b/.changeset/preview-count-over-field-non-null.md deleted file mode 100644 index 5df758e3656..00000000000 --- a/.changeset/preview-count-over-field-non-null.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -Draft-preview analytics: `count` over a declared field counts its non-null values, matching every live face - -A dataset measure `{ aggregate: 'count', field: 'payer' }` compiles to the cube -metric `{ type: 'count', sql: 'payer' }`, and the draft-preview evaluator carried -that field in and never read it — it answered the ROW count, nulls included, -while every SQL face lowers the same measure to `COUNT("payer")`, defined over -non-null values. A drafted chart therefore showed a different number than the -published one, silently, and the number it showed was the one `count(*)` gives: -the author's choice to count a specific column had no effect on the preview path. - -Measured on one dataset, one row set, two `AnalyticsService` instances differing -only in `draftRowsResolver` (the live half being `NativeSQLStrategy`'s generated -SQL on a real SQLite): rows `{meals, 'bob'}` and `{meals, null}` answered -`payer_count` 1 live and 2 on preview. Both now answer 1. - -Unchanged, and pinned by the same differential: `count` with no field and `count` -with `field: '*'` still answer the row count (the compiler writes -`sql: m.field ?? '*'`, so the star is the "no field declared" spelling), and -`count_distinct` still answers a cardinality. A group in which no row carries a -value counts `0`, never null — `emptyGroupValueFor` rules counting nothing the -identity `0`. - -The live path is unchanged. - -Bumped `patch` rather than `minor`: the package's published surface is -byte-unchanged — `src/index.ts` is not in this diff, `aggregate()` is -module-private and `evaluateAnalyticsQueryOverRows` is not on the barrel — and -the only user-visible effect is a drafted chart's number moving to the number -the published chart already showed, which is a correction toward the live -standard rather than the backwards-compatible feature addition `minor` denotes. diff --git a/.changeset/protection-block-unknown-key-refusal.md b/.changeset/protection-block-unknown-key-refusal.md deleted file mode 100644 index 0116075967a..00000000000 --- a/.changeset/protection-block-unknown-key-refusal.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the `protection` block's unknown-key refusal now names the surface, lists the declared keys and suggests the rename (#16845) - -`ProtectionSchema` (`shared/protection.zod.ts`) was a bare `z.object({ … }).strict()` with **no error map**, so an unknown key inside a `protection:` block was refused with zod's own default text and nothing else: - -``` -AgentSchema.safeParse({ name: 'a', protection: { lockk: 'system' } }) - ✗ protection: Unrecognized key: "lockk" -``` - -`lockk` is one keystroke from the declared `lock`, and the author — human or AI, whose whole correction loop is the error text — was told the key was wrong and given no surface name, no declared-key list and no rename. The block is mounted on very nearly every authorable metadata type in the platform (objects, views, dashboards, datasets, reports, apps, flows, webhooks, permissions, positions, email templates, agents, tools, skills), so that was the message everywhere a protection key was misspelled. - -It is now built with the `strictObject` helper — the same conversion #16328 made for the manifest `permissions` block — and answers: - -``` - ✗ protection: Unrecognized key(s) on the `protection` block of this metadata item: `lockk`. - Did you mean `lockk` → `lock`? … The declared keys are `lock`, `reason` and `docsUrl`. -``` - -Curated alongside it: prose-slot aliases (`description` / `message` / `explanation` / `lockReason` → `reason`), documentation-link aliases (`docs` / `link` / `url` / `href` / `helpUrl` / `documentationUrl` → `docsUrl`), a wrong-layer prescription for the field-level `readonly` / `readOnly` booleans (which map to a `lock` *policy*, not a boolean), and one prescription for the whole private `_lock*` envelope family. Two of those aliases correct a measurably **wrong** answer: the edit-distance fallback used to point `docs` and `link` — each two edits from `lock` — at the lock policy rather than at `docsUrl`. - -**Not a breaking change: the accept set does not move.** `strictObject(options, shape)` is `z.object(shape, { error }).strict()`, and a zod error map is consulted only for an issue already being raised, so it can neither admit a value that was rejected nor reject one that was accepted. Measured rather than argued — the same parse probe across the declared key set, every accepted input, and every rejection's issue `code` reads byte-identical before and after. diff --git a/.changeset/protocol-version-gap-key-rename.md b/.changeset/protocol-version-gap-key-rename.md deleted file mode 100644 index aa5a7b884be..00000000000 --- a/.changeset/protocol-version-gap-key-rename.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/cli": minor ---- - - - -feat(cli)!: the `--json` payload key `specVersionGap` is renamed to `protocolVersionGap` (#14261) - -**BREAKING** — a published machine surface changes a key name. `os validate --json` and -`os build --json` emit **`protocolVersionGap`** where they emitted `specVersionGap`. A -consumer reading `specVersionGap` reads `undefined` after this release and must switch to -the new name. There is **no alias and no dual-key transition window**: one axis, one name. - -The value shape is unchanged — `null` when the app's declared compatibility range admits -the installed `@objectstack/spec`, otherwise the same advisory record with the same -members. Nothing else on either payload moves: no other key is added, removed or -reshaped, and the text faces of both commands are byte-identical. - -## Why the name had to move - -The axis this advisory reports moved in **#13860**: it used to read the undeclared -`manifest.specVersion` and now reads `manifest.engines.protocol`, which is declared -(`PluginEnginesSchema`), stamped by every scaffold, and enforced at boot. The published -key name stayed behind for one release, deliberately — renaming a machine face with -pinned consumers is a break, and no ruling covered it at the time. - -Leaving it is a correctness problem, not untidiness. A key spelled `specVersion*` invites -the reader — an AI agent above all — to infer that a writable `manifest.specVersion` -exists. `ManifestSchema` is not `.strict()` and **silently drops unknown keys** (#14192), -so acting on that inference does not produce an error: it produces a manifest that looks -entirely normal and whose `specVersion` line never took effect. That is the same -ghost-key breadcrumb mechanism that caused #13860 in the first place, left standing on -the output side. - -## What a consumer should do - -```diff -- if (payload.specVersionGap) { … } -+ if (payload.protocolVersionGap) { … } -``` - -The breaking surface was measured before the rename and is closed inside this repository: -the only consumers of the old key were three in-repo e2e suites, which move in this same -change; **zero external consumers were found**. Graded `minor` by the maintainer's -explicit grading of 2026-09-02; the banner above carries the breaking-ness the level -cannot. diff --git a/.changeset/publish-honours-or-refuses-declared-manifest-id.md b/.changeset/publish-honours-or-refuses-declared-manifest-id.md deleted file mode 100644 index 1dc053f6dae..00000000000 --- a/.changeset/publish-honours-or-refuses-declared-manifest-id.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -`os package publish` no longer publishes under a manifest id the author did not write. A `manifest.id` the artifact declares is now used or refused — never silently swapped for a derived one. - -Before this, `deriveManifestId` adopted `manifest.id` only when it parsed as `PackageSchema.manifestId`, and any other declared value fell through to `local.`. Nothing said so: the substituted id appeared in the ordinary progress line, byte-identical to the run where the artifact declared no id at all. - -``` -manifest.id = 'crm' before: → Registering package 'local.acme-crm'... (exit 0) -manifest.name = 'Acme CRM' - after: ✗ Invalid manifest-id 'crm'. … (exit 1) -``` - -`sys_package.manifest_id` is **immutable once set** — "renaming a package requires creating a new package" — so the value chosen there is a permanent, globally unique identifier. Choosing it silently, against the author's own declaration, is the one field that must not be rewritten without a word. - -- **A declared `manifest.id` reaches the existing preflight gate.** If it is not a manifest id the control plane accepts, the publish refuses before any network call, quoting the schema's own issue and description and naming where the id came from. No second rule is introduced in the CLI: the judgement is still `PackageSchema.manifestId`, which is the same schema node `CreatePackageRequestSchema.manifestId` declares for the `manifest_id` this command POSTs. -- **Honouring the declared value instead was not available.** The values that used to fall through are, by construction, exactly the ones that schema rejects, so forwarding one would only move the same refusal to the server, later and with a worse message. -- **Absent, blank and non-string `manifest.id` are unchanged** — none of those is a declaration, and each still derives from `manifest.name`, then the artifact filename. - -What to do if a publish that worked now refuses: the message names the three ways out. Fix `manifest.id` in `objectstack.config.ts` to a reverse-domain id and rebuild; remove the key to keep publishing under the derived `local.…` id (the value the previous release was already using); or pass `--manifest-id`. Every id the control plane accepts publishes with unchanged bytes. diff --git a/.changeset/quiet-pugs-tickle.md b/.changeset/quiet-pugs-tickle.md deleted file mode 100644 index c523bf123bf..00000000000 --- a/.changeset/quiet-pugs-tickle.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os explain query` now teaches the two keys `QuerySchema` actually declares. - -The entry's example and its two optional-table rows named `filters` and `sort`. -Neither is a key of `BaseQuerySchema`, which is a plain `z.object` — so both -were dropped silently: an author who copied the example got a query that parsed -clean and ran with no filter and no ordering, with nothing in the output saying -so. - -Both faces now read the schema's own spellings: - -- `where` — one condition **tree**, not a `Filter[]`. A field-keyed entry is a - condition on that field (a bare value is implicit equality, an object is a map - of `$` operators), and `$and` / `$or` / `$not` combine conditions. -- `orderBy` — sort nodes, each `{ field, order }`. The direction key is spelled - `order`; `direction` is rejected by name. - -No schema changed, and no accept set moved: the correction is to the catalog -entry only. The `os explain` catalog sweep also gains a key-retention assertion -— an example must parse **and** come back with every key it declares — so the -next entry whose schema strips a key is named instead of passing. diff --git a/.changeset/rate-limit-budget-unknown-keys-refused.md b/.changeset/rate-limit-budget-unknown-keys-refused.md deleted file mode 100644 index 14f57b43c85..00000000000 --- a/.changeset/rate-limit-budget-unknown-keys-refused.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): refuse unknown keys inside a rate-limit budget — `RateLimitConfigSchema` goes strict, so one declaration stops answering for two doors - -**BREAKING** accept-set narrowing on a published spec schema, landing after the -v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). - -Clause-②: no (narrowing) - - - -`ServerRateLimitConfigSchema` was declared -`strictObject({ … guidance: { keyBy, store } }, RateLimitConfigSchema.shape)` — -built from the OPEN schema's own shape object. One declaration therefore answered -for TWO emitted defs with opposite doors: `system/ServerRateLimitConfig` refused -an undeclared `keyBy` and handed back the prescription, while -`shared/RateLimitConfig` — the same shape, mounted bare on `apis[].rateLimit` — -accepted the key and dropped it in silence. Both guidance entries prescribed to -nobody there. A misspelled budget was the same story one key over: -`windowSeconds: 60` parsed green and metered the 60000 ms default, a -thousandfold miss on the one key whose job is to bound spend, reported as -success. - -**What is refused:** any key the budget does not declare, wherever it is mounted, -with a message naming the surface and the offending key. A near miss carries the -declared spelling (`window` / `windowSeconds` are answered with `windowMs`; -`max` / `maxRequest` / `limit` with `maxRequests`). `keyBy` and `store` keep -their wrong-layer prescriptions — the limiter's key is the resolved principal -falling back to the caller IP, and its counters live in the kernel `cache` -service (ADR-0069 D2) — and those two now reach the author on both mounts -instead of one. - -**What stays accepted:** every declared key, byte-identically, with the same -defaults. `server.security.rateLimit` keeps its two bounds checks -(`maxRequests > 0`, `windowMs > 0`) and answers exactly as before. The published -JSON Schema, the authorable surface and the API surface are all unchanged — -`check:authorable-surface`, `check:api-surface` and `check:docs` pass with no -regeneration, because in `io: 'output'` zod already emitted -`additionalProperties: false` for the stripping shape too. - -**Breaking for metadata that was already silently broken.** An `apis[].rateLimit` -carrying an undeclared key now fails `objectstack validate`, `objectstack build` -and the metadata write path instead of publishing with the key discarded. -Measured blast radius before landing: every shipped `rateLimit` block writes -only declared keys — three in `content/docs/`, one in `skills/objectstack-api`, -and none at all in `examples/`, the `os init` templates or the -`create-objectstack` blank template, which declare no budget. - diff --git a/.changeset/raw-mount-declared-envelope.md b/.changeset/raw-mount-declared-envelope.md deleted file mode 100644 index a887ed4f7d8..00000000000 --- a/.changeset/raw-mount-declared-envelope.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/plugin-hono-server': patch ---- - -**`getRawApp()` mounts now answer an escaped throw with the declared ADR-0112 envelope.** A route mounted on the Hono handle funnels through neither the adapter's `wrap()` nor any registrar wrapper, so an escaped throw was answered by Hono's own default handler — `500 text/plain "Internal Server Error"`, no `success` flag, no `code`, and the thrown value's own declared `status` / `code` discarded. A transport error seam on the raw handle now renders the same throw-to-envelope rule a direct-mount route already used, so both doors answer one shape: a throw declaring `503` / `SERVICE_UNAVAILABLE` answers `503 application/json` with `{"success":false,"error":{"code":"SERVICE_UNAVAILABLE",…}}`, and a throw declaring no envelope still answers `500` with no cause in the body. - -The escape hatch is unchanged: consumers still mount framework-natively, still stay outside `getMountedRoutes()`, and still need no adapter verb. A thrown value carrying its own `Response` (Hono's `HTTPException`) keeps the response it declared. A consumer that installs its own `getRawApp().onError(...)` replaces the seam. - -Also fixed alongside it: `afterResponse` observers — and therefore `http_requests_total{status}` — reported a hard-coded `500` for any request that ended in a throw, which stops being the status actually sent once a declared envelope is rendered. diff --git a/.changeset/read-audit-preserve-view-instant.md b/.changeset/read-audit-preserve-view-instant.md deleted file mode 100644 index 47b7614fb9b..00000000000 --- a/.changeset/read-audit-preserve-view-instant.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -fix(plugin-audit): record-view rows keep the VIEW instant instead of the buffer-drain instant (#16829) - -`sys_audit_log`'s `record_views` rows answer "when did this user look at this record?". Read auditing batches its INSERTs off the request path by design, so `buildRow` writes `created_at: event.viewedAt` rather than letting the column's `NOW()` default stamp a whole batch with one flush timestamp — up to `flushIntervalMs` after the fact, with read order inside the window destroyed. - -`persistReadAuditRows` wrote that row under `{ context: { isSystem: true } }`, and the module's comment cited that flag as what carried the view instant through. It never was. `isSystem` exempts a write from the readonly strip; the layer that decides `created_at` on an insert is the audit stamp hook `sys_stamp_audit_insert`, which reads `session.preserveAudit` and has never read `isSystem`. What was actually carrying the value was that hook's pre-#15964 line, `record.created_at = record.created_at ?? now` — client-preferred on every insert, with no flag and no privilege required. #15964 closed that accident (maintainer ruling 2026-09-06), and the ordinary branch has stamped `now` since: on this path, the flush instant. - -The write now declares both context keys, for two different layers: - -```ts -await engine.insert( - 'sys_audit_log', - rows as any, - { context: { isSystem: true, preserveAudit: true } } as any, -); -``` - -`isSystem` still carries the readonly-strip exemption the row needs; `preserveAudit` is the one the stamp hook reads. `preserveAudit` is the ruled historical-import channel (#3493, reaffirmed by #15964's ruling) — the door audit left open for reinstating an original timeline — and a view row's original timeline is the moment of the view, so this use is inside its declared purpose rather than a bypass of it. - -**What changes for a deployment.** Only for deployments that opted objects in to record-view auditing (`AuditPlugin`'s `readAudit.objects`). Rows written from now on carry the view instant. ⛔ Rows already written under the flattened behaviour are not repaired by this change: their `created_at` is the drain time of the batch they were in, and the view instant they should have carried was never persisted anywhere else, so it cannot be recovered. Only builds cut from `main` after #15964 are affected — the objectql half has not shipped in a published version. - -**No exported symbol, schema, route or config key moves.** The only observable change is that a `created_at` this writer already intended to write now survives. diff --git a/.changeset/readonly-create-side-bucket-exclusion-narrows.md b/.changeset/readonly-create-side-bucket-exclusion-narrows.md deleted file mode 100644 index a126ca48f05..00000000000 --- a/.changeset/readonly-create-side-bucket-exclusion-narrows.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/lint": minor ---- - -fix(objectql)!: the create-side static-`readonly` strip judges the user-writable `managedBy` buckets, as update already did (#15719) - - - -**BREAKING** for a non-system caller that CREATES a static `readonly` column on an -object declaring `managedBy: 'platform'`, `'config'` or `'system-data'` under a name -outside the reserved `sys_` namespace: the forged value used to be persisted and is -now stripped, with the field's own `defaultValue` re-derived (#3043) and the drop -reported on the usual channels (`readonlyStripWarning` at `warn`, `onFieldsDropped` -under reason `readonly`, `strictReadonlyWrites` refusing before any driver dispatch). -That is exactly what the same caller's UPDATE of the same column already did. Shipped -as `minor` under the repo's launch-window convention. - -## The census, both halves — neither one is the whole reading - -**(b) is greater than zero, so the affected objects are named.** 20 shipped objects sit -in the three now-judged buckets and carry a static `readonly` column between them — 64 -columns in all: - -- `platform` (6 objects, 14 columns): `sys_attachment`, `sys_business_unit`, - `sys_business_unit_member`, `sys_comment`, `sys_report_schedule`, `sys_saved_report` -- `config` (6 objects, 29 columns): `sys_capability`, `sys_email_template`, - `sys_permission_set`, `sys_position`, `sys_sharing_rule`, `sys_webhook` -- `system-data` (8 objects, 21 columns): `sys_approval_delegation`, - `sys_notification_preference`, `sys_notification_subscription`, - `sys_notification_template`, `sys_position_permission_set`, - `sys_user_permission_set`, `sys_user_position`, `sys_user_preference` - -**And the shipped behaviour delta is ZERO.** Of the 81 object declarations in this tree -carrying `managedBy`, **none** is named outside `sys_` — every one of the 20 above -included — so the namespace test, which this change does not touch, keeps all of them -exempt exactly as before. `sys_metadata_history.recorded_by`, seeded by a direct -non-system `engine.insert` from the metadata repository, is doubly exempt -(`engine-owned` bucket **and** `sys_`) and is pinned as such. - -⚠️ **Read both halves together.** "Behaviour-free" on its own overstates it — the -population the narrowing reaches is real and named above, and an app that declares one -of those buckets on its own object gets the strip. The population on its own -understates it — not one shipped object changes behaviour on this release. What moves -is the contract for **app-authored** objects, which is the population the ruling is -about. - -## What was wrong - -`staticReadonlyInsertSubject` returned `null` for `managedBy` set to **anything**, -carried over byte-for-byte from the deleted DataProtocol ingress copy on ADR-0086 / -#3004 grounds: those columns have their own 403 guards, and a silent strip must not -swallow the payload the guard exists to reject. The argument is sound and the bucket -list was not. `managedBy: 'system-data'` means "platform-defined schema, -**admin/user-writable data**" by its own definition, and `object.zod.ts` says in the -same breath that it "carries no such guard; its writes are adjudicated by the -delegated-admin gate / RLS / permission sets". So the create side skipped the strip on -objects whose data is the user's, while the update side stripped them — and #14147's -"one semantics, one enforcement point" was not literally true on that population. - -## What it does now - -The exclusion follows its reason. `null` is returned for the `sys_` namespace, and for -the three buckets whose columns really do carry a fail-closed refusal: - -| bucket | its own refusal | the create-side strip | -|:--|:--|:--| -| `engine-owned` | ADR-0103 engine-owned write guard | steps around it | -| `append-only` | ADR-0103, same guard (locked default) | steps around it | -| `better-auth` | ADR-0092 identity write guard | steps around it | -| `platform` | none — full user CRUD by default | judges it | -| `config` | none — admin-authored, writable by default | judges it | -| `system-data` | none — "admin/user-writable DATA" | judges it | - -An **unrecognised** bucket value is deliberately not read as platform-internal: the one -legacy value that can still arrive is `'system'`, retired in protocol 17 (#3355) and -converted to `'system-data'` — a judging bucket — so exempting unknowns would exempt -precisely the rows that conversion targets. The partition is pinned against -`@objectstack/spec`'s own enum, so a seventh bucket fails a test instead of landing -silently on one side. - -The ruling's fallback ("leave it, if those buckets' readonly columns already carry -their own 403") does not apply: of the 64 columns above, 14 are the ADR-0086 -package-provenance family (`package_id`, `managed_by`, `customized`, `drift_status`, -`drift_detail`, `is_system`, all on `config` objects) and the other 50 are `id` / -`created_at` / `updated_at` stamps, which that guard does not reach. - -`@objectstack/lint` mirrors this predicate to decide which objects its create-verb -`flow-update-readonly-field` / `hook-api-update-readonly-field` findings may describe, -and is narrowed in the same stroke — a lint that kept the wider exemption would go on -suppressing findings for a strip that now really happens. - -⛔ The UPDATE path is untouched, and so is `beforeInsert`'s post-hook strip position. -The asymmetry is closed by moving CREATE toward UPDATE. diff --git a/.changeset/readonly-insert-superseded-prose.md b/.changeset/readonly-insert-superseded-prose.md deleted file mode 100644 index b81a862b98a..00000000000 --- a/.changeset/readonly-insert-superseded-prose.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/objectql": patch -"@objectstack/rest": patch ---- - -Documentation only: seven in-source prose sites that still stated the superseded readonly-on-INSERT contract as live now state the ruled one. - -The 2026-09-03 maintainer ruling (option C, #14147) put the static `readonly` strip inside `engine.insert` under the same `isSystem` gate as `engine.update`, and deleted the metadata-protocol create-ingress copy. Comments and test headers written before that ruling still said, in the present tense, that a non-system INSERT is exempt from the static strip, or that the strip lives at the DataProtocol create ingress. Each now states the ruled contract, and the superseded sentence is kept only as history, marked as superseded. - -No behaviour changes and no test was deleted, skipped or re-scoped — the diff is comments only. It is a `patch` rather than `skip-changeset` because it was measured to publish: `@objectstack/objectql`'s comment edit moves source line numbers, so `dist/{index,core}.{js,mjs}.map` change, and `@objectstack/rest` inlines that same objectql source into its bundle, so `dist/index.{js,cjs}.map` change with it. Every emitted `.js` / `.mjs` / `.cjs` and every `.d.ts` / `.d.mts` / `.d.cts` is byte-identical before and after, and all six maps ship inside the published tarballs. diff --git a/.changeset/refused-end-node-outcome.md b/.changeset/refused-end-node-outcome.md deleted file mode 100644 index ec300d77502..00000000000 --- a/.changeset/refused-end-node-outcome.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/service-automation': minor ---- - -Flow `end` nodes honour `outcome: 'refused'` — a terminal `refused` run, distinct from `failed` - -`packages/spec` has declared the shape since 17.4.0: an `end` node accepts -`outcome: 'completed' | 'refused'`, a `refused` end requires a `message`, -`ExecutionStatus` carries `refused`, and `ExecutionLog` / `AutomationResult` / -the trigger response carry `refusalMessage`. The engine produced none of it — -it returned on every `end` node without reading its config — so an author who -wrote a refusal shipped a plain completion: the run recorded `completed`, the -caller got the flow's `successMessage`, and the authored reason reached nobody. - -The `end` node now honours it: - -- **The run terminates `refused`.** A refusal is a *successful evaluation that - says no*, so the result is `success: true, status: 'refused'` with no `error` - and no `errorMessage` — and, deliberately, no `successMessage`: the flow's - completion toast is for a completion. All three terminal producers answer - identically (a triggered run, a resumed screen flow, and an attempt under - `errorHandling.strategy: 'retry'`, where a refusal also stops the ladder - rather than consuming retry budget). -- **The `message` is rendered per record**, through the same interpolation a - `screen` node's `description` gets — one implementation (`interpolateText`), - never a second template engine — so `'Refused: {record.name} is a confirmed - duplicate'` reaches the caller naming the record. -- **Both are persisted on the run.** `sys_automation_run.status` gains - `refused` and a new `refusal_message` column carries the rendered text; the - refusal is never folded into `error`, which would tell every reader the run - broke. `RunRecord` gains `refusalMessage` and `TerminalRunStatus` gains - `refused`, so history rows are written, aged and read back like any other - terminal. -- **A refused run is never resumed.** It writes no continuation, so `resume` - answers `RUN_NOT_FOUND`. - -Untouched on purpose: a paused run still returns `silent` with no -`successMessage`, and a plain `end` — or one declaring `outcome: 'completed'` — -completes exactly as before. - -An `end` declaring `outcome: 'refused'` **inside a structured region** (a `loop` -body, a `try`/`catch` region) is refused loudly rather than honoured: a refusal -terminates the run and a region body cannot end one. Previously such a node was -a silent no-op like every other `end` in a region, so nothing that ever worked -stops working — put the refusing `end` on the top-level graph and route the -region's exit to it. diff --git a/.changeset/repeater-item-schema-titles-class-guard.md b/.changeset/repeater-item-schema-titles-class-guard.md deleted file mode 100644 index 9e8f0e6322e..00000000000 --- a/.changeset/repeater-item-schema-titles-class-guard.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): a repeater's property-panel table has column NAMES, and an untitled item schema is now loud (#17232) - -## What was wrong - -Studio renders a `type: 'repeater'` form field as a table whose column headers -read `items.properties[k].title ?? k` off the JSON Schema served by -`GET /meta/types` — derived by `packages/metadata-protocol`'s `toJsonSchemaSafe`, -i.e. `z.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any' })`. -The bundle overlay `resolveMetadataFormSchemaTitles` (#16458 / PR #17227) only -replaces a title that is already there, so an item schema carrying no -`.meta({ title })` falls through to the raw machine key — in **every** locale, -English included. The maker read `actionUrl`, `defaultCollapsed`, `dateGranularity` -inside an otherwise fully translated panel. This is a missing authoring label in -the contract, not a translation gap. - -PR #17227 titled exactly one repeater, `dashboard.header.actions`, and was scoped -by dispatch to that one. **The class stayed silent**: the next repeater to land -would reproduce the defect with every gate green. - -## Measured on `origin/main` at `e758131b39` - -22 repeater fields are declared across 11 `*.form.ts` files. Derived through the -platform's own predicate rather than a source regex: - -- **1** was fully titled — `dashboard.header.actions`, PR #17227's instance. -- **1** has no object row shape at all — `action.locations` is an array of enum - STRINGS, so it renders no column headers and leaks no key. It is **not** a - carrier, which is why the class is **20** untitled tables today and not the 21 - the card premised. -- **20** were untitled. - -## What changed - -**Thirteen carriers are now titled** — every row property of `action.params`, -`app.areas`, `dataset.dimensions`, `dataset.measures`, `flow.nodes`, -`flow.edges`, `flow.variables`, `page.variables`, `page.regions`, -`page.interfaceConfig.sort`, `report.order`, `report.blocks` and -`skill.triggerConditions` carries a `.meta({ title })`. `page.interfaceConfig.sort` -is titled through the shared `SortItemSchema` it composes. - -**The silence is closed.** `packages/spec/src/kernel/repeater-item-titles.test.ts` -enumerates every repeater declared across every `*.form.ts` in the package, -derives each row schema through `z.toJSONSchema`, and requires a title on every -authorable row property. Carriers still owed one sit in an EXACT, shrink-only -ledger: a repeater absent from the ledger must be fully titled, and a ledger -entry whose debt has been paid must be deleted. A new repeater is therefore red -on the day it lands, and the ledger can only shrink. - -Two exclusions the pin makes deliberately, each with its own control: - -- a `retiredKey()` tombstone is a parse-time refusal, not an authorable column - (`flow.nodes[].outputSchema`); -- a scalar-item repeater has no row properties to name (`action.locations`), - and is pinned by name so an object-shaped one cannot land there silently. - -## What is still owed, and why - -Seven carriers remain on the ledger because their item schemas live in files held -by other in-flight PRs at the time of writing — `dashboard.widgets` and -`dashboard.globalFilters` (`ui/dashboard.zod.ts`), `view.columns` / `view.sort` / -`view.tabs` (`ui/view.zod.ts`), and `field.options` + `object.fields.options` -(the one `SelectOptionSchema` in `data/field.zod.ts`). The pin OBSERVES them -without editing them, so the ledger states the whole class rather than the slice -one PR could reach. - -Localisation is additive and unchanged by this round. `.meta({ title })` is the -English authoring layer by contract — `translation.zod.ts` states it in those -words — and a bundle's `metadataForms..fields...label` -overlays it per locale. No form file here enumerates repeater children, so -`os i18n extract` emits no new catalog keys and no catalog moves. Until those -leaves are authored, a non-English panel shows the English title rather than the -machine key — strictly better than today, and the localisation layer is still owed. diff --git a/.changeset/reserved-identity-name-position-guard.md b/.changeset/reserved-identity-name-position-guard.md deleted file mode 100644 index 1e2ca18ee39..00000000000 --- a/.changeset/reserved-identity-name-position-guard.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -feat(plugin-security): a position row can no longer spell an ADR-0068 built-in identity name (#15972) - -`sys_position.name` and `sys_user_position.position` were unconstrained, so a tenant could mint a row spelling any framework-reserved built-in identity name — `platform_admin`, `org_owner`, `org_admin`, `org_member`. PR #15948 closed every in-repo READER that turned such a name into authority; it could not stop the row existing, and a reader is not an invariant: an out-of-repo consumer that reads the NAME instead of the capability rung reopens the hole with nothing mechanical to catch it. - -Both declarations now carry an object-level `validations[]` rule whose CEL list literal is **generated** from `BUILTIN_IDENTITY_NAMES`, the `@objectstack/spec` constant that declares the identities. The set is a closed enumeration — imported, never retyped, and never widened to an `org_*` pattern, so an ordinary tenant position named `org_manager` still writes. Object-level validations are evaluated by the engine on insert, by-id update and multi-row update, so the data API, the seeders and metadata import are all covered by one refusal carrying one code (`VALIDATION_FAILED`). - -Two doors, two shapes, for a reason: - -- **`sys_position`** exempts the platform's own catalog provenance (`managed_by` of `platform`, or its legacy `system` spelling). `bootstrapBuiltinRoles` seeds exactly these four names per organization on purpose, and that catalog is unaffected. A `package`- or tenant-authored row is refused. -- **`sys_user_position`** takes **no** exemption. No writer in any package creates an assignment row spelling a built-in identity name — `platform_admin` standing comes from the unscoped `admin_full_access` grant, the `org_*` trio from `sys_member.role` — so every such row is a name pretending to be an identity. - -Existing rows are not migrated and nothing rewrites them (maintainer ruling: refuse new writes only). The rule is an INVARIANT, so a row that already spells a reserved name is refused on any edit until it is renamed — frozen, not bricked. `scripts/measure-reserved-identity-name-census.mjs` is the read-only census that reports such rows from an operator-supplied export. - -Housekeeping this change drags along, disclosed because a reviewer should not have to discover it: a validation rule's `name` is snake_case by contract, and `scripts/tenant-audit-census.mjs` counts every snake_case `name:` literal in a `*.object.ts` as a "declared object" (it already counts the four `actions[]` names on `sys_position`, so that figure was never a count of objects). The two new rule names move it 298 → 300, so the census artefacts are regenerated with the script's own `--write`. That block regenerates **whole**, so it also refreshes two figures this diff did not cause — `tracked non-test sources scanned` 557 → 562 and `engine-shaped types recognised` 59 → 58 — which are drift accumulated since the block was last measured at `9cefca9a3`. diff --git a/.changeset/retire-adr-0030-notification-event-migration.md b/.changeset/retire-adr-0030-notification-event-migration.md deleted file mode 100644 index 2ae652749b4..00000000000 --- a/.changeset/retire-adr-0030-notification-event-migration.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -'@objectstack/metadata': minor -'@objectstack/spec': minor ---- - -**BREAKING** — retire the `adr-0030-notification-event` data migration. - -`migrateSysNotificationToEvent` had no way to be run: zero production callers -anywhere in the repo, and no `os migrate` sub-command, while the two sibling -members of `CREATION_ATTESTED_MIGRATION_IDS` had both. The runner, its barrel -export, its tests, the ruled `sys_migration` receipt-claim matrix, that matrix's -pin, and the id's membership in `CREATION_ATTESTED_MIGRATION_IDS` are removed -together. Pre-ADR-0030 `sys_notification` rows are not carried by the platform -on this line. - -## What is gone, and what an upgrader does about it - -⭐ **Nothing is renamed and nothing replaces it**, so there is no new spelling to -adopt — every item below is a deletion, and the fix is to stop using it. - -- `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) — deleted. - No replacement exists, and none is coming: an `os migrate notification-event` - sub-command was considered and refused. Delete the call. The compiler delivers - this one: the import fails to resolve. -- `SysNotificationMigrationResult`, `SysNotificationMigrationOptions` and - `SysNotificationMigrationReceipt` (same entry point) — deleted with it. They - described that runner's own result, options and receipt and nothing else. -- `CREATION_ATTESTED_MIGRATION_IDS` (`@objectstack/spec/system`) — was a - three-member tuple and is now a two-member one holding - `'adr-0104-file-references'` and `'adr-0104-value-shapes'`. Both ADR-0104 ids - keep their sub-commands, their receipt rows and their birth attestation; only - the notification id left. Code typed against - `(typeof CREATION_ATTESTED_MIGRATION_IDS)[number]` that names the notification - id no longer compiles — delete that arm. - -`NOTIFICATION_EVENT_MIGRATION_ID` (`@objectstack/spec/system`) is **kept**. A -deployment attested at birth, or one that made the operator call while the runner -shipped, still holds a `sys_migration` row keyed `'adr-0030-notification-event'`, -and the constant is that row's name. Nothing writes or reads a row under it any -more — `attestFreshDatastore` no longer includes it — and it is not a -registration: it gates nothing and never did. - -## Reversal path - -Two answers were considered and both refused: an `os migrate notification-event` -sub-command is a permanent operator surface for a migration with no measured -demand, and a boot-time invoker is an unattended data rewrite nobody asked for. -⚠️ Nobody has measured whether any live deployment carries pre-ADR-0030 -`sys_notification` rows. If a **named** deployment turns out to hold rows it -needs, the migration returns as an operator-runnable sub-command shaped exactly -like `files-to-references` / `value-shapes` — dry-run default, `--apply` gate, -documented consequence — under its own card. - - diff --git a/.changeset/retire-list-view-page-mount.md b/.changeset/retire-list-view-page-mount.md deleted file mode 100644 index 07f1fb2c304..00000000000 --- a/.changeset/retire-list-view-page-mount.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': minor -'@objectstack/metadata-protocol': minor ---- - -**BREAKING** — retire the `type: 'page'` list-view mount and its `pageName` binding. - -A list view could declare `type: 'page'` and name a published page in `pageName`, -and the view was to render nothing of its own and delegate to the page renderer. -Only the spec half of that was ever built. **No renderer ever routed the member**: -objectui's list-view switch shares its `default:` arm with `case 'grid'`, so a page -view has always drawn an empty table where the page was supposed to be, and the -three parse refusals that policed the binding policed a mount that never mounted -anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09. - -## FROM → TO - -| you wrote (17.4 and earlier) | write instead | -| --- | --- | -| `{ type: 'page', pageName: 'sales_home', columns: [] }` on a list view | nothing on the view. Delete it, and reach the page from the app's `navigation`: `{ id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }` | -| `pageName` beside any other list-view `type` | delete the key — it was refused already, and is now a tombstone | -| a list view that wanted rows | pick a row-drawing `type` — `grid` and its siblings, all unchanged | - -**The one-line fix:** delete `type: 'page'` and `pageName` from the list view; put -the page behind an app navigation item, which is a different key on a different -surface (`PageNavItem.pageName`) and is the page mount that has always rendered. - -`os migrate meta --from 17` lists the mechanical edits for existing sources; apply -them by hand. - -## The retirement kit - -- **`pageName`** — a `retiredKey()` tombstone on `ListViewSchema` and - `ObjectListViewSchema`. `tsc` types the key `never`, and a value reaching a parse - raises the prescription rather than a bare unrecognized-key report. -- **`'page'`** — an enum VALUE, so there is no tombstone to hang a prescription on - (the def survives, one value lighter, and the four generated-surface ratchets are - blind to that by construction). The `type` enum's own `error` map carries it, - keyed on `issue.input` so only the value that used to be legal gets the - "was removed" message; every other invalid `type` keeps zod's default text. -- **`checkListViewPageMount`** — the exported object-level refinement existed only - to police this mount, so it is removed with it, along with its three refusal - messages. A downstream mirror that re-attached it (the reason it was exported) - should drop the `.superRefine` line; the compiler delivers this one. It held no - `ERROR_CODE_LEDGER` row — the three refusals were message constants, not codes. -- **`validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`** (`@objectstack/lint`) — the - `os validate` and publish-gate rule that resolved a mount against `stack.pages`. - Removed: there is no reference left to resolve. Its nav twin - (`validateNavTargetRefs`, on the app navigation item) is **untouched**. -- **`RuntimeStackContext.pages`** (`@objectstack/lint`) and the `page` row of - `CLOSURE_CONTEXT_KEY_BY_TYPE` (`@objectstack/metadata-protocol`) — the live page - universe joined the per-write snapshot for that one rule, and leaves with it. A - `PUT /api/v1/meta/view` publish no longer pays a `sys_metadata` round trip for a - collection nothing consults. Hosts calling `runRuntimeAuthoringRules` / - `evaluateRuntimeAuthoringGate` with an explicit `context.pages` drop that key. -- **`defineStack`** — the `validateCrossReferences` branch that resolved a mount's - `pageName` against `stack.pages` is gone. The surviving three page references in - that function (an app nav item's `pageName`, a modal action's `target` at two - rungs) keep their own policy. -- **The metadata form** — `view.form.ts`'s `page` section, whose one input was - `pageName`, is removed. A form input for an unwritable key is the false-compliant - UI half of a retirement. - -## What an operator with a STORED page view sees - -A `sys_metadata` `view` row written before this release can carry `type: 'page'` and -a `pageName`. Nothing breaks at read: the ADR-0087 conversion -`view-page-mount-removed` (protocol 18) replays on rehydration and strips both keys, -so the row is served canonical. `type` is **stripped, not rewritten** — it defaults -to `grid` in the schema, so the row lands on exactly what it already rendered -without the platform guessing a view type. - -The strip is announced once per row per process, on whichever seam served it. -Grep for `carries a pre-protocol shape` — there are **three** emitters, one per -rehydration seam, and they differ: - -- `[DatabaseLoader] stored view/ carries a pre-protocol shape; ` -- `[ObjectQLPlugin] stored view/ carries a pre-protocol shape; ` -- `[Protocol] stored view/ carries a pre-protocol shape; The row - itself is unchanged — re-save it (Studio edit -> save, or run - "os migrate meta --stored --apply") to persist the canonical shape.` - -`os migrate meta --from 17` lists the same edits for authored sources; -`os migrate meta --stored --apply` rewrites the stored rows so the warn stops, and -the next save through `PUT /api/v1/meta/view` heals one row the way it heals any -pre-protocol shape. - -⚠️ The conversion walks `stack.views[]` in all three persisted spellings; it does -**not** reach `objects[].listViews.*`, which no conversion in the registry reaches. -An object body still carrying a page mount is refused at its own door with the -prescription rather than converted. Measured population for both at the ruling: -**zero** authored `type: 'page'` list views in this repository or any consuming app -the seats can read — the in-tree `type: 'page'` hits are all app nav items. - - diff --git a/.changeset/retired-permission-bits-parse-time-accept-set.md b/.changeset/retired-permission-bits-parse-time-accept-set.md deleted file mode 100644 index e787cf9bb47..00000000000 --- a/.changeset/retired-permission-bits-parse-time-accept-set.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): state the retired `allowRestore` / `allowPurge` parse-time accept set exactly (#17425) - -Documentation only — no schema, no key, no exported symbol and no accepted value moves. What changes is what the tombstone's own prose claims about itself, in the three places a consumer reads it: the `permission.zod.ts` docblocks (published in the tarball, both as `dist/*.d.ts` and as the `src/**/*.zod.ts` sources this package ships), and the two hand-written permission docs pages. - -The prose said the retired bits are refused, and separately that "every other value" lands on the tombstone. Read together those two sentences describe a truthy/falsy split, and that is not what the schema does. Measured on this tree, `ObjectPermissionSchema` tolerates exactly ONE value: the boolean literal `false` the published 17.x toolchain materialized into every permission entry of every artifact it built, accepted as inert residue and silently stripped under the retired-defaulted-key class rule. Every other value of any type — including the string `"false"`, the number `0` and `null` — is refused exactly like `true`, with `code: 'invalid_type'`, `expected: 'never'` and the same guidance string, at the key's own path. - -The consequence consumers were missing is now stated with it: a successfully parsed permission entry can carry neither key on any input that came from JSON, so a post-parse guard against either bit is dead code — presence, truthiness and `=== true` alike can never be true on validated data. A `false`-versus-other distinction is observable only to pre-parse tooling reading raw sources, where the retired default is inert legacy residue and any other value is a hard ADR-0049 violation. - -One measured exception is documented and pinned, because it is the only post-parse observation that survives: an in-memory TypeScript input carrying an explicit `undefined` for either key parses and keeps the key as an own property whose value is `undefined`, so a presence check can be true there. JSON cannot spell it, and a serialize round-trip drops it again. - - diff --git a/.changeset/rls-accessible-org-ids-resolved-into-variable-bag.md b/.changeset/rls-accessible-org-ids-resolved-into-variable-bag.md deleted file mode 100644 index 2ddc533207e..00000000000 --- a/.changeset/rls-accessible-org-ids-resolved-into-variable-bag.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(security): resolve `current_user.accessible_org_ids` into the RLS variable bag (#16518) - -`patch` — a bug fix in a released package. No API signature changes, no exported -symbol added, no spec or ADR edit: the contract already promised this, and only -the line that delivers it was missing. - -## What was wrong - -`packages/spec/src/contracts/rls-membership-resolver.ts` does not merely reserve -the name `accessible_org_ids`. It declares the field's SHAPE (`:53`, -`accessible_org_ids?: string[]`), states at `:35` that the key is CORE-resolved -and not an app resolver, and lists it at `:70` in -`RESERVED_RLS_MEMBERSHIP_KEYS` — so an app's membership resolver is refused when -it tries to supply the set itself. `ExecutionContext.accessible_org_ids` goes -further and names the RLS spelling outright: *"RLS policies may reference it as -`organization_id IN (current_user.accessible_org_ids)`"*. - -`RLSUserContext` declared `id`, `organization_id`, `positions`, `org_user_ids` -and `email`, and nothing copied `accessible_org_ids` out of the execution -context. So the key was reserved on the grounds that core resolves it, and core -did not resolve it — a slot with a declared shape and no filler, which is the -ADR-0049 "declared but unenforced" shape. - -**The cost is the invisible one.** A predicate such as -`employer_org IN (current_user.accessible_org_ids)` compiled to an unresolved -variable, every applicable policy dropped out, and `RLS_DENY_FILTER` returned -**zero rows with no error raised**. Nothing failed. An empty list is -indistinguishable from "this user really has no data", which is how the shape -survived three green static gates and, in the reporting app, left ten policies -across six objects inert — the entire multi-tenant isolation model. - -The failure direction is **closed**: zero rows, never a cross-tenant read. This -is a usability and declared-means-enforced defect on a security surface, not a -leak. - -## What it does now - -`RLSCompiler.compileFilter` copies `ExecutionContext.accessible_org_ids` into -`RLSUserContext`, following `org_user_ids`' precedent exactly — both are -core-resolved membership sets the runtime **pre-resolves**, precisely so this -compiler never has to issue a subquery. The compiler is unchanged otherwise; it -already handled the value correctly once present. - -The producer already existed and is unconditional: `resolve-authz-context.ts` -types the set as required and `assemble-execution-context.ts` copies it on every -face, in every posture (*"in `single` posture the set is resolved but no wall -consumes it"*). Only the consuming line was missing. - -One consequence worth naming: **reserved now means reserved at the compiler -too.** `stageRlsMembership` screens reserved keys out of a *resolver's* answer, -but a bag already present on the context was spread through unscreened, and -landed in the variable bag because nothing named the field. Now that the kernel -names it, the compiler's own "a membership key never clobbers a named field" -rule covers it and the kernel's value wins. - -## Measured, end to end - -A rig on real drivers (`driver-sql`, `driver-sqlite-wasm`), six rows across -three organizations, a caller holding membership in two of them: - -| predicate | before | after | -|:--|--:|--:| -| `employer_org IN (current_user.accessible_org_ids)` | **0 of 6** | **4 of 6** — the rows of both orgs | -| same, caller scoped to ONE org | 0 of 6 | 2 of 6 — that org only | -| same, caller with no set / an empty set / an org with no rows | 0 of 6 | 0 of 6 — unchanged, still fails closed | -| a predicate naming a NON-EXISTENT variable | 0 of 6 | 0 of 6 — unchanged (#16119's face, untouched) | -| `org_user_ids`, `organization_id`, `email`, `id`, an app membership key | — | byte-identical | - -An app **could** work around the defect by supplying the same set under its own -unreserved key through `rlsMembership` and rewriting its predicates to -`current_user.my_org_ids`; that reads 4 of 6 on the same rig, before and after. -The workaround costs every app a membership-resolver registration it should not -need and moves every predicate off the documented spelling — and it is no longer -necessary. diff --git a/.changeset/rls-check-defaults-to-using.md b/.changeset/rls-check-defaults-to-using.md deleted file mode 100644 index e42de6c8fb3..00000000000 --- a/.changeset/rls-check-defaults-to-using.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -fix(plugin-security)!: a row-level security policy that declares no `check` now holds INSERTs and UPDATEs to its `using` (#19942) - -Clause-②: no (narrowing) - - - -**BREAKING**: this narrows the set of writes the write gate accepts. A write that is admitted today can be refused after this change. It ships as `minor` under the launch-window convention, the same way the insert-side `check` reorder did (#16805). - -The published contract has always said this. `RowLevelSecurityPolicySchema.check` read "defaults to USING clause if not specified" (it now states the default per operation across the applicable policies, #19953), and PostgreSQL treats a policy without `WITH CHECK` the same way. The write gate did not do it. It compiled only the policies that declared `check`, so a policy with only a `using` never checked a write. With `using: "record.status != 'closed'"`, a caller could INSERT a closed row. The row was stored even though the same caller could not read it afterwards. - -**Writes that are now refused.** Each refusal is the existing row-level CHECK denial, `403 PERMISSION_DENIED`, and nothing is stored. There is no transition switch. - -- **Any policy with only a `using`.** A single-row INSERT, or a by-id UPDATE, is refused when its resulting row falls outside the `using` of every applicable write-class policy (`insert`, `update` or `all`) and none of those policies declares a `check`. To let a write move a row outside a policy's scope, declare a `check` on that policy. -- **The platform's `_self` policies.** These have `operation: 'all'` and `using: user_id == current_user.id`. They now refuse a write that sets `user_id` to another user, on the self-service tables a member may write: `sys_user_preference`, and the revoke patch on `sys_api_key`. A member can no longer create a preference row for someone else. A member can no longer re-own their API key by adding `user_id` to a revoke patch. Before this change both writes were admitted, and the second one had `user_id` stripped later. -- **Re-pointing `created_by` under the ownership floor.** This is a by-id UPDATE that changes `created_by` while the floor still applies to that write. The readonly strip used to remove the new value, and the update was admitted. It is now refused with 403. The new row is judged before that strip runs (#16790). -- **A `using` that does not compile.** This applies to an `insert` or `all` policy that has only a `using`. That `using` is now also the insert check, and the policy fails closed: every insert it governs is refused. Before this change the insert was admitted, because no check ran. - -**What does not change.** - -- If any applicable policy declares `check`, only the declared checks decide, exactly as before. A policy with only a `using` alongside them adds nothing to the check. -- The platform's ownership floor (`owner_only_writes`) is part of a defaulted check only when the by-id write gate kept it for that write. A record share at edit depth, a `public_read_write` object, or a covering controlled-by-parent master gate still replaces the floor. Those writes are not refused again on the new row. -- `select` policies never gate a write's new row. -- Bulk updates without a single id are still scoped by the `using` where clause. Their new rows are now checked row by row as well, by the separate multi-row entry (#19950). -- The `modifyAllRecords` bypass on private and platform-global objects still skips the check. diff --git a/.changeset/rls-predicate-references.md b/.changeset/rls-predicate-references.md deleted file mode 100644 index feb36afed13..00000000000 --- a/.changeset/rls-predicate-references.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -Two new gating rules — `rls-predicate-unknown-field` and `rls-predicate-unknown-user-variable`: an RLS predicate that lowers correctly but names a column the object does not declare, or a `current_user.*` value nothing pre-resolves, is now an authoring-time `error`. - -The three shipped `rls-predicate-*` rules judge a predicate's **shape** — does it parse, does it lower, does it fit the platform's CEL bounds. Nothing judged what it **points at**. Measured as four injections at one site, in one run: `billing_address.country == "US"` reported `rls-predicate-unenforceable` and `is_private == = false` reported `rls-predicate-unparseable`, while `is_private_nope == false || owner_id == current_user.id` and `is_private == false || owner_id == current_user.nope` reported **nothing at all** — from the same site the linter had just reported twice. - -Both silent shapes are expensive rather than cosmetic, and they do **not** fail in the same direction — which is the part the card's own measurement did not reach. - -An unresolved `current_user.*` is refused by the pushdown compiler in **every** position, including under `!` and in a trailing `||` arm, so that half always fails **closed**: `RLSCompiler` drops the policy, the layer falls back to the `RLS_DENY_FILTER` sentinel, and the object disappears for every holder of the permission set — not because they were denied but because the narrowing they were granted resolves to nothing. - -An unknown **field** takes its direction from **position**, and one of the two is fail-**open**. `SecurityPlugin`'s field-existence safety net recognises only a *leading* `field ==` / `=` / `in` (`extractTargetField` is that shape match), so a miss there drops the policy and arms the deny sentinel — zero rows. A miss the net does not recognise — a negation (`nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])`) or any arm after the first — leaves the policy **kept**, and the phantom column lowers to a negated constraint that a row without that column *satisfies* (`noValueSatisfiesNegation`: `$ne` / `$nin` / `$notContains`). The authored narrowing is then **defeated rather than enforced**: measured at 3 of 3 rows, against 1 of 3 for the real narrowing and 0 of 3 for the same phantom column in a positive position, on the read path and on the write path's `matchesFilterCondition` alike. - -⛔ That is **not** a cross-tenant leak — tenancy is a separate layer and it holds; what is defeated is the narrowing authored inside the wall. Measured on driver-memory; driver-mongodb follows the same shared ruling; **driver-sql is NOT MEASURED** and is expected to fail closed by raising `no such column`. The runtime repair is tracked separately as #17042 and is deliberately not attempted here — these rules report the miss, in both directions, and the diagnostic says which direction applies so an author is not told "this denies everything" about a predicate that in fact matches everything. - -- **Two rules beside the three, not a widening of them.** The existing ids say *unenforceable* / *unparseable* / *over-budget* and are correct inside that scope; they are untouched, and the two controls above still report under them and under neither new id. The prescriptions differ (rewrite the predicate / fix the column name / pre-resolve the variable), and an author who suppresses one must not thereby suppress the other. The guards are disjoint by construction: the reference pass runs only where `isSupportedRlsExpression` has already said yes. -- **Where the existence answer comes from.** Field paths are read off the pushdown compiler's **own output** — the lowered `FilterCondition`'s keys are the columns the driver will be handed — and resolved through `object-graph.ts`, the shared index every field-existence rule in this package already uses. No new input path, no second parse of the predicate. The rule therefore inherits that module's three skips, each the difference between a finding and a false one: an object this stack does not define, an object with no readable field map (an ADR-0015 `external` object, an introspected datasource), and registry-injected system columns such as `created_at`, which are real at runtime and appear in no authored `fields`. -- **The `current_user` set is derived, not transcribed.** It is `RESERVED_RLS_MEMBERSHIP_KEYS` from `@objectstack/spec/contracts` — the keys an `IRlsMembershipResolver` may never supply *because the kernel already owns them*. A key added there stops being reported the same day, with no edit in this package. -- **§7.3.1 membership keys are left alone, and that boundary is the reason this rule can exist.** An app stages arbitrary sets into `ExecutionContext.rlsMembership` and references them as `field in current_user.`; the spec documents the pattern and `rls-predicate-unparseable`'s own hint recommends it. In an `in` position an unknown key is indistinguishable from a correct one and is never reported. It is decidable in the other positions only because the merge is array-only — the sole value an app-staged key can ever hold is an array, which a scalar position cannot use on any request — so `owner_id == current_user.nope` is refused while `assigned_to_id in current_user.team_member_ids` stays silent. A key used in both positions takes the membership answer. - -**What moves for consumers.** A stack whose RLS predicate names a renamed column or an un-pre-resolved context value built clean before and now fails `os validate` / `os lint` / `os compile`. That is the point — the policy had already stopped doing what it was written to do, denying the whole object in one position and granting every row in the other. - -A stack whose predicates all resolve is byte-identically clean. The reading is the shipped showcase: 3 RLS clauses, all 3 judgeable against declared objects, **zero** findings — with three firing controls at the real site (an injected dangling column, an injected unknown variable, and an injected fail-open negation shape each produce exactly one finding) and two nonsense controls (an injected membership test against an unknown key, and a real-field/real-variable predicate, stay silent). `plugin-security`'s seed sets and hotcrm's built-permissions fixture also emit zero, but ⛔ **those two are not readings**: every policy target in the seeds is an object that package does not declare, and the hotcrm fixture carries no `objects` key at all, so all 71 and all 4 clauses respectively are skipped by construction. Declaring one of their objects makes the fixture report 2 — which is what a control is for. diff --git a/.changeset/rls-reserved-membership-keys-refused-by-name.md b/.changeset/rls-reserved-membership-keys-refused-by-name.md deleted file mode 100644 index 07cd55324b4..00000000000 --- a/.changeset/rls-reserved-membership-keys-refused-by-name.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -security(rls): the RLS compiler refuses `RESERVED_RLS_MEMBERSHIP_KEYS` by name - -A caller-supplied `ExecutionContext.rlsMembership` entry could supply a RESERVED -kernel key — `id`, `organization_id`, `positions`, `org_user_ids`, -`accessible_org_ids`, `email` — whenever the kernel had not resolved a value for -that key on the request. `RLSCompiler.compileFilter` admitted a membership key on -the test `userCtx[key] === undefined` ("did the kernel happen to resolve one"), -not on whether the key is reserved, so an absent kernel value handed the name to -the bag. - -The direction was widening. With the key unresolved, the predicate referencing it -fails CLOSED — it joins the dropped-policy path and the compile returns the deny -sentinel, which yields zero rows. The bag instead produced a satisfiable filter -over caller-chosen values, converting a denial into a match. - -The merge now refuses reserved keys by name, at the one seam both faces pass -through (the read layer compiles `using` there, the ADR-0058 D4 write gate -compiles `check` there). `stageRlsMembership`'s existing screen covers only the -registered resolver's answer, and only when a resolver is registered at all — it -returns at its first line otherwise — so it could not carry this guarantee. - -No behaviour change for non-reserved membership keys, and none when the kernel -did resolve the reserved value: the kernel's value already won, and still does. -A refused key simply stays unresolved, so its policies drop out and fail closed -through the reason vocabulary that already exists. diff --git a/.changeset/rls-undeclared-column-denies-in-every-position.md b/.changeset/rls-undeclared-column-denies-in-every-position.md deleted file mode 100644 index 3f6752cbab4..00000000000 --- a/.changeset/rls-undeclared-column-denies-in-every-position.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/lint": patch ---- - -fix(plugin-security)!: an RLS predicate naming an undeclared column now denies in EVERY position and polarity, on the read face and the write face alike (#17042) - - - -**BREAKING** — a fail-open-to-fail-closed narrowing on row-level security. A policy that widened yesterday denies today. Shipped as `minor` under the launch-window convention, the same grading the insert-side `check` post-image narrowing used. - -A predicate naming a column the object does **not declare** could not narrow, and in a **negation-carrying position** it did not deny either — it **widened** the policy to every row inside the tenant wall, and on the write path it **permitted** the write the policy was authored to refuse. - -⛔ It is **not** a cross-tenant leak. Tenancy is a separate layer and it holds. What was defeated is the narrowing the policy author wrote *inside* the wall — an owner-only or private-record policy silently becoming "every row". - -Two independent sites, each with its own reason, each measured against the same two controls (a real column must still narrow; the *same* phantom column in a **positive** position must still refuse): - -- **Read face.** `extractTargetField` is a **leading-only** `==` / `=` / `in` shape match, so `nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])` and any arm after the first returned `null`; the policy was **kept**, the drop counter never incremented and the deny sentinel never armed. The kept filter then met the settled include-direction ruling — a row that *has* no such column satisfies "column != x". Measured on the matcher: **3 of 3** rows for each negated shape, against **1 of 3** for the real narrowing and **0 of 3** for the same phantom column in a positive position. -- **Write face — the worse one.** `computeWriteCheckFilter` compiled `check` clauses with **no field-existence check at all**, and the ADR-0058 D4 post-image gate evaluates that filter in-process. Measured end to end on both SQL drivers: every negated phantom **permitted** the insert, in both post-image polarities, while a positive phantom refused (by accident of an absent value comparing unequal) — which is why a suite that only ever exercised the positive shape stayed green over the hole. - -**The repair is one seam, not two.** `RLSCompiler.compileFilter` — the single choke point both the read layer and the write gate already pass through — now takes the object's declared-column set and judges every column the policy names on the **compiled** `FilterCondition` tree. That is positional-agnostic by construction: the pushdown compiler lowers `!` to `$not`, `||` to `$or` and `&&` to `$and`, so a column lands as a plain object key whatever position it was authored in, and there is no spelling of negation left for a shape match to miss. Widening the regex instead was rejected: a matcher that must enumerate every spelling of negation is the same "recognises only what it was told about" defect one level over, and it would additionally have broken the ADR-0095 carve-out that *depends* on the regex recognising only the leading shape. A policy dropped this way joins the existing fail-closed path — same deny sentinel, same WARN line — rather than growing a parallel mechanism. - -⛔ **The matcher's include-direction ruling is untouched.** A row lacking a column *does* satisfy "column != x" for an ordinary user query, and re-semanticing every filter in the repo to fix one caller is not the trade. The defect was that a policy compiler lowered an undeclared column into a filter at all; the matcher now never sees a phantom, and a regression test pins the raw matcher still answering 3 of 3 for the same filter so a later reader can see which half moved. - -**Who is affected.** Only a permission set carrying an RLS policy whose predicate names a column its object does not declare — an authoring mistake `@objectstack/lint` already reports on all of these shapes. For such a policy the object now returns **zero rows** for every holder of the set (read) and refuses every governed insert / update (write), where before a negated spelling returned everything and permitted everything. ⚠️ **An installation relying on such a policy to grant access will lose that access at the upgrade, and that is the intended direction**: what it was "granting" was the absence of enforcement. Correct the column name; the linter names the miss and offers the object's real field list. - -**driver-sql, previously unmeasured, is now measured, and it refines the picture.** On the **read** face `driver-sql` and `driver-sqlite-wasm` never widened — they failed closed by **raising** `INVALID_FILTER` / 400 when the phantom column reached the statement builder, so the read-face defect was driver-dependent (in-process matchers widened; SQL raised). On the **write** face they failed open exactly like every other driver, because the `check` is evaluated in-process and never reaches SQL. After this change both faces answer uniformly on both drivers. `driver-mongodb` remains inferred from the shared ruling rather than measured. - -`@objectstack/lint`'s diagnostic for this miss is corrected in the same change. Its **detection is unchanged** — all the negated shapes were already reported. Its consequence text was stale in one half and misattributed in the other: it described the field miss as having two directions decided by position, and it credited the write leg's fail-closed to a safety net that path never had. It now states one direction for both clauses, and records the older runtime's fail-open write behaviour explicitly so an operator reading it against a deployment that predates this guard is not told the wrong thing. diff --git a/.changeset/rollup-non-numeric-aggregand.md b/.changeset/rollup-non-numeric-aggregand.md deleted file mode 100644 index 1673c5baed1..00000000000 --- a/.changeset/rollup-non-numeric-aggregand.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -`os lint` now refuses a `min`/`max` roll-up whose answer cannot be stored in the column it rolls up into — `rollup/non-numeric-aggregand`, at `error`. - -`FieldSchema.summaryOperations` admits `min`/`max` over ANY child field, and the engine's `aggregateSummaryValue` returns the driver's answer verbatim (only an empty-set fallback stands between the backend and the stored value). A `summary` field is a member of the spec's `NUMERIC_VALUE_TYPES`, so `valueSchemaFor` answers `z.number().finite()` for it and `driver-sql`'s `createColumn` emits a float column. An ordinary "latest shipment" roll-up — `max` over a `datetime` child field — therefore computes an instant into a column the value contract says holds a finite number, and nothing between author and driver correlated the two. It is refused at authoring time rather than tolerated in a consumer (Prime Directive #12). - -- **The accept set** is the numeric class union the boolean class, read from `NUMERIC_VALUE_TYPES` and `BOOLEAN_VALUE_TYPES` rather than typed out. The first is the set that DEFINES the criterion — it is the membership `valueSchemaFor` consults to answer `z.number().finite()`, so a type joining it moves the value contract and this door together. The second is admitted on the authority of the `min(flag)=0` / `max(flag)=1` ruling pinned by the spec's own `AGGREGATION_CASES` (#11152): the answer is a number, so it fits. -- **It is NOT `isAggregateCompatibleWithFieldType`.** That table deliberately accepts `min`/`max` over the temporal class, because there the answer is returned to a caller and "return[s] a value of the field's OWN type" (#15768). Reusing it here would accept the very declaration this rule exists to refuse. The two questions look alike and are not — "can every backend give one answer" versus "does that answer fit the column this roll-up is stored into" — so this predicate is that table's `min`/`max` row narrowed by exactly the temporal class, and a test pins the disagreement. -- **Scope.** `min`/`max` only. `count` reads no value off the field; `sum`/`avg` over a non-numeric child is a different shape, whose accept set the aggregate table's own rows already exclude, and is not widened into here. -- **Silent where it cannot resolve.** An unknown child object, a field the child does not declare, or a field with no declared type produce no finding — the aggregate table's own consumer tier ("a consumer that cannot resolve a field's type must NOT call the predicate with a guess"). A partially-loaded model cannot draw a false refusal. - -No export moves: the rule id is an inline literal inside the already-exported `lintDataModel`, beside `rollup/missing-summary`. Measured across this repository, no declaration trips the new refusal — all three `min`/`max` roll-ups aggregate a `number` child field — so this adds a door rather than migrating anything. diff --git a/.changeset/runtime-gate-overlay-redefinition-universe.md b/.changeset/runtime-gate-overlay-redefinition-universe.md deleted file mode 100644 index 317d41c5784..00000000000 --- a/.changeset/runtime-gate-overlay-redefinition-universe.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor ---- - -fix(metadata-protocol): the runtime authoring gate judges an OVERRIDDEN item from the body the runtime serves (#16224) - -The #4463 runtime authoring gate resolves references against the live metadata universe, and since #15950 it gathers that universe from BOTH homes — the `SchemaRegistry` and `sys_metadata`. That fold was **additive**: a stored row contributed a name the registry did not carry and never displaced a registry entry. Where an org or env-wide overlay REDEFINES an item a code package already declares, the gate therefore judged that item's CONTENT from the registry's copy — a body the runtime had already stopped serving. - -Measured end to end, in one process and one instant. A code package ships `dataset/D` with measure `m`; an env-wide overlay redefines `D` without it: - -- a dashboard widget bound to `values: ['m']` was **accepted**, and the runtime cannot serve it; -- a widget bound to the measure the overlay DOES declare was **refused** `422 widget-measure-unknown`, and the runtime can. - -One cause, both directions: an acceptance that should have been a refusal and a refusal that should have been an acceptance. - -The hand-rolled additive merge is replaced by `mergePackageAwareOverlay` with `foldObjectExtendersFromRegistry` as its transform — the merge, and the transform, that `getMetaItems` (the read API behind `GET /meta/:type`) already runs. The gate's universe is now the universe the platform answers reads from, by construction rather than by agreement, and ADR-0048 package slotting arrives with it: an overlay shadows the entry it actually overrides, and two installed packages shipping one `type/name` remain two entries. - -**#15950's resolved-vs-base distinction is kept by folding, not by declining.** Its argument was never "an overlay must not win" but "an UNRESOLVED body must not win" — the registry's copy of an object is its RESOLVED schema (ADR-0029 D9.2: base layer plus its `extend` contributors), a `sys_metadata` row is the base layer alone — and it names its own remedy, which is what `getMetaItems` does to its winner. Pinned: an `object` overlay wins on its own columns AND keeps the registry's `extend` contributors. For a name the registry does not carry the result is byte-for-byte #15950's additive contribution, pinned in the same process. - -Graded `minor` rather than `patch`: `PUT /meta/:type` is a published verb and this narrows its accept set. An `active` publish that names a reference the overlay removed now answers `422 INVALID_METADATA` where it answered `200` — one legal published answer replaced by another, not the repair of a value the schema already refused. The write it now refuses is one the runtime could never serve; the write it now accepts is one the runtime always could. diff --git a/.changeset/s3-adapter-key-namespace.md b/.changeset/s3-adapter-key-namespace.md deleted file mode 100644 index 66f679c9a3c..00000000000 --- a/.changeset/s3-adapter-key-namespace.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/service-storage": minor ---- - -**Clause-②: yes** — a new REQUIRED member on two published option types (`S3StorageAdapterOptions.keyPrefix`, and the `s3` member of `StorageServicePluginOptions`), so the accept set a consumer writes against narrows. Contract-review tier. - -**BREAKING** — `S3StorageAdapterOptions` and `StorageServicePluginOptions.s3` now require `keyPrefix: string | null`. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. - -The **S3 adapter can now be confined to a key namespace**, and the confinement is structural rather than conventional: a caller holding the adapter has no door through which it can reach an unprefixed key. - -`keyPrefix` is applied on `upload` / `download` / `delete` / `exists` / `getInfo`, on both presigned doors, on every multipart door, and into `list()`'s `Prefix` — and it is stripped off every key and every `list()` cursor coming back. Callers therefore supply and receive unprefixed keys at every door, in both directions, and `list('')` enumerates this adapter's namespace and nothing else. Keys are concatenated, never path-joined, so a caller key such as `../elsewhere` stays a literal key inside the namespace instead of escaping it. - -**Why it is required rather than optional.** A shared bucket with no namespace has one thing keeping one deployment out of another's objects: that every `sys_file` metadata check above the adapter was written correctly. On a route that takes an identifier out of a request, one missed check is a cross-deployment read the object store cannot refuse, because what it sees is a well-formed key. An optional prefix reproduces exactly that gap the first time a host forgets to set it, silently — so the choice is made at the call site or the code does not compile. `null` is the written, greppable way to ask for bucket-root keys, and it produces byte-identical keys to those written before this option existed. - -For the same reason an empty or whitespace-only string is **refused at construction** rather than treated as "no prefix": that is what an unset environment variable looks like after interpolation. A leading `/` and any `..` segment are refused too, and a missing trailing `/` is appended — the last of those is load-bearing, not tidiness: S3 `Prefix` is a raw string match, so `tenant_1` without the delimiter also matches `tenant_10/...`, and one namespace would enumerate its neighbour through the isolation mechanism itself. - -Two further seams move with it: - -- `StorageServicePlugin` carries the **host's** namespace onto every adapter a `storage` settings re-read rebuilds, and deliberately reads no prefix out of the settings values. A boundary an administrator inside the deployment can set or clear is a preference, not a boundary; without this, one settings save returned a hosted deployment to a shared, unprefixed key space. A host that declared no `s3` constructor options expressed no namespace, and settings-configured S3 stays bucket-root as before. -- `resolveStorageTarget` puts the namespace in the target's **`location`**, not merely its fingerprint: two prefixes in one bucket are two disjoint object sets, so moving the prefix strands what the old one held exactly as moving the bucket does, and the swap must print the migration warning. `env_7` and `env_7/` normalise to one target, so the same namespace spelled two ways is not read as a move. - -`LocalStorageAdapterOptions` is deliberately unchanged: `resolvePath()` already refuses any `..` and joins every key under `rootDir`, so the local adapter's containment boundary exists and a second mechanism would be two ways to say one thing. - -**Migrating:** every `new S3StorageAdapter({ ... })` and every `new StorageServicePlugin({ adapter: 's3', s3: { ... } })` gains one member. Single-tenant deployments write `keyPrefix: null` and their keys do not move. Deployments sharing a bucket write the namespace they want and should treat the change as a store move — existing objects are not migrated into the new namespace. - - diff --git a/.changeset/sandbox-crash-is-a-fault-at-every-door.md b/.changeset/sandbox-crash-is-a-fault-at-every-door.md deleted file mode 100644 index 8ebf60fd383..00000000000 --- a/.changeset/sandbox-crash-is-a-fault-at-every-door.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -"@objectstack/rest": minor -"@objectstack/runtime": minor ---- - -fix(rest,runtime): a sandboxed body that crashed now answers the sanitised 500 at the bulk REST door and at `/api/v1/actions`, instead of a declared 4xx or a 400 carrying the crash text (#17273) - - - -**BREAKING** — the answer two published doors give moves for existing inputs. No -export, signature or declared type changes; what changes is the response an -existing call observes, and a client branching on `error.code` or on the status -for the affected shape now falls to its 5xx path instead of its refusal path. -Shipped as `minor` under the launch-window convention (`major` is refused while -the fixed group versions in lockstep), so this banner — not the level — is the -breaking-ness signal. - -**What changes for an operator.** #15071 ruled that a crash inside a sandboxed -hook or action body is a FAULT, not the refusal a declared code names, and -converged the single-record `/api/v1/data` door on it. Two doors that door does -not decide kept the old answer, and both are closed here. Measured, driven end -to end: - -The bulk / metadata / UI routes — everything reporting through -`handleRouteError` / `sendThrownError` — for a crash that declared a 4xx: - -``` -FROM 409 {"error":"hook 'guard' threw: TypeError: ctx.input.title.trim is not a function", - "code":"DELETE_RESTRICTED","object":"account"} -TO 500 {"error":"Internal server error","code":"INTERNAL_ERROR"} -``` - -`POST /api/v1/actions/:object/:action`, for a body that really crashed inside -QuickJS (`return ctx.input.title.trim();` with a numeric `title`): - -``` -FROM 400 {"success":false,"error":{"code":"VALIDATION_ERROR", - "message":"TypeError: not a function","httpStatus":400}} -TO 500 {"success":false,"error":{"code":"INTERNAL_ERROR", - "message":"Internal server error","httpStatus":500}} -``` - -and, when that crash also declared a status of its own, `409 DELETE_RESTRICTED` -with the same `TypeError:` message becomes the same sanitised 500. - -The full ` '' threw: …` wrapper still reaches the server log on both -paths, so nothing an operator diagnoses with is lost. - -**The `/actions` answer was also contradicting its own published page.** The -error catalog states for this very route that "a `TypeError` / a -`ReferenceError` / a driver's own error class is a crash (500)", and this module's -header says `did it reject or crash? reject → 400; crash → 500`. The door said -400. The code now matches the page; the page is unchanged. - -**What does NOT change.** An ordinary sandboxed REFUSAL — a body that throws a -business error and does not crash — is untouched at both doors: same status, -same code, same sentence, same structured fields. A refusal whose text merely -mentions a native error name ("Import failed with a TypeError in row 4") is -still a refusal, because the name list is anchored. Non-sandbox producers are -untouched. The 5xx passthrough arm's unconditional prose-drop is not narrowed: -the fault terminal withholds prose too. - -**Why.** A declared code, and a declared status, are the author's statement -about a failure mode they handled; a crash is not that mode. Answering one with -a business status shipped an internal, stack-shaped sentence to an end user and -told the client the wrong thing about what happened. #15071's own residue note -said closing it meant moving a status a passthrough decided — that is what this -does, deliberately and in the shrinking direction: the wire loses the crash -text and the producer's code, and gains nothing. - -**If you were relying on the old answer,** the affected shape is a sandboxed -hook or action body that FAULTS (`TypeError`, `ReferenceError`, a driver's own -class). It now surfaces as a 5xx to clients, retry policies and alerting rather -than as a 4xx — which is the point of the change. diff --git a/.changeset/sandbox-crash-outranks-declared-code-arm.md b/.changeset/sandbox-crash-outranks-declared-code-arm.md deleted file mode 100644 index 4ecad6d3f4e..00000000000 --- a/.changeset/sandbox-crash-outranks-declared-code-arm.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -fix(rest): a hook that crashes after declaring a code now answers 500 UNCLASSIFIED_FAULT instead of the declared status with the crash text (#15071) - - - -**BREAKING** — the answer this published door gives moves for existing inputs. -No export, signature or declared type changes; what changes is the response an -existing call observes, and a client branching on `error.code` for the affected -shape now falls to its 5xx path instead of its refusal path. Shipped as `minor` -under the launch-window convention (`major` is refused while the fixed group -versions in lockstep), so this banner — not the level — is the breaking-ness -signal. - -**What changes for an operator.** A sandboxed hook or action body that declared a -refusal code and then CRASHED — `throw`-ing nothing, but hitting a bug on a later -line — used to answer the single-record `/api/v1/data` routes with the code's own -business status and the QuickJS debug sentence as the client-facing message, for -example `409 DELETE_RESTRICTED · "hook 'guard' threw: TypeError: x is not a -function"`. It now answers `500 UNCLASSIFIED_FAULT` with the sanitised message -and no crash text, which is what the same crash carrying no declared code has -always answered. The full wrapper still reaches the server log through the -existing `[REST] Unhandled error` / withheld-fault path, so nothing an operator -diagnoses with is lost. - -**What does NOT change.** An ordinary declared refusal — a hook that throws a -business error carrying a code and does not crash — is untouched: same status, -same code, same sentence, same structured fields. So is every non-sandbox -producer of those codes, and so is the `developerMessage` channel, which keeps -the rule it already had for a fault. - -**Why.** A declared code is the author's statement about the failure mode they -handled; a crash is not that mode. Answering one with a business status shipped -an internal, stack-shaped sentence to an end user and told the client the wrong -thing about what happened, while the door one branch down already sanitised the -identical crash. Maintainer ruling, 2026-09-04, decision batch #27, on #15071. - -**If you were relying on the old answer,** the affected shape is a hook that -declares one of the classification's ten code-gated refusals and then faults: it -now surfaces as a 5xx to clients and retry policies rather than as a 4xx. That is -the point of the change — the crash was never the refusal the code named. diff --git a/.changeset/schedule-trigger-acting-organization.md b/.changeset/schedule-trigger-acting-organization.md deleted file mode 100644 index 92b0230f388..00000000000 --- a/.changeset/schedule-trigger-acting-organization.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-automation": minor -"@objectstack/trigger-schedule": minor -"@objectstack/lint": minor ---- - -fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization behind a tenancy wall, and both its query and its run are confined to it (#16659, narrowed by #17396) - - - -> ⚠️ **Read this banner with #17396's ruling applied — it NARROWS everything below, and the narrowing shipped in the same launch window, so no released version ever saw the wider rule.** Two deployment facts now sit in front of every statement here, and neither is metadata: (1) package-authored scheduled work is gated by `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` and is **OFF by default in every tenancy posture and every kernel** — while it is off NOTHING below happens, because nothing arms; (2) with it on, the declaration requirement below applies under a **walled** posture (`group` / `isolated`) only. Under `single` an armed time-triggered flow declares nothing, carries no organization, and resolves the deployment's one organization beneath it exactly as it did before #16659. ⇒ Wherever this banner says "a time-triggered flow MUST declare", read "under a wall, with scheduled work switched on". The lint finding it announces, `flow-schedule-organization-missing`, is **deleted**: lint can see neither fact. - -**Registered as an ADR-0087 semantic migration** -(`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable -is renamed, retired or re-typed — no `packages/spec` key changes its name, its -type or its optionality, no stored shape moves, and every flow, node and -start-node `config` that parses today parses byte-identically afterwards, -because the start node's `config` is an OPEN record (ADR-0018) and the new -`organization` key is an addition to a slot that already accepted anything. So -`objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a -value only the deployment holds, a `sys_organization.id` minted at runtime, with -no authored artifact and no stored representation a rewrite could act on — and -inventing one is precisely what the ruling forbids. ⚠️ That is the argument -against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a -migration that cannot be expressed declaratively gets a structured TODO -(surface, reason, acceptance criteria) rather than nothing, and what follows IS -a prescription in that sense — declare `config.organization` once per -organization, no fan-out, then act on the three consequences of the split named -below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — -behaviour-only, no shape moved, a deployment judgement no transform can make, -registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before -this narrowing landed, so the enforcement rides the 17.x line by the -launch-window convention while the prescription belongs at the major boundary -where `migrate meta` users look. - -**BREAKING** in the accept-set sense, and in TWO places rather than one — -landing in the launch window as `minor` on all four packages (the lockstep -convention: during the window the bump level is not the carrier, this banner and -the disposition above are). Nothing that was refused becomes admitted. ⚠️ #17396 -changes that last sentence in one direction: under `single` with the switch on, -a flow that this changeset would have left unarmed **binds and runs**. That is a -widening, it lands in the same window, and it is why #17396's own changeset is -also a `minor`. - -1. **Bind time.** A `schedule` or `time_relative` flow that declares no - `organization` is no longer armed. -2. **Run time — the DATA PLANE.** A time-triggered run now carries a - `tenantId`, and a `time_relative` sweep now carries one on its own query. - Where a run previously read, updated and deleted across every organization, - it is now confined to the one it declares. - -⚠️ **Read (2) as a narrowing that can stop something that was working**, because -it is one. Two shapes to plan for, and neither is hypothetical: - -- **A deployment running ONE time-triggered flow to cover ALL organizations must - now declare one flow per organization.** That is the ruling - (「不允许跨组织的定时任务」) and it is the whole point, but it is migration - work: there is no fan-out, and a sweep wanted in N organizations is N - declarations. Nothing detects the shape for you — the flow simply starts - seeing one organization's rows. - - ⚠️ **And the split has three effects the sentence above does not carry.** Each - is deployment work, and none of them is detected for you either: - - 1. **A NULL-organization row fans out N-fold.** The driver's scope is - `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no - tenant column value stays visible to a *scoped* read — this PR's own - negative control fixture selects exactly that row under scope, on purpose. - After the split every `organization_id IS NULL` row in a swept object is - therefore matched **once per flow**: N runs, N notifications, each acting - as a different organization. Before the split it was matched once. ⇒ Either - backfill the tenant column on swept objects or declare the object - platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the - scope rather than multiplying under it. - 2. **The current window's dispatch claims are abandoned.** The dedup key - embeds the FLOW NAME — `schedule::` and - `time-relative:::` — so N differently-named - flows claim under N different keys. A window already delivered under the - old name can deliver again, once, under each new one. ⇒ Cut over at a - window boundary, or accept one duplicate window. - 3. **A run suspended before the upgrade is not retroactively confined.** - Resume rebuilds the run's context from `context_json` - (`suspended-run-store.ts`), and a row written before this change carries no - `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills - it. Not a regression (that is how it already ran), but the banner would - otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight - suspended time-triggered runs, or accept that the tail of them is - unconfined. -- **On a SINGLE-organization install a time-triggered flow WAS delivering** — - the #8844 guard derives the only organization there — and after this change it - is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` - that install loses nothing at run time once the line is added: the scope is - `org = :tenant OR org IS NULL` and its one organization is the only scope there - was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss - has no legal configuration.** That driver refuses *any* call handed a tenant - scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) - — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / - `bulk*` / `aggregate`, one call at a time, regardless of how many - organizations the install holds. So a time-triggered flow that touches - per-organization data on that driver is refused per call if it declares an - organization and unarmed at boot if it does not. The declaration is not what - breaks it — the driver has no row-level tenant isolation to offer either way — - but this change is what moves such a flow from the "no organization context at - all → served" case into the refused one. Multi-organization deployments use - `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are - genuinely platform-global can declare them so (`tenancy: { enabled: false }`, - ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the - refusal on data that really is per-organization. - -A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. - -Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 - -A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. - -- **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. -- **`@objectstack/lint`** ⚠️ **nothing, after #17396.** This changeset originally added `flow-schedule-organization-missing` at `warning`; that id is deleted in the same window and was never published. The reason is the rule family's own criterion — *is this stack enough to know the flow is dead?* — answered honestly: it is not, because the deployment switch and the tenancy posture decide it and neither is in any stack. The near-miss diagnostic it shared with the triggers stays at BIND, where both facts are readable. -- **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. -- **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. - -**What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. - -⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. - -**Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. - -No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. - -**What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: - -- **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. -- **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. - -**The four flows this repo itself ships** — ⚠️ this paragraph is superseded by #17396 and kept for the record of what was measured. Their answer is now the deployment switch, not an authoring repair: off, they are listed as *disabled by deployment policy*; on under `single`, they run as written; on under a wall, they still need a declaration no package can carry. The original measurement follows. - -**They stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. diff --git a/.changeset/scheduled-work-deployment-switch.md b/.changeset/scheduled-work-deployment-switch.md deleted file mode 100644 index 1d5e1f9d511..00000000000 --- a/.changeset/scheduled-work-deployment-switch.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/types": minor -"@objectstack/spec": minor -"@objectstack/trigger-schedule": minor -"@objectstack/service-automation": minor -"@objectstack/runtime": minor -"@objectstack/lint": minor -"@objectstack/cli": minor ---- - -feat(types,triggers,service-automation,runtime,cli,spec,lint)!: package-authored scheduled work is a deployment decision — `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, off by default everywhere (#17396) - - - -Maintainer ruling, 2026-09-12, verbatim, untranslated: - -> schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,有没有可能定义为一个环境变量,根据环境变量控制? - -> 如果多租户暂时只接禁用定时任务,完整的考虑一下影响面。 - -> group 默认也关,云端每库一租户全局默认关 - -**A new deployment variable, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, decides whether this deployment runs PACKAGE-AUTHORED scheduled work at all** — time-triggered flows (`type: 'schedule'` with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. It is read at boot beside `resolveTenancyPosture` and is ⛔ **not** a metadata concept and ⛔ **not** a new spec key: whether a clock-driven workload is affordable is a fact about the deployment — its database, its tenants, its budget — that no package author can know, and a metadata key would ask them to. - -**OFF by default, in every posture and in every kernel.** Unset means off; `true` / `1` / `on` / `yes` (case-insensitive) means on. ⛔ Deliberately not the opt-out `!== 'false'` shape `OS_MULTI_ORG_ENABLED` uses, which reads a typo as "on" — here that would arm exactly the workload an operator meant to refuse. - -⛔ **Platform-internal scheduled work is NOT gated** and runs either way: approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill. The boundary is **authored by a package**, not "runs on the job service" — the platform's own maintenance is part of the runtime a deployment asked for. - -**BREAKING**, in two directions, and both land inside the same launch window as #16659 / PR #17334, so no published version ever saw the rule this narrows. - -1. **A NARROWING, and it is the one to plan for.** A deployment that upgrades and does nothing runs **no** packaged time-triggered flow and **no** packaged `defineJob`. Anything that was firing from a package stops. ⇒ Set `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` if you depend on it. Nothing detects the shape for you at authoring time, by design — but nothing is silent either: every such flow is listed in `getTriggerBindingAudit()` and the `os dev` / `os start` startup summary with a DISTINCT reason, **disabled by deployment policy**, ⛔ never as "binding failed"; the packaged-job loop says so once per app at `info` with the count; and `os doctor` prints the effective value in both states. -2. **A WIDENING of what binds.** With the switch on and tenancy posture `single`, a time-triggered flow that declares **no** `config.organization` now binds and runs — under #16659 alone it was refused. That posture holds exactly one organization (a second is refused), so the run carries **no** organization and every tenant-scoped insert beneath it resolves that one the way a single-organization install always did; a `timeRelative` sweep there runs **unscoped**. ⛔ Nothing is invented: the key is OMITTED, never filled from the install, the platform organization, or the swept record's own `organization_id`. - -**Under a walled posture (`group` / `isolated`) the 2026-09-08 ruling on #16659 stands unchanged**: a time-triggered flow declares `config.organization` or it is not armed, there is no fan-out, and no organization is ever chosen for it. `group` is walled here for a measured reason rather than by analogy — `resolveSystemWriteOrganization` refuses an organization-less system insert under any wall and `TenancyService.defaultOrgId()` answers `null` (ADR-0093 D3), so an organization-less group-wide sweep could read the whole group while every row it inserts is refused. Which organization such a sweep's inserts belong to is not yet decided; until it is, `group` behaves as walled. - -**`flow-schedule-organization-missing` is DELETED** from `@objectstack/lint` (the id and its exported constant, `FLOW_SCHEDULE_ORGANIZATION_MISSING`; both are unreleased — they were introduced by the still-unconsumed #16659 changeset in this same window, so no consumer can be holding either). The rule family's criterion is *is this stack enough to know the flow is dead?*, and the honest answer here is no: the deployment switch and the tenancy posture decide it, and neither is in any stack. A finding that is false for the default deployment is noise. ⛔ The near-miss diagnostic did **not** go with it — `describeMissingScheduleOrganization` and its `organizationId` / `tenantId` / … scan still fire at BIND, the one door that can read both facts, and only where the key is actually required. - -**ADR-0087 semantic entry 18 (`schedule-flow-acting-organization-required`) is REWRITTEN, not added.** Its acceptance criteria required every time-triggered flow to declare; that is no longer the rule. It now prescribes the two decisions in order — decide the switch, then declare per organization under a wall — and records that `os lint` reporting nothing is the criterion being met rather than a check that was skipped. The unconsumed `.changeset/schedule-trigger-acting-organization.md` carries a banner saying the same, so a reader of either one cannot get the narrower half alone. - -**Where the switch is read, and where it is not.** Both triggers gate at `start()`, ahead of the descriptor and the declaration, so an operator on a deployment that was never going to run a flow is not sent to fix a descriptor nothing would have read. `AutomationEngine.activateFlowTrigger` reads the same resolver and does not call `start()` at all when it is off — that is what keeps the audit's reason precise, since a refusal arriving as a THROW can only be reported through the catch that says "Failed to bind". Neither read is cached: the resolver reads `process.env` live, so a host that rebinds after the environment changes sees the value current at the bind. The scope is `schedule` and `time_relative` only — `record_change` and `api` are fired by a caller that already exists and already carries an identity, and a kind added to `FlowTriggerKind` later is OUTSIDE the switch until someone decides otherwise, because a new capability that disappears on arrival is the worse default. diff --git a/.changeset/scope-resubmit-discriminator-invariant.md b/.changeset/scope-resubmit-discriminator-invariant.md deleted file mode 100644 index 14866f7ff6b..00000000000 --- a/.changeset/scope-resubmit-discriminator-invariant.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -Correct the `resolveRecordedContinuation` discriminator's stated invariant in -`approval-service.ts` to what was measured. The comment claimed the -`action: 'resubmit'` audit row was "at most one per request"; a `resubmit` whose -own resume strands opens no next round, so the row stays `returned` and a second -`resubmit` after `restoreConsumedSuspension` lands a second such row. The -comment now records that more than one row can exist, states why the read is -correct anyway (it is a presence check with `limit: 1`, deciding identically on -one row or two), and points at the pin that measured it. - -Prose only — no behaviour change, no door narrowed, no guard touched. The audit -trail's one-row-per-advancement shape is accepted residue; requiring one row per -advancement is a separate change. diff --git a/.changeset/scoped-packages-dispatcher-door.md b/.changeset/scoped-packages-dispatcher-door.md deleted file mode 100644 index 9091ec92833..00000000000 --- a/.changeset/scoped-packages-dispatcher-door.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -fix(runtime): mount the scoped `/api/v1/environments/:id/packages*` door, and reconcile the package read/delete responses to their declared schemas (#16781) - -**The door.** `mountPackagesRoute` mounted `/packages*` at the unscoped prefix only, while automation / actions / ai each registered a scoped variant twenty lines away. On a host composed as `@objectstack/plugin-hono-server` + this plugin with `enableProjectScoping: true` and **without** `@objectstack/hono`'s `createHonoApp`, that left `GET /api/v1/environments/:id/packages`, `GET …/packages/:id` and `DELETE …/packages/:id` answered by the transport's own `notFound` — a bare 404 on routes `content/docs/api/environment-routing.mdx` documents. The domain has resolved scoped package paths since #15859; nothing mounted one. - -`mountPackagesRoute` is now wrapped in a `base`-taking `registerPackageRoutes(base)`, exactly like its three siblings, and called a second time with the scoped base. **The same handler, no second implementation.** The unscoped mounts keep their registration position and their unconditional mounting, so the change is purely additive: no route that answered before stops answering. - -**The wire.** Two responses gained the key their own declared schema requires (contract review of #16628, finding F2). Both additions are **additive** — no key left either payload: - -- `GET /packages` now sends **`hasMore`** (`ListInstalledPackagesResponseSchema`). It is `false`: this door applies its `status` / `type` / `enabled` filters and returns every remaining row, reading no `limit` and no `cursor`, so there is no next page to announce. -- `DELETE /packages/:id` now sends **`packageId`** (`UninstallPackageApiResponseSchema`). `registryRemoved` and `persisted` stay on the wire unchanged. - -A client that reads only the keys it read before is unaffected; a client parsing either payload against the published schema stops being refused. - -The `DELETE /packages/:id` route-ledger row now carries `responseSchema: 'UninstallPackageApiResponseSchema'`, backed by new conformance coverage that drives the real handler. `GET /packages` is deliberately left blank: its rows are the ASSEMBLED package body, while `InstalledPackageSchema` wraps the AUTHORING-stage `ManifestSchema` — the #14242 stage mismatch, which no `@objectstack/spec/api` export declares yet. Both directions of that boundary are pinned, so the row becomes fillable against a red test rather than a guess. diff --git a/.changeset/scoped-sdk-honours-metadata-prefix.md b/.changeset/scoped-sdk-honours-metadata-prefix.md deleted file mode 100644 index 590236786a3..00000000000 --- a/.changeset/scoped-sdk-honours-metadata-prefix.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/client': patch ---- - -fix(client): the scoped SDK reads `metadata.prefix` off the advertised routes instead of restating `/meta` - -`metadata.prefix` is a live `RestServerConfig` key: REST mounts every metadata -route under `metaPath = ${basePath}${metadata.prefix}` and the discovery handler -advertises the same value as `routes.metadata = ${realBase}${metadata.prefix}`. -Three surfaces describe one set of paths — the mounts, the discovery document, -and this SDK. - -`ScopedEnvironmentClient` restated `/meta` as a literal in all six of its -metadata methods — `getTypes`, `getItems`, `getItem`, `saveItem`, `deleteItem`, -`getHistory` — so on a deployment that moved the prefix, every one of them -called a path the server does not mount. The unscoped twin of each method was -already correct (it builds `${baseUrl}${getRoute('metadata')}`), so one SDK -disagreed with itself: the unscoped half read the advertised value while the -scoped half guessed. Measured on a live server booted at -`metadata: { prefix: '/metadata' }`, all six went to -`/api/v1/environments//meta`, which that deployment answers 404. - -The six now build through `metaUrl()`, which takes its base from `_apiBase()` -and its prefix from the new `_metaPrefix()` — the exact sibling of the -`_dataPrefix()` derivation that fixed `crud.dataPrefix`, fallback discipline -included. `_metaPrefix()` prefers the advertised `routes.metadata`, recovers the -prefix from `routes.data` as a second equation over the same `realBase` when the -advertised value is not the conventional one, and **declines to `/meta`** -whenever the document does not determine the answer: an SDK must not become -unusable because a server's discovery document is missing a key. - -Deployments on the default prefix are unaffected, by construction and by -measurement: the conventional-suffix rule is taken first, so a default -deployment is answered from `routes.metadata` alone, and a client that never -connected never reaches a rule at all. The pinned negative control asserts the -six request URLs of a default deployment byte for byte, for a connected client -and for an unconnected one, and that the unconnected client puts no discovery -request on the wire. - -The unscoped metadata methods are untouched. diff --git a/.changeset/sdui-manifest-one-producer.md b/.changeset/sdui-manifest-one-producer.md deleted file mode 100644 index f6b55f41eb1..00000000000 --- a/.changeset/sdui-manifest-one-producer.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/console': patch ---- - -The prebuilt Console dist now ships `dist/sdui.manifest.json`: the ADR-0080 public-tier -component manifest of the objectui registry at the pinned commit. - -It is the same file the framework repository tracks at its root and gates on every pull -request. `scripts/build-console.sh` copies it in, and one producer writes it: -`scripts/gen-sdui-manifest-node.mjs`, which reads objectui's built tree at the pin. No -earlier published `@objectstack/console` carried this file. The RC cut used to write a -browser-dumped copy into `dist/`, but the release build replaced `dist/` before packing, so -none reached a tarball (17.0.0, 17.3.0 and 17.4.0 each list 0 matches). That browser dump is -retired. It was byte-identical to the tracked file over the same built tree. - -For now the file is only present in the tarball. This package's `exports` map exposes -`./package.json` and nothing else, so resolving `@objectstack/console/dist/sdui.manifest.json` -through `exports` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Anything that resolves through -`exports` cannot read the file yet. That includes the CLI's JSX-page manifest fallback, which -catches the error and keeps parse-level validation, as before. diff --git a/.changeset/sdui-parser-stageorder-funnel-only.md b/.changeset/sdui-parser-stageorder-funnel-only.md deleted file mode 100644 index 57810963f21..00000000000 --- a/.changeset/sdui-parser-stageorder-funnel-only.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/sdui-parser': patch ---- - -`dashboard-widget-options.ts` header: `stageOrder` is a `funnel`-only key, not `funnel` / `pyramid` - -The accepted-set census comment at the top of the module (carried into the -published `index.d.ts`) described `stageOrder` as "funnel/pyramid stage order". -There is no `pyramid` widget type: `ChartTypeSchema` refuses it, so an author -who copied the pair got a parse refusal. The line now says what the schema's -own `.describe()` says: `funnel` is the only widget type that reads the key. -Comment-only — the accepted set, the diagnostic code and the emitted JS are -unchanged. diff --git a/.changeset/security-fls-unknown-field.md b/.changeset/security-fls-unknown-field.md deleted file mode 100644 index 0c3782ac142..00000000000 --- a/.changeset/security-fls-unknown-field.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -New gating rule `security-fls-unknown-field`: an object-qualified field-permission key naming a field the object does not declare is now an authoring-time `error`. - -`security-fls-unqualified-key` has always caught the *bare* spelling — `fields: { budget: … }` — because the runtime evaluator matches FLS keys by their `.` prefix and a bare key matches nothing. The qualified-but-dangling spelling (`fields: { 'crm_account.description_nope': { readable: false } }`) has the identical runtime consequence and was reported by nothing: `PermissionEvaluator.getFieldPermissions` strips the prefix and looks the remainder up as a column, so a remainder no column answers to contributes nothing to the merged permission map. The masking the author declared **never enforces**, and the field stays as readable and as editable as the object-level grant leaves it — for every holder of the set. - -The failure direction is **fail open**, and this spelling is the one that accumulates: unlike a bare key it looks correct in review, survives rename refactors invisibly, and is exactly what a field rename leaves behind. - -- **A second rule, not a widening of the first.** `security-fls-unqualified-key` is correct inside its declared scope and is untouched; the two defects have different prescriptions (add the object prefix / fix the field name) and suppressing one must not suppress the other. Two ids, two messages. -- **Where the existence answer comes from.** The rule resolves through `object-graph.ts`, the shared index every field-existence rule in this package already uses — no new input path. It therefore inherits that module's three skips, each of which is the difference between a finding and a false one: an object this stack does not define (it may be another installed package's), an object with no readable field map (an ADR-0015 `external` object, an introspected datasource), and registry-injected system columns such as `created_at` or `owner_id`, which are real at runtime and appear in no authored `fields`. -- **A truncated key is the same defect and is reported by the same rule.** `fields: { 'crm_account.': … }` passes the runtime's prefix test and resolves to the empty column name, so it matches nothing exactly as a dangling name does. `PermissionSetSchema.fields` is `z.record(z.string(), FieldPermissionSchema)` — a bare string key with no pattern and no refinement — and this rule is the only reader of those keys, so before this change nothing reported it at all. A key naming an object this stack does not declare still falls to skip 1, truncated or not. -- **It mirrors the evaluator, including on a multi-dot key.** Only the first dot separates object from field, because `ObjectSchema.name` is `/^[a-z_][a-z0-9_]*$/` and cannot contain one. `'crm_account.owner.name'` therefore asks for a column literally named `owner.name` and is reported: FLS keys address columns, never joins, and resolving that as a relationship hop would have been a fail-open divergence from the gate the rule mirrors. - -**What moves for consumers.** A stack carrying a dangling FLS key built clean before and now fails `os validate` / `os compile`, and is refused at the runtime publish door for `permission` and `object` writes (this rule joins the existing `validateSecurityPosture` registration; no new registry entry). That is the point — the key was never enforcing anything. A stack whose FLS keys all resolve is byte-identically clean: measured on the shipped showcase, whose six authored keys emit zero findings, with a firing control (one injected dangling key produces exactly one finding) beside the zero. diff --git a/.changeset/seed-locale-producer-wiring.md b/.changeset/seed-locale-producer-wiring.md deleted file mode 100644 index a5e6555ec40..00000000000 --- a/.changeset/seed-locale-producer-wiring.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/spec": patch ---- - -`AppPlugin` now supplies `SeedLoaderConfig.locale`, so the `Seed.locale` axis takes effect on the default boot path. - -The locale filter axis landed complete on the consumer side: the loader reads `Seed.locale`, composes it with `env` by conjunction, and names every dataset it drops. What it never had was a **producer** — no first-party call site passed `config.locale`, so `filterByLocale` returned its input on its first line and `dataset.locale` was never read at all. Authoring the key changed nothing. That is the same shape `Seed.env` spent releases in before framework#4704. - -- **The locale is resolved from the app's own `i18n.defaultLocale`** — the same envelope key, read the same way `loadTranslations` already reads it for `setDefaultLocale` — and threaded into all three `SeedLoaderRequest`s `AppPlugin` builds: the inline boot seed, the per-org replayer registered for tenant provisioning, and the dev hot-reload seeder. -- **An app that declares no locale sends no `locale` key at all**, rather than an `'en'` default. Absence is the loader's unrestricted spelling, so a stack that never opted in keeps loading every dataset exactly as before; defaulting would have turned a wiring change into a data change, silently dropping a `locale: ['zh-CN']` dataset on every stack without an `i18n` block. A blank or non-string `defaultLocale` is treated as absence for the same reason. -- **Resolved at the call sites, not inside `load()`.** The sibling `env` axis resolves itself in the loader off an ambient `NODE_ENV`; a locale has no ambient source, and the only layer that knows which locale a stack runs in is the app config the loader is never handed. So this axis needs a real producer, which is what this change is. - -`SeedLoaderService#warnOnUnresolvedLocaleScope` **stays**. It is not a signpost for an unwired state that has now gone away: three of this repo's six seed-request builders are publish/install-time paths that are handed no stack config and still pass no locale, embedding hosts build their own requests, and a stack may declare no `i18n` block at all. Every one of those still reaches `load()` with locale-scoped datasets and no `config.locale`, and the warning is what keeps that loud instead of silently inert. - -The liveness ledger row `seed.locale` moves `experimental` → `live` with a `producer` pointer naming this wiring, and records which call sites supply the locale and which do not rather than claiming the frontier away. - -⚠️ **Release-note reconciliation, for whoever compiles this release.** The sibling changeset `seed-locale-axis.md` (from the PR that landed the consumer half) states in the present tense that no first-party call site supplies `config.locale`, that the axis is inert on the default boot path, and that the liveness ledger records `seed.locale` as `experimental`. All three sentences describe the state that changeset shipped into, and **this change ends all three**. If both land in one release, the notes must read them in order — or fold them into one entry — rather than publishing the earlier state as current. ⛔ That sibling changeset is deliberately not edited here: it accurately records what its own PR did, and release notes are compiled centrally. - -⛔ Out of scope, unchanged: rows already written under a different locale stay resident. Every seed is an `upsert` and the loader only writes, so switching a stack's locale on a non-empty database does not remove the other market's rows. diff --git a/.changeset/seed-locale-publish-install-scope-contract.md b/.changeset/seed-locale-publish-install-scope-contract.md deleted file mode 100644 index 3938e7ea065..00000000000 --- a/.changeset/seed-locale-publish-install-scope-contract.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`Seed.locale` now states its own bound: the publish and install paths do not filter by locale — they load every dataset and warn. Wording only; ⛔ no behaviour changed. - -The loader evaluates this axis against `SeedLoaderConfig.locale`, which only the boot path supplies (`AppPlugin` reads the app's declared `i18n.defaultLocale`, #16595). Three publish/install-time request builders — package apply, draft publish and marketplace install — are handed no stack config and pass no locale, so a `locale`-scoped dataset reaching one of them is loaded for **every** locale and `warnOnUnresolvedLocaleScope` names each one it let through. That was already the published contract: `content/docs/data-modeling/seed-data.mdx` declared it verbatim for the embedding-host case. What it was not was discoverable from the key itself — an author reading `SeedSchema.locale` had no way to learn where the axis stops, and the ledger row's note still ended with a to-do. - -- **The `.describe()` and TSDoc carry the bound.** Both ship to consumers — `src/**/*.zod.ts` and `dist` are in this package's `files[]`, measured with `npm pack --dry-run` — so the sentence reaches an author's editor rather than only a docs page they may never open. -- **The liveness ledger row records a DECISION, not a to-do.** `packages/spec/liveness/seed.json`'s `locale` note ended 「Filed as its own card」. That card was ruled 2026-09-10 (option A 「不扩散」 — publish and install are locale-neutral acts, 「无违约、非缺陷」, because the docs page had already declared this bound and the runtime warns by name). The note now cites the ruling and states what would reopen it. -- **⛔ Deliberately not done.** Making the three call sites pass a locale, and turning the warning into a refusal, were both explicitly not ruled. The first would give one concept two sources of truth — the app's declared locale versus the platform default — and would silently stop loading a dataset that loads today; the second would turn a succeeding published path into a failing one. Neither buys safety while measured usage is zero. - -⚠️ **The reading this rests on, and its reach.** Real first-party seed data using `locale`, measured repo-wide 2026-09-22 against `1f53b0b685`: **zero**. A structural scan of all 49 `defineSeed` call sites found 24 in real app data under `examples/` and none declaring the key; the scan is self-lit, because the same pass does find the two `locale`-declaring call sites on the docs page. That reach is **this repo only** — cloud, hotcrm and external customer apps stay unmeasured. A measured use of `locale`-scoped seed data in any of them reopens the decision. diff --git a/.changeset/serve-org-remedy-defers.md b/.changeset/serve-org-remedy-defers.md deleted file mode 100644 index c9ddb300abd..00000000000 --- a/.changeset/serve-org-remedy-defers.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`serve`: the multi-org runtime's stage-1 refusal no longer prints its own install remedy for a `declared-unresolvable` failure — it defers to the importer's message, which the same refusal already prints as its `cause:` line. - -Driven on both shapes that kind covers, the minted bullet ("Repair the INSTALL … run `pnpm install`, check that a production prune did not drop it, and that its dist is actually built") was wrong twice over. For a genuinely broken install it repeated, word for word, the three remedies the cause line four lines below already carried. For a location install the finder cannot tie to the declaration, the cause says outright that re-running `pnpm install`, un-pruning a deploy and rebuilding a dist all change nothing — so one screen contradicted itself. - -The arm now says only what it uniquely knows (the app DOES declare the package, so re-reading `package.json` will not help) and names the cause as the authority on the remedy — the same deferral the `declared-no-loadable-entry` arm has had since it landed. diff --git a/.changeset/single-posture-organization-census.md b/.changeset/single-posture-organization-census.md deleted file mode 100644 index 2e949a573cc..00000000000 --- a/.changeset/single-posture-organization-census.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-auth": minor ---- - -fix(plugin-auth): a `single`-posture deployment holding more than one organization is reported at `error` instead of booting silently (#17010) - -ADR-0131 §1.2(3) states that its precondition — many organizations with the organization wall inert — 「is today a refused boot」. It is not. A deployment that never REQUESTS a walled posture and simply HOLDS more than one `sys_organization` row under `single` boots, serves, and says nothing: `resolveDefaultOrgId` answers the bootstrap org, else the sole org when exactly one exists, else `null` — silently. The harm then surfaces far away and looks like an unrelated data outage: users reconciled from then on are bound to no organization, a platform admin reads zero rows of every organization-stamped object while analytics still counts them, and system-context writes are refused `ambiguous-organization` by the per-write guard. - -The tenancy service now takes a `count(sys_organization)` census on that same seam and reports at `error` when a non-walled deployment holds more than one, naming the posture it DECLARED, the count it HOLDS, and the two ways out: declare a walled posture (`OS_TENANCY_POSTURE=group` / `isolated`, plus the `@objectstack/organizations` package that activates it), or hold one organization and model the sub-units as business units. - -**The boot is not refused.** This change only reports; whether the boot should instead be refused stays open for the maintainer, and nothing here has to be undone if that is the answer. The per-write `ambiguous-organization` refusal is untouched. - -Cost is one `count()` per process: the census sits downstream of the walled-posture early return (a `group`/`isolated` deployment pays nothing and says nothing) and downstream of the memoized resolution, and an engine that cannot answer stays silent rather than guessing. A healthy install — exactly one organization, or none bootstrapped yet — is silent by construction. diff --git a/.changeset/solution-blueprint-module-header.md b/.changeset/solution-blueprint-module-header.md deleted file mode 100644 index a91da82efd3..00000000000 --- a/.changeset/solution-blueprint-module-header.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`ai/solution-blueprint.zod.ts` publishes its own sentence again, instead of a list of the symbols it happens to export. - -The file always carried a real module header — ADR-0033 §4 plan-first authoring, and how the `apply_blueprint` tool expands each entry into a proper metadata body. But only a blank line separated that header from `const SNAKE_CASE`, and TSDoc's own attachment rule says a block belongs to the declaration it immediately precedes. The header-zone selector reads that rule back, so the header counted as the regex constant's documentation and was disqualified as the module's. Both generators then fell through to their export-list fallback, and the row published into the `objectstack-ai` skill index read: - -``` -- `…/ai/solution-blueprint.zod.ts` — Exports: BlueprintConditionSchema, BlueprintSummaryOperationsSchema, … -``` - -A true statement about the file that says nothing about its subject — on the one row whose job is to send an agent to this source for exact field shapes. - -`SNAKE_CASE` now carries the one-line doc it always deserved. A comment is not a declaration, so the preamble ends there and the header becomes the module's own block. The published row and the public reference page both open on it: - -``` -- `…/ai/solution-blueprint.zod.ts` — Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring) -``` - -The selector is untouched. Under its own rule it was deciding correctly, and a census of every source under `packages/spec/src` found this file to be the only one of its kind: 19 shipped `*.zod.ts` sources have a header-zone block sitting against a declaration, and in the other 18 that block genuinely documents the symbol it sits against (`Transport Protocol Enum` against `TransportProtocol`, `Shared history for this file` against `AGENT_HISTORY`). Only here did a module header sit against a constant it says nothing about. - -Neither generator can see this class — each compares its artifact against itself, and each reproduced the selector faithfully, so a generator-only check passes on the defect. A pin now asserts the content of the published row directly. diff --git a/.changeset/sour-moons-smile.md b/.changeset/sour-moons-smile.md deleted file mode 100644 index abfed44a45f..00000000000 --- a/.changeset/sour-moons-smile.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os generate schema` can now reach its own `fs.writeFileSync`. - -`runSchemaGeneration` called `z.toJSONSchema(ObjectStackDefinitionSchema, { target: 'draft-2020-12' })` -bare — the one `toJSONSchema` call site in this repository that neither fell back nor used the -`unrepresentable` convention. That call has no JSON form in either io direction on today's tree (a -transform in the output direction, a function type in the authoring direction), so the `catch` below -it printed and exited 1 for every repository and every flag combination: the command could never -write the IDE schema it exists to write. - -It now runs the same three-tier ladder `packages/spec/scripts/build-schemas.ts` already runs for -every schema it publishes — output, then the authoring (`io: 'input'`) direction, then that direction -with `unrepresentable: 'any'` as `packages/metadata-protocol` spells it — and each tier re-raises any -error the known-unsupported predicate does not recognise, so a real conversion failure is still loud. - -No new flag, no new key and no new exported symbol: the change is confined to the body of a -module-private function. - -The published document lands on the third tier today. It is the authoring derivation, so a property -carrying a `default` is not reported as required; the nodes that have no JSON form in any direction — -`onEnable`, and the inline-callable branch of each `handler` under `hooks`, `functions` and -`packages` — are published as unconstrained, which means an IDE validates everything else in -`objectstack.config.ts` and asks nothing about those. diff --git a/.changeset/spec-api-provenance-anchors.md b/.changeset/spec-api-provenance-anchors.md deleted file mode 100644 index f1366745395..00000000000 --- a/.changeset/spec-api-provenance-anchors.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Provenance comments in `api/` were re-anchored - -Comment and docblock lines under `src/api` (all but `rest-server.zod.ts`) that -cited tracker numbers which no longer resolve on GitHub now cite the commit in -this repository's history that decided the matter, and say in their own words -what was decided. Comments only: no type, schema, export or runtime behaviour -changes. diff --git a/.changeset/spec-app-nav-guard-docblock-anchor.md b/.changeset/spec-app-nav-guard-docblock-anchor.md deleted file mode 100644 index adda4a73a6d..00000000000 --- a/.changeset/spec-app-nav-guard-docblock-anchor.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`app.zod.ts` docblocks: the navigation target-exclusivity guard now cites the commit that decided it, and says what that commit decided - -Two docblock sentences in `src/ui/app.zod.ts` (which ships as source through the -package's `src/**/*.zod.ts` entry) cited a tracker number that no longer resolves -on GitHub. They now carry the lesson in words and anchor to commit `4cfc93b802` -in this repository's history: the `filters` docblock deliberately states no -precedence order, because objectui's hand-written mirror copied one from this -docblock and ended up accepting a combination the schema refuses; and -`objectNavTargetExclusivity` is exported so a mirror chains the schema's own rule. - -Docblock text only. No schema, guard, accept set, export or `.describe()` string -changes. diff --git a/.changeset/spec-approval-continue-restored-contract.md b/.changeset/spec-approval-continue-restored-contract.md deleted file mode 100644 index fc5e4198d1e..00000000000 --- a/.changeset/spec-approval-continue-restored-contract.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Declare `continueRestoredRun` on the `IApprovalService` contract, so the approvals half of the operator repair pair is reachable through the published interface rather than only off the implementation class. - -`IAutomationService.restoreConsumedSuspension` re-arms the pause a failed resume consumed and, by its own contract, does not replay the resume signal — the continuation must be re-issued. For an approval suspension nothing could re-issue it: every front door guards on a live request — `pending` for decide and send-back, `returned` for resubmit, and `pending` or the revise window for recall — and the stranding call leaves the row where none of them can issue the continuation it owes. The issuer landed as a class member on `plugin-approvals`; this declares it, so a caller programs against the contract instead of importing the implementation. - -Additive and OPTIONAL, the way `cancelRun` / `restoreConsumedSuspension` are declared on `IAutomationService`: an existing implementation still conforms, and a service that does not declare the member has no operator door for it — a caller must probe for presence and refuse fail-closed rather than answer success for a verb it could not dispatch, because promising a repair verb that will refuse is worse than promising nothing. No REST or CLI route is declared or implied. diff --git a/.changeset/spec-cloud-provided-environment-credential.md b/.changeset/spec-cloud-provided-environment-credential.md deleted file mode 100644 index b15aea36c29..00000000000 --- a/.changeset/spec-cloud-provided-environment-credential.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`CLOUD_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system`) gains a member: -`sys_environment_credential`. `isPlatformProvidedObjectName('sys_environment_credential')` -now returns `true`, so a reference to that name resolves instead of being -diagnosed as a platform-prefixed name nothing registers (#18309). - -This widens an accept set. The list is a closed set and the name was not in it, -so the object-reference ladder now accepts a value it used to warn on, and the -widening reaches every surface that consults the predicate: a dataset `object`, -an action parameter `reference`, a field `reference`, a dashboard -`optionsFrom.object`, a navigation `requiresObject` and a translation -`objects.` subtree naming `sys_environment_credential` all stop being -diagnosed. - -Why this name: as read in the cloud repository at `cb8ee7ff60`, -`@objectstack/service-tenant` registers it on exactly the path the list's -existing `sys_package`, `sys_package_version` and `sys_package_installation` -members take — `objects/sys-environment-credential.object.ts` exported through -`objects/index.ts`, listed in `tenantObjects`, spread into -`manifestService.register({ objects })` by `tenant-plugin.ts`. That reading is -the cloud repository's and is carried here on its filer's name; per this list's -header it cannot be conformance-tested from this repo, and this change does not -claim to have re-taken it. - -Unlike the earlier additions, this one fixes no diagnostic that fires today: no -`*.object.ts` in this repository references the name, so nothing shipped was -being mis-diagnosed. What was wrong is the registry's own claim about the name. -This repository's governed records already treat the object as real — ADR-0007's -inventory table lists it as existing, and ADR-0131 cites a measured cross-tenant -read of its rows — while the list that decides whether a reference resolves said -no package registers it. The first author to write the reference would have been -told it looked like a typo. - -One entry is added; no other member moves and nothing is removed or narrowed. -The cloud-side half of the contract — that `@objectstack/service-tenant` -registers the table — is owned by the cloud repository per the list's header and -is not asserted from here. diff --git a/.changeset/spec-cloud-provided-package-version.md b/.changeset/spec-cloud-provided-package-version.md deleted file mode 100644 index 1ee95fcc3fa..00000000000 --- a/.changeset/spec-cloud-provided-package-version.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`CLOUD_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system`) gains a member: -`sys_package_version`. `isPlatformProvidedObjectName('sys_package_version')` now -returns `true`, so a reference to that name resolves instead of being flagged as -a platform-prefixed name nothing registers (#16745). - -This widens an accept set. The name was previously refused, the list is a closed -set, and nothing in the published header enumerated this member — so the ladder -now accepts a value it used to warn on, and the widening reaches every surface -that consults the predicate: a dataset `object`, an action parameter -`reference`, a dashboard `optionsFrom.object` and a navigation `requiresObject` -naming `sys_package_version` all stop being diagnosed. - -Why this name and not another: the list already carried `sys_package` and -`sys_package_installation` — the head and tail of the three-table package family -that `cloud/package.zod.ts` declares — but not the release-snapshot table -between them, whose row schema this repository ships as -`cloud/package-version.zod.ts`. Platform metadata that ships with the product -references it: `sys_metadata.package_version_id` in `@objectstack/metadata-core` -is a `Field.lookup('sys_package_version', …)`. - -One entry is added; no other member moves and nothing is removed or narrowed. -The cloud-side half of the contract — that `@objectstack/service-tenant` -registers the table — is owned by the cloud repository per the list's header and -is not asserted from here. diff --git a/.changeset/spec-cloud-subpath-retired.md b/.changeset/spec-cloud-subpath-retired.md deleted file mode 100644 index ecbef526de2..00000000000 --- a/.changeset/spec-cloud-subpath-retired.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/cli": patch -"@objectstack/metadata": patch ---- - -feat(spec)!: the `@objectstack/spec/cloud` subpath is removed — the cloud control plane's contracts leave the open-source spec, and the package & marketplace format moves to `@objectstack/spec/marketplace` (#16325) - - - -**BREAKING** — a published subpath export of `@objectstack/spec` is deleted, with no -alias and no deprecation window (maintainer, 2026-08-27, verbatim: 「项目在创业阶段, -用户也很少,短期不考虑渐进。」). Shipped as `minor` under the repo's launch-window -convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness -is carried by this banner plus the ADR-0087 disposition; the hand-migration prescription -is registered under protocol major 18 as `cloud-subpath-retired`. - -## What moved, and why - -Maintainer direction (2026-09-06, verbatim): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」, -ruled option B "cut by owner" on #16325 (director batch #62, 2026-09-07, 「同意」). -`packages/spec/src/cloud/` held two families with different owners: - -- **The cloud control plane's own contracts** — `environment.zod`, `environment-package.zod`, - `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` (62 JSON-Schema - defs, 2087 lines). Their producer and every consumer live in the closed cloud repo; the - open-source tree read exactly one type from them. They are gone from `@objectstack/spec`: - `environment` and `tenant` are re-declared in the cloud repo (objectstack-ai/cloud#2037), and - the other four are deleted outright — zero consumers in any repo (#16526, ruled A). All of it - is recoverable from git history at `d5d8d50db`. -- **The package & marketplace format** — `package.zod`, `package-version.zod`, `marketplace.zod`, - `package-l10n`, `template-manifest.zod` (30 defs, 1400 lines). A package author needs it and the - open-source CLI's `os package publish` speaks it, so it STAYS, relocated to `src/marketplace/` - and published as `@objectstack/spec/marketplace`. Every def, key and JSON Schema is - byte-identical under the new `$id` category (`RENAMED_DEFS`, 32 entries; nothing left the - author-facing contract). - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `import { PackageSchema, CreatePackageRequestSchema, … } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/marketplace'` — same symbols, same shapes | -| `import { EnvironmentArtifactSchema } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/system'` (it was only ever a re-export of that declaration) | -| `import type { EnvironmentType } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/api'` (re-declared beside the discovery fold table that reads it) | -| `import { EnvironmentSchema, TenantPlanSchema, ProvisionEnvironmentRequestSchema, … } from '@objectstack/spec/cloud'` | no open-source replacement — these are the cloud repo's own declarations now | -| `/docs/references/cloud/` | `/docs/references/marketplace/` for the format pages (redirected); the control-plane pages have no successor | - -Why the mis-binding hazard closes with this: `client.environments.*` keeps its erased `any` -deliberately (#11925/#12036), and the camelCase `Environment` row used to be the obvious-looking -binding for it — it compiled and read `undefined` at runtime against the snake_case wire. That -type no longer exists in the open-source package, so the wrong binding is structurally -impossible rather than warned about in a docblock. - -`@objectstack/cli` and `@objectstack/metadata` change only an import path (`marketplace` and -`system` respectively); no behaviour moves. diff --git a/.changeset/spec-data-provenance-anchors.md b/.changeset/spec-data-provenance-anchors.md deleted file mode 100644 index 56b4e045926..00000000000 --- a/.changeset/spec-data-provenance-anchors.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Provenance comments in `data/` were re-anchored - -Comment and docblock lines under `src/data` (all but the files other open work -holds) that cited tracker numbers which no longer resolve on GitHub now cite -the commit in this repository's history that decided the matter, and say in -their own words what was decided. Comments only: no type, schema, export or -runtime behaviour changes. diff --git a/.changeset/spec-data-rest-provenance-anchors.md b/.changeset/spec-data-rest-provenance-anchors.md deleted file mode 100644 index 3104d6f1f7d..00000000000 --- a/.changeset/spec-data-rest-provenance-anchors.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Provenance comments in the rest of `data/` were re-anchored - -Comment and docblock lines in `src/data/object.zod.ts`, -`src/data/filter-logic-conformance.ts` and `src/data/object.form.ts` that cited -tracker numbers which no longer resolve on GitHub now cite the commit in this -repository's history that decided the matter, and say in their own words what -was decided. Comments only: no type, schema, export or runtime behaviour -changes. diff --git a/.changeset/spec-field-option-visiblewhen-pair-qualified.md b/.changeset/spec-field-option-visiblewhen-pair-qualified.md deleted file mode 100644 index 26162c65b8d..00000000000 --- a/.changeset/spec-field-option-visiblewhen-pair-qualified.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -A field-option docblock names both sibling issues by repository - -The docblock on `SelectOptionSchema`'s `visibleWhen` in `src/data/field.zod.ts` cited a pair -of objectui issues as `objectui#6110 + #6111`, which reads the second number as -this repository's. It now qualifies each number on its own, so both point at the -objectui records the sentence describes. Comment only: no type, schema, export -or runtime behaviour changes. diff --git a/.changeset/spec-functional-completeness-symbol-anchors.md b/.changeset/spec-functional-completeness-symbol-anchors.md deleted file mode 100644 index 5cc858614da..00000000000 --- a/.changeset/spec-functional-completeness-symbol-anchors.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): `functional-completeness`'s three `objectql/engine.ts` citations name symbols instead of line numbers (#16960) - -The module doc block of `kernel/functional-completeness.ts` cited the runtime that -justifies each rule by line number. All three had rotted: re-measured on `origin/main` -`7ddf13dca` (`engine.ts` is 15,309 lines), the quoted texts live at 8630, 8978 and 921 -against cited 3001, 3191 and 346 — drifts of 5,629, 5,787 and 575. Each quoted text -occurs exactly once in `engine.ts`, so those are readings rather than artefacts. - -The citations are the only limb tying a rule's justification to the runtime that -implements it, and that limb is walked by a human reading it — nothing in the module can -notice the runtime moved. `:3191` was the dangerous one: the line it names today is -ordinary-looking `dispatch:` code, so a reader following it lands somewhere plausible and -never learns they were sent to the wrong place. - -Each now names the enclosing symbol in the repo-root `path#symbol` form -`packages/spec/liveness/field.json` already uses — -`packages/objectql/src/engine.ts#buildSummaryIndex`, `#planFormulaProjection`, -`#expandRelatedRecords` — beside the verbatim snippet. A corrected line number would rot -again on the next refactor; a symbol plus a unique snippet is greppable and survives -movement. The anchor form also moves these three from -`check-spec-docblock-symbol-anchors`' not-judged bucket into resolution (that gate now -reports `3 symbol (3 declaration)` where it reported `0`), so a rename reddens CI. - -Doc text only — no schema, export, type or runtime behaviour changes. It ships because -this block is emitted into the published `dist/kernel/index.d.ts`. diff --git a/.changeset/spec-kernel-contracts-provenance-anchors.md b/.changeset/spec-kernel-contracts-provenance-anchors.md deleted file mode 100644 index 1a4b640cea7..00000000000 --- a/.changeset/spec-kernel-contracts-provenance-anchors.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Provenance comments in `kernel/` and `contracts/` were re-anchored - -Comment and docblock lines under `src/kernel` and `src/contracts` cited tracker -numbers that no longer resolve on GitHub. Each one now cites the commit in this -repository's history that decided the matter, or the ADR that records it, and -says in its own words what was decided. Where nothing could be anchored, the -sentence keeps its reason and the number is gone. Comments only: no type, schema, -export or runtime behaviour changes. diff --git a/.changeset/spicy-pears-count.md b/.changeset/spicy-pears-count.md deleted file mode 100644 index 3476abdd66c..00000000000 --- a/.changeset/spicy-pears-count.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Correct `FieldReferenceSchema`'s first TSDoc `@example`: a `{ $field }` comparand names a column of the SAME row, never a relation path. - -The example spelled its comparand as `{ "$eq": { "$field": "order.owner_id" } }` and captioned it as a join ON clause, while the same docblock's "Execution support" prose states that a dotted path is refused by SQL push-down with `INVALID_FILTER` (HTTP 400). Copied as written it does not fail at the schema door — both spellings parse — so it fails later and quietly: the in-memory evaluator answers `false` for a flat row, and SQL push-down refuses. The ON clause it advertised no longer exists either; `query.joins` was removed and related records are read through `expand`. The example is now the same-table cross-field comparison both execution paths compile, and the docblock header no longer advertises a join surface. `@objectstack/spec` publishes `src/**/*.zod.ts`, so this docblock ships to authors and to IDE hover. diff --git a/.changeset/spooky-poems-repeat.md b/.changeset/spooky-poems-repeat.md deleted file mode 100644 index ea154703ac4..00000000000 --- a/.changeset/spooky-poems-repeat.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Say it out loud when a `.refine()` never reaches the published JSON Schema. - -`z.toJSONSchema()` has no arm for a `custom` check, so every rule written as a -`.refine()` / `.superRefine()` is enforced by the runtime and absent from the -`json-schema/` tree that ships inside this package — a published file that is -WIDER than the Zod type it was generated from, in the direction where an -author's (or an AI's) validator says yes and the platform then says no. Measured -on zod 4.4.3: 688 refinement sites across 240 published schemas, none of which -projected anything. - -Nothing about what the schemas accept changes. Each affected file now carries an -`x-dropped-refinements` annotation naming the paths whose rules it does not -state — `x-` keywords are ignored by every validator, so the accepted document -set is byte-for-byte what it was — and the generator reports the population on -every run and refuses to grow it silently -(`packages/spec/dropped-refinements.baseline.json`). - -Clause-②: no diff --git a/.changeset/spotty-jars-shave.md b/.changeset/spotty-jars-shave.md deleted file mode 100644 index eea558bf3d6..00000000000 --- a/.changeset/spotty-jars-shave.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os validate` and `os lint` now judge the same stack `os build` judges when a project declares its metadata only in `packages[]`. - -A project in the ADR-0130 D4 artifact shape — every definition inside `packages[]`, no collections at the top level — was handed to the author-time rule table as an **empty stack** by both commands, so all 44 rules reported nothing and both exited 0 having read none of the project. `os build` folds the packages back in first (`authoringRuleUnionStack`) and refuses the same stack. Two of the three authoring gates were certifying an unread project as clean, and `os validate` is the check an author runs before shipping. - -Both commands now hand the rule table the stack that same helper returns — one fold, shared with `os build`, not a second implementation. It is a rule **input** only: neither command's output, `--json` payload nor `os lint`'s metadata score changes, and a stack that still carries its top-level collections is returned by identity, so single-package projects are unaffected by construction. - -⚠️ **A project that was silently passing may now fail.** That is the defect surfacing, not a new rule: the finding was always there and `os build` was always reporting it. Run `os build` on the same tree to see the identical diagnostic. diff --git a/.changeset/standalone-plugin-scaffold-unscoped-private.md b/.changeset/standalone-plugin-scaffold-unscoped-private.md deleted file mode 100644 index 5792287e884..00000000000 --- a/.changeset/standalone-plugin-scaffold-unscoped-private.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os create plugin` names the standalone scaffold `plugin-` and marks it `private` - -The default (standalone) emission wrote `"name": "@objectstack/plugin-"` into a -project scaffolded for a developer outside this monorepo — a scope they cannot publish -to — and did not mark the manifest `private`. Nothing failed at scaffold time: the name is -never resolved from a registry inside the project, so `pnpm install`, the type-check and -the scaffold smoke were all green on it, and the cost landed later at `npm publish`. The -emitted README compounded it by instructing `pnpm add @objectstack/plugin-`. - -The standalone default now emits: - -- `"name": "plugin-"` — unscoped, and the same string as the directory the - scaffolder prints and creates; -- `"private": true` — the line that actually stops an accidental publish, whatever the - name says; -- a README whose install instruction is a local reference (`pnpm add link:../plugin-`) - and whose import specifier matches the emitted package name. - -`os create plugin --in-repo` is unchanged: it still emits a publishable -`@objectstack/plugin-` with no `private` flag, because that placement lands under -`packages/plugins/` where every sibling genuinely carries that scope. - -No action is needed for a project already scaffolded. If you generated one with the old -name and have not published it, rename `package.json`'s `name` to `plugin-` (or a -scope you own) and update the README's install line; the exported symbol and the plugin's -runtime `name` are unaffected. diff --git a/.changeset/standalone-stamp-comment-accuracy.md b/.changeset/standalone-stamp-comment-accuracy.md deleted file mode 100644 index 9e8254b9569..00000000000 --- a/.changeset/standalone-stamp-comment-accuracy.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch -"@objectstack/objectql": patch -"@objectstack/cli": patch ---- - -docs(metadata-protocol,objectql,cli): comments describing the standalone stamp now name `env_local`, the value the tree actually produces - -The v5.0 `project` to `environment` rename reached the two remaining stamps in `@objectstack/runtime` and `@objectstack/metadata` in a previous release: `createStandaloneStack` and `MetadataPlugin` both stamp **`env_local`**. Six comments in three other packages still described that stamp as `'proj_local'`, so they named a value nothing in the tree produces any more. - -No behaviour changes. The reason this is a `patch` rather than a no-publish diff is measured, not assumed: two of the six sites are TSDoc on **exported** interface members (`AssembleMetadataProtocolOptions.runPlatformMigrations`, `ObjectQLPluginOptions.runPlatformMigrations`) and land in the shipped `dist/*.d.ts`, and the `@objectstack/cli` site lands in the shipped `dist/utils/schema-migrate.js` because that package builds with `removeComments` unset. All three packages ship `dist` in `files[]`, so the corrected text is what an author reads on hover after upgrading. - -The sites were judged individually rather than search-and-replaced, because they are not all the same edit: - -- Five sites whose verb describing the stamp is present indicative describe today's tree — two of them point the reader at `runtime/src/standalone-stack.ts` to go and look — and take the current spelling. -- `packages/cli/src/utils/schema-migrate.ts` names `'proj_local'` as the value the historical arming deduction consumed. There the literal is preserved as history and its present-tense relative clause moves into the past, with today's spelling named beside it; rewriting it to `env_local` would have falsified the record in the other direction. - -The causal claim at every site is about **presence**, not spelling: the retired gate read `environmentId === undefined`, so it would have misfired identically under either literal. That reading is preserved at all six. diff --git a/.changeset/strict-env-scope-roots-dyn.md b/.changeset/strict-env-scope-roots-dyn.md deleted file mode 100644 index a0ca4a33de6..00000000000 --- a/.changeset/strict-env-scope-roots-dyn.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -"@objectstack/formula": minor -"@objectstack/lint": minor ---- - -fix(formula): the strict declaredness env declares `SCOPE_ROOTS` as `dyn`, so a bare reference behind a root name is no longer masked (#16412) - - - -**BREAKING** in the accept-set sense — an accept-set narrowing on published -CHECKERS, in the same sense as a route that starts refusing a request it should -always have refused — landing in the launch window as `minor` on both packages (during the window the bump level is -not the carrier of breaking-ness; this paragraph and the disposition above -are). Nothing that was already reported stops being reported, and no source -that is correct starts being reported. - -`firstUndeclaredReference` asks cel-js's checker for the first undeclared -identifier in a source. That checker returns exactly ONE error, and the helper -acts only on `Unknown variable: X`, so whenever the first error is of another -class every undeclared reference behind it in the same source went unjudged and -the helper answered `null` — which is also the value that means "every -reference is rooted". Four published call sites read that answer, and none of -them can tell the two readings apart. - -The widest way to reach that state was a disagreement between two environments -in this package about the same names. The strict env declared every -`SCOPE_ROOTS` member (`data`, `config`, `record`, `result`, `item`, `event`, -`input`, `user`, …) as `map`, while the permissive env that `celEngine.compile` -type-checks in leaves them `dyn`. `map` has no `==`, `<` or `+` overload, so an -ordinary comparison on one of those names compiled clean and then faulted `no -such overload` in the strict env only — taking the single error slot and -silencing everything behind it. An author reaches it by naming an object field -or a flow variable after a namespace root and reading it bare, which on a -metadata-editing form is not even a coincidence: that layer binds the row under -edit as `data`. - -The strict env now declares those roots `dyn`, which is what the list's own -doc-comment already claimed it was for — member access, arithmetic and -comparison on a root all deferring to runtime — and which `map` delivered only -the first of. The two environments agree about these names, so the class cannot -arise rather than being compensated for downstream. - -What starts reporting, measured on each published surface: - -- `@objectstack/formula` `validateExpression` with `scope: 'record'` — a bare - reference behind a root name is the hard error it always was for the same - identifier written first (`ok` was `true` with zero errors; it is now `false`). -- `@objectstack/formula` `validateExpression` with `scope: 'flattened'` — the - did-you-mean warning reaches a misspelled field behind a root name. -- `@objectstack/lint` `visibility-bare-identifier` — a bare identifier behind a - root name in a `visibleWhen` predicate is a finding. Per that rule's own - message the console otherwise falls open and the element renders - unconditionally. -- `@objectstack/lint` flow-variable shadowing — a shadowed field read behind a - root name is warned. That rule's documented blind spot is now name-local, as - its wording always claimed: the colliding name itself is still not reported. - -⚠️ One published answer also WIDENS, and it is not a reporting surface. -`inferExpressionType` (`@objectstack/formula`, re-exported from the package -root; read by `@objectstack/mcp` as `validate_expression.inferredType`) infers a -formula's coarse value type through `inferCelType`, which shares this same -strict environment. While the roots were `map` there was no `==`, `<` or `+` -overload for them, so an expression using a namespace root as a DIRECT OPERAND -did not type-check at all and the answer was `'unknown'`. With the roots `dyn` -those expressions type-check and the answer is the truthful CEL type: -`result + 1` and `record ? 1 : 2` → `'number'`, `record == "x"` → `'boolean'`, -`data == "x" ? "a" : "b"` → `'text'`, uniformly for every name on the list. No -answer changes from one concrete type to another and nothing narrows to -`'unknown'` — `size(record)` and `"a" in record` still answer, and a root that -is only the base of a member access (`record.amount > 100`) never consulted this -declaration. A consumer that keys off a concrete type therefore sees strictly -more expressions classified, never a different classification; for the -motivating consumer that means a formula written as `data == "x" ? "a" : "b"` is -now correctly seen as text rather than as unprovable. Pinned on both sides in -`validate.test.ts`. - -⛔ Two first-error classes are NOT closed by this, and both stay pinned. A CEL -TYPE name (`type`, `string`, `int`, …) is declared by CEL itself, so no -declaration this package makes can reach it; measured on the strict env, the -message for `type == 'grid'` is byte-identical under a `map` and a `dyn` root -declaration. And `has()` handed a non-select argument still faults its own -class, which `@objectstack/lint`'s visibility rule masks at its own call site -(#16118) and which nothing else masks. - -The narrowing this helper is built on is unchanged: it still acts only on -`Unknown variable`, so `type(record.x) == string`, comprehension macros, guard -idioms, optional chaining and stdlib calls report nothing, and a widening of -that regex onto the overload message remains refused. diff --git a/.changeset/strict-object-aliases-two-roles.md b/.changeset/strict-object-aliases-two-roles.md deleted file mode 100644 index b924bb80955..00000000000 --- a/.changeset/strict-object-aliases-two-roles.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Correct `aliases`' documented contract: it is not "only for what edit distance cannot reach". - -`strictObject`'s `aliases` option was documented as a universal in the three places an adopter reads — the module docblock in `shared/strict-object.ts`, the `StrictObjectOptions.aliases` JSDoc an editor shows on hover, and the same JSDoc on the published `strictUnknownKeyError`'s `StrictUnknownKeyErrorOptions.aliases` — all saying aliases are "semantic near-misses edit distance cannot reach". The word *cannot* denies the option's second job. - -The lookup is `aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, maxDistance, 1)[0]`: an alias is consulted **before** the distance fallback and wins outright. So an entry is equally right when distance *does* reach the key and answers with the wrong one — `hosts` is 2 edits from the declared `hooks` against a budget of `Math.max(2, Math.floor(5 / 3))` = 2, so on the plugin `permissions` block the entry is what keeps an author off lifecycle hooks. - -Neither role is rare, and the correction carries its own count rather than the hedge it replaces. Measured over every surface the `strictObject` registry records, 2026-09-11: **1910** alias entries, **1658** unreachable by distance, **252** reachable — 211 where the fallback would have answered identically, and **41** where it answers a different key the entry overrules. - -The failure mode the old sentence produced is precise and has a live carrier: an adopter with a reachable-but-wrong near-miss read "edit distance cannot reach", concluded `aliases` was not the tool for their case, and left the confident wrong suggestion in place. - -Prose only. No alias is added or removed, no schema, key list, strictness or error message changes, and `visibleWhen → visible` (verified still unreachable) stays as the proving case for the gap half. diff --git a/.changeset/summary-column-doc-sweep.md b/.changeset/summary-column-doc-sweep.md deleted file mode 100644 index 9bbdecdc94e..00000000000 --- a/.changeset/summary-column-doc-sweep.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Correct the `summary` column representation stated in the sort-hint TSDoc. Since #16318 an engine-maintained `summary` column is an exact `table.decimal` on tables created after that change and a `table.float` on tables created earlier; the shipped doc comment still said flatly that it is a `table.float`. Comment text only — the sort behaviour it describes is unchanged, and the column type was never what makes a `summary` field sortable (having a provisioned column is). diff --git a/.changeset/sys-account-issuer-retired.md b/.changeset/sys-account-issuer-retired.md deleted file mode 100644 index cdfcd0ba9d0..00000000000 --- a/.changeset/sys-account-issuer-retired.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -"@objectstack/platform-objects": minor -"@objectstack/plugin-auth": minor -"@objectstack/client": minor -"@objectstack/cli": minor -"@objectstack/spec": minor ---- - -feat(auth)!: adopt better-auth's account-issuer rollback — drop `sys_account.issuer`, retire the backfill, lift the `@better-auth/*` family to an exact `1.7.3` (#17440) - - - -**BREAKING** — a platform object drops a declared field and `@objectstack/plugin-auth` -drops six published symbols. Shipped as `minor` under the launch-window convention -(`major` is refused by `check-changeset-no-major`; breaking-ness is carried by this -banner plus the ADR-0087 disposition above). The hand-migration prescription is -registered under protocol major 18 as `sys-account-issuer-retired`. - -better-auth `1.7.3` removed the issuer-scoped account identity outright -(`better-auth/better-auth#10909`): `createLocalAccountIssuer` is deleted, -`accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the -`account.issuer` column and its unique index are gone from `get-tables`. There is no -drop-in replacement. `#16186` pinned the family at an exact `1.7.2` as a stopgap; this is -the durable half, per the maintainer ruling of 2026-09-10 on `#16629`. - -## 迁移:FROM → TO - -| FROM | TO | the one-line fix | -|:--|:--|:--| -| `sys_account.issuer` (column + `{ fields: ['issuer','account_id'], unique: true }`) | — | nothing replaces it; identity is `(provider_id, account_id)`, declared UNIQUE on `sys_account` since the object was created | -| reading `account.issuer` off a row or off `client.accounts.list()` | `sys_sso_provider.issuer`, resolved through the account's `provider_id` | `provider_id` is unique per environment, so it names the authority on its own | -| `backfillAccountIssuer(ql, …)` | — | delete the call; there is no successor pass | -| `CREDENTIAL_ISSUER` / `oauthIssuerFor(id)` | — | drop the argument; `internalAdapter.createAccount({ userId, providerId, accountId, password })` takes no `issuer` | -| `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult` | — | delete the import; the compiler names every site | -| `@better-auth/*` at an exact `1.7.2` (eleven members) | an exact `1.7.3` (eleven members) | the family moves as ONE line — `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions | - -## ⭐ Existing deployments: run the pre-flight BEFORE the column is dropped - -Uniqueness moves from `(issuer, account_id)` to `(provider_id, account_id)` — a -**narrower** key. Two rows sharing `provider_id` + `account_id` and differing only in -`issuer` are legal under the old key and are ONE account under the new one. - -``` -os migrate account-issuer # read-only; exits non-zero when the drop must not proceed -# … take a backup (the operator's act, and the apply step's precondition) … -os migrate apply --allow-destructive -os migrate account-issuer # post-check: reads zero -``` - -The pre-flight reads **rows**, never the index declaration. `syncDeclaredIndexes` logs a -plain UNIQUE whose CREATE failed on existing duplicates onto the durability channel and -lets the boot continue (`#14902` / `#15479`), so a database can carry the declaration -without the constraint — and on such a database the drop does not fail loudly, it -degrades silently: the rows become indistinguishable and a sign-in can resolve onto the -wrong user's account. `os migrate apply --allow-destructive` re-runs the same pre-flight -and refuses the drop before writing any DDL. A read that throws, or a scan that -truncates, refuses too — an unread table is not a clean one. - -⛔ Colliding rows are never merged or dropped for you: which row survives is application -knowledge, and two different people can be behind one colliding key. Keep the row whose -provider account is live, delete the rest so a fresh sign-in re-links, and re-run. - -The boot refusal is unchanged and needs no new machinery: a runtime already refuses to -start against unapplied destructive drift, naming the command to run, and never -auto-migrates. - -## ⚠️ A `provider_id` re-pointed at a different IdP must have its bindings REBUILT - -This is the one case `issuer` still discriminated. After the drop no column records which -IdP vouched for a row, so if a re-pointed provider's new IdP mints a subject the old one -had already issued to somebody else, the key resolves that sign-in onto the other -person's account. Under the old key that failed loudly (`unable_to_link_account`); under -the new one it is silent. - -⇒ `sys_sso_provider` now **refuses an `issuer` change while `sys_account` rows are still -bound to that `provider_id`** (`RESOURCE_CONFLICT` / 409). Delete the provider's account -bindings first; each user re-links on their next sign-in. - -## Why the column was a liability, not an asset - -A credential row whose `issuer` was not the local credential issuer was invisible to -`findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not -found" warn pointing at the `sys_user` row rather than at the account. **Four checklist -items had that recorded as a knownGap, each rediscovering it.** Its discriminating power -here was near zero anyway: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: -true }`, so `provider_id → issuer` is a function within an environment. - -## Also in this change - -`pnpm check:vendor-export-contract` (from `#16186`) keeps its exactness requirement and -still resolves every named symbol — its self-test re-anchors from the now-retired -`@better-auth/core/db` specimen onto a live edge, and gains a case asserting the two -deleted names are imported nowhere. `#11627`'s hash-shadow-key machinery is untouched: it -is a generic driver capability serving five UNIQUE members of the >768-char class. diff --git a/.changeset/sys-user-role-help-posture-split.md b/.changeset/sys-user-role-help-posture-split.md deleted file mode 100644 index 382107a4bec..00000000000 --- a/.changeset/sys-user-role-help-posture-split.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): `sys_user.role`'s help text names the platform-admin route that works on every tenancy posture, and keeps the unscoped grant `single`-only (#19875) - -The `role` field's `description` is authored metadata: it ships in the published bundle, is extracted into the `en` i18n bundle, and is the help text an administrator reads on the field in Setup. It said: - -> Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2) — grant platform-admin standing with an unscoped `admin_full_access` assignment in `sys_user_permission_set`. - -On a walled deployment (`OS_TENANCY_POSTURE` `group` or `isolated`) that remedy does nothing: since the walled legacy-anchor retirement, an unscoped `admin_full_access` row no longer confers platform-admin standing there. The only route there is the deployment's configured administrator list. The help text now reads: - -> Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2). To grant platform-admin standing, list the user's verified email in `OS_PLATFORM_OWNER_EMAIL`; under the `single` tenancy posture an unscoped `admin_full_access` assignment in `sys_user_permission_set` also confers it. - -This describes both anchors as they work today. The configured, email-verified address confers standing on every posture. The unscoped grant row still confers it under `single`, and nowhere else. No behaviour changes: this is a text correction only. - -- **`en.objects.generated.ts`** follows by regeneration (`pnpm i18n:extract`), not by hand. -- **The existing pin** on this description (`platform-objects.test.ts`) now also requires the text to name `OS_PLATFORM_OWNER_EMAIL` and to qualify the grant with `single`. Without that, restoring the unqualified sentence would pass every test. diff --git a/.changeset/sys-user-role-prose-retired-action.md b/.changeset/sys-user-role-prose-retired-action.md deleted file mode 100644 index 3d1f2800d66..00000000000 --- a/.changeset/sys-user-role-prose-retired-action.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -`sys_user.role`'s field description and its `readonly` comment stop pointing at the retired Set Platform Role action (#15188) - -Both strings named `set_user_role` / "Set Platform Role", an action retired in #9968 — the description told an operator to press a button that no longer exists anywhere in the product. This is not a source comment: a field `description` is authored data that ships in the published bundle and is extracted into the i18n bundles, so it surfaces in the admin UI's field help and in generated reference material. The correct path was already there and already the only one: platform-admin standing comes from an unscoped `admin_full_access` grant in `sys_user_permission_set` (ADR-0068 D2), which is exactly what the #9968 removal note in the same file says. - -- **`description`** now reads "Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2) — grant platform-admin standing with an unscoped `admin_full_access` assignment in `sys_user_permission_set`." It states what the column IS (a vendor authentication-layer scalar that stays published as `user.role`) and where the operator actually goes, and it deliberately does not claim the scalar confers nothing: `judgePlatformAdmin` still reads `user.role === 'admin'` as the legacy fallback it has always been, so a pre-D2 deployment carrying the value is not locked out. Saying "this field grants nothing" would have replaced one false sentence with another. -- **The `readonly` comment** keeps its ADR-0092 anchor and now states the true reason the field is not editable — nothing writes it since #9968 — instead of naming a writer that is gone. -- **`en.objects.generated.ts`** follows by regeneration (`pnpm i18n:extract`), not by hand: the default locale's leaves are rewritten from the source on every run. - -**Deliberately unchanged, and pinned so it stays that way.** The same file carries a third mention inside the #9968 removal note — *"a working \"Set Platform Role\" button **was** a supported, one-user-at-a-time resurrection channel…"*. It is past tense, it narrates what was removed, and it is true; sweeping it up with the other two would turn a true sentence false. A new test pins the removal note's tombstone opener and that past-tense sentence as occurrence counts over the source text, so both directions fail: deleting the history drops a count to 0, and re-introducing the retired action's name in live prose pushes one past 1. diff --git a/.changeset/temporal-text-operator-declared-type-gate.md b/.changeset/temporal-text-operator-declared-type-gate.md deleted file mode 100644 index 7c525afb319..00000000000 --- a/.changeset/temporal-text-operator-declared-type-gate.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/driver-sql": minor -"@objectstack/driver-turso": minor -"@objectstack/driver-sqlite-wasm": minor -"@objectstack/service-analytics": minor ---- - -feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) - - - -**BREAKING** in the answer sense, on every SQL face, landing in the launch -window as `minor` under the lockstep convention this cluster's siblings use. - -**The behaviour that GOES AWAY, by name: searching a date as a string.** On the -SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and -`driver-turso`'s local transport — a `Field.date` / `Field.datetime` / -`Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator -matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 -row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; -`{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three -now return nothing**, and their `$notContains` mirrors now return every valued -row. If you are relying on any of them, this is a row-set change and the -replacement is a range filter — spelled out below. The behaviour was never -declared by any contract row and it never worked outside SQLite: the same three -filters were a `DATABASE_ERROR` 500 on live Postgres. - -Nothing that was refused becomes admitted, and no new error code is minted — the -refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the -numeric and boolean classes. - -Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: -「a text operator over a column whose DECLARED type is temporal is type-gated -exactly like the numeric and boolean classes; the SQLite ISO-text match is not -a contract」. - -## What was wrong — one filter, three answers across one driver family - -`{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding -`2026-01-05`: - -| face | before | mechanism | -|:--|:--|:--| -| `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | -| `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | -| `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | - -Three answers to one filter, and no face declared which was canonical. The -SQLite answer was the accident of a storage form, not a capability: the same -query against Postgres was a 500. - -## What it does now - -The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` -(`@objectstack/spec`), the set the SQL compilers consult at compile time -because the stored value is not visible until run time. Every face that reads -it — `SqlDriver` (and everything that inherits its compiler), -`driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — -compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / -`$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to -the TRUE constant. Postgres's 500 becomes that declared answer; complementarity -holds; the constants compose with the existing NULL-safe rules and the `$not` -rewrite unchanged. - -**The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask -for "records in 2026" writes a range instead, which every dialect has always -answered the same way: - -```ts -// before — matched only on the SQLite family, 500 on Postgres -{ on_day: { $contains: '2026' } } -// after — the prescription, identical on every backend -{ on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } -``` - -## Boundaries, so a reader does not over-read this - -- **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON - TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on - a JSON column — not a substring test. It keeps compiling exactly as before. -- **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not - caveated.** `driver-memory` canonicalises a declared temporal write to ISO - TEXT (#4047), for a `Date` input and a string input alike, so a positive text - operator MATCHES there — the exact complement of the answer this changeset - declares. That divergence is filed as #17348 and pinned by name in that - driver's conformance suite, alongside a correction: the two rows previously - read as pinning the no-match answer pass because their comparand omits the - milliseconds, not because anything type-gates. `formula` and `having` cannot - key on the declaration at all — `matchesFilterCondition(record, filter)` takes - a bare record ("this evaluator sees a bare record and has no schema to - consult", its own docblock), and `having` filters AGGREGATED rows whose columns - carry no field declaration. ⛔ So "on every face" is NOT delivered by this - change, and this changeset does not claim it: the SQL family answers the - declared rule, the JS faces do not yet. -- **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there - is keyed on the STORED value — which is why its non-string column is a number - and not a date — so a temporal fixture would assert one stored form across all - five drivers that import it, the stored-form guarantee the ruling refused - option (b) for. -- **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its - cell rests on the compiled-shape pin, which reads the constant a statement - would carry without executing one. diff --git a/.changeset/tidy-emus-relate.md b/.changeset/tidy-emus-relate.md deleted file mode 100644 index bc68e0ee4aa..00000000000 --- a/.changeset/tidy-emus-relate.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@objectstack/service-datasource': patch ---- - -`os datasource introspect` now generates the authorised `*.object.ts` shape - -The Object draft rendered by `generateObjectDraft` (and served by -`POST /api/v1/datasources/:name/external/tables/:remote/draft`) used the -annotated-object-literal form. The director-seat ruling of 2026-09-12 (decision -batch #122 item 1) makes `ObjectSchema.create({ … })` the one authorised shape -for a `*.object.ts`, and the draft is destined for a committed `*.object.ts` — -the command's own `--out objects/wh_order.object.ts` example says so. Drafts -generated before this release were therefore written in the shape the platform -refuses. - -FROM → TO, for a draft you already committed — one mechanical rewrite: - -```ts -// FROM -import type { ServiceObject } from '@objectstack/spec/data'; - -const wh_order: ServiceObject = { - name: 'wh_order', - // … -}; - -export default wh_order; - -// TO -import { ObjectSchema } from '@objectstack/spec/data'; - -export const wh_order = ObjectSchema.create({ - name: 'wh_order', - // … -}); -``` - -Two things change beyond the wrapper. The spec import is now a **value** -import, because the factory runs when the file is evaluated — an `import type` -would be elided and the module would throw on its own first line. And the -export is **named only**: the `export default` is gone, matching the barrel the -scaffolder writes (`export { X } from './x.object.js'`). A barrel that imported -the draft as a default (`import X from './x.object.js'`) becomes -`import { X } from './x.object.js'`. - -Regenerating the draft is the other route: re-run -`os datasource introspect --table --out `. - -The two authored comment blocks the draft carries — the remote-primary-key note -and the ADR-0028 unprefixed-name TODO — are unchanged. - -Clause-②: no diff --git a/.changeset/tidy-moons-repeat.md b/.changeset/tidy-moons-repeat.md deleted file mode 100644 index 97c4a40cee6..00000000000 --- a/.changeset/tidy-moons-repeat.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -`POST /api/v1/data/{object}/batch` with `operation: "delete"` stops reporting a deletion it -did not perform. - -This is the third and last of the by-id delete doors to read the engine's answer — the -single-record `DELETE` and `deleteMany` already do. Each row of the batch answered -`success: true` for every engine result that was not the driver contract's `false`. The -`false` arm — "no row matched" — has reported honestly since #4435 and #5088; the other -ending had not: a row that MATCHED and was deliberately NOT removed was counted in -`succeeded` and reported as deleted, byte-identical to a real deletion. - -`sys_permission_set` is the shipped shape of it. Deleting a package-declared set is an -ADR-0005 RESET: the overlay tombstones and the record re-projects to the declared body -instead of vanishing. That behaviour is unchanged and deliberate — what was wrong is the -answer, and on a security-configuration write it told an operator a permission set was gone -while it was still being enforced. - -`IDataEngine.delete` declares `Promise` — the driver boolean for a by-id -write, a count of rows removed otherwise — so a numeric zero is the one value that positively -means the record is still there. The per-row `success` now reads that count instead of being -a literal. - -On the wire, for one row of a `delete` batch: - -- removed — `success: true`, counted in `succeeded` (unchanged); -- matched but not removed — `success: false`, counted in `failed`, and no `errors[]` entry: - a surviving record is an outcome, not a fault, and this envelope's two per-row codes - (`ROLLED_BACK`, `NOT_ATTEMPTED`) both describe a row that never ran; -- unknown id — `success: false` with `errors[0].code: RECORD_NOT_FOUND` (unchanged); -- an off-contract `undefined` from a third-party driver keeps its #4435 reading. - -Because `succeeded` and `failed` partition `results`, a surviving row also makes the -request-level `success` false, and an `atomic` batch containing one now rolls back rather -than committing under a response that called every row deleted. A non-atomic batch is not -stopped by it: nothing was thrown, so the `continueOnError` stop does not apply and the -remaining records are still attempted. `returnRecords: false` keeps `success`, so the honest -value survives that projection too. - -Clause-②: no diff --git a/.changeset/time-update-interval-sub-day-retired.md b/.changeset/time-update-interval-sub-day-retired.md deleted file mode 100644 index 6bbf1841cc2..00000000000 --- a/.changeset/time-update-interval-sub-day-retired.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/driver-memory": minor ---- - -fix(spec)!: `TimeUpdateInterval` retires its three sub-day intervals and derives its members from `DateGranularity` (#17296) - - - -## ADR-0087 disposition - -`second`, `minute` and `hour` leave a published closed enum that reaches TWO authored sites: an analytics request body's `timeDimensions[].granularity`, and an analytics cube dimension's `granularities[]`, which is stored metadata (`defineCube()` / `defineStack({ analyticsCubes })`). The stored half is rewritten by the D2 conversion `cube-sub-day-granularities-removed`, which strips the retired members from `analyticsCubes[].dimensions..granularities` and drops the key entirely when nothing coarser remains (an empty list would read as "offers none", the absent key as "offers all"). The semantic entry `time-update-interval-sub-day-retired` carries the half no transform can decide: a dimension that offered ONLY sub-day intervals needs an author to say what it actually serves. `day`, `week`, `month`, `quarter` and `year` are untouched and parse byte-identically. - -**BREAKING** for anyone authoring or sending `granularity: 'second'`, -`'minute'` or `'hour'`, and for anyone importing the `TimeUpdateInterval` -TYPE. Landing in the -launch window as `minor` under the lockstep convention this cluster's siblings -already use. - -## What was wrong - -`TimeUpdateInterval` declared **eight** intervals. The rest of the contract -never carried three of them, and this is the measurement rather than the -argument: - -| layer | declares | -|:---|:---| -| `TimeUpdateInterval` (`data/analytics.zod.ts`) | **8** — the five below plus `second`, `minute`, `hour` | -| `DateGranularity` (`data/query.zod.ts`) — what a `groupBy` entry and every driver bucket expression are typed by | 5 | -| `@objectstack/core`'s `BUCKET_GRANULARITIES` — the canonical bucket-KEY output contract a drill-down crosses | 5 | -| `driver-mongodb`'s `MONGODB_DATE_GRANULARITIES` | 5 | - -`DriverCapabilitiesSchema.supports.queryDateGranularity` — the one mechanism a -backend has for saying which granularities it buckets natively — is a -`z.record(DateGranularity, boolean)`. Measured: `{ day, week, month, quarter, -year }` parses; the same record plus `hour` raises `unrecognized_keys: ["hour"]`. -**No driver could advertise sub-day bucketing even if it had one.** That is what -makes this a retirement rather than a capability gap: a declared value one -backend cannot serve is a gap and the contract has a place to say so, but a -declared value *no* backend can even claim has no counterpart anywhere in the -contract that carries it. - -Driven against the built packages, two rows fourteen hours apart on one UTC -calendar day, before this change: - -| face | `granularity: 'hour'` | `granularity: 'day'` (control) | -|:---|:---|:---| -| `driver-memory` analytics | `NOT_IMPLEMENTED` / 501 | 1 group, `2026-09-06` | -| `driver-mongodb` bucket builder | `NOT_IMPLEMENTED` / 501 | `$dateToString` `%Y-%m-%d` | -| engine in-memory aggregation — the fallback every SQL/ObjectQL analytics query carrying a granularity lands on, since `NativeSQLStrategy` declines on a granularity | **200, 2 groups keyed on the RAW instant** | 1 group, `2026-09-06` | - -Two honest refusals and one silently wrong answer. No third behaviour, and no -backend that bucketed it. - -## What changed - -- `TimeUpdateInterval` is now `z.enum(DateGranularity.options, …)` — the members - come from the single source instead of a second literal list that disagreed - with it by three members for as long as both existed. -- A refusal message splits two populations that are not the same mistake: a - **retired** sub-day name gets the retirement and the `os migrate meta --from - 17` line; anything else gets the vocabulary. `driver-memory`'s own analytics - door carries the same split. -- `driver-memory`'s `NOT_IMPLEMENTED` / 501 answer for these three is **not - silenced** — the declaration it announced is gone, so the class moves to the - 400 the retirement makes correct. The 501 arm stays, and a pin measures that - its population is now empty (`TimeUpdateInterval.options` equals - `BUCKET_GRANULARITIES`), so the day one of the two is widened alone it lights - up again instead of a freshly declared value being called undeclared. - -## What this does NOT decide - -Sub-day analytics bucketing as a **capability**. Offering it means widening -`DateGranularity`, the `queryDateGranularity` record, the canonical bucket-key -vocabulary and every driver's bucket expression together — new capability, -decided as such, rather than a name that parses in one enum and resolves -nowhere. diff --git a/.changeset/translate-flow-walks-adr-0031-regions.md b/.changeset/translate-flow-walks-adr-0031-regions.md deleted file mode 100644 index 31fba9e49b6..00000000000 --- a/.changeset/translate-flow-walks-adr-0031-regions.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`translateFlow` overlays screen nodes inside ADR-0031 regions, at any depth - -`translateFlow` (`system/i18n-resolver.ts`) read the flat `flow.nodes` array and -nothing else. But `FlowNode.config` carries ADR-0031 regions — -`loop.config.body`, `parallel.config.branches[].nodes`, -`try_catch.config.try`/`.catch` — each holding a full `nodes` array that nests -arbitrarily, and a `type: 'screen'` node inside one is a real screen: the -executor pauses on it and the client receives its `ScreenSpec.nodeId`. - -So `flows..screens..{title,fields.*}` was authored for such a -node, parsed (the bundle schema is keyed by node id and knows nothing about -depth) and was then silently never applied. The wizard step rendered its -source-locale heading and field labels while its siblings one level up were -translated. - -The descent now runs through `mapFlowNodeList`, a per-flow region-aware -copy-on-write walk shared with the ADR-0087 conversions' `mapFlowNodes`, which -reads `FLOW_REGION_SLOTS_BY_TYPE` — the single declaration of where a region -lives (`automation/region-slots.ts`). This resolver is therefore not a fifth -hand-rolled reader of that table; the fourth pass written against the flat -one-liner is the last one that had to be. - -Reference identity is unchanged and is pinned: a node that resolves nothing -comes back as the same reference, every container `config` and region `nodes` -array on the way down is copied only when a descendant actually changed, and a -flow the bundle does not carry is returned as the same object. - -⛔ No wiring changed. `translateFlow` is still deliberately absent from -`translateMetadataDocument`'s dispatch table and no liveness row moved — that -decision belongs to the downstream runner card, as its docblock records. diff --git a/.changeset/two-factor-verify-echoes-live-user-row.md b/.changeset/two-factor-verify-echoes-live-user-row.md deleted file mode 100644 index f6a18df22c1..00000000000 --- a/.changeset/two-factor-verify-echoes-live-user-row.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/plugin-auth": patch -"@objectstack/client": patch ---- - -`POST /two-factor/verify-totp` and `/two-factor/verify-otp` now echo the user row as it stands when the response is written, instead of the pre-rotation snapshot the vendor closes over. - -On the enrolment lane — a signed-in caller confirming a new factor — better-auth writes `twoFactorEnabled: true`, rotates the session, and only then calls the `valid(ctx)` closure it built at entry. That closure still holds the pre-rotation session, so a successful verification answered `user.twoFactorEnabled: false` to the very caller who had just switched 2FA on. An account portal reading that body renders the factor as still OFF right after enrolment, and a bearer client that caches the echoed user carries the wrong flag until its next `get-session`. - -`two-factor-rotated-token-echo` already repaired the body's other stale member, `token`, on exactly these routes and on exactly this predicate — the response staged a session cookie whose token differs from the one echoed. The `user` member is stale for the same reason, so it is repaired under the same predicate rather than a new one. - -- **Two narrowings, both load-bearing.** Only the members the vendor already echoed are written, so the published payload shape (`AuthWireUser`) cannot widen — better-auth's own output filter is a deny-list, and forwarding a raw row would put every column it happens to carry on the wire. And the row is re-read through `internalAdapter` by the id the response itself published, so the repair travels the same output transform that produced the echo (a driver that stores booleans as `1`/`0` cannot change a member's wire type) and can never substitute a different principal into a response. -- **`/two-factor/verify-backup-code` is untouched.** It does not rotate and already echoed the live row; it is in neither path list, its row is not read, and it is pinned as a negative control on both the in-memory engine and a real `SqlDriver` — an unconditional re-read would have "fixed" the broken lane and quietly rewritten one that was already right. -- **The failure posture is inherited.** A row read that throws or answers nothing degrades to the vendor's own echo, never to a failed verification and never to a lost `token` repair, which is written first for that reason. - -`@objectstack/client` drops the `AuthTwoFactorVerificationResult.user` warning that told callers to re-read the session for the live flag; the wire shape it declares is unchanged. diff --git a/.changeset/unmounted-audit-ledger-is-configuration.md b/.changeset/unmounted-audit-ledger-is-configuration.md deleted file mode 100644 index d04be9c3256..00000000000 --- a/.changeset/unmounted-audit-ledger-is-configuration.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/service-settings': patch ---- - -Stop reporting an unmounted `sys_audit_log` as a failed audit write. - -`buildConfigChangeAuditSink` wrote the `config_change` compliance row blind and reported -the throw it got back. On a deployment that never mounted the OPTIONAL -`@objectstack/plugin-audit` — `objectstack serve --preset minimal`, an EE host that mounts -no `audit`, a hosted tenant kernel — there is no ledger to write to, so every tenant -settings write produced a durability complaint about a deployment behaving exactly as -composed, plus an `Insert operation failed` line per write from the engine one frame down. - -The sink now probes the engine registry for `sys_audit_log` before the write and skips at -`debug` when it is absent, so no insert is attempted and neither channel says anything. The -probe records nothing and is re-taken per write, so a ledger mounted later in the same boot -starts recording. An engine that cannot answer the probe still gets the write attempted. - -No API change: the exported signature, the row shape, `CONFIG_CHANGE_ACTION` and -`CONFIG_CHANGE_OBJECT_NAME` are all unchanged. The remaining fault arm — a ledger that IS -mounted whose insert genuinely fails — now reports on the `error` channel rather than -`warn`, which is AGENTS.md's durability-degradation level for a write that claims to be -audited and is not. - -Clause-②: no diff --git a/.changeset/validate-refuses-blank-structural-condition.md b/.changeset/validate-refuses-blank-structural-condition.md deleted file mode 100644 index ed1b732af56..00000000000 --- a/.changeset/validate-refuses-blank-structural-condition.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -fix(lint)!: `objectstack validate` refuses a blank structural `condition`, the rule `registerFlow` has carried since #17322 (#17495) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention: `major` is refused by `check-changeset-no-major`, and -breaking-ness is carried by this banner plus the ADR-0087 disposition): -`validateStackExpressions` — the pass behind `objectstack validate` — now -reports an `error` for a structural `condition` whose source is blank after -trimming. It reported nothing at all before. - -The value was already refused by two of the three doors. `FlowEdgeSchema.condition` -composes `EvaluatedExpressionInputSchema` (#15807), so `' '` on an edge is -refused at `FlowSchema.parse`; #17322 rebound `AutomationEngine.registerFlow` to -that same rule, so the same value on a node's `config.condition` stops the flow -registering. `objectstack validate` was the door that still said nothing — so an -author got a clean bill, deployed, and the flow never registered: each boot path -in `service-automation`'s plugin wraps `registerFlow` in `try`/`catch`, logs one -`warn` naming the flow, and continues. On a `start` node that key is the -**trigger gate**, so the whole flow is armed by nothing. - -FROM → TO, for a build that used to pass and now fails: - -```yaml -# FROM — validate said nothing; registerFlow refuses it at boot -nodes: - - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: ' ' } } - - { id: branch, type: decision, config: { condition: { dialect: cel, source: ' ' } } } - -# TO — either write the predicate you meant… -nodes: - - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: 'record.active == true' } } - - { id: branch, type: decision, config: { condition: { dialect: cel, source: 'record.rating >= 4' } } } - -# …or drop the key. An ABSENT condition is still not a malformed one: a start -# node with no `condition` is an ungated trigger, and that is unchanged. -``` - -The refusal is the edge door's own sentence, not a second one — the finding -carries `EVALUATED_EXPRESSION_SOURCE_REQUIRED` verbatim, located at the node and -slot the author wrote (`flow 'f' · node 'gate' (start) condition`), because all -three doors now ask one imported schema. - -Unchanged, deliberately: the **evaluator**. A condition already stored blank -still answers `false` at run time — #15662's ruling on that half stands. What -moved is that it can no longer be authored past validate. diff --git a/.changeset/value-domain-note-settings-door-repointed.md b/.changeset/value-domain-note-settings-door-repointed.md deleted file mode 100644 index 7ef1135207f..00000000000 --- a/.changeset/value-domain-note-settings-door-repointed.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): the `field.valueDomain` liveness note stops claiming the settings door is "unchanged until then" - -The `valueDomain` row of the published `liveness/field.json` ledger ended on a sentence written -while the re-point was still in the future: - -> The settings door (`service-settings/value-domains.ts`) re-points onto the shared predicate in -> its own follow-up card and is unchanged until then. - -Both halves of the 2026-09-02 ruling have since landed — the settings half (#15434) and the engine -half (#15316) — and the engine half rewrote this note wholesale while carrying that sentence -forward verbatim. "Unchanged until then" therefore described a state that no longer existed: the -door it names had already re-pointed, one commit earlier. - -The sentence now says what is true of that door, read off its source rather than off a PR title: -its second copy of all three definitions is deleted, `firstRejectedDomainMember` asks -`isValueDomainMember` — the same call `record-validator.ts` makes — and what remains on that side -is the door's own business (which declarations it agrees to enforce, how a multi-value carrier is -walked, the fragments the env-override log line needs). A re-added local table reddens -`value-domains.shared-predicate.pin.test.ts`. - -Ledger-note text only. The row's `status` is untouched — it tracks the engine write path, and -`liveness/state-counts.md` is derived by `gen:liveness-counts` from the row states, none of which -move here (`check:liveness` reports the counts file current). diff --git a/.changeset/value-envelope-nullish-attribution.md b/.changeset/value-envelope-nullish-attribution.md deleted file mode 100644 index 5b75dc91d0f..00000000000 --- a/.changeset/value-envelope-nullish-attribution.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -fix(service-automation): a `null` / `undefined` envelope is refused attributed, not as a raw `TypeError` (#16439) - -`AutomationEngine.evaluateValueEnvelope` derives its verdict from `valueEnvelopeRefusals` — the same call `registerFlow` makes — so registration's reject set and evaluation's reject set are one set by construction. That covered every malformed **envelope**, and exactly two shapes fell outside it: `null` and `undefined`. Neither published primitive judges them (the shape rule is a no-op on anything not `isExpressionEnvelopeShaped`, and `validateExpression` reads an absent `source` as "not authored"), so both returned no findings and the method went on to read `envelope.source` off nothing — `TypeError: Cannot read properties of null (reading 'source')`, with no `where`, no source and no rule. Driven across the ten shapes the card enumerates, eight failed attributed and only these two did not. - -Both now fail attributed like the other eight, led by the published `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` sentence and carrying the `where` and the source. The rule is stated in the **shared** refusal, never as a guard in the evaluator: a reject reason living only on the evaluation side would end the very property this design has. - -Refused rather than admitted, and the asymmetry with the predicate path is deliberate: `structuralConditionRefusal` admits `null` / `undefined` because the condition *field* is optional, so absence there means "the author wrote no predicate". A value slot's envelope **is** the value, so an absent one is a caller handing nothing where a value was required. - -**Why `patch`, not `minor` and not nothing.** Nothing changes for authored metadata: the only production call site guards with `isExpressionEnvelopeShaped`, which neither shape satisfies, and the value-role feeder emits only envelope-shaped objects, so `registerFlow` never presents a nullish value to the shared refusal — measured, and pinned. An authored `null` in an `assignments` slot is still a literal, still parses and still registers. What does move is the runtime behaviour of a **public method on an exported class**: a direct caller that passed a nullish envelope used to get a language-level `TypeError` and now gets an attributed `Error`. That is a published surface, so it is not silent — but it adds no API, no option and no capability, and no correct caller has to adapt, which is what makes it a patch rather than a minor. diff --git a/.changeset/verify-in-process-handle.md b/.changeset/verify-in-process-handle.md deleted file mode 100644 index 1e23f2eff57..00000000000 --- a/.changeset/verify-in-process-handle.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/verify": minor ---- - -**Clause-②: yes** — new exported symbols on a published package (`bootStackOnce`, `isVerifyRefusal`, and ten new members on the `VerifyStack` every `bootStack` caller already holds), so the accept set a consumer writes against widens. Contract-review tier. - -Every `VerifyStack` now carries an **in-process handle** on the stack `bootStack` boots — a way to run a hook, a validation rule, a flow, an action, a seed or a read against the REAL engine and assert on what the engine did, instead of writing through HTTP and inferring from persisted rows, or rebuilding the engine's semantics in a test stand-in. - -New members on `VerifyStack` (the same object `bootStack` returns; `api` / `apiAs` / `signIn` / `signUp` / `stop` are unchanged): - -- `hooks.run(object, 'insert' | 'update' | 'delete', input, { as })` — one write through the engine's own door as the caller `as` (a bearer token from `signIn` / `signUp`). The bound hook chain, field defaults, declared validations and the SecurityPlugin middleware run inside it, in the engine's order, because this is the very call the REST data ingress makes. Returns what the engine returned; a refusal rejects with the engine's own error (`code`, `statusCode`). -- `validate(object, record, { as, mode? })` — the engine's dry-run validation pass (`ObjectQL.validate`), nothing written. -- `flows.run(name, params, { as })` / `flows.resume(run, input, { as })` — the runtime's `/automation` trigger and resume routes driven in-process (no Hono, no socket): the caller's resolved identity is forwarded exactly as the route forwards it, and the engine's `AutomationResult` comes back (plus `flowName`, so the value hands straight to `resume`). A never-dispatched refusal or a failed run rejects with the route's ADR-0112 envelope. -- `actions.run(object, action, { as, recordId?, params? })` — the `/actions/:object/:action` route driven in-process, the one door carrying the whole action contract (ADR-0066 D4 gate, ADR-0104 param contract, subject-record load, trusted body context). Returns the handler's value. -- `seed(object, rows)` / `rows(object, where?, { as? })` — real ObjectQL writes (the platform's own seed-replay context) and reads (system-scoped, or as a caller under that caller's grants and RLS). -- `metadata.object(name)` / `objects()` / `items(type)` / `types()` — the booted `SchemaRegistry`, by its own singular type vocabulary. -- `tenancy()` — the `tenancy` service AuthPlugin registered (`posture`, `requestedPosture`, `isolationActive`, `degraded`). -- `contextFor(token)` — the dispatcher's own request-identity resolution, exposed so a test can drive any kernel service as a real caller. - -Also new: `bootStackOnce(config, opts?)`, a per-process memo of `bootStack` keyed on the `config` and `opts` object identities — the worker-scoped shared boot `packages/qa/dogfood` kept privately, promoted for suites that run many files under `isolate: false`. - -Exported types: `VerifyHandle`, `VerifyRefusal` (with the `isVerifyRefusal` predicate), `AsUser`, `FlowRun`, `FlowRunRef`, `EngineRow`. - -**Zero re-implemented semantics.** Every method is a thin facade over a door the kernel wired at boot; the handle assembles no `ExecutionContext`, orders no hooks, evaluates no permission. The package's own tests pin each method against the real service behind it (the PR's ablation record breaks each service in turn and shows only that method's pin going red), pin `hooks.run` against the REST write on the same row **and** the same refusal, and port one hotcrm exemplar (`opportunity_lifecycle`) onto `hooks.run` as the proof of ergonomics. - -No boot option was added: the tenancy posture a stack runs under is still chosen by `multiTenant` (the `--multi-tenant` option `os verify` already has) and read back through `tenancy()`. `os verify`, `runCrudVerification` and `runRlsProofs` are unchanged. diff --git a/.changeset/view-item-owner-hidden-retired.md b/.changeset/view-item-owner-hidden-retired.md deleted file mode 100644 index 8480f576071..00000000000 --- a/.changeset/view-item-owner-hidden-retired.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: retire the view item's `owner` and `hidden` keys — declared, stored, and read by nothing (#20085) - -**BREAKING** — `owner` and `hidden` are removed from the view item -(`defineViewItem`, `ViewItemSchema`, and the `view` metadata door's ViewItem -record `{ name, object, viewKind, config }`). ADR-0049 enforce-or-remove; triage -direction, verbatim: 「retire both keys」. - -Both keys were accepted by the strict authoring door and by the wire member the -`PUT /api/v1/meta/view` door validates, and `saveMetaItem` stored them verbatim — -but nothing ever read or wrote either. Measured before removal, each against a -lit control: no reader or writer of the view-item keys in the framework, in -objectui at its pinned commit and at `main`, or in cloud. Both view-switcher read -paths (`GET /meta/view?object=` and `getViewsByObject`) filter on `viewKind` + -`object` and sort on `order`. So `hidden: true` hid nothing, and a view with -`owner` set was listed for every user who can read the object. The `owner` half -was a visibility claim nothing enforced, which is the security shape ADR-0049 is -about. Per-user view scoping is a parked direction (ADR-0017, amended -2026-09-04), not a shipped mechanism. - -### FROM → TO - -| removed | what to write instead | -| --- | --- | -| view item `owner` | delete the key. Nothing restricts a view item to one user today; a view item is visible to everyone who can read its object. | -| view item `hidden` | delete the key. To take a view out of the switcher, delete the view item (or stop shipping it from source). | - -**The one-line fix: delete `owner:` and `hidden:` from every view item.** -`os migrate meta --from 17` lists the mechanical edits for existing sources. - -⚠️ Runtime behaviour is deliberately **unchanged**. Neither key ever changed what -a view showed or to whom, so removing one removes no behaviour. What changes is -the answer an author gets: a view item carrying either key is now refused at -parse, with a prescription, instead of being saved with no effect. - -### The retirement kit - -- **Tombstones, on the shared shape.** Both keys are `retiredKey()` tombstones on - the view-item base shape. That shape feeds two doors: the strict authoring door - (`ViewItemSchema`) and the `.strip()` wire member (`ViewItemWireSchema`, which - the `view` write door and the assembled-manifest `viewItems` channel both run). - A bare deletion there would have been a silent strip (ADR-0104), so one - tombstone serves both: `tsc` types the key `never` on `defineViewItem`'s input, - and every parse raises the prescription. -- **D2 conversion `view-item-owner-hidden-removed`** (step 18, retired from the - load path). It strips both keys from the view item **record** spelling as a - lossless delete, in both collections a record travels in: `views` (stack - sources, and stored `sys_metadata` rows, which the rehydration seam replays as - `{ views: [row] }`) and the assembled-manifest `viewItems` channel (package - export and environment artifacts). Without the second, an artifact assembled - before this release would fail its registration parse. -- **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ViewItem:owner`, `ui/ViewItem:hidden`, - `ui/ViewItemWire:owner`, `ui/ViewItemWire:hidden`. -- **No liveness row, and no authorable-surface line.** Both instruments read a - def's top-level `properties`, and `ViewItem` / `ViewItemWire` are - discriminated unions that have none. So the four surface ratchets stay - byte-identical on this retirement, and that reading is expected on this route. -- **No deprecation window**, per the project's startup-stage posture. - -⛔ **Untouched: the flattened-overlay members' own `owner` / `hidden`.** Those -two members of the `view` door (a lean personalization PUT carrying no `config`) -declare the same names on a different door, which this change does not touch. A -`{ object, viewKind, hidden: true }` overlay still parses. The conversion leaves -overlays alone for the same reason. - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` -is published, so this is breaking for consumers no telemetry was consulted for. - -Clause-②: no - - diff --git a/.changeset/view-metadata-type-not-unknown.md b/.changeset/view-metadata-type-not-unknown.md deleted file mode 100644 index 7b0a4a9ff6b..00000000000 --- a/.changeset/view-metadata-type-not-unknown.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): the published type `ViewMetadata` names a `view` body instead of being `unknown` (#19871) - -Clause-②: no (narrowing) - -**BREAKING for TypeScript code that annotates with `ViewMetadata`**: a narrowing of a published -TYPE, landing in the launch window as `minor` (the lockstep convention: the bump level is not the -carrier, this banner and the disposition below are). The runtime accept set does not move at all: -`ViewMetadataSchema` is unchanged, and a 44-body parse probe answers byte-identically before and -after. - -`ViewMetadata` was declared as `z.input`. That schema is a -`z.preprocess`, whose input type is `unknown`, so the name documented as "any persisted view -metadata body: container | ViewItem record | flattened overlay" type-checked any value at all, -including bodies the schema refuses. It is now the union of the input types of -`VIEW_METADATA_MEMBERS`, the four members the schema's union runs, so `unknown`, a non-object and a -key no member declares are compile errors, and a body of each member still type-checks. - -**What still differs from the runtime verdict.** The type is the members' declared shape, not the -door's answer. The door still accepts bodies the type refuses (it removes the console's row `id`s -and three members strip undeclared keys), and still refuses bodies the type admits (the identity -precondition, the members' refinements, and a body mixing keys of different members, which -TypeScript checks against the union as a whole). `ViewMetadataSchema.safeParse` remains the only -judge. - -**If your code stops compiling.** A value you annotated as `ViewMetadata` is not one of the four -member shapes. Correct the body, or type a value that is still unvalidated as `unknown` and let -`ViewMetadataSchema.safeParse` decide. - -`ViewMetadataParsed` is not changed by this change. It is re-derived from the same members, as their output types, by its own entry (#19920). - -The `@objectstack/metadata` changelog entry for #19852 gives `ViewMetadata` being `unknown` as the -reason a saved `view` file is written with no annotation; that reason is superseded here, and the -outcome stands for another one: `ViewMetadata` is no longer the `z.input` type of -`ViewMetadataSchema`, the schema `getMetadataTypeSchema('view')` binds. - - diff --git a/.changeset/view-overlay-owner-hidden-retired.md b/.changeset/view-overlay-owner-hidden-retired.md deleted file mode 100644 index 726a7ed32a8..00000000000 --- a/.changeset/view-overlay-owner-hidden-retired.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: retire the flattened view overlay's `owner` and `hidden` keys — accepted at the save door, stored, and read by nothing (#20230) - -**BREAKING** — `owner` and `hidden` are removed from the flattened view overlay: -the lean `view` body with no `config` that `PUT /api/v1/meta/view/:name` (the -Studio and MCP save) accepts, members 3 and 4 of the `view` metadata door, and -the same members in the assembled-manifest `viewItems:` channel. ADR-0049 -enforce-or-remove; triage direction, verbatim: 「follow #20085's disposition for -the same key pair」. This completes the family: the view item record's `owner` / -`hidden` are retired in this same release by its own entry, with the same texts. - -⚠️ **This supersedes one sentence of the view item retirement's note in this same -release.** That note says the flattened overlay's own `owner` / `hidden` are -untouched and that a `{ object, viewKind, hidden: true }` overlay still parses. -True of that change alone; after this one, such an overlay is refused too. Read the -two notes together: after this release, neither door accepts either key. - -Clause-②: no (narrowing) - -The overlay door declared both keys separately from the view item's pair. A bound -overlay such as `{ object, viewKind, hidden: true }` saved clean and one row was -stored with the key, and nothing ever read it. Both view-switcher read paths -(`GET /meta/view?object=` and `getViewsByObject`) filter on `viewKind` + `object` -and sort on `order`, so `hidden: true` hid nothing, and a view with `owner` set -was listed for every user who can read the object. - -Writer census, taken before removal: no writer of either overlay key in this -framework or its examples, in objectui at its pinned commit and at `main` (the -toolbar writes only `rowHeight`, `sort`, `hiddenFields`, `columnState` and -`inlineEdit`; the switcher only `label`, `isPinned`, `isDefault` and `sortOrder`), -or in the HotCRM app. The cloud repository was not reachable from the census. - -### FROM → TO - -| removed | what to write instead | -| --- | --- | -| flattened overlay `owner` | delete the key. Nothing restricts a view to one user today; a view is visible to everyone who can read its object. | -| flattened overlay `hidden` | delete the key. To take a view out of the switcher, delete the view item (or stop shipping it from source). | - -**The one-line fix: delete `owner:` and `hidden:` from every view body you save.** -`os migrate meta --from 17` lists the mechanical edits for existing sources. - -⚠️ Runtime behaviour is deliberately **unchanged**. Neither key ever changed what -a view showed or to whom. What changes is the answer an author gets: a save that -carries either key is refused `422 INVALID_METADATA`, with the prescription -located at the key, instead of being stored with no effect. The prescriptions are -the view item's own texts, so the family answers with one voice on both doors. - -### Stored rows - -Every read of a stored `view` row replays the conversion chain before the row is -served or badged, and the D2 conversion strips both keys there. What that leaves -depends on what else the row holds: - -- **A row with any other view key** (a column state, a sort, a default flag, an - order): served and badged valid without the keys. A GET then a PUT of the whole - row saves (if it was otherwise valid), so the console's next read-merge-write of - it saves, and `os migrate meta --stored --apply` rewrites it. -- **A hide-only row**, holding nothing but its identity (`name`, `object`, - `viewKind`, `label`) and `owner` / `hidden`, such as - `{ object, viewKind, hidden: true }`: the strip leaves identity only, which the - `view` door refuses ("only identity fields"). The row is served badged invalid - (it was badged valid before this release). A whole-row re-save, or one that adds - only identity (a rename sets `label`), answers `422 INVALID_METADATA`. - `--apply` reports it `failed` and leaves it as stored; every read strips it - again. A write that adds a real view key, such as a toolbar toggle, saves. - **Fix: delete the row** (it never changed what anyone saw), or add the - personalization setting its author meant and save that. - -### The retirement kit - -- **Tombstones on both overlay members.** `retiredKey()` in - `flattenedViewOverlayFields()`, with the view item's prescription texts. Both - members `.strip()`, so a bare deletion would have dropped the key in silence - (ADR-0104). -- **D2 conversion `view-overlay-owner-hidden-removed`** (step 18, retired from the - load path). A lossless delete from the flattened spelling (no `config`, no - container slot) in `views` (stack sources and stored rows) and `viewItems` - (assembled artifacts). It is disjoint from `view-item-owner-hidden-removed` by - `config`, so no row is judged by both. -- **D3 semantic entry `view-overlay-owner-hidden-retired`**: the family's one D3 - record, naming its D2 conversion. The view item record's pair is a separate - family with its own conversion and its own D3 entry; the two share the - prescription texts. -- **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ViewMetadata:owner`, `ui/ViewMetadata:hidden`. - `ui/ViewMetadata` is unemitted (its `z.undefined()` guards have no JSON Schema - form), so no build gate judges these rows and the four surface ratchets are - byte-identical on this retirement. The rows are pinned by the retirement test. -- **No liveness row**: the `view` ledger walks the container keys only. -- **No deprecation window**, per the project's startup-stage posture. - -⚠️ **The out-of-repo population is NOT MEASURED.** `@objectstack/spec` is published, -and production `sys_metadata` rows are not reachable from the repository. Stored -rows are stripped on read by the conversion above, and a hide-only row among them -needs the fix above. A client that still sends either key is refused at its next -save. - - diff --git a/.changeset/visiblewhen-app-scope-root-prose.md b/.changeset/visiblewhen-app-scope-root-prose.md deleted file mode 100644 index 5c0991aa422..00000000000 --- a/.changeset/visiblewhen-app-scope-root-prose.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): stop advertising `app` as an expression-scope root the shipping renderer mounts (#17203) - -Six prose faces of the UI schemas told an author that a CEL predicate could name `app` — that the shipping renderer mounts it alongside `features` and `os.user`. It does not, and it never contractually did. `@objectstack/formula`'s `SCOPE_ROOTS` has never declared `app`, and ADR-0068 has never ruled it; decision batch #67 (2026-09-07) ruled option B — the engine's `SCOPE_ROOTS` is the contract and ObjectUI aligns to it — and ObjectUI shipped that, so `buildExpressionScope` no longer binds `app`. The producer-side option-A card (widen `SCOPE_ROOTS` to match the old prose) was closed `not_planned` in the same ruling. - -The `app` token is deleted from all six. `features`, `os.user`, `data`, `current_user`, `record` and `user` all stay, in place and in their existing order, and the "renderer behaviour, NOT contract-guaranteed" framing is unchanged: - -- `ui/page.zod.ts` — the "Ambient roots" docblock, and the **published `.describe()`** on `PageComponentSchema.visibleWhen`, which republishes verbatim into `content/docs/references/ui/page.mdx` (regenerated here). -- `ui/action.zod.ts` — the param-level `visible` docblock, and the **action-level `visible`** docblock, which stated the same claim unbackticked (`record/user/app/features`) and was invisible to a probe shaped for the backticked token. -- `ui/component.zod.ts` — the `page:tabs` ambient-root name-resolution example, and its "also mounts the ambient …" sentence. - -Why this was worth correcting rather than leaving to rot: this `.describe()` is the surface an authoring tool and a metadata-generating agent read (ADR-0033 lists AI as a primary consumer), and it was the last place anywhere that could still teach either to write `app.tier == 'pro'`. The resulting predicate does not fail uniformly and is silent both ways — a field `visibleWhen` and a nav / area `visible` fail OPEN (the gate stops hiding), a conditional-formatting `condition` and a row-action `visible` / `disabled` fail CLOSED (the rule silently stops matching). - -No accept set moves: `SCOPE_ROOTS` is untouched, every schema parses exactly what it parsed before, and a predicate naming `app` is accepted and rejected precisely where it was. This narrows what the protocol advertises, and nothing else. A pin test now holds all six faces, published and TSDoc alike. diff --git a/.changeset/wild-jars-hammer.md b/.changeset/wild-jars-hammer.md deleted file mode 100644 index 5134e3d9b68..00000000000 --- a/.changeset/wild-jars-hammer.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Correct what `retiredFromLoadPath` declares about its own reach. - -The flag's docs said a retired conversion is "never at load" and that "the load -seam never sets this — only `objectstack migrate meta` (and the fixture CI) -replays it". Neither half held. Three data-at-rest call sites pass -`includeRetired: true` on purpose — `applyConversionsToStoredItem` (which pins -it rather than offering it), flow rehydration in the automation engine, and the -artifact-ingestion door `applyArtifactForwardConversions` — and `migrate meta` -does not reach the option at all: `applyMetaMigrations` looks each step's -conversion up by id and calls `apply` directly. - -What the flag actually governs is the **authoring** surface: it keeps the entry -off `normalizeStackInput`, the single funnel for `defineStack`, `validate`, -`lint`, `compile`, `info` and `doctor`, so a live author meets the tombstone -instead of a silent rewrite. That split is what ADR-0087's -`## Addendum (2026-07-31)` and the artifact-door ruling both bought. - -Documentation only — no behaviour, no schema key and no export moves. The -corrected text ships in `dist/*.d.ts`, and the split it describes is now pinned -by a test that drives `normalizeStackInput` and `applyConversionsToStoredItem` -over the same bytes, so the sentence and the behaviour cannot drift apart again. - -Authors setting this flag on a **default flip** (old and new shapes both legal, -meaning different things) should read the corrected doc: the flag does not -confine such a rewrite to history — the data-at-rest seams still apply it. diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index d6dbf08da20..8fdd5382756 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -75,7 +75,7 @@ docker run -p 8080:8080 \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET \ -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.4.0 + ghcr.io/objectstack-ai/objectstack:17.5.0 ``` (`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come @@ -93,7 +93,7 @@ docker run -p 8080:8080 \ -e OS_ARTIFACT_URL="https://releases.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.4.0 + ghcr.io/objectstack-ai/objectstack:17.5.0 ``` Both schemes work: `https://…` is fetched at boot, `file:///…` is read directly @@ -144,7 +144,7 @@ COPY . . RUN npx os build # → dist/objectstack.json # ── Runtime: the official ObjectStack runtime image ────────────────── -FROM ghcr.io/objectstack-ai/objectstack:17.4.0 +FROM ghcr.io/objectstack-ai/objectstack:17.5.0 COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json ``` @@ -162,7 +162,7 @@ image)? The official image is nothing more than: ```dockerfile title="Dockerfile (self-built runtime, equivalent)" FROM node:22-slim -RUN npm install -g @objectstack/cli@17.4.0 +RUN npm install -g @objectstack/cli@17.5.0 WORKDIR /srv/app RUN chown node:node /srv/app diff --git a/content/docs/releases/index.mdx b/content/docs/releases/index.mdx index 2240dfba24a..cac90c15144 100644 --- a/content/docs/releases/index.mdx +++ b/content/docs/releases/index.mdx @@ -18,7 +18,7 @@ migration steps, then covers new capabilities and notable fixes. ## Versions -- [v17.0.0](/docs/releases/v17) — Files become owned `sys_file` records with server-enforced `accept`/`maxSize` and a governed download path, bulk export becomes its own opt-in privilege, the SDK is reconciled against the routes the server actually mounts (21 dead methods out, 40+ real ones in), approval nodes route approvers dynamically via CEL expressions and decision outputs, a datasource that cannot connect fails the boot, and Node 22 becomes the supported floor; 17.1 adds partial field masking, record-view auditing on `sys_audit_log`, and a per-object read-only approval visibility tier — and makes a deactivated permission set or position actually stop granting access, withdraws the bulk-export wildcard from the shipped admin sets, and gives all three flow doors one honest HTTP status table; 17.2 tightens by-id `update`/`delete` against a silently-dropped `where` predicate or a mismatched id, retires `sys_position.permissions` and other dead ADR-0049 surfaces, and stops analytics from answering the wrong number on a cross-object filter (current series: 17.4.0, released 2026-09-09). +- [v17.0.0](/docs/releases/v17) — Files become owned `sys_file` records with server-enforced `accept`/`maxSize` and a governed download path, bulk export becomes its own opt-in privilege, the SDK is reconciled against the routes the server actually mounts (21 dead methods out, 40+ real ones in), approval nodes route approvers dynamically via CEL expressions and decision outputs, a datasource that cannot connect fails the boot, and Node 22 becomes the supported floor; 17.1 adds partial field masking, record-view auditing on `sys_audit_log`, and a per-object read-only approval visibility tier — and makes a deactivated permission set or position actually stop granting access, withdraws the bulk-export wildcard from the shipped admin sets, and gives all three flow doors one honest HTTP status table; 17.2 tightens by-id `update`/`delete` against a silently-dropped `where` predicate or a mismatched id, retires `sys_position.permissions` and other dead ADR-0049 surfaces, and stops analytics from answering the wrong number on a cross-object filter (current series: 17.5.0, released 2026-09-29). - [v16.0.0](/docs/releases/v16) — One org identifier (`organizationId`) across hooks and actions, quorum + per-group sign-off (会签) approvals with metadata-declared decision actions, time-relative automations, filtered roll-ups, strict dashboard widgets, an identity-scoped MCP stdio transport, and a platform-wide enforce-or-remove sweep that makes dead metadata loud; 16.1 adds a `requires` capability-provider preflight, two more dashboard build gates, and `runAs:'user'` automations that run with the triggering user's real grants (final release: 16.1.0). - [v15.0.0](/docs/releases/v15) — Explain record access layer by layer, a docked AI workspace in the Console, project-ready Gantt charts, and phone sign-in; 15.1 adds permission-following attachments, no-code third-party connectors, dashboard-wide filters, pinyin search, and whole-record inline editing — with materially safer multi-tenant and write-path defaults (final release: 15.1.1). - [v14.0.0](/docs/releases/v14) — ADR-0090 vocabulary convergence completed, object `enable.*` flags become real gates, admin user management, phone/SMS auth, book-audience enforcement, data-lifecycle contract, and effective-dated grants (final release: 14.8.0). diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 6429c60b624..0e69fb16876 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -52,7 +52,7 @@ The official image is `ghcr.io/objectstack-ai/objectstack`, and its tags mirror ```bash # docker-compose.yml, or your orchestrator's manifest -image: ghcr.io/objectstack-ai/objectstack:17.4.0 +image: ghcr.io/objectstack-ai/objectstack:17.5.0 ``` On a host running the artifact directly under systemd, the same move is a file diff --git a/docker/README.md b/docker/README.md index 75bd9cf7dc0..80da9e3880e 100644 --- a/docker/README.md +++ b/docker/README.md @@ -29,7 +29,7 @@ Multi-arch: `linux/amd64` + `linux/arm64`. [Self-Hosted Deployment](https://objectstack.ai/docs/deployment/self-hosting)): ```dockerfile -FROM ghcr.io/objectstack-ai/objectstack:17.4.0 +FROM ghcr.io/objectstack-ai/objectstack:17.5.0 COPY --chown=node:node dist/objectstack.json /srv/app/objectstack.json ``` @@ -40,7 +40,7 @@ docker run -p 8080:8080 \ -v "$PWD/dist/objectstack.json:/srv/app/objectstack.json:ro" \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.4.0 + ghcr.io/objectstack-ai/objectstack:17.5.0 ``` `OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come @@ -72,7 +72,7 @@ for a `file:…` path — one box only, wrong for multi-node) and MongoDB (`libsql://…` / Turso). Add one by extending the image: ```dockerfile -FROM ghcr.io/objectstack-ai/objectstack:17.4.0 +FROM ghcr.io/objectstack-ai/objectstack:17.5.0 USER root RUN npm install -g tedious USER node @@ -100,5 +100,5 @@ reverse-proxy / multi-node guidance: ## Local build of this image ```bash -docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.4.0 docker/ +docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.5.0 docker/ ``` diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index 0c0788285c1..1ca7030f7d2 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,481 @@ # @objectstack/example-crm +## 4.0.97 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [fdeeea0] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [08b213e] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [4af758d] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [cea85fd] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [310760d] +- Updated dependencies [2b6a207] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [c17ff70] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [74327d3] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [4fef271] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [13d5294] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [c02fa12] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [0862063] +- Updated dependencies [5c5b67f] +- Updated dependencies [f9977c1] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [3fd3a4f] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [b7b6cdd] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [b81da66] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [fa00ebf] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [2bcd5cf] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [cc40033] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [95f729a] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [5049a3c] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [5c7aa46] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [45f428d] +- Updated dependencies [9449512] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [76ddab7] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [ea4d164] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [e77a23f] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [e6965dd] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [777d0c2] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/service-i18n@17.5.0 + ## 4.0.96 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 681122206fe..b8486472992 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-crm", - "version": "4.0.96", + "version": "4.0.97", "description": "Minimal CRM example \u2014 a smoke-test workspace that exercises the metadata loading pipeline (objects \u2192 views \u2192 app \u2192 dashboard \u2192 hook \u2192 flow \u2192 seed). For a full-featured enterprise CRM see https://github.com/objectstack-ai/hotcrm.", "license": "Apache-2.0", "private": true, diff --git a/examples/app-multi-package/CHANGELOG.md b/examples/app-multi-package/CHANGELOG.md index ad88a3a4ff5..6b427c69b97 100644 --- a/examples/app-multi-package/CHANGELOG.md +++ b/examples/app-multi-package/CHANGELOG.md @@ -1,5 +1,442 @@ # @objectstack/example-multi-package +## 0.0.4 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + ## 0.0.3 ### Patch Changes diff --git a/examples/app-multi-package/package.json b/examples/app-multi-package/package.json index 49552902090..107d3a5f269 100644 --- a/examples/app-multi-package/package.json +++ b/examples/app-multi-package/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-multi-package", - "version": "0.0.3", + "version": "0.0.4", "description": "One release artifact carrying TWO packages that share a namespace (ADR-0130 D4) — the producer-side fixture for `packages[]`", "license": "Apache-2.0", "private": true, diff --git a/examples/app-showcase/CHANGELOG.md b/examples/app-showcase/CHANGELOG.md index fdef41f6691..b7749d89dc4 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,521 @@ # @objectstack/example-showcase +## 0.3.19 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [fdeeea0] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [08b213e] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [4af758d] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [32be735] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [cea85fd] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [d46deba] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [310760d] +- Updated dependencies [2b6a207] +- Updated dependencies [497655f] +- Updated dependencies [7c2c5ae] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [be5c602] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [9ccc417] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [c17ff70] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [74327d3] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [b4b83b3] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [4fef271] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [13d5294] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [c02fa12] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [0862063] +- Updated dependencies [5c5b67f] +- Updated dependencies [f9977c1] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [d58b8b6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [3fd3a4f] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [b7b6cdd] +- Updated dependencies [beac798] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [9bfbacb] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [b81da66] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [9d81af7] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [57c2b73] +- Updated dependencies [f09d412] +- Updated dependencies [adbbc5d] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8d76c2d] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [fa00ebf] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [e01d347] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [2bcd5cf] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [cc40033] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [d3958ba] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [95f729a] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [15bf186] +- Updated dependencies [b285508] +- Updated dependencies [5049a3c] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [fc0db22] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [5c7aa46] +- Updated dependencies [2304b16] +- Updated dependencies [5b674f5] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [45f428d] +- Updated dependencies [9449512] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [40626bd] +- Updated dependencies [76ddab7] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [ea4d164] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [e77a23f] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [e6965dd] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [777d0c2] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [43e17b8] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/cloud-connection@17.5.0 + - @objectstack/service-i18n@17.5.0 + - @objectstack/connector-rest@17.5.0 + - @objectstack/connector-openapi@17.5.0 + - @objectstack/connector-mcp@17.5.0 + - @objectstack/connector-slack@17.5.0 + - @objectstack/service-datasource@17.5.0 + ## 0.3.18 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index d98f17aa531..17be7fd2763 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-showcase", - "version": "0.3.18", + "version": "0.3.19", "description": "Kitchen-sink showcase workspace — exercises every metadata type, every view type, every chart type, and the major end-to-end capability chains (security, automation, analytics). Built for demonstration, debugging, and coverage-driven verification.", "license": "Apache-2.0", "private": true, diff --git a/examples/app-todo/CHANGELOG.md b/examples/app-todo/CHANGELOG.md index 56769e423a0..c4eae438930 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,584 @@ # @objectstack/example-todo +## 4.0.97 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [fdeeea0] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [08b213e] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [3c86008] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [f19dbcf] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [4af758d] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [7baf04a] +- Updated dependencies [63b6818] +- Updated dependencies [6b2ec3b] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [cea85fd] +- Updated dependencies [cb04f45] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [be7382d] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [310760d] +- Updated dependencies [2b6a207] +- Updated dependencies [497655f] +- Updated dependencies [c5d270a] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [400167a] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [62a6dc3] +- Updated dependencies [17005cc] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d6137fd] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [922c755] +- Updated dependencies [74832b6] +- Updated dependencies [c17ff70] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [be7aeb8] +- Updated dependencies [c7448dc] +- Updated dependencies [74327d3] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [be7763a] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [55523fd] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [4fef271] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [13d5294] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [c02fa12] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [7ffddfa] +- Updated dependencies [0862063] +- Updated dependencies [5c5b67f] +- Updated dependencies [f9977c1] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [b76aad5] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [3fd3a4f] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [1c8b320] +- Updated dependencies [a90272a] +- Updated dependencies [5dba7f3] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [afc3b64] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [2bbb462] +- Updated dependencies [90ff10a] +- Updated dependencies [e9eb224] +- Updated dependencies [3bd221d] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [d1ca874] +- Updated dependencies [b7b6cdd] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [8490127] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [ae0c90c] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [0b866bf] +- Updated dependencies [c839986] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [009da14] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [aa04ea2] +- Updated dependencies [172b4cf] +- Updated dependencies [fc6ddb8] +- Updated dependencies [b81da66] +- Updated dependencies [4463966] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [9d81af7] +- Updated dependencies [e7f69db] +- Updated dependencies [b373596] +- Updated dependencies [7465eeb] +- Updated dependencies [84156c7] +- Updated dependencies [57c2b73] +- Updated dependencies [f09d412] +- Updated dependencies [adbbc5d] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8d76c2d] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [fa00ebf] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [a08e059] +- Updated dependencies [fc646cf] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [949e99b] +- Updated dependencies [16c5a33] +- Updated dependencies [16c5a33] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [16c5a33] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [cfe2387] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [2bcd5cf] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [cc40033] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [a78f731] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [c74de10] +- Updated dependencies [db74b16] +- Updated dependencies [2b24b8b] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [95f729a] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [c5d6b2b] +- Updated dependencies [2d91c9a] +- Updated dependencies [2f122b6] +- Updated dependencies [b285508] +- Updated dependencies [5049a3c] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [4a1df19] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [9801da1] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [5c7aa46] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [45f428d] +- Updated dependencies [9449512] +- Updated dependencies [b2b6a06] +- Updated dependencies [8538edf] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [92ea760] +- Updated dependencies [76ddab7] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [ea4d164] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [01388fe] +- Updated dependencies [d61139f] +- Updated dependencies [1c4270f] +- Updated dependencies [f904e61] +- Updated dependencies [5de9372] +- Updated dependencies [f8fea00] +- Updated dependencies [fb6a2de] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [e77a23f] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [a016f08] +- Updated dependencies [b110578] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [3977410] +- Updated dependencies [46cf705] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [bccf311] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [e6965dd] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [777d0c2] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [4280055] +- Updated dependencies [032452a] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/mcp@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/client@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/service-i18n@17.5.0 + - @objectstack/driver-sqlite-wasm@17.5.0 + - @objectstack/knowledge-memory@17.5.0 + - @objectstack/service-knowledge@17.5.0 + ## 4.0.96 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index 48173191e16..60250bdbf83 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-todo", - "version": "4.0.96", + "version": "4.0.97", "description": "Example Todo App using ObjectStack Protocol", "license": "Apache-2.0", "private": true, diff --git a/examples/embed-objectql/CHANGELOG.md b/examples/embed-objectql/CHANGELOG.md index 13626bd7892..8a8fd25fa6d 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,504 @@ # @objectstack/example-embed-objectql +## 0.0.37 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [63b6818] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [17005cc] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [922c755] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [4fef271] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [5dba7f3] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [afc3b64] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [2bbb462] +- Updated dependencies [90ff10a] +- Updated dependencies [3bd221d] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [8490127] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [ae0c90c] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [0b866bf] +- Updated dependencies [c839986] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [009da14] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [aa04ea2] +- Updated dependencies [172b4cf] +- Updated dependencies [4463966] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [b373596] +- Updated dependencies [7465eeb] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [a08e059] +- Updated dependencies [fc646cf] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [949e99b] +- Updated dependencies [16c5a33] +- Updated dependencies [16c5a33] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [16c5a33] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [cfe2387] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [a78f731] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [c74de10] +- Updated dependencies [db74b16] +- Updated dependencies [2b24b8b] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [c5d6b2b] +- Updated dependencies [2d91c9a] +- Updated dependencies [2f122b6] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [4a1df19] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [9801da1] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [b2b6a06] +- Updated dependencies [8538edf] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [92ea760] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [a016f08] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/driver-memory@17.5.0 + ## 0.0.36 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index e60ead7f026..736c48b957f 100644 --- a/examples/embed-objectql/package.json +++ b/examples/embed-objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-embed-objectql", - "version": "0.0.36", + "version": "0.0.37", "private": true, "description": "Embed the ObjectQL engine as a plain library via @objectstack/objectql/core — no kernel, no plugins, no metadata protocol (ADR-0076).", "type": "module", diff --git a/packages/adapters/hono/CHANGELOG.md b/packages/adapters/hono/CHANGELOG.md index 7e02a1a80a1..16c40a4c5dc 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,79 @@ # @objectstack/hono +## 17.5.0 + +### Patch Changes + +- Updated dependencies [7f62536] +- Updated dependencies [7d0f911] +- Updated dependencies [fdeeea0] +- Updated dependencies [3c48234] +- Updated dependencies [08b213e] +- Updated dependencies [89a652b] +- Updated dependencies [4af758d] +- Updated dependencies [bdb247d] +- Updated dependencies [cea85fd] +- Updated dependencies [1a25f4a] +- Updated dependencies [182bbde] +- Updated dependencies [75237a9] +- Updated dependencies [310760d] +- Updated dependencies [2b6a207] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [156792e] +- Updated dependencies [99fcb4a] +- Updated dependencies [74832b6] +- Updated dependencies [c17ff70] +- Updated dependencies [74327d3] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [0318faf] +- Updated dependencies [4fef271] +- Updated dependencies [a484966] +- Updated dependencies [2767af8] +- Updated dependencies [215840f] +- Updated dependencies [13d5294] +- Updated dependencies [c02fa12] +- Updated dependencies [0b4022b] +- Updated dependencies [0862063] +- Updated dependencies [f9977c1] +- Updated dependencies [3875ae6] +- Updated dependencies [95fb417] +- Updated dependencies [3fd3a4f] +- Updated dependencies [90ff10a] +- Updated dependencies [b7b6cdd] +- Updated dependencies [a9fb83e] +- Updated dependencies [b81da66] +- Updated dependencies [fa00ebf] +- Updated dependencies [2bcd5cf] +- Updated dependencies [cc40033] +- Updated dependencies [95f729a] +- Updated dependencies [5049a3c] +- Updated dependencies [5c7aa46] +- Updated dependencies [45f428d] +- Updated dependencies [9449512] +- Updated dependencies [d1c01ff] +- Updated dependencies [76ddab7] +- Updated dependencies [ea4d164] +- Updated dependencies [c3ebe4a] +- Updated dependencies [0a56d3b] +- Updated dependencies [cefe068] +- Updated dependencies [288fe9c] +- Updated dependencies [e77a23f] +- Updated dependencies [6e3462d] +- Updated dependencies [331a1a2] +- Updated dependencies [e6965dd] +- Updated dependencies [5a95b0e] +- Updated dependencies [ca31ff6] +- Updated dependencies [0ced0aa] +- Updated dependencies [777d0c2] +- Updated dependencies [f04be62] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] + - @objectstack/types@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/plugin-hono-server@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index 389dc2f5e13..4fd636ad50c 100644 --- a/packages/adapters/hono/package.json +++ b/packages/adapters/hono/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/hono", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apps/account/CHANGELOG.md b/packages/apps/account/CHANGELOG.md index 8b2c00325d1..7287dd71bb0 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,469 @@ # @objectstack/account +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index db70f15728e..746221f7657 100644 --- a/packages/apps/account/package.json +++ b/packages/apps/account/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/account", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack Account — the end-user account/self-service console app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/apps/setup/CHANGELOG.md b/packages/apps/setup/CHANGELOG.md index a1a5027d1e6..0af0d3e11b8 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,469 @@ # @objectstack/setup +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index fc4866f62d2..4eba739f617 100644 --- a/packages/apps/setup/package.json +++ b/packages/apps/setup/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/setup", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack Setup — the platform administration app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/apps/studio/CHANGELOG.md b/packages/apps/studio/CHANGELOG.md index f3fe0e382c5..7f1c5054e1b 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,469 @@ # @objectstack/studio +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index a18b7881a66..b88d2549e9f 100644 --- a/packages/apps/studio/package.json +++ b/packages/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/studio", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack Studio — the metadata builder app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 36d8ee67c7f..0487eb841ca 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,3969 @@ # @objectstack/cli +## 17.5.0 + +### Minor Changes + +- fe71032: feat(driver-sql,objectql,cli)!: the ADR-0104 file-family column step, and the kernel→driver supply that arms it (#15989) + + + + **BREAKING** on the published storage behaviour of `@objectstack/driver-sql`. A deployment that runs `os migrate files-to-references --apply` now has its media columns **retyped and their values rewritten** into the bare-`sys_file`-id encoding, and its driver writes bare ids from the next boot. This completes the maintainer ruling on #15041 (「15041 应该改为实际 id 保存。选A,其他同意」) whose encoding half shipped in the previous release. + + Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + ## The column step + + `os migrate files-to-references --apply` gains a further step, run **only after** the backfill and its self-check report zero blocking rows — and it moves nothing at all until three gates pass: + + 1. the migration's own gate (zero blocking rows); + 2. **every** abort pre-check, across **every** planned column, before a single statement runs; + 3. no refusals — a column the driver could not plan stops the columns it could. + + **PostgreSQL** and **SQLite** only. ⛔ MySQL is refused by name and belongs to #17788, where its statement ORDER is settled against a real instance rather than transcribed. + + Per column, the shape is read off the column's **physical type**, not off the dialect: a `json` column is retyped (`ALTER … TYPE varchar(2048) USING (col #>> '{}')`), while a column that is already `varchar` — the population `os generate migration --format sql` creates and a JSON-arm driver fills with quoted ids — has its values unquoted in place. SQLite has only the second shape, since it has no json type. + + ### ⛔ The abort clause is NOT the one the ADR sketched + + The #15041 addendum prescribed the retype with nothing in front of it while *requiring* the step to abort "on the first cell that is not a JSON string". Those two sentences contradict each other, and which was wrong was settled by running it. Measured on live PostgreSQL 16.13, `USING (col #>> '{}')` is **accepted** over a row holding an inline metadata blob, because `#>> '{}'` extracts *any* json type as text: the bytes survive, but the column is no longer `json`, so an object becomes a plain string in a column whose declared contents are ids — silently, in a migration that reports success. The director ruling (decision batch #120 item 1) replaced the clause with the pre-check that implements the requirement: `json_typeof(col) IS DISTINCT FROM 'string'` on PostgreSQL, and `json_valid(col) AND json_type(col) <> 'text'` on SQLite, where excluding invalid JSON is what keeps a re-run idempotent over cells a previous run already moved. + + Both the destructive form and the guarded one are executed side by side, on one fixture, in this release's own test suite — so the difference stays a measurement rather than a comment. + + ## The kernel→driver supply seam + + `SqlDriverConfig.fileColumnsMoved` shipped last release and no host outside the driver supplied it. It is supplied now: `ObjectQL.registerDriver` hands every driver that has the seam a closure over the new `ObjectQL.haveFileColumnsMoved()`, which reads `sys_migration.columns_moved_at` — and requires the `adr-0104-file-references` flag to be verified **as well**, since the stamp alone would attest a column move with nothing attesting the values inside it. + + ⭐ **Every way of not knowing still answers "not moved".** The option omitted, a resolver that throws or rejects or answers a non-`true` value, a resolver that never runs because the host never calls `initObjects`, a driver with no such seam, no `sys_migration` object, no row, an unreadable table, a null or empty stamp — all the JSON arm. That is the encoding every deployment in the world is on, and a driver that guessed the other way would write bare ids into a JSON column. + + ⛔ **A host that names `fileColumnsMoved` in its own config wins**, in either polarity. The engine only ever fills an empty slot, and never contradicts an explicit composition: overruling a declared `false` is precisely the bare-ids-into-a-JSON-column failure this mechanism exists to prevent. + + ## New published surface + + - `@objectstack/spec` — `hasMovedFileColumns(flag)`, the single arbiter of the conjunction above, beside `isDataMigrationFlagVerified` and `authorisesIrreversibleAction`. + - `@objectstack/objectql` — `ObjectQL.haveFileColumnsMoved()`, sharing one memoized read (and one `invalidateDataMigrationFlags()`) with `isFileReferencesMigrationVerified()`, so the two answers can never come out of one another's date. + - `@objectstack/platform-objects` — `recordFileColumnMove(engine, migrationId)`, which refuses to stamp a deployment with no verified flag row. `readDataMigrationFlag` now carries `columns_moved_at`; it previously dropped it, which made a moved deployment indistinguishable from an unmoved one to every caller. + - `@objectstack/driver-sql` — `SqlDriver.setFileColumnsMovedResolver()`, `SqlDriver.planMediaColumnMove()`, and the statement builders `mediaColumnMovePlan` / `mediaColumnMoveDialect` / `isJsonColumnType` with `MEDIA_COLUMN_MOVE_DIALECTS`, `MEDIA_COLUMN_MOVE_ROLLBACK_NOTES` and `MEDIA_ID_MOVE_WIDTH`. The statements live in the package that owns the dialects and measured them; a second copy in the CLI would be a second copy of the clause the ruling got wrong. + + ## What does NOT change + + A deployment that does not run `--apply` is byte-for-byte where it was: the column stays `json`, the write still JSON-encodes, and the read still accepts both encodings. A backfill re-run does not set the stamp and — deliberately — cannot clear it either: `recordDataMigrationRun` omits the key rather than writing a preserved value, so a ledger read that FAILS cannot demote a moved deployment back onto the JSON arm. A partial or failed column step records nothing at all, which leaves such a datastore on the arm that reads both encodings. + + `multiple: true` media is untouched on both arms: its value is a list of ids and a JSON column on every deployment. +- 89a652b: feat(cli): `objectstack dev --cert --key ` terminates TLS in the dev process, and the canonical origin follows the listener (#16804) + + An interactive MCP client refuses to start an OAuth sign-in against a non-TLS + URL, so the self-serve identity path the product advertises — "interactive + clients just open a browser login" — could not be exercised against a local dev + server at all. The only way round it was a hand-built https reverse proxy plus + `OS_AUTH_URL`, a page of setup that every developer, demo and video recording + repeated off-camera. + + **Bring your own certificate.** Nothing here generates one, and nothing here — + not the code, not `--help`, not any doc page — says anything about installing a + certificate into a system trust store. 「⛔ 不生成自签 CA;⛔ 不打印、不文档化任何 + 「把 CA 装进系统信任库」的指引——信任库是开发者自己的事」. The trust store is the + developer's own business; this feature's whole job is to *use* the certificate + they already have. + + ```bash + objectstack dev --cert ./localhost.pem --key ./localhost-key.pem + ``` + + Both flags are required together — half a pair is refused by name — and an + unreadable file is refused rather than degraded to a plain-http listener. + + **What follows the listener.** With both flags given, everything this boot + advertises is `https://localhost:`: the two `/.well-known/*` discovery + documents, the CSRF allow-list, the ready banner's `API:` / `MCP:` rows, the + `🤖 MCP server` connect hint, and the runtime state file the `os dev` parent and + external supervisors dial. Only the built-in default at the end of the base-URL + chain moves — `OS_AUTH_URL`, `BETTER_AUTH_URL` and `OS_BASE_URL` keep winning, + an `http://` value included, because they name where a deployment is *reached* + rather than what this process *bound*. + + **Without the flags nothing changes**, byte for byte — pinned by ablation legs + rather than asserted. + + `@objectstack/plugin-hono-server` gains the option this is built on: + `HonoPluginOptions.tls` (`{ cert, key }` PEM bytes) makes the adapter bind a TLS + listener with the same fetch handler, the same route table and the same graceful + drain. Absent, the listener is plain http exactly as before. +- 8b48903: feat(spec): `spec-changes.json` ships a per-release section, verified against both tarballs (#17080) + + Clause-②: yes (widening) — one new OPTIONAL section on a published artifact plus one new + `os validate --json` key. Nothing previously present is renamed, retired or reshaped: the + `aggregate` and `perMajor` records and every existing key keep their spelling and meaning. + Contract-review tier. + + `spec-changes.json` (ADR-0087 D4) is keyed to the **protocol major**, while this repo's + launch-window convention ships BREAKING entries as **minors**. A consumer crossing one minor + therefore reads a file whose finest question is "16 → 17" — answered long ago — with + `added: 0, removed: 0`, which reads as *nothing changed*. Measured on the published tarballs: + between `@objectstack/spec@17.3.0` and `17.4.0` the export surface gained **225** exports and + lost **51**, and the shipped manifest reported zero of each. + + **What ships now.** The published artifact carries a `release` section — `fromVersion` → + `toVersion` at package-version resolution, with `added` / `removed` (the exports that arrived + and left, each named `": ()"`) and `converted` / `migrated` (the ADR-0087 + D2/D3 entries first registered in that release): + + ```bash + jq '.release | {fromVersion, toVersion, added: (.added | length), removed: (.removed | length)}' \ + node_modules/@objectstack/spec/spec-changes.json + os validate --json | jq .specReleaseChanges # the same data, via the CLI + ``` + + **The committed copy is unchanged and stays deterministic.** The section is a function of a + previously *published* tarball, so it is generated at publish time only; `check:spec-changes` + keeps the registry-only projection in the tree exactly as it was. + + **A wrong change file is worse than none, so it is gated.** Before anything reaches npm the + release lane recomputes the delta from the two tarballs — the previously published one and the + one about to be published — and refuses to publish when the section disagrees, naming the + disagreeing exports and the direction of each disagreement. A release whose data would mislead + does not ship. + + **Absence stays distinguishable from zero.** When the previous tarball carries no export + snapshot the section is omitted rather than emitted empty, and `specReleaseChanges` is `null` + in exactly that case: a consumer must never read "could not be computed" as "nothing changed", + which is the defect this closes. + + New public exports on `@objectstack/spec`: `SpecReleaseChangesSchema`, + `SpecReleaseSurfaceSchema`, `composeReleaseChanges`, and the types `SpecReleaseChanges`, + `SpecReleaseSurface`, `PreviousReleaseRegistries`, `ReleaseSurfaceDiff`. +- ed5a1e7: `os serve` now announces **`objectstack:seed-settled`** on its existing ipc channel when this boot's seeding has come to rest, and `os dev` forwards it to its own parent process when one holds the channel. A script that spawns a dev server can finally wait for the boot to finish without reading the child's output. + + `✓ Server is ready` is true about the HTTP server and says nothing about the app. Seeding races a soft budget (`OS_INLINE_SEED_BUDGET_MS`, default 8s) and past it finishes in the background, so the banner can be a minute ahead of the seed's own result — measured downstream at **82 seconds of silence after the banner, then 120 `ERROR` lines**. The same command on the same corpus settles before the banner on a machine where the seed fits its budget, so the defect is invisible on exactly the boxes that would have caught it. Everything that distinguishes the two cases arrives on the child's inherited stdio, and reading that costs the boot its TTY. + + - **The producer is not new.** `@objectstack/runtime` already declares every seed source and settles it at the moment its boot-time write is done, publishing the tally under `@objectstack/spec`'s `seed-settlement` contract. This is the hop outward: the CLI subscribes to two hooks the kernel already fires and reads a snapshot it already publishes. No service is registered and no tally is mutated — the contract is read-only by design. + - **Sent once, and never before `objectstack:listening`.** Seeding that settles during `runtime.start()` is latched and released after the bound port is published, so a parent that waits for the listening message and only then listens for the settle cannot miss it. + - ⛔ **Keyed on `inFlight`, not `pending`.** Multi-tenant replay and `skipSeedData` register a seed source and deliberately never run it, keeping `pending` above zero for the life of the process. A `pending`-keyed message would never be sent on those boots, and its absence would be indistinguishable from a boot still writing — the same ambiguity this closes, one level up. Those boots get the message with `suppressed` reasons attached instead, so a consumer can say *why* no rows landed. + - **Failure settles too.** A seed that failed has still come to rest; withholding there would recreate the hang. `ok` is a verdict on the per-source counts the boot recorded, and the message carries those counts. + - **The over-budget banner no longer omits seeding.** `Seeds:` is fed by outcomes recorded when a load *finishes*, so past the budget the row was ABSENT and the transcript was byte-identical to an app that declares no seeds — which is how the defect hid. It now reads `pending — N sources still writing`, with a line saying seeding continues in the background; suppressed sources are named rather than reported as pending. + + ⛔ An ipc channel is **not** made a requirement of either command: `process.send` is undefined under an ordinary terminal boot, both sends are no-ops there, and no byte of that transcript changes. Nothing in the existing `objectstack:listening` publication moves. + + Note that `os dev` consumes `objectstack:listening` itself (it is how the bound-port readout and the MCP connect hint learn the real port) and relays only `objectstack:seed-settled`. Spawn `os serve` directly to receive both in one place. +- 49cd715: feat(cli)!: `os generate` refuses a name whose barrel alias no consumer could import by name (#17410) + + `os generate view class` exited **0** and wrote `export { default as class } from './class.view';`. That line parses — an ES module export clause admits a reserved word as a `ModuleExportName` — so both landed layers admitted it, each correctly by its own terms: the #16726 charset gate because every character of `class` is a lowercase letter, and the #16541 parse check because the bytes really are parseable TypeScript. The import side is not: `import { class } from './views'` needs an `ImportedBinding`, and a reserved word is not one. So the command reported success and produced a barrel entry nothing can name, with the failure deferred into the author's own file where it reads as their mistake. + + A third layer now stands behind those two. After the identifier is derived and before anything is written or previewed, the barrel alias is put through TypeScript **in the exact position a consumer must write it**, and the command refuses when the compiler will not take it — naming the constraint, showing the line that would have been written, and writing nothing. This delivers the #16726 ruling's own closing sentence, 「`os generate view class` is therefore refused at the door rather than emitting a barrel line that binds a reserved word.」, which the charset mechanism specified in that same ruling could not. + + ⛔ **No third charset** — the #16726 ruling forbids one and none is added: no character is judged. ⛔ **Nothing is rewritten.** Emitting a non-reserved alias while keeping the authored name was the other option and it loses on the reasoning that already refused option B: it decouples the name the author wrote from the name that gets emitted, silently. So this refuses, and the name you author stays the name that lands. + + **What this narrows:** 46 names — the 36 always-reserved words (`class`, `new`, `enum`, `default`, `import`, …) plus the ten reserved because a module is automatically in strict mode (`let`, `yield`, `static`, `implements`, `interface`, `package`, `private`, `protected`, `public`, and `await`, reserved at a module's top level). Every one is charset-legal and every one used to reach `exit 0` for the six generators that suffix their `const` binding (`view`, `action`, `flow`, `dashboard`, `app`, `skill`). The seventh, `object`, binds the bare identifier, so the parse check already refused **some** of them there — but only the always-reserved ones: `os g object let`, `os g object yield` and `os g object static` also exited 0, because a strict-mode reservation is a semantic diagnostic and that check is syntactic. Pick a name that survives as an import binding — `os g view order_line` works, and binds `orderLine`. + + **This is an observable change to accepted input:** those 46 names exit **0** today and will exit non-zero after this lands. Every one of them produced a barrel entry no consumer could name, so this is the fix rather than a break — but if you script `os generate`, a name in that set now stops the command instead of writing an unusable file. + + **One durability note.** The refused set is decided by the TypeScript compiler, asked in position, rather than by a list this package keeps — which is why it is right in both directions today. The consequence is that a TypeScript upgrade can move it: a word that becomes reserved starts being refused, and a word that stops being reserved starts being accepted. Both are correct, neither is a regression, and neither is predicted by a changeset. + + **What this deliberately does NOT narrow:** contextual reserved words. `type`, `as`, `from`, `async`, `get`, `set`, `of`, `keyof`, `readonly`, `satisfies`, `infer`, `declare`, `namespace`, `using`, `accessor`, `undefined`, `arguments`, `eval` and the rest are legal import bindings, they generate today, and they still generate. Refusing one of them would break a name that works — the expensive failure direction, and the one a hand-written keyword list gets wrong. There is no keyword list here for exactly that reason: a list is simultaneously too narrow (it stops at the obvious 36 and ships the defect for the other ten, which a syntactic-only check cannot even see, because the compiler reports strict-mode reservations as semantic diagnostics) and too wide (it swallows the contextual set). The judge is the compiler, asked in position. + + ⛔ Neither layer in front is relaxed or reordered. `os g object class` still meets the parse check's own diagnostic in the compiler's words, a name outside the charset still meets the schema's own pattern, and the new layer is asked last, so it can only narrow what all three would otherwise have admitted. + + +- 097d268: feat(spec)!: `manifest.id` enforces the reverse-domain rule its registry face already had (#17534) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the repo's convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition): + `ManifestSchema.id` was `z.string()` and accepted any string. It now enforces + reverse-domain notation — the same rule `PackageSchema.manifestId` has always + carried, now declared once and referenced from both sites so the two cannot + drift again. + + Two declarations named one identity and disagreed. The registry enforced the + shape; the key an author actually writes did not. So a package scaffolded, + validated, built and booted with an id the publish path would refuse, and the + author met the rule for the first time at the most expensive possible moment. + + FROM → TO, for metadata that used to parse and now fails: + + ```ts + // FROM — accepted by defineStack, refused at publish + defineStack({ manifest: { id: 'my_app', /* … */ } }); + defineStack({ manifest: { id: 'com.acme.my_app', /* … */ } }); + + // TO — dot-separated lowercase segments; hyphens inside a segment, never underscores + defineStack({ manifest: { id: 'com.example.my-app', /* … */ } }); + defineStack({ manifest: { id: 'com.acme.my-app', /* … */ } }); + ``` + + The refusal carries the repair rather than restating the rule: it names the key, + echoes the value, shows both documented examples, and — having first checked the + candidate against the pattern itself — suggests `com.example.blank` for a bare + word and `com.dogfood.flow-fixture` for a value whose only fault is an + underscore. A suggestion it cannot verify it does not make. + + ⚠️ **Changing an id is a republish, not an edit.** An id is an identity: the + registry addresses a package by `manifest_id`, an installed row is keyed on it + and a dependent declares it. Before renaming, confirm nothing still addresses + the old value. That is why this ships as an ADR-0087 **semantic** entry + (`manifest-id-reverse-domain-required`) with a structured TODO and no automatic + rewrite — `objectstack migrate meta` will not rename an id for you. + + `manifest.namespace` is unchanged and still admits underscores, so the two are + derived from a project name under different rules and neither is the other. Both + scaffolders were producing ids the new rule refuses and both now derive a + conforming one: the bundled `create-objectstack` template ships + `com.example.blank` and interpolates `com.example.` in kebab form, + and `os init` derives its id from the project name instead of interpolating the + snake_case namespace (`os init my-app` produced `com.example.my_app`). + + ## ⚠️ One consent path reverses direction: fail-OPEN → fail-CLOSED + + Narrowing `manifest.id` also narrows the **accept set of the artifact load + path**, and on one route that is a **fail-OPEN → fail-CLOSED reversal on a + consent/permission path**. Stating it explicitly because a reversal in that + direction is owed a named direction and a named population, however small the + population turns out to be. + + **What changed.** `AssembledPackageBodySchema` extends `ManifestSchema`, so the + artifact package entry schema now carries this rule too. An assembled package + whose `manifest.id` is `''` used to parse: `artifactPackageId` is + `manifest.id || manifest.name`, so such a package was carried under its `name`, + while an install-time `grantedPermissions` record keyed by `''` matched no + carried package and was registered nowhere. The package loaded **with no + consent record at all** — reported as unbound, warned about, and otherwise + allowed to run. That is the fail-OPEN half. Such an entry is now refused + outright (`INVALID_ARTIFACT_PACKAGE_ENTRY`, 422) and the artifact does not + materialize at all — fail-CLOSED. + + **Who is affected: artifacts carrying `manifest.id: ''`, and they were already + half-broken in both directions.** + + - They could never be **published**: the registry face + (`PackageSchema.manifestId`) has carried this exact pattern all along — the + same regex literal, now the shared `MANIFEST_ID_PATTERN` — so the publish path + has always refused them. + - Their granted-permissions **consent already did not apply**: a record keyed by + `''` bound to nothing, silently, on every load. + + ⇒ For that population this converts a silent, already-ineffective consent + binding into an explicit refusal that names `manifest.id`. Nobody who could + publish an artifact loses the ability to load it; what they lose is a shape that + only ever half-worked. + + ⛔ This is the **artifact package door** refusing a malformed id, **not** the + permission enforcer acquiring teeth. The install-time granted permission set is + still registered and not enforced (#17147) — nothing on the tree queries that + registry, and the repo-wide pin asserting so is unchanged and still green. +- 24d622b: feat(spec, cli): an application contributes its own first-run credentials to the development boot banner — `devHint` / `devLogins[]` (#17556) + + Clause-②: yes (widening) + + ## What an operator sees + + `os dev` seeds a platform admin on an empty DB, and the banner prints it as the only + credential a first-run operator is handed. #17081 made that line honest about what the + account *cannot* see; it could not name an account that *can*, because the platform does + not know an application's audiences. Measured on a downstream app, of five personas the + four it seeds each rendered their navigation group and the one the banner printed + rendered none — and the operator read the empty shell as a broken product. + + Two new top-level keys on the stack definition close that. Declaring either adds a block + BENEATH the seeded-admin lines, on a development boot only: + + ``` + 🔑 Dev admin: admin@objectos.ai / admin123 + seeded on empty DB · dev only — do not use in production + platform admin — Setup, Studio and every record, but NO app-declared capability, so + an app that gates navigation on requiredPermissions may show it an empty menu; grant + it a permission set under Setup → Users, or sign in as an account your app seeds + + 👥 App logins: 2 declared by this app + Hiring admin — admin@quillstone.example / demo1234 + Job seeker — candidate01@mail.example / demo1234 + declared in this app's `devLogins` · dev only — the platform seeded none of them + + 💡 App hint: run `pnpm seed:demo` first, then sign in as the Hiring admin + ``` + + ## What is writable that was not + + The top-level stack door has been strict since #8687, so before this both spellings were + an `unrecognized_keys` refusal. The accept set gains exactly: + + - **`devHint?: string`** — one sentence printed under the credential block. Composes as + `'single'`: two stacks declaring different hints is a composition error naming the key, + never a silent last-wins. + - **`devLogins?: DevLogin[]`**, where `DevLogin` is `{ email: string; password?: string; + label?: string }`, closed against unknown keys from birth. Composes as `'concat'`, so + composing two applications keeps both publishers' personas. An artifact ENVELOPE key + like `plugins` / `devPlugins`: it stays at the top level and is refused inside + `packages[].manifest`, because the banner's only reader looks at the top level. + + `DevLoginSchema` / `DevLogin` / `DevLoginParsed` are exported from + `@objectstack/spec/system`. Nothing is renamed, nothing is retired, and no value that + parsed before is refused now. + + ## Three properties worth knowing before you author one + + - **Declaring is not seeding.** An entry CREATES NOTHING: it names an account the + application seeds by other means (`data` fixtures, `onEnable`, its own script) so the + banner can point at one that shows something. An entry naming an unseeded account + prints a credential that will not work, exactly as a README line would — which is why + the banner says the application declared it. + - **Additive, never a replacement.** The seeded-admin block still prints, unchanged and + first. An application-controlled key able to suppress a platform disclosure would let + an app hide a live credential the operator was just handed. + - **Development only, and scrubbed.** The block renders only under `os dev`, + `objectstack serve --dev` or `NODE_ENV=development`; any other boot is byte-identical + to one declaring nothing. The values are author-controlled text reaching a terminal, so + every C0/C1 control byte is replaced with U+FFFD before printing — an escape sequence + in a hint cannot erase the rows above it or repaint a forged `🔑 Dev admin` row. ⚠️ + Whatever is written here is committed to the application's repository and printed to a + terminal: it is a development fixture, never a real secret. +- 0fff2c0: fix(cli): `os build` says what the ADR-0046 package-docs collector did not read (#18170) + + Package docs are collected from exactly one directory — `/src/docs` + (ADR-0046 §3.2). Under an ADR-0130 multi-package layout, where every top-level + directory under `src/` is a package, a docs directory belongs to its package: + `src//docs/`. Move one there and the two conventions disagree in the worst + possible way — the collector reads nothing, the build prints its usual + `Collecting package docs (ADR-0046)...` step line, exits **0**, and writes an + artifact with no `docs[]` at all. Measured on `objectstack-ai/hotcrm` at + `590b095` (pin 17.4.0), `git mv src/docs src/sales/docs` as the only change: + four package docs gone, nothing in the output naming the loss. + + `os build`, `os validate` and `os lint` now report one **warning** per + `src//docs/` directory that holds Markdown, through the doc-issue channel + they already share (text face and `--json` `warnings` alike): + + ``` + ⚠ src/sales/docs: src/sales/docs/ holds 4 Markdown file(s) that were NOT + collected: package docs are read from src/docs/ only (ADR-0046 §3.2), so these + are absent from the artifact's `docs[]` and from every book that includes them. + Move them into src/docs/ (doc names carry the package namespace prefix, so + packages do not collide there), declare them inline as `defineStack({ docs })`, + or delete them if they are not package docs. Found: … + rule: docs/uncollected-directory + ``` + + **Nothing that built before builds differently.** The rule is `warning`, not + `error`, on purpose: an error fails the build, and a `src//docs/` directory + is not declared anywhere the build can read — the collector can only *guess* it + was meant as ADR-0046 docs, and refusing a tree that is green today on a guess + is worse than the silence it replaces. What changes is that the loss is now + audible. Existing behaviour on the flat layout is byte-identical: `src/docs/` is + never itself flagged, and a subdirectory under it is still the + `docs/flat-directory` error it always was. + + **What this deliberately does NOT do**: it does not collect those files. + Reading package docs from each package directory widens the accepted set and + needs a decision this change does not make — an ADR-0130 D4 artifact registers + per package, so per-package docs have to say which package body they belong to, + and where they attach in an option-B artifact is open. The card + (objectstack-ai/objectstack#18170) offers both repairs and names the loud + failure as its minimum; that is the half delivered here. +- f32f480: fix(cli)!: a named export the config's default export already declares is reported instead of silently dropped (#18419) + + + + `objectstack.config.ts` is loaded as a module: `loadConfig()` takes the default export as the base and merges every named export onto it as a top-level stack key. A named export whose name the default export **already carries** loses — the default's value wins — and until now it lost in complete silence. `os build` exited 0, the artifact carried the default's value, and nothing was written at any level: + + ```ts + export default defineStack({ manifest, objects: [Task] }); + export const objects = [Task, Invoice]; // Invoice never reached the artifact + ``` + + The loader now says so on stderr, names every shadowed key, and states the rule and the remedy. It is an **advisory, not a refusal** — the stack that comes out is valid, it is merely missing what the shadowed export carried — which is the disposition this package already gives the same failure class (`#3786`'s undeclared authoring keys are "advisory, never fatal"; `#4095`'s orphaned runtime members are "reported rather than dropped"). It goes to stderr rather than stdout because `loadConfig()` is handed no `--json` flag and twelve commands call it, so a `--json` run's stdout stays a single parseable document. `LoadedConfig.shadowedNamedExports` carries the same names structurally. + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` (the lockstep convention: `major` is refused by `check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087 disposition above): the collision test now reads **own keys only**. `key in merged` walked the prototype chain, so every `Object.prototype` member — `toString`, `valueOf`, `constructor`, `hasOwnProperty`, `propertyIsEnumerable`, `toLocaleString`, `isPrototypeOf` — was treated as a key the default export "already carries" when the default carries no such key at all. Such an export was skipped by the merge and therefore never reached the strict parse that refuses an undeclared stack key by name, so `export const toString = …` beside a valid stack built green while `export const collectPackageDirs = …` was refused. That hole is closed: those names now merge like any other and are refused by name, the same sentence every other undeclared helper export has always got. + + Nobody's metadata or stored data changes. A config affected by the narrowing was already shipping that export's value nowhere; what changes is that the build now says so instead of exiting 0. Move the helper into a sibling module and import it, which is what the config-authoring docs have always prescribed for a helper exported beside the stack. +- df0c856: Clause-②: yes + + `os build` reads package docs from **each package directory** of an ADR-0130 layout — `src//docs/*.md` — and attaches them to the **owning package's body** (`packages[i].manifest.docs`), linted against **that package's own `namespace`** (#18431). + + A module can now ship its own docs. Before this, ADR-0046 collection was anchored at exactly one path, `/src/docs`, so an ADR-0130 project that moved its docs into their packages lost all of them — loudly since #18428, but lost. The maintainer's ruling (batch #147 item 4) decided the two contract questions that blocked the widening, and both are implemented literally: + + - **Where they attach**: to `packages[i]`, ⛔ never the artifact top level. A body's docs are served because the load path **registers every body**: `AppPlugin` hands the whole artifact to `getService('manifest').register(…)`, which runs `resolveArtifactPackageOrder` (every package body, when `packages` is present) and calls `registerApp(body)` for each; `registerApp` feeds `registerMetadataCollections`, whose `METADATA_ARRAY_KEYS` carries `docs`. A doc written onto a body therefore reaches the registry under its owning package, so a flattened copy would buy nothing and would destroy the ownership D1 is about. + - **Whose namespace the lint uses**: the owning package's. A doc outside any package keeps `stack.manifest.namespace`. A multi-package artifact therefore has **one prefix rule per package** and ⛔ no single global prefix — and ⛔ no fallback between the two: a package doc that fails its own package's prefix is refused, never re-tried against the artifact's. + + **What it costs — the refused classes measured here.** Three classes of input that `os build` accepted before are refused now. Each follows from the ruled prefix rule — the owning package's `namespace`, ⛔ with no fallback to the artifact's — reaching docs the artifact's own prefix used to judge, or docs the docs lint did not reach at all; each needs the artifact to carry a `packages[]`, which `composeStacks(…, { manifest: 'preserve' })` produces from N authored stacks and which a hand-written entry also parses into (`ArtifactPackageSchema`); and each is pinned in the unit tier rather than only stated here. + + **(1) A package doc carrying the ARTIFACT's prefix instead of its own.** A package that declares a namespace DIFFERENT from the artifact manifest's used to have its docs judged by the artifact's prefix; they are judged by its own now. + + ``` + FROM packages[i] with namespace "sales" inside an artifact whose manifest.namespace is "crm" + shipping a doc named crm_orders_guide -> accepted before, REFUSED now + TO rename it to sales_orders_guide (and the file to sales_orders_guide.md) + ``` + + The refusal is `docs/namespace-prefix`, an error, and it names that exact spelling. + + **(2) A package that ships docs and declares NO namespace at all.** `manifest.namespace` is optional, so such a body is legal and its docs used to be judged under the artifact's prefix — the one global rule. With one prefix rule per package and no fallback, that package's own namespace is the only one that can answer for its docs, and ADR-0046 §3.2 requires it. + + ``` + FROM packages[i] with NO namespace, inside an artifact whose manifest.namespace is "crm", + shipping docs (inline, or now from src//docs/) -> accepted before, REFUSED now + TO declare namespace: "sales" on that package — its docs then take the "sales_" prefix + or move those docs up to the stack level, where stack.manifest.namespace still judges them + ``` + + The refusal is `docs/namespace-required`, an error, located at `packages[i].manifest.namespace` — the key to add. + + **(3) A hand-written `packages[i].manifest.docs` entry with no copy of that doc at the artifact top level.** `packages` is an authorable key of the stack definition, and its docblock says a hand-written entry still parses — it is an assembled body carrying no collections. That body admits every collection the artifact envelope does not keep for itself, `docs` among them, and `DocSchema.name` says a namespace prefix is "recommended, not required". Before this change nothing linted such a doc at all: the CLI's docs pass read the stack's own `docs` and `src/docs/` and never looked at `packages[]`, and no `@objectstack/lint` rule reads `.docs`, so `os build` exited 0 whatever the doc was called. Clause 2 makes it that package's doc, so every docs rule now reaches it under that package's namespace. + + ``` + FROM packages[i] with namespace "sales" carrying docs: [{ name: "playbook", ... }] + and NO copy of that doc at the artifact top level -> exited 0 before, REFUSED now + TO rename it to sales_playbook — or fix whichever rule the message names, because + the whole docs lint reaches it now, not the prefix rule alone + ``` + + The refusal for that example is `docs/namespace-prefix`, an error, at `packages[i].docs/playbook`. A doc name a sibling package also declares is a cross-owner `docs/duplicate-name`; an image is `docs/no-images`; and so on through ADR-0046's v1 bans. + + ⚠️ **Those are the refused classes this change MEASURED — ⛔ not a claim that they are all of them.** Two of the three were added after a contract review of this card falsified an earlier draft of this note that had called the list complete; the closed claim is therefore dropped rather than re-made one class further out. The boundary that is honest: every refusal above is ONE rule — a package's docs are judged by that package's own `namespace`, with no fallback to the artifact's (the ruling's clause 2) — reaching a set of docs it did not reach before, and a shape nobody has measured yet can meet that rule the same way. If a build that was green fails on a doc, read the rule id the message carries: `docs/namespace-prefix` wants the owning package's prefix, `docs/namespace-required` wants that package to declare a `namespace`, `docs/duplicate-name` names both owners, and the content rules (`docs/no-images`, `docs/no-mdx`, `docs/filename`, `docs/flat-directory`) are ADR-0046's v1 bans, themselves unchanged. + + In the other direction the same change is a widening, and the larger half: before it a package's `src//docs/` was not read at all, and a package could not ship a doc under its OWN prefix. An artifact whose every doc is package-owned also no longer needs a `stack.manifest.namespace` of its own. + + ⚠️ Same-prefix LINKS and metadata-embed references are deliberately NOT partitioned with the naming rule — both resolve across the whole artifact. A doc's prefix says who judges its NAME; a link asks whether the target EXISTS, and ADR-0130 D1 exists so that N packages may share a namespace and cross-link inside it. Partitioning links too would have turned an ordinary cross-package link into `docs/broken-link` and stopped an artifact that built green from building; that was caught by this card's contract review and is pinned in the unit tier. + + Also in this change: + + - **The #18428 warning stays**, and now says *why* a directory was not read. Unchanged, word for word, for a stack that declares no `packages[]` — where "read from `src/docs/` only" is still the whole truth. For a directory that names **no** package it lists the declared packages and the two spellings a directory is matched against (`id`, and the last dot-segment of that `id`); for one that names **more than one** it names the candidates and refuses to guess. ⛔ `namespace` is not a matching spelling: ADR-0130 D1 exists so that N packages can share one, so matching on it would be ambiguous exactly where it matters. + - **A cross-owner duplicate doc name stays an error.** It PRESERVES a refusal rather than adding one: before the split every doc reached the lint in one flattened array, so two owners declaring one name already raised `docs/duplicate-name`. Splitting the set per package would have dropped that silently, and ADR-0130 D1 lets packages of one artifact share a namespace, so the prefix does not keep them apart. The rule is authoring hygiene — ⛔ not a claim that one registration overwrites the other, which ADR-0048 §3.3/§3.4 retired. + - **`os dev` mirrors `os build`.** The config-load path collects the same per-package directories onto the same bodies, so dev serves what a built artifact serves. + - **The step line counts the whole collection**, package sets included, and says how many came from package directories — a build that read four package docs no longer announces `0 collected`. + + Single-package projects are untouched: with no `packages[]` there is nothing to attribute, the flat `src/docs/` keeps attaching exactly where it always did, and the emitted artifact is byte-identical. +- 095c7f6: `os validate` runs the per-package author-time rule pass `os build` already ran — the false-clean residue #17069 left one layer down. + + `os build` runs the artifact's authoring rules **twice**: once over the union-folded stack, then a second `runAuthoringRules('build', …)` pass over each `artifactPackages(…)` entry with `packageBodyAsStack(…)` as resolution context, de-duplicated against the union run. `os validate` ran the union pass and stopped — it imported neither seam. By `compile.ts`' own description the survivors of that second pass are the per-package findings no union finding already carried under the same rule, `where`, message and non-top-level position — deliberately narrower than everything the union run missed, because two entries rendering the same `where` still collapse. That whole set was findings `os build` reported and `os validate` **structurally could not**. The direction is false-clean, and on the worse door: the fast pre-flight is what an author runs *before* shipping, so its clean bill of health is the strongest false assurance the three commands can give. + + Measured on `origin/main` 09e16a574 over `examples/app-multi-package`, both commands exiting 0: + + ``` + os build --json warnings: 4 <- 3 union + 1 per-package survivor + os validate --json warnings: 3 <- the survivor is the defect + ``` + + After: both report 4, the same set, in the same order. + + **The loop is now one seam, not two copies.** `runPerPackageAuthoringRules` lives beside `artifactPackages` / `packageBodyAsStack` in `utils/artifact-packages.ts`, whose header already forbids a second copy of that shape by name. What would have drifted between two hand-written loops is not the package reading but the **verdict** — the de-duplication key, the severity split, the `where` prefix. `os build`'s observable output is unchanged (text face byte-identical modulo timings; `--json` payload identical). + + **Severity mapping is `os build`'s, unchanged.** A per-package `error` refuses (exit 1); an advisory joins `warnings`. So `os validate` is narrowed only to the bar the command that *ships* already holds: every input it can now refuse is one `os build` already refuses. + + **BREAKING** — `os validate --strict` can now fail a project it passed before. Measured on a two-package fixture whose union fold is clean and whose per-package run is not (`core` owns `pp_account`; a sibling package owns the view that displays `pp_account.industry`), driving the CLI from source: + + | `os validate` on that fixture | before | after | + |---|---|---| + | `--json` | warnings 0, exit 0 | warnings 1, exit 0 | + | `--json --strict` | exit 0 | **exit 1** | + + The one warning is `field-no-consumers` at `package 'com.example.ppflip.core' — object "pp_account" · field "industry"`, which `os build` already reports on the same fixture: nothing is refused here that `os build` does not already refuse, and the default (non-strict) face is unchanged in that measurement. A run that must keep its old verdict drops `--strict`; a project that wants to keep the flag fixes what the per-package pass reports, which is what `os build` has been reporting all along. + + Why the union fold does not see it: `packageBodyAsStack` hands each package the artifact's whole `packages[]` as resolution context, so a cross-package *reference* still resolves and the reference-integrity rules stay quiet — but a reachability rule asks what the **stack** reads, and per package the stack is that one package's own body. A field whose only consumer lives in a sibling package is therefore live to the union run and inert to the per-package run, and that is the shape that reaches `--strict`. + + Graded `minor` rather than `patch` for the new observable step line, the new advisories and the newly reachable non-zero exit; the launch window refuses `major`, so the breaking-ness is carried by the banner above and the ADR-0087 disposition below. + + Unchanged and out of scope: the ADR-0130 D4 union fold (#17069, fixed — `authoringRuleUnionStack` is in both commands), `--json` rendering (#11727), and disagreements *within* the per-package pass's verdicts (#18204). `os lint` still runs the union pass alone; its `artifactPackages` / `packageBodyAsStack` imports serve its own intra-package duplicate-name advisory, not the shared table. + + +- 9bd631f: `os lint` runs the per-package author-time rule pass the other two doors already ran + + `os build` has run the author-time rule table a second time, once per + `packages[]` entry with that package's body as the stack and the artifact's own + `packages[]` as resolution context, since #16611; `os validate` joined it in + #18677. `os lint` ran the union fold and stopped, so every finding that pass + produces — in the build command's own words, the per-package findings no union + finding already carried under the same rule, `where`, message and non-top-level + position — was reported by the command that ships and invisible on the fastest + of the three doors. That bound is deliberately narrower than everything the + union run missed: two entries rendering the same `where` still collapse. All + three now call the one shared pass. + + Measured on a two-package project whose union run is clean and whose per-package + run is not (one package owns an object, a sibling package owns the view that + displays its field): + + | | before | after | + |---|---|---| + | `os build --json` | warnings 1 | warnings 1 | + | `os lint --json` | total 0, exit 0 | total 1, exit 0 | + | `os lint --json --strict` | exit 0 | exit 1 | + + **BREAKING** — `os lint --strict` can now fail a project it passed before. A + per-package finding is a finding this door could not see, `--strict` is + documented as "treat warnings as errors", and the verdict moves with it. The + default face is unchanged in the measurement above, and the severity mapping is + `os lint`'s own: an `error` fails the run, a `warning` fails it only under + `--strict`, an `info` stays a suggestion. Nothing is refused here that `os build` + does not already refuse, so the pre-flight is narrowed to the bar the command + that ships already holds and never past it. A run that must keep its old verdict + drops `--strict`; a project that wants to keep it fixes what the pass reports, + which is the same thing `os build` has been reporting all along. + + Clause-②: yes (narrowing) + + +- 0e671d2: **Clause-②: yes** — `os build` accepts a source layout it previously read nothing from, so what an author may write and have collected widens. ⛔ Nothing narrows: every tree that built green still builds green, with the same `docs[]` and the same warnings. + + `os build` now derives **each package's docs directory from the packages the artifact registers**, not from a fixed depth under `src/` (maintainer ruling, decision batch #204 item 5, letter B). + + Before this, the sweep asked one question per direct child of `src/`: does `src/CHILD/docs/` hold Markdown? So a project whose packages sit one level deeper — the shape this repo's own ADR-0130 D4 reference fixture `examples/app-multi-package` has, `src/packages/PKG/` — was invisible to it. A doc at `src/packages/orders/docs/ord_guide.md` was dropped **silently**: no `docs[]` entry, exit 0, and not even the `docs/uncollected-directory` warning, because the sweep never looked there. That is the #18170 defect verbatim, one level down, and after #18431 it was out of reach of both the diagnostic and the collection. + + Both layouts are now one case rather than two: + + ``` + src/orders/docs/sales_guide.md -> packages[].manifest.docs (unchanged) + src/packages/orders/docs/sales_guide.md -> packages[].manifest.docs (new) + ``` + + **How the directory is found.** A registered package carries no source path — `ArtifactPackageSchema` is a `strictObject` whose only key is the assembled body — so the only thing that can locate one on disk is its NAME, and the two spellings a docs directory is matched against are unchanged: the package's `id`, and the last dot-separated segment of that `id`. ⛔ Never `name` (a display string, free to be re-worded) and ⛔ never `namespace` (ADR-0130 D1 exists so N packages may share one). + + **No second depth was pinned.** The walk descends only in SEARCH of a registered package and stops at the first directory that names one — so a package's own subtree stays its source, and a `docs/` inside it is not a second docs directory. With no `packages[]` there is nothing to search for, so there is no descent at all: a single-package stack is walked exactly one level, its `docs[]` and its warning text byte-for-byte what they were. That is the fence the ruling preserved from batch #147 item 4, held by construction rather than by a branch guarding it. + + **One new refusal.** Depth-free resolution makes one package able to answer to two doc-bearing directories (`src/core/docs` and `src/packages/core/docs` in one tree). Both are reported and neither is collected — the same answer this collector already gives when one directory names two packages. ⛔ It is not merged and ⛔ not silently halved: docs attach by package index, so collecting both would drop one without a word. + + A directory that matches **no** package and one that matches **more than one** keep their existing, distinct diagnostics, now at whatever depth they are found. +- f289f2b: fix(cli): **BREAKING** — `os generate schema` is retired, and it now says why and points at `os validate` and the per-type JSON Schemas `@objectstack/spec` publishes (#19098) + + Clause-②: no (narrowing) + + + + **⛔ If a script, a Makefile or a CI step in your project runs `os generate schema` + (or `os g schema`), it will now exit 1 and write nothing.** That is the intended + outcome: the command is gone by maintainer ruling, and the failure is how you find + out. Nothing in this repository reads the file it wrote. + + `minor`, not `major`: during the launch window this stack ships breaking changes as + `minor` (pre-1.0 semantics under lockstep versioning — see + `scripts/check-changeset-no-major.mjs`). + + **What the command actually did.** `os generate schema` wrote + `objectstack.schema.json`, a JSON Schema of the whole stack definition for an editor + to check `objectstack.config.ts` against. It projected `ObjectStackDefinitionSchema` + through a bare `z.toJSONSchema`, so the refinements the platform enforces beyond the + shape — a non-blank string, a required one-of, a banned key — were missing from the + file. Measured against the published projection on the tree the ruling was made on, + the file lacked 874 keywords, every one of them an absence. An editor pointed at it + reported a config as valid, and the platform then refused that config. + + **Why it is retired rather than repaired.** A TypeScript configuration is typed by its + own `define*` helper, and `objectstack.config.ts` is typed end to end by + `defineStack`, so no config format this CLI loads is one an editor validates against + a JSON Schema. JSON metadata already has the per-type schemas `@objectstack/spec` + publishes, which carry the published projection. Repairing the command would have + added a permanent public export to `@objectstack/spec` for a file with no reader. The + ruling generates no replacement file. What you see now: + + ``` + ✗ `os g schema` was retired — its JSON Schema passed configs the platform refuses (maintainer ruling). + + The file it wrote described only the shape of a stack. Every rule the + platform enforces beyond that shape — a non-blank string, a required + one-of, a banned key — was missing from it, so an editor showed a config + as valid and the platform then refused it. By maintainer ruling it is + retired, not repaired, and no replacement file is generated. + + Check a project against the rules that actually run: + + os validate + + For JSON metadata, point your editor at the per-type schemas that + @objectstack/spec publishes. They state the rules a JSON Schema can + express, and name the ones it cannot under `x-dropped-refinements`: + + node_modules/@objectstack/spec/json-schema//.json + + `objectstack.config.ts` needs neither: `defineStack` types it in your + editor. Delete the `os generate schema` call, and any editor setting + that maps `objectstack.schema.json` — nothing writes that file now. + + Docs: https://objectstack.ai/docs/deployment/cli + ``` + + **What to do.** The refusal's pointer is the whole of it. Delete the + `os generate schema` call, and any editor setting that maps `objectstack.schema.json` + (a `json.schemas` or `yaml.schemas` entry, for example). Run `os validate` to check a + project against the rules that actually run. For JSON metadata, point the editor at + `node_modules/@objectstack/spec/json-schema/`, one file per metadata type. There is no + call to rename: nothing replaces the command. + + **Reach outside this repository is NOT MEASURED.** There is no telemetry, so whether + any project runs the command or reads its file is unknown. Inside this repository + nothing does: no reader and no editor mapping of `objectstack.schema.json` exists. + If these release notes also record that `os generate schema` can now write its file, + this retirement supersedes that repair. + + **Two neighbouring answers change with it.** The retirement ledger is now read before + the command routes its sub-commands and before it asks for a ``, which is what + lets `os generate schema` (no name) reach the refusal at all. So `os g agent` with no + name now prints the agent retirement instead of `Missing required argument: `. + The ledger lookup also reads its own keys only: `os g constructor ` used to be + taken for a retired type and crashed with a `TypeError`, and it now falls through to + the ordinary type checks. + + The docs row that advertised the command — "Autocomplete and validation for + `objectstack.config.ts` (via `os generate schema`)" in + `content/docs/api/data-flow.mdx` — is gone, with the diagram's JSON Schema node above + it. +- ccccdcc: **Clause-②: yes (widening)** — a new member (`type: 'doc'`) on the published, strict navigation-item union, and a new exported schema (`DocNavItemSchema`), so the accept set an app author writes against grows. Nothing previously admitted is refused: the `docs/nav-target` build rule judges only `doc` items, which no stack could carry before. Contract-review tier. + + A documentation entry on the app menu: the new `type: 'doc'` navigation item (`DocNavItemSchema`, ADR-0046) targets a `book` and/or a `doc`, and at least one is required. + + ```ts + { id: 'nav_help', type: 'doc', label: 'Help Centre', book: 'crm_manual' } // opens the book + { id: 'nav_guide', type: 'doc', doc: 'crm_lead_guide' } // opens that page + { id: 'nav_both', type: 'doc', book: 'crm_manual', doc: 'crm_lead_guide' } // that page, in that book + ``` + + - **`book` alone** opens the book at its first readable page with the book sidebar. Membership is derived by the book's group rules, so a doc added later that matches a rule appears under the entry with no navigation edit. The package id also names a book — the package's implicit book. + - **`doc` alone** opens that page; its book context is the doc's own book, else the package's implicit book. `doc` is a doc NAME (the source filename stem, lowercase snake_case): `crm_lead_guide.md` or `docs/crm_lead_guide` is refused. + - **Neither** is refused when the app is parsed, with a message naming both keys. The rule also reaches the published JSON Schema (`json-schema/ui/DocNavItem.json`) as an `anyOf` of `required`, so a validator reading the schema refuses the same shape. + - **Audience**: the entry has no gate of its own — it inherits the docs audience gate. A `book` entry shows the member only the pages they may read, and is not shown to a member who may read none; a `doc` entry the member may not read is not shown. `visible` / `requiredPermissions` can only narrow that further. + - **`os build` / `os validate` / `os lint`** refuse a `doc` entry whose `book` or `doc` names nothing in the package (new rule `docs/nav-target`, with a did-you-mean). This runs in the docs step because that is where docs from `src/docs/*.md` join the artifact. It checks app `navigation`, `areas[].navigation` and `manifest.navigationContributions`. + - Near-misses are answered: `docName` → `doc`, `bookName` → `book`, and `book` / `doc` written on another item type points at `type: 'doc'`. + + The console renders the new entry in a later objectui release; until then a `doc` item parses and publishes, but the menu does not show it. +- a0920b4: fix(driver-turso): a REMOTE `TursoDriver` refuses to plan the ADR-0104 media column move instead of answering that there is nothing to move, and `os migrate files-to-references` reports that refusal as a column step it could not judge (#19894) + + Clause-②: yes (narrowing) + + **BREAKING for callers that plan the media column move on a remote Turso datasource** — `TursoDriver.planMediaColumnMove()` in `remote` transport mode (for example a `libsql://` URL) now throws a `NOT_IMPLEMENTED` / `501` error, where it used to answer `{ plans: [], refusals: [] }`. The `local` (`:memory:` and `file:`) and `replica` modes plan exactly as before, and the local modes plan exactly what `SqlDriver` plans for the same declaration. + + What the refusal replaces, measured on the transport's SQLite-backed test double: a table with a `file` and an `image` field, synced through each of the three remote schema doors (`syncSchemasBatch`, `syncSchema`, `initObjects`), held its two TEXT media columns on the remote database, and the remote face answered an empty scan on every door. The inherited planner walks the objects `SqlDriver`'s own schema sync registers, which no remote schema door reaches, and probes each table through the placeholder in-memory Knex connection a remote driver is built with. The local and embedded-replica faces planned two `unquote` moves for the same declaration. `os migrate files-to-references` printed that empty scan as "Column step: nothing to move — this datastore declares no single-value media column". + + - **The command reports the refusal instead of failing on it.** `os migrate files-to-references` calls the planner only after the backfill and its self-check have passed, and an `--apply` run has recorded the deployment flag by then. Measured on the command's own test doubles before this change, a planner that throws ended the run with the error alone (`--json`: `{"error": …, "code": "NOT_IMPLEMENTED"}`) and exit 1, with no backfill report, no verify report and no word about the flag it had recorded. The column step now catches a `NOT_IMPLEMENTED` refusal by its code and reports it as a skip: the text face prints `Column step: NOT JUDGED` with the driver's message, and `--json` gains `columnMoveRefused` — `{ error, code }` when the driver refused, `null` otherwise — beside `columnMove: null` and `columnsMovedAt: null`. The backfill, verify and flag reports are emitted as on any other run, nothing is stamped, and the exit code is the self-check's, as it already was for the command's other column-step skips. + - **Any other throw from the planner still fails the command**, through the same error report and exit 1 as before. + - **A genuinely empty scan still reads "nothing to move".** + - **No new error code.** `NOT_IMPLEMENTED` / `501` is a standard code, the envelope this transport already uses for its remote transaction, auto-number, deferred-DDL and drift-detection refusals. + + **If you are refused:** the backfill, its self-check and, on `--apply`, the deployment flag are unaffected. A remote Turso datasource keeps its single-value media columns on the JSON encoding: measured on the same double, the remote face writes a file id as a JSON string and reads it back as the id, and it does not read the record of a completed column move. + + +- fdb2669: fix(cli): `os info` and `os lint` refuse a stack whose `packages` is present but not an array, instead of reading it as no packages (#19925) + + Clause-②: no (narrowing) + + **BREAKING for `os info` and `os lint` on a hand-written stack.** A config whose + `packages` is present but is not an array (`{}`, `0`, `'x'`) is now refused with + `INVALID_ARTIFACT_PACKAGES` (ADR-0112, `status: 422`) and exit code 1. These + commands used to read it as a stack with no packages. `os info` exited 0 and + reported every package-owned collection as empty. `os lint` exited 0 with + `passed: true`. Only two spellings reach these commands with such a value: a + config exported as a plain object, and `defineStack(…, { strict: false })`. + The default `defineStack` parse, `os validate` and `os build` already refused + it. + + The accept set only shrinks back to what the declaration has always said. + `ObjectStackDefinitionSchema` declares `packages` as an array of package + entries. Ruling A on #15293 settled that a present non-array value is + malformed, not absent. The runtime, `@objectstack/core` and the plugin readers + already refused it. The CLI's stack-collection reader and its three + package-docs readers still answered "no packages". They now judge the value + through one helper, which hands a non-array to `resolveArtifactPackageOrder`. + So the refusal's code, status and sentence are core's own, and the CLI keeps no + second copy of the rule. + + **What is not affected.** An absent `packages` reads exactly as before, and so + does a `packages` array. A malformed entry inside an array is still refused as + `INVALID_ARTIFACT_PACKAGE_ENTRY`. `os serve` and `os dev` refused this stack + before the change, because the runtime's manifest service raises the same + refusal at boot, and they still do. + + **`packages: null` follows core's resolver.** Ruling `5805260775` on #19926 + makes `null` malformed at every reader, and the resolver's `null` refusal is + landing separately (#19926, PR #20228). The CLI readers do not answer `null` + themselves; they hand it to `resolveArtifactPackageOrder` and return what it + answers. Today that is its absent answer, so `null` still reads as no + packages. Once the resolver refuses `null`, these commands refuse it too, with + no change to the CLI. + + **If you are refused.** Omit `packages` for a single-package stack, or give it + an array of `{ manifest: … }` entries. The refusal says the same. + + +- 8d1f7ab: feat!: retire the saved-report stack — `sys_saved_report` / `sys_report_schedule`, `/api/v1/reports`, `client.reports`, `IReportService`, the `reports` capability and `@objectstack/plugin-reports` (#20102) + + **BREAKING** — the saved-report stack is removed whole, with no deprecation window + (maintainer ruling 2026-09-25, 「A. 退役」). It persisted a raw object query + (`object_name` + `{ filter, fields, orderBy, limit, groupBy }`) with a render format + and an owner, and could e-mail it on a schedule. Measured on the main branch of this + repository, objectui and cloud before removal: zero callers of the routes, the SDK + namespace or the service contract outside their own tests, and no app declaring the + capability. + + **NOT affected: the `report` metadata kind.** `ReportSchema`, `defineReport`, + `/meta/report`, datasets and the analytics service are unchanged. The two shared the + word "report" and nothing else. + + FROM → TO, per surface: + + - `requires: ['reports']` → **refused** by `defineStack` (`STACK_CAPABILITY_UNKNOWN`, + 422) with the prescription "requires: 'reports' was removed in @objectstack/spec + 17.5.0 … Delete the token." Fix: delete the token. `os serve` on an older artifact + that still carries it warns with the same prescription and ignores it; `os validate` + and `os build` over a plain-object config (no `defineStack` call, so no parse-time + vocabulary check) report it as a non-fatal capability advisory carrying the same + prescription, never "check for a typo". The token is + gone from `PLATFORM_CAPABILITY_TOKENS` and `PLATFORM_CAPABILITY_PROVIDERS`; the new + `RETIRED_PLATFORM_CAPABILITY_GUIDANCE` (`@objectstack/spec/kernel`) carries the + prescription. + - `IReportService`, `SavedReport`, `ReportSchedule`, `ReportQuery`, `ReportFormat`, + `ReportRunResult`, `SaveReportInput`, `ScheduleReportInput` + (`@objectstack/spec/contracts`) → removed, no replacement export. Fix: delete the + import. + - `SysSavedReport`, `SysReportSchedule` (`@objectstack/platform-objects/audit`) and + the names `sys_saved_report` / `sys_report_schedule` in + `PLATFORM_PROVIDED_OBJECT_NAMES` → removed. A stack referencing either name is now + flagged as a probable typo instead of resolving. + - `GET|POST /api/v1/reports`, `GET|DELETE /api/v1/reports/:id`, + `POST /api/v1/reports/:id/run`, `POST /api/v1/reports/:id/schedule`, + `GET /api/v1/reports/:id/schedules`, `DELETE /api/v1/reports/schedules/:scheduleId` + → unmounted: each answers the standard unmatched-route `404`, byte-identical to a + path that never existed. Their nine error codes (`REPORTS_LIST_FAILED`, + `REPORT_DELETE_FAILED`, `REPORT_GET_FAILED`, `REPORT_NOT_FOUND`, + `REPORT_RUN_FAILED`, `REPORT_SAVE_FAILED`, `REPORT_SCHEDULE_FAILED`, + `SCHEDULES_LIST_FAILED`, `SCHEDULE_DELETE_FAILED`) leave `ERROR_CODE_LEDGER` with + their only emitter. + - `client.reports.*` (`list`, `save`, `get`, `delete`, `run`, `schedule`, + `listSchedules`, `unschedule`) → removed. Fix: delete the call. A report is `report` + metadata, read through `meta.*` and queried through `analytics.*`; a saved ad-hoc + object query is a ListView on that object. + - `RestServer`'s constructor keeps the position of the retired saved-report provider, + typed `undefined`, so no later positional argument re-binds. Pass `undefined` there; + passing a provider is a compile error. + - `@objectstack/plugin-reports` → no longer built or published from this repository, + and `@objectstack/cli` no longer depends on it or mounts it. Fix: remove the + dependency. There is no successor package and no scheduled-delivery replacement. + + **Existing databases.** `sys_saved_report` / `sys_report_schedule` tables in a deployed + database are left in place, untouched — no backfill, no reaper, no drop — under the + repository's convention for a retired platform object: the platform never drops a + table that metadata stops declaring, and `os migrate plan` lists such a table in its + informational unmanaged-tables section so an operator can decide. + + `@objectstack/metadata-protocol` (patch): the `INVALID_SORT` hint for a sort node + spelled `{ field, direction }` no longer names the retired saved-report contract as + the source of that vocabulary; it names the better-auth adapter's `sortBy`, which + still uses it. Code and status are unchanged. + + Breaking ships as `minor` per the launch-window convention + (`scripts/check-changeset-no-major.mjs`). + + **Clause-②: yes (narrowing)** — a published capability token, a service contract and + its types, two platform objects, eight routes, nine registered error codes and an SDK + namespace are removed; nothing previously refused is now accepted. + + +- 1207baf: RLS policies are admitted when they are authored: the engine judges every read-scope `using`, at the save door and at `os validate` / `os build` / `os lint` + + A row-level-security policy (`rowLevelSecurity[]` on a permission set) could carry a `using` predicate that lowers cleanly and that the engine then refuses to run: a text operator (`startsWith` / `endsWith` / `contains`) aimed at a number field, a date field compared against a value its storage cannot read, a filter on a virtual (formula) field, or a `{…}` placeholder string. Nothing refused it when it was written. The first answer was a refused analytics query, long after the author had moved on. And the metadata save door (Studio, REST `/meta`, MCP) did not run the RLS predicate rule at all, so a predicate `os validate` already refused was accepted there. + + - **The engine's own verdict.** `validateRlsPredicateEnforceability` takes the engine's judge-only filter admission (`IObjectQLEngine.judgeFilter`) as an optional input and judges the lowered `using` of every `select` / `all` policy with it. A refusal is reported under the existing id `rls-predicate-unenforceable`, and the message quotes the engine's code, status and sentence verbatim. The rule never models the engine's checks: without the input it answers exactly as before. + - **Both doors hand in a real engine.** The metadata save door probes its host engine for `judgeFilter` and passes the bound method through the publish gate. The CLI commands build an engine with no driver from the stack's own objects and pass its method. + - **The save door now runs the rule for `permission` writes** (`surfaces: ['cli', 'runtime-publish']`, `runtimeTypes: ['permission']`), so every predicate `os validate` refuses is refused there too, as a `422 INVALID_METADATA` whose `issues[]` carries the same sentence. + - **New optional inputs.** `AuthoringRuleContext.judgeFilter` (and so `AuthoringRuleRun.judgeFilter` for `runAuthoringRules`), the `judgeFilter` argument of `runRuntimeAuthoringRules`, and an optional second parameter of `validateRlsPredicateEnforceability`. A caller that passes nothing gets the previous verdicts. + + **BREAKING**: a permission set whose read-scope `using` the engine cannot run now fails `os validate` / `os build` / `os lint`, and a publish of it through the metadata save door is refused with `422`. Stored rows keep being read, and a re-save of one is judged like any other publish. `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns the save-door refusal into a logged warning for a migration window. The refusal's hint names the fix for each class: write the caller's value as a `current_user` key rather than a `{…}` placeholder, point a text operator at a field that holds a string, compare a date field against a value its storage reads, or denormalise a computed value onto a stored field. Every policy authored in this repository, in its examples and in the default permission sets was measured, and none is refused. + + Two edges are not closed here, both deliberately: + + - The judge sees only `using` clauses in the read scope. A `check` is matched in memory against the post-image and never reaches the engine's filter admission. + - At the save door, the judge reads the engine's live registry. An object that exists only in the same publish batch, or only in an organization overlay, is one that registry does not hold, so it gets the engine's unknown-object answer: no field-type verdict, while the placeholder and comparand checks still run. At the CLI door, an object the stack does not define gets the same answer. + + Clause-②: yes (narrowing) + + +- 5a6267f: `os test` reports the suite and scenario names an author writes, and selects scenarios with `--tags` (#20289) + + Clause-②: no + + A Quality Protocol suite's `name`, each scenario's `name` and `description`, and scenario `tags` were parsed at load and then read by nothing: the report headed each suite with its file's basename, printed every scenario by its `id`, and `os test --tags critical` failed with `Nonexistent flag: --tags`. + + - **Names in the report.** The suite heading is now the suite's `name` followed by its file — `📄 Running suite: Accounts smoke (accounts.test.json)` — and each scenario line is its `name` with the `id` in brackets — `✅ Scenario: An account can be created [acct-create] (12ms)` (the id alone when the two are equal). A failed scenario's `description` is printed under its line, before the error. A suite whose file fails to load is still headed by the file alone, since no name was parsed. + - **`--tags TAG[,TAG...]`** runs only the scenarios carrying AT LEAST ONE of the listed tags (any-of, exact, case-sensitive) — the comma-list reading of Odoo's `--test-tags` and the everyday use of Playwright's `--grep @a|@b`. With the flag, an untagged scenario is left out. Left-out scenarios are **deselected**: not run, counted on the summary (`--tags smoke selected 1 of 4 scenarios; 3 deselected (not run, not counted as passed).`), never counted as passed. A requested tag that no loaded scenario carries is named on the summary. An empty entry (`--tags smoke,`) is refused before anything runs. Without the flag nothing changes: every scenario runs. + - **Exit status.** A selection that matches no scenario takes the posture an empty pattern already has: exit `0` with `No scenario matched --tags …`, and exit `1` under `--fail-on-empty`, whose description now covers both cases. The `Found N test suites.` line and the `SUCCESS: All N scenarios passed.` / `FAILED: …` summary lines keep their spelling. + - **`@objectstack/core`:** `QA.TestResult` gains `scenarioName` and `description` on every result, and `suiteName` on every result `runSuite` produces (absent only from a lone `runScenario` call, which has no suite). + - **`@objectstack/spec`:** the liveness ledger (`liveness/qa.json`) moves the four keys above to `live`, citing their readers. `TestScenario.requires`, the family's fifth key, is checked in this same release and has its own note: an unmet `params` or `services` entry skips the scenario with its reason, and `requires.plugins` is retired into `requires.services`. +- 0bbe400: feat(spec,core,cli)!: a scenario's `requires` is checked before it runs — unmet `params` or `services` SKIP it with a reason; `requires.plugins` is retired into `requires.services` (#20289) + + Clause-②: yes (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, + never by the level). + + A Quality Protocol scenario's `requires` block declared preconditions — + `params` (environment variables) and `plugins` (plugins that must be loaded) — + that nothing checked: measured on a stub target, a scenario naming a missing + plugin and an unset variable reported PASSED exactly like its no-requirements + control. ADR-0049 enforce-or-remove, verdict ENFORCE (the mainstream has + declared preconditions: JUnit `@EnabledIfEnvironmentVariable`, pytest `skipif`), + ruled B for the shape: each key is judged against something `os test` can + actually observe. + + - **`requires.params`** — each variable must be set to a non-empty value in the + environment of the process running `os test` (not the target server's, which a + suite cannot see). An empty value counts as unset: an unconfigured CI secret + arrives as an empty string. + - **`requires.services`** (new) — each entry is a discovery service key + (`CoreServiceName`: `auth`, `automation`, `analytics`, `ai`, `storage`, …; a + misspelling is refused when the suite loads) that the target must declare + `enabled` with status `available` in its discovery document (ADR-0076 D12). + It is read from the discovery request the HTTP adapter already makes once per + run; a suite that requires no service issues no extra request. + - **SKIPPED.** A scenario with an unmet entry runs no step — `setup` included — + and `os test` prints it with its reason, naming every unmet entry and, for a + service, the services the target does declare available: + `Skipped: requires.services 'ai' is not available on the target (enabled: false, status: unavailable). The target declares available: auth, data, metadata.` + It is counted on its own — `SUCCESS: 3 scenarios passed. 1 skipped (not run, not counted as passed).` — + and never as passed. Skips alone exit `0`; a run in which EVERY selected + scenario was skipped prints `No scenario ran: …` instead of `SUCCESS`, exits + `0`, and exits `1` under `--fail-on-empty`. With nothing skipped, the summary + lines keep their spelling. + - **`@objectstack/core`:** `QA.TestResult` gains `status` (`'passed' | 'failed' | 'skipped'`) + and, on a skipped result, `skipped` (`reason`, `unmet[]`, `availableServices`); + `passed` stays and is `false` on a skip. `TestRunner` takes an optional + `{ env }` (default: this process's environment), and `TestExecutionAdapter` + gains an optional `readTargetServices()` — `HttpTestAdapter` answers it from + its one discovery probe. An adapter without it skips a service requirement + rather than running it. + + ``` + FROM { "id": "ai-summary", "requires": { "plugins": ["@objectstack/service-ai"] }, "steps": [...] } + -> ran anyway; the missing plugin surfaced as whatever failure it caused, or passed + TO -> os test refuses the suite at load: + ✗ scenarios.0.requires.plugins: `scenarios[].requires.plugins` was removed in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever checked it: … + Delete the key and name the service the scenario needs in `requires.services`, … + Plugin → service: @objectstack/service-analytics → analytics, @objectstack/plugin-auth → auth, … + + FROM { "requires": { "services": ["ai"] }, … } (new key) + TO -> against a target whose discovery does not declare `ai` enabled and available: + ⏭️ Scenario: Summarise an account [ai-summary] (skipped) + Skipped: requires.services 'ai' is not available on the target (…). The target declares available: … + ``` + + **Fix.** `requires.plugins: [""]` → `requires.services: [""]`, + using the mapping the refusal prints (derived from `CORE_SERVICE_PROVIDER`, the + provider table discovery itself reports): `@objectstack/plugin-auth` → `auth`, + `@objectstack/service-analytics` → `analytics`, `@objectstack/service-automation` + → `automation`, `@objectstack/service-storage` → `storage`, and so on; the `ai` + service is provided by ObjectStack Cloud/Enterprise. A plugin that fills no + discovery service slot has no service to require — gate that scenario with a + `params` variable or select it with `--tags`. `tsc` refuses `plugins` at a typed + authoring site (its input type is `never`). A `TestResult` consumer that counted + `!passed` as a failure should read `status` — a skipped result is `passed: false` + and is not a failure. + + **What does not change.** A scenario without `requires` runs exactly as before, + and a suite that requires no service issues no discovery request it did not + already issue. + + ### The retirement kit + + - **Schema.** `TestScenarioSchema.requires` is a non-strict `z.object()`, so + `plugins` is a `retiredKey()` tombstone carrying its prescription (a bare + deletion would have stripped it in silence); `services` is new, closed over + `CoreServiceName`. + - **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `qa/TestScenario:requires.plugins`. + No D2 conversion: a QA suite is a loose JSON file `os test` loads, never a + stack collection member or a stored row. The family's D3 entry, + `qa-scenario-requires-plugins-retired`, carries the prescription to + `os migrate meta` and the upgrade guide. + - **Ledger and docs.** `liveness/qa.json` moves `qa.scenarios.requires` from + `dead` to `live`, citing the runner's judgement and the adapter as producer; + `state-counts.md` moves `qa` to 9 live / 0 dead. The `os test` section of the + CLI reference documents the check, the skip line and the exit posture, and the + generated `qa/testing` reference page is regenerated. + + +- ba5927f: **BREAKING — one authoring shape for a stack config.** `objectstack validate` and `objectstack build` now refuse a config whose default export was not built by `defineStack(...)` (either mode) or `composeStacks(...)`, with `STACK_PROVENANCE_MISSING` and exit 1, right after the config loads and before any other check. `composeStacks` refuses an input no producer built the same way. + + Why: the stack family's cross-field refusals (`STACK_CAPABILITY_UNKNOWN`, `STACK_CROSS_REFERENCE_INVALID`, `STACK_NAMESPACE_PREFIX_INVALID`, `STACK_SINGLE_APP_VIOLATION`, `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`, `STACK_TRIGGER_CAPABILITY_REQUIRED`) run inside `defineStack` only. The same defective stack exported as a plain object passed both commands at exit 0, and `objectstack build` shipped it. Re-running those refusals on whatever the config exports cannot fix that: a built stack carries each bound action twice, so the re-run refuses every correct project that has one. So the commands check who BUILT the export instead. + + - `@objectstack/spec`: `defineStack` and `composeStacks` stamp a non-enumerable `Symbol.for` provenance mark on what they return. The mark is invisible to the schema, to `Object.keys` and to `JSON.stringify`, so no compiled artifact changes. New export: `hasStackProvenance(value)` — `true` only for a value one of the two producers returned. New registered error code: `STACK_PROVENANCE_MISSING` (422), raised by `composeStacks` for an unbuilt input. + - `@objectstack/cli`: `loadConfig` reads the mark off the default export before merging named exports into it (the merge is a spread, which drops the mark), and exposes it as `LoadedConfig.stackProvenance`. `objectstack validate` / `objectstack build` refuse on `false` through their existing error path: under `--json`, `error` + `code: 'STACK_PROVENANCE_MISSING'`. The envelope has no new fields. `objectstack dev` compiles through `objectstack build`, so it refuses the same way when it compiles. `objectstack serve`, `objectstack migrate`, `objectstack lint` and `objectstack generate` load configs exactly as before. + + **Migration** — FROM a plain-object (or copied) default export TO the value `defineStack` returns: + + ```ts + // FROM + export default { + manifest: { id: 'com.example.app', namespace: 'app', version: '1.0.0', type: 'app', name: 'App' }, + objects: [/* … */], + }; + // or: export default { ...defineStack({ … }), api: { … } }; + + // TO + import { defineStack } from '@objectstack/spec'; + + export default defineStack({ + manifest: { id: 'com.example.app', namespace: 'app', version: '1.0.0', type: 'app', name: 'App' }, + objects: [/* … */], + // every stack key inside the call — `api`, `plugins`, `requires`, … + }); + ``` + + One-line fix: wrap the export in `defineStack(...)`, and move any key spread onto a copy into the call. For compositions, wrap each input: `composeStacks([defineStack({ … }), …])`. Once wrapped, a config that used to pass can now fail with one of the family's own codes. Those findings were always there; the plain export hid them. Fix each one as its message says. Host-style configs whose `plugins` hold plugin instances are covered by the same rule, and the same wrap fixes them (`defineStack` accepts plugin instances). A project already exporting `defineStack(...)` or `composeStacks([...])` of `defineStack` inputs is unaffected. + + Clause-②: yes (narrowing) + + +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- d0f06ff: feat(cli)!: `os generate` refuses a metadata name outside the charset `packages/spec` declares for an object `name`, before it derives anything from it (#16726) + + Maintainer ruling, decision batch #82 (2026-09-08), option A — **a gate, not a sanitiser**. `os generate ` used to accept any name at all; since #16724 it has refused names whose emitted TypeScript does not parse. It now also refuses, ahead of that check and ahead of every derivation, any name the object-`name` declaration in `@objectstack/spec` rejects. The refusal names the value and quotes the schema's own rule, and writes nothing. + + ⛔ Nothing is rewritten. The rejected alternative was to derive a legal identifier the way `os create` does, which decouples the name the author wrote from the name that gets emitted with nothing announcing it — the failure mode that multiplies silently when metadata is written in bulk. So the name you author and the name that lands in the file are always the same string. + + **What this narrows:** kebab-case (`order-line`), uppercase (`Order`), dotted (`foo.bar`) and digit-initial (`2fast`) names were accepted before and are refused now — `order-line` used to generate `order_line.object.ts` binding `orderLine`. Write the snake_case name directly (`os g object order_line`). ⛔ No new charset was minted and no flag bypasses the gate; #16724's parse check is unchanged and stays as the backstop behind it (`class` passes the charset and is still refused for `object`, because `const class:` is not a declaration). + + +- e2c2620: fix(cli): `os i18n check` counts the coverage an app actually owns, so `--strict` / `--threshold` can gate an app package (#16681) + + ## What was wrong + + `collectExpectedEntries` walks the Studio metadata-form registries + unconditionally — identically for every config, an empty one included — so + every stack's expected set carries ~773 `metadataForms.*` keys that + `@objectstack/platform-objects` translates and the runtime already serves. + + Two of the three commands that see that family already knew it is not the + author's. `os lint` hides it and says so ("platform built-ins: 773 i18n + issue(s) hidden — rerun with `--include-platform`"); `os i18n extract` has + `--no-metadata-forms`. `os i18n check` is the one command that publishes a + **percentage**, and it carried the baseline in its denominator: + + ``` + Coverage by locale + en ████████████████████████ 100.0% (1265/1265, missing 0) + zh-CN █████████░░░░░░░░░░░░░░░ 38.9% (492/1265, missing 773) + ``` + + That is an application with every key it owns translated. `--strict` and + `--threshold` — the two flags whose entire purpose is CI gating — therefore + could not gate an app package at all, and the only way to move the number was + to ship a copy of the platform's bundle, which would *override* the platform's + own and go stale at the next upgrade. The workaround was worse than the defect. + + ## What it does now + + **Ownership is observed, not assumed.** The baseline counts toward coverage + when the stack under examination ships those translations itself, and does not + when it does not — read from the config's own `translations` bundles, requiring + a non-empty string leaf so an `--fill=empty` scaffold is not mistaken for a + claim of ownership. An app gets a number about its own surface with no flag; + `platform-objects`, which does ship the family, stays gated on it with no flag + either. An unconditional exclusion would have turned the app side green by + deleting the platform's own gate, and is what the negative-control tests forbid. + + **The flag is `os lint`'s, spelling and all.** `--include-platform` forces the + baseline in; `--no-include-platform` forces it out, for a package that ships a + partial baseline and does not intend to own the rest. Absent, the decision is + the observed one — three states, not two. + + **Both output faces carry the decision.** `--json` gains + `platformMetadataForms: { mode, excludedKeys }`, and the console prints + `platform built-ins: N key(s) not counted — rerun with --include-platform to + gate them here` under the coverage table, rendered from those same two numbers. + + `os lint` is unchanged. The shared `computeI18nCoverage` seam still counts the + baseline by default, because lint folds it away one seam later and counts what + it folded for its own hint line. + + ## Compatibility + + Additive on the command surface; an invocation that was refused is now + accepted, and no flag is removed or renamed. The behaviour that changes is the + **default coverage number for a stack that ships no `metadataForms` bundle** — + it stops reporting a debt that stack must not pay. A run that wants the old + numbers back asks for them with `--include-platform`, on the same argv. +- 4bbf766: Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). + + **BREAKING** (return shape) — `walkAddressedPageComponents` is a published export of `@objectstack/spec` and its return value is now the rebuilt roots pair `{ regions?, slots? }` where it used to be the regions array alone. A caller that only enumerates components through the visitor and ignores the return value is unaffected. A caller that reads the return value binds `const { regions } = walkAddressedPageComponents(doc, visit)` and reads `regions` exactly as it did before; `slots` is the other half of the same rebuild and is present exactly when the input page authors slots. The bump stays `minor` because the launch-window convention `scripts/check-changeset-no-major.mjs` enforces refuses a `major` while the fixed group is in lockstep — during that window the version number carries nothing about breaking-ness, so this banner and the disposition below are the carriers. + + **`walkAddressedPageComponents` widens in both dimensions.** The shared page walk behind `translatePage` and the CLI extractor (`os i18n extract` / `os i18n coverage`) rooted at `regions[].components[]` only and descended `properties.children` only. A slotted record page authors `regions: []` and puts everything under `slots.`, so the walk visited nothing on it and `pages.` carried exactly two addressable keys however many components the page authored; a `page:tabs` / `page:accordion` keeps its panels' components under `properties.items[].children`, one level deeper than the descended slot, so a related list inside a tab was unreachable on any page kind. The walk now roots at `regions[].components[]` **and** `slots.` (one component or an array per slot, regions first, then slots in authored order — both root level for the collision arbitration and for the page-name `page:header` route, so a slotted page's `slots.header` is translated as the page's header), and descends `properties.children` **and** `properties.items[].children` (matched by shape, so a custom container speaking the same vocabulary is walked too; `body` / `footer` remain undescended — a renderer back-compat fallback, not an authorable spelling). The depth cap, the cycle guard and the ruled id arbitration are unchanged. + + - Signature: the parameter is `AddressedPageRoots` (= `Pick`) instead of `Pick`, and the walk returns the rebuilt roots pair `{ regions?, slots? }` (each key present exactly when present on the input) instead of the regions array alone. `PageLike` gains `slots`. An enumeration-only consumer that ignores the return value needs no change; a consumer reading the returned regions destructures `{ regions }`. + - `translatePage` carries the rebuilt `slots` back onto the document. + + **`dashboards..globalFilters.` is a new bundle group.** A dashboard's filter bar draws directly above the widget titles the bundle has always translated, and neither a filter's label nor its static option labels had a key. The group is keyed by the filter's `name` (`GlobalFilterSchema.name`, declared as defaulting to `field` — a filter that authors no `name` is keyed by its `field`) and carries `label` and an `options.` map keyed by the option `value` spelled as a string. `translateDashboard` overlays it on the served document, which is what objectui's filter bar already reads; the exported `globalFilterKey()` is the one key derivation both the resolver and the extractor use. `optionsFrom` options are fetched rows and are deliberately not addressable. + + **`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. + + **`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards..globalFilters.created_at.label` entry for the system-overview dashboard's date-range filter, which authors no `name` and is therefore keyed by its `field`. + + **Why no ADR-0087 ledger entry.** Nothing an author writes moves. The authorable side is purely additive — `dashboards..globalFilters.` is a new optional group and every bundle that was valid before is valid unchanged — no spec key is retired, no stored `sys_metadata` shape changes, and no conversion or migration id is touched, so `objectstack migrate meta` has nothing to act on. The one incompatible surface is a published function's TypeScript return type, which reaches every affected consumer through the compiler. + + +- fb39b38: fix(cli): `os migrate meta --from N` — the invocation every tombstone prescribes — lists the conversions it was sent to list, and an empty range stops reading as success (#17134) + + `--to` defaulted to `PROTOCOL_MAJOR`, the major the runtime implements. But retirements land throughout a major's line, and their ADR-0087 conversions are registered under the NEXT one: `@objectstack/spec@17.4.0` tombstones `dashboard.refreshInterval` while the conversion that renames it is `toMajor: 18`. The `retiredKey()` house sentence names the major the source was **authored** against — `Run \`os migrate meta --from 17\` …` — so the prescribed invocation composed the range `17 → 17`, which `composeMigrationChain` selects **no step** for, and the command answered: + + ``` + ✓ Nothing to migrate — the metadata is already canonical for this range. + ``` + + exit 0, printed immediately under the five refusals that named that exact command. **29 shipped tombstones across 15 source files prescribe it.** + + Two changes, both in `packages/cli`: + + - **`--to` now defaults to the highest major this build of `@objectstack/spec` carries a migration step for** (`Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS)`), so the tombstone template's presumption holds in every window rather than only after the next major has shipped. Nothing is migrated "past" the runtime: every registered conversion maps a shape the installed schemas already **refuse** onto the one they accept, which is why the terminus is the only target for which the command's own `schemaValid` verdict is reachable. `Math.max` keeps the runtime's major as the floor for the reverse case. + - **A range holding no step is answered as one.** `already canonical` was a green verdict on a check that never ran, so the empty-range case now says so, names the range that would list the conversions (`--to N`), and no longer returns past the schema verdict that contradicted it — the same run used to report `schemaValid: false` in `--json` while the human output claimed the metadata was canonical and stopped. + + **What changes for you.** `os migrate meta --from ` with no `--to` now replays one hop further than it did, so a cross-major run prints that hop's semantic TODOs as well — the same wall a `--from N-1` run has always printed, one major on. The mechanical rewrite list is still first. `--to` is unchanged when you pass it, `--stored` is untouched, exit codes are unchanged (this command reports findings, it does not exit on them), and a range that holds real steps and rewrote nothing still answers `Nothing to migrate`. +- cca1dc0: + + feat(cli,metadata-core)!: the protocol version is emitted under `protocolVersion`, never under a `runtime`-shaped name (#15585) + + **BREAKING** — two published machine surfaces change a key name. There is **no alias + and no dual-key transition window**: one axis, one name. + + | Surface | Was | Now | + |:--|:--|:--| + | `os migrate meta --json` payload | `runtime` | `protocolVersion` | + | `OS_PROTOCOL_INCOMPATIBLE` diagnostic (`ProtocolIncompatibleError.diagnostic`) | `runtimeVersion` | `protocolVersion` | + | `checkProtocolCompat()` / `assertProtocolCompat()` 2nd parameter | `runtimeVersion` | `protocolVersion` | + + The **value** is unchanged on every one of them: it is `PROTOCOL_VERSION`, the protocol + major padded to a semver (`'17.0.0'`), exactly as before. Nothing else on either payload + moves — no other key is added, removed or reshaped, and both text faces are byte-identical. + The parameter rename is positional, so no call site changes. + + ## Why the name had to move + + `PROTOCOL_VERSION` is the protocol major padded to a semver and never tracks the installed + `@objectstack/cli` or runtime package version. Printed or emitted under the word *runtime* + it read as one: on a 17.3.0 install `runtime: "17.0.0"` reads as an apparent downgrade or + a stale install, next to the real package versions of the same upgrade session. + + The human line was repaired first and now reads + `Chain: protocol 17 → 17 (this runtime implements protocol 17)`. The machine face is the + worse half and was left standing, because a key on a published payload is a contract + change: an agent scripting an upgrade has no prose to disambiguate at all, and the + diagnostic's own `message` — which *is* unambiguous — is the one part a machine consumer + does not parse. + + ## What a consumer should do + + Read the new key. The old one is absent, so a consumer that does not move reads + `undefined` rather than a wrong value. + + ```diff + - const v = payload.runtime; // os migrate meta --json + + const v = payload.protocolVersion; + + - const v = err.diagnostic.runtimeVersion; // OS_PROTOCOL_INCOMPATIBLE + + const v = err.diagnostic.protocolVersion; + ``` + + The diagnostic surfaces through every package that re-emits it — `@objectstack/runtime` + spreads it into `ArtifactReferenceError.detail`, `@objectstack/metadata-protocol` throws it + from the package install boundary, and `@objectstack/services-package` reads it during + hydration — so a consumer reading it from any of those reads the new name too. + + `runtimeMajor` on the same diagnostic is deliberately **unchanged**: it is an integer + protocol major, not a semver in a version position, and it does not carry the ambiguity + this rename closes. + + The breaking surface was measured before the rename and is closed inside this repository: + the only reader of the `--json` key was this repo's own e2e pin and the only reader of the + diagnostic member was `metadata-core`'s own unit test, both of which move in this same + change; the published `skills/objectstack-upgrade/SKILL.md` documents `--json` without ever + naming the field. **Zero external consumers were found.** Graded `minor` rather than + `major` for the launch window; the banner above carries the breaking-ness the level cannot. +- 9cdffbe: One physical representation for the NUMERIC column family, read by every producer of DDL + + `packages/spec` now states, per field type, what column a numeric field gets, and all three + producers read it: `SqlDriver.createColumn`, `os generate migration --format sql` and + `os generate migration --format typescript`. Measured on live PostgreSQL 16.13, one object + through all three producers, before and after: + + ``` + BEFORE AFTER + driver sql gen ts gen all three + number real numeric(18,2) numeric(8,2) numeric(65,30) + currency real numeric(18,2) numeric(8,2) numeric(65,30) + percent real numeric(5,2) numeric(8,2) numeric(65,30) + slider real numeric(18,2) numeric(8,2) numeric(65,30) + summary real numeric(18,2) numeric(8,2) numeric(65,30) + progress real numeric(5,2) numeric(8,2) numeric(65,30) + rating real integer integer integer + ``` + + 7 of 7 columns diverged before, 0 of 7 after. Every arm of the old split lost data in its own + direction: `real` is IEEE-754 binary32, so a `currency` of `1234567.89` read back `1234567.9`; + `numeric(5,2)` and `numeric(18,2)` silently ROUND a legitimate `33.333` to `33.33` (round + half-up — executed, not inferred); `numeric(8,2)` refused `1234567.89` outright. `65,30` is + MySQL's documented `DECIMAL` maximum and therefore the portable one, and it is the only + candidate measured to lose nothing on a nine-value corpus. + + Both migration formats also take the physical `NOT NULL` from `storage.notNull` and never from + `required`, which is where `SqlDriver.createColumn` has taken it since ADR-0113: `required` is + the write-time contract the record validator enforces, and binding the DDL to it made every + post-deploy tightening a destructive migration. + + **BREAKING** — new columns only; no existing column is retyped, no migration is planned, and no + backfill runs. Four consequences to know before creating new tables: + + - `rating` is an INTEGER column, and the two server dialects dispose of a fractional star count + DIFFERENTLY — do not read one answer for both. PostgreSQL REFUSES `4.5` outright, where a + `real` column accepted it. MySQL does NOT refuse: it ROUNDS, and `4.5` becomes `5` with no + error, which is a silent alteration and the reason to declare a `slider` (in the exact-decimal + set) for anything that wants fractional values. SQLite refuses nothing either: it stores `4.5` + as a REAL in an INTEGER-affinity column, unchanged from today. + - An exact-decimal column is bounded where a float is not, in BOTH directions. It keeps 30 + fractional digits: a magnitude whose significant digits run past the 30th decimal place loses + the tail silently — `1.2345678901234567e-15` stores as `0.000000000000001234567890123457`, so + the loss begins around |x| < 1e-13 and is total below 1e-30 — and magnitudes at or above 1e35 + are REFUSED, where `real` kept about seven significant digits out to ~1e38. A refusal is loud; + the rounding it replaces was not. + - Reads are bounded by the wire contract, not by the column. `find()` hands back a JS number + (`z.number().finite()`), so a value that was never a JS double does not survive the round trip + exactly — `1234567890123456.123` reads back `1234567890123456`, and 2^53+1 reads back 2^53. + The fidelity this buys is an exact COLUMN read through a double: values written by this + platform round-trip exactly, and SQL-side writers, `summary` roll-ups computed in SQL and any + magnitude at or above 2^53 are bounded by the read seam. Widening that is a wire-contract + change and is not in this release. + - A generated migration no longer emits `NOT NULL` for a field marked only `required: true`. + Declare `storage: { notNull: true }` for a physical constraint — which is what the platform's + own table has always done since ADR-0113, and what `os migrate meta` deliberately does NOT + supply on your behalf (the conversion that stamped it was withdrawn by maintainer ruling on + 2026-09-08). A source author who wants the column they had must write that block themselves; + `required: true` keeps its own meaning, the write-time contract the record validator enforces. + + SQLite emits byte-identical DDL for the six exact-decimal members: knex compiles both + `table.decimal(name, p, s)` and `table.float(name)` to the same `float` column there. + + +- 87ad73b: + + feat(cli)!: the `--json` payload key `specVersionGap` is renamed to `protocolVersionGap` (#14261) + + **BREAKING** — a published machine surface changes a key name. `os validate --json` and + `os build --json` emit **`protocolVersionGap`** where they emitted `specVersionGap`. A + consumer reading `specVersionGap` reads `undefined` after this release and must switch to + the new name. There is **no alias and no dual-key transition window**: one axis, one name. + + The value shape is unchanged — `null` when the app's declared compatibility range admits + the installed `@objectstack/spec`, otherwise the same advisory record with the same + members. Nothing else on either payload moves: no other key is added, removed or + reshaped, and the text faces of both commands are byte-identical. + + ## Why the name had to move + + The axis this advisory reports moved in **#13860**: it used to read the undeclared + `manifest.specVersion` and now reads `manifest.engines.protocol`, which is declared + (`PluginEnginesSchema`), stamped by every scaffold, and enforced at boot. The published + key name stayed behind for one release, deliberately — renaming a machine face with + pinned consumers is a break, and no ruling covered it at the time. + + Leaving it is a correctness problem, not untidiness. A key spelled `specVersion*` invites + the reader — an AI agent above all — to infer that a writable `manifest.specVersion` + exists. `ManifestSchema` is not `.strict()` and **silently drops unknown keys** (#14192), + so acting on that inference does not produce an error: it produces a manifest that looks + entirely normal and whose `specVersion` line never took effect. That is the same + ghost-key breadcrumb mechanism that caused #13860 in the first place, left standing on + the output side. + + ## What a consumer should do + + ```diff + - if (payload.specVersionGap) { … } + + if (payload.protocolVersionGap) { … } + ``` + + The breaking surface was measured before the rename and is closed inside this repository: + the only consumers of the old key were three in-repo e2e suites, which move in this same + change; **zero external consumers were found**. Graded `minor` by the maintainer's + explicit grading of 2026-09-02; the banner above carries the breaking-ness the level + cannot. +- 0aa88eb: `os package publish` no longer publishes under a manifest id the author did not write. A `manifest.id` the artifact declares is now used or refused — never silently swapped for a derived one. + + Before this, `deriveManifestId` adopted `manifest.id` only when it parsed as `PackageSchema.manifestId`, and any other declared value fell through to `local.`. Nothing said so: the substituted id appeared in the ordinary progress line, byte-identical to the run where the artifact declared no id at all. + + ``` + manifest.id = 'crm' before: → Registering package 'local.acme-crm'... (exit 0) + manifest.name = 'Acme CRM' + after: ✗ Invalid manifest-id 'crm'. … (exit 1) + ``` + + `sys_package.manifest_id` is **immutable once set** — "renaming a package requires creating a new package" — so the value chosen there is a permanent, globally unique identifier. Choosing it silently, against the author's own declaration, is the one field that must not be rewritten without a word. + + - **A declared `manifest.id` reaches the existing preflight gate.** If it is not a manifest id the control plane accepts, the publish refuses before any network call, quoting the schema's own issue and description and naming where the id came from. No second rule is introduced in the CLI: the judgement is still `PackageSchema.manifestId`, which is the same schema node `CreatePackageRequestSchema.manifestId` declares for the `manifest_id` this command POSTs. + - **Honouring the declared value instead was not available.** The values that used to fall through are, by construction, exactly the ones that schema rejects, so forwarding one would only move the same refusal to the server, later and with a worse message. + - **Absent, blank and non-string `manifest.id` are unchanged** — none of those is a declaration, and each still derives from `manifest.name`, then the artifact filename. + + What to do if a publish that worked now refuses: the message names the three ways out. Fix `manifest.id` in `objectstack.config.ts` to a reverse-domain id and rebuild; remove the key to keep publishing under the derived `local.…` id (the value the previous release was already using); or pass `--manifest-id`. Every id the control plane accepts publishes with unchanged bytes. +- f04be62: feat(types,triggers,service-automation,runtime,cli,spec,lint)!: package-authored scheduled work is a deployment decision — `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, off by default everywhere (#17396) + + + + Maintainer ruling, 2026-09-12, verbatim, untranslated: + + > schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,有没有可能定义为一个环境变量,根据环境变量控制? + + > 如果多租户暂时只接禁用定时任务,完整的考虑一下影响面。 + + > group 默认也关,云端每库一租户全局默认关 + + **A new deployment variable, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, decides whether this deployment runs PACKAGE-AUTHORED scheduled work at all** — time-triggered flows (`type: 'schedule'` with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. It is read at boot beside `resolveTenancyPosture` and is ⛔ **not** a metadata concept and ⛔ **not** a new spec key: whether a clock-driven workload is affordable is a fact about the deployment — its database, its tenants, its budget — that no package author can know, and a metadata key would ask them to. + + **OFF by default, in every posture and in every kernel.** Unset means off; `true` / `1` / `on` / `yes` (case-insensitive) means on. ⛔ Deliberately not the opt-out `!== 'false'` shape `OS_MULTI_ORG_ENABLED` uses, which reads a typo as "on" — here that would arm exactly the workload an operator meant to refuse. + + ⛔ **Platform-internal scheduled work is NOT gated** and runs either way: approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill. The boundary is **authored by a package**, not "runs on the job service" — the platform's own maintenance is part of the runtime a deployment asked for. + + **BREAKING**, in two directions, and both land inside the same launch window as #16659 / PR #17334, so no published version ever saw the rule this narrows. + + 1. **A NARROWING, and it is the one to plan for.** A deployment that upgrades and does nothing runs **no** packaged time-triggered flow and **no** packaged `defineJob`. Anything that was firing from a package stops. ⇒ Set `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` if you depend on it. Nothing detects the shape for you at authoring time, by design — but nothing is silent either: every such flow is listed in `getTriggerBindingAudit()` and the `os dev` / `os start` startup summary with a DISTINCT reason, **disabled by deployment policy**, ⛔ never as "binding failed"; the packaged-job loop says so once per app at `info` with the count; and `os doctor` prints the effective value in both states. + 2. **A WIDENING of what binds.** With the switch on and tenancy posture `single`, a time-triggered flow that declares **no** `config.organization` now binds and runs — under #16659 alone it was refused. That posture holds exactly one organization (a second is refused), so the run carries **no** organization and every tenant-scoped insert beneath it resolves that one the way a single-organization install always did; a `timeRelative` sweep there runs **unscoped**. ⛔ Nothing is invented: the key is OMITTED, never filled from the install, the platform organization, or the swept record's own `organization_id`. + + **Under a walled posture (`group` / `isolated`) the 2026-09-08 ruling on #16659 stands unchanged**: a time-triggered flow declares `config.organization` or it is not armed, there is no fan-out, and no organization is ever chosen for it. `group` is walled here for a measured reason rather than by analogy — `resolveSystemWriteOrganization` refuses an organization-less system insert under any wall and `TenancyService.defaultOrgId()` answers `null` (ADR-0093 D3), so an organization-less group-wide sweep could read the whole group while every row it inserts is refused. Which organization such a sweep's inserts belong to is not yet decided; until it is, `group` behaves as walled. + + **`flow-schedule-organization-missing` is DELETED** from `@objectstack/lint` (the id and its exported constant, `FLOW_SCHEDULE_ORGANIZATION_MISSING`; both are unreleased — they were introduced by the still-unconsumed #16659 changeset in this same window, so no consumer can be holding either). The rule family's criterion is *is this stack enough to know the flow is dead?*, and the honest answer here is no: the deployment switch and the tenancy posture decide it, and neither is in any stack. A finding that is false for the default deployment is noise. ⛔ The near-miss diagnostic did **not** go with it — `describeMissingScheduleOrganization` and its `organizationId` / `tenantId` / … scan still fire at BIND, the one door that can read both facts, and only where the key is actually required. + + **ADR-0087 semantic entry 18 (`schedule-flow-acting-organization-required`) is REWRITTEN, not added.** Its acceptance criteria required every time-triggered flow to declare; that is no longer the rule. It now prescribes the two decisions in order — decide the switch, then declare per organization under a wall — and records that `os lint` reporting nothing is the criterion being met rather than a check that was skipped. The unconsumed `.changeset/schedule-trigger-acting-organization.md` carries a banner saying the same, so a reader of either one cannot get the narrower half alone. + + **Where the switch is read, and where it is not.** Both triggers gate at `start()`, ahead of the descriptor and the declaration, so an operator on a deployment that was never going to run a flow is not sent to fix a descriptor nothing would have read. `AutomationEngine.activateFlowTrigger` reads the same resolver and does not call `start()` at all when it is off — that is what keeps the audit's reason precise, since a refusal arriving as a THROW can only be reported through the catch that says "Failed to bind". Neither read is cached: the resolver reads `process.env` live, so a host that rebinds after the environment changes sees the value current at the bind. The scope is `schedule` and `time_relative` only — `record_change` and `api` are fired by a caller that already exists and already carries an identity, and a kind added to `FlowTriggerKind` later is OUTSIDE the switch until someone decides otherwise, because a new capability that disappears on arrival is the worse default. +- 9bd4344: feat(auth)!: adopt better-auth's account-issuer rollback — drop `sys_account.issuer`, retire the backfill, lift the `@better-auth/*` family to an exact `1.7.3` (#17440) + + + + **BREAKING** — a platform object drops a declared field and `@objectstack/plugin-auth` + drops six published symbols. Shipped as `minor` under the launch-window convention + (`major` is refused by `check-changeset-no-major`; breaking-ness is carried by this + banner plus the ADR-0087 disposition above). The hand-migration prescription is + registered under protocol major 18 as `sys-account-issuer-retired`. + + better-auth `1.7.3` removed the issuer-scoped account identity outright + (`better-auth/better-auth#10909`): `createLocalAccountIssuer` is deleted, + `accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the + `account.issuer` column and its unique index are gone from `get-tables`. There is no + drop-in replacement. `#16186` pinned the family at an exact `1.7.2` as a stopgap; this is + the durable half, per the maintainer ruling of 2026-09-10 on `#16629`. + + ## 迁移:FROM → TO + + | FROM | TO | the one-line fix | + |:--|:--|:--| + | `sys_account.issuer` (column + `{ fields: ['issuer','account_id'], unique: true }`) | — | nothing replaces it; identity is `(provider_id, account_id)`, declared UNIQUE on `sys_account` since the object was created | + | reading `account.issuer` off a row or off `client.accounts.list()` | `sys_sso_provider.issuer`, resolved through the account's `provider_id` | `provider_id` is unique per environment, so it names the authority on its own | + | `backfillAccountIssuer(ql, …)` | — | delete the call; there is no successor pass | + | `CREDENTIAL_ISSUER` / `oauthIssuerFor(id)` | — | drop the argument; `internalAdapter.createAccount({ userId, providerId, accountId, password })` takes no `issuer` | + | `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult` | — | delete the import; the compiler names every site | + | `@better-auth/*` at an exact `1.7.2` (eleven members) | an exact `1.7.3` (eleven members) | the family moves as ONE line — `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions | + + ## ⭐ Existing deployments: run the pre-flight BEFORE the column is dropped + + Uniqueness moves from `(issuer, account_id)` to `(provider_id, account_id)` — a + **narrower** key. Two rows sharing `provider_id` + `account_id` and differing only in + `issuer` are legal under the old key and are ONE account under the new one. + + ``` + os migrate account-issuer # read-only; exits non-zero when the drop must not proceed + # … take a backup (the operator's act, and the apply step's precondition) … + os migrate apply --allow-destructive + os migrate account-issuer # post-check: reads zero + ``` + + The pre-flight reads **rows**, never the index declaration. `syncDeclaredIndexes` logs a + plain UNIQUE whose CREATE failed on existing duplicates onto the durability channel and + lets the boot continue (`#14902` / `#15479`), so a database can carry the declaration + without the constraint — and on such a database the drop does not fail loudly, it + degrades silently: the rows become indistinguishable and a sign-in can resolve onto the + wrong user's account. `os migrate apply --allow-destructive` re-runs the same pre-flight + and refuses the drop before writing any DDL. A read that throws, or a scan that + truncates, refuses too — an unread table is not a clean one. + + ⛔ Colliding rows are never merged or dropped for you: which row survives is application + knowledge, and two different people can be behind one colliding key. Keep the row whose + provider account is live, delete the rest so a fresh sign-in re-links, and re-run. + + The boot refusal is unchanged and needs no new machinery: a runtime already refuses to + start against unapplied destructive drift, naming the command to run, and never + auto-migrates. + + ## ⚠️ A `provider_id` re-pointed at a different IdP must have its bindings REBUILT + + This is the one case `issuer` still discriminated. After the drop no column records which + IdP vouched for a row, so if a re-pointed provider's new IdP mints a subject the old one + had already issued to somebody else, the key resolves that sign-in onto the other + person's account. Under the old key that failed loudly (`unable_to_link_account`); under + the new one it is silent. + + ⇒ `sys_sso_provider` now **refuses an `issuer` change while `sys_account` rows are still + bound to that `provider_id`** (`RESOURCE_CONFLICT` / 409). Delete the provider's account + bindings first; each user re-links on their next sign-in. + + ## Why the column was a liability, not an asset + + A credential row whose `issuer` was not the local credential issuer was invisible to + `findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not + found" warn pointing at the `sys_user` row rather than at the account. **Four checklist + items had that recorded as a knownGap, each rediscovering it.** Its discriminating power + here was near zero anyway: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: + true }`, so `provider_id → issuer` is a function within an environment. + + ## Also in this change + + `pnpm check:vendor-export-contract` (from `#16186`) keeps its exactness requirement and + still resolves every named symbol — its self-test re-anchors from the now-retired + `@better-auth/core/db` specimen onto a live edge, and gains a case asserting the two + deleted names are imported nowhere. `#11627`'s hash-shadow-key machinery is untouched: it + is a generic driver capability serving five UNIQUE members of the >768-char class. + +### Patch Changes + +- bd25e89: `bin/run.js` — the entry `os` / `objectstack` names — resolves its commands from `dist/` whatever an ambient `NODE_ENV` says, so an exported `NODE_ENV=development` no longer kills the CLI in a project whose tsconfig maps a package to TypeScript source (#12271). + + `@oclif/core` skips its TypeScript path lookup only when `isProd()` — a negated `['development', 'test'].includes(NODE_ENV)`. Under either value it resolved the CLI's **own** command modules from `src/` and registered tsx on the way, and tsx honours the tsconfig of the **current working directory**. An application that maps a CommonJS workspace package to its TypeScript source for *type* resolution — `"@objectstack/formula": ["../../packages/formula/src/index.ts"]` — therefore steered this CLI's *runtime* module graph into `.ts` files, after which Node's CommonJS resolver walked their extensionless siblings and found nothing: + + ``` + [MODULE_NOT_FOUND] import() failed to load …/packages/cli/src/commands/doctor.ts: + Cannot find module './registry' + ``` + + Measured at two example apps with `NODE_ENV` as the only variable: `os compile`, `os dev --compile --fresh`, `os serve --dev` and `os start` each exited 1 on that signature under `development`, and each compiled or booted cleanly under `production`. The app with no `paths` block was the only one unaffected. + + - **The fix is one declaration**: `settings.enableAutoTranspile = false`, checked by oclif ahead of `isProd()`. `bin/run.js` is the built entry and `bin/run-dev.js` is the source entry — a division `check:cli-test-child-env` already enforced on every test that spawns the CLI; the entry simply never asserted it about itself. + - ⛔ **Not a child-environment scrub.** `os serve --dev` and `os start` are top-level processes with no parent to scrub, and the casualty was the CLI's own command table rather than the user's config, so no per-spawn `NODE_ENV` handling could reach it. + - **`NODE_ENV=development objectstack start` works again** — the debugging mode `os start` has advertised in a comment all along, and did not deliver. + - ⚠️ **What it costs, measured**: the only thing oclif keeps its TypeScript lookup alive for in production is a **linked** plugin, so a `plugins link`ed TypeScript plugin would no longer be auto-transpiled through the published entry. That path is not reachable today — `@oclif/plugin-plugins` sits in `devDependencies` and oclif's core-plugin loader only matches names under `dependencies`, so `os plugins` is not a registered command (`os --help` lists 34 topics and none is `plugins`), which is what `content/docs/plugins/index.mdx` already documents. On an unbuilt checkout the entry now answers oclif's `command not found` under `development`/`test` exactly as it already did with `NODE_ENV` unset. +- 0f95f43: docs(identity): re-point the cloud-identity `ADR-0024` citations at the records that decide them (#14361) + + From this repository's point of view `ADR-0024` names two unrelated decisions. + `docs/adr/0024-mcp-connectors.md` is *MCP Servers as Connectors* — an open, + vendor-neutral tool protocol, with a Decision section numbered §1–§5 and no + D-lettered clauses at all. The identity surface's citations mean something else + entirely: the identity-and-access decision taken in `objectstack-ai/cloud` as + its own ADR-0024, whose open mechanism half has been mirrored into this repo + since 2026-09-07 as + [ADR-0135](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0135-identity-and-access-architecture.md). + A reader following one of those citations landed on a real page about the wrong + subject, which is worse than a dangling id: a plausible-looking record invites + belief rather than a second question. + + 79 citation lines were read one at a time and re-pointed. 73 mean a clause + ADR-0135 restates and now name it with its letter — D4 (source-of-truth marking, + managed vs env-native), D5.2 (the break-glass last-administrator invariant), D6 + (SSO per production environment, including the opt-in DNS domain-verification + clause this tree spelled `ADR-0024 ②`) and D9 (environment users and + organization membership). 6 mean a clause ADR-0135 deliberately leaves in the + cloud record and now carry the anchors gate's cross-repo qualifier + `cloud ADR-0024`: `V1` (the SSO default-role provisioning, the roadmap and + commercial framing) and `§7` (the `ai_seat` synthesis, which ADR-0135 does not + restate). + + What actually reaches a consumer of these packages: + + - `@objectstack/plugin-auth` — the **operator-facing break-glass refusal + detail** now reads `break-glass invariant, ADR-0135 D5.2 — an environment must + always keep at least one administrator who can sign in`. The condition that + raises it, its status, its error code and the rest of its wording are + unchanged; only the ADR number moves. ⚠️ A deployment that greps that message + for the literal `ADR-0024` should grep for `ADR-0135`. The guard's + registration log line moves the same way. + - `@objectstack/platform-objects` — `sys_sso_provider`'s `domain_verified` field + help text, its `protection.reason`, and the matching leaf in all four shipped + locale bundles (`en`, `es-ES`, `ja-JP`, `zh-CN`). + - `@objectstack/spec` — the doc comment above `AuthConfigSchema`'s + `ssoDomainVerification`, published both in `dist/` and as + `src/system/auth-config.zod.ts`. + - `@objectstack/core`, `@objectstack/cli` — doc comments only, published in + `dist/`; no runtime string and no behaviour. + + No behaviour moves. No schema accepts or refuses anything it did not accept or + refuse before, no security or permission semantics are touched, and no ADR + record is written or edited. Bare `ADR-0024` still resolves exactly as it did: + the 15 citations that mean the local MCP-connectors record are byte-identical to + `main`, and `check:adr-anchors` reports the same resolving-citation totals before + and after. Historical archives are deliberately untouched — 36 CHANGELOG lines + across seven packages, and the 22 lines under `docs/adr/`, which is a governed + surface this change does not enter. +- 2d5945a: `serve.ts`'s observability knob block points at the cloud mirror in the house style, keeps the sync duty, and names the package that owns the list (#15295) + + The block above `buildServeObservability()` instructed the reader to *"keep the + two in sync"* with `apps/cloud/server/observability.ts` — a path that has not + existed in this repository since `apps/cloud` moved to `objectstack-ai/cloud` + (`git ls-tree origin/main -- apps/` returns exactly `apps/docs`, the positive + control that makes that a reading rather than a broken query). A reader was + being sent to a file they cannot open, with no hint that it lives in another + repository. + + **The duty is live, so it stays.** The cloud file still exists and still reads + these names as `process.env` lookups (measured on `objectstack-ai/cloud` and + recorded on #15295, with that file's own `process.env` hit count as the firing + control) — for every knob in the block except `OS_OTLP_FLUSH_MS`, which was + added on this side after that measurement and is therefore unverified rather + than mirrored. The comment states that boundary rather than a bare count, so a + reader counting six entries under a claim about five cannot be misled about + which of them the reading covers. Deleting the clause would have dropped a real + obligation whose failure mode is quiet: the two exporters drift and the cloud + host stops reading the variables an operator set. + + Three things change, all inside one comment block: + + - the path is re-spelled in this repo's settled style for a cloud-repo + reference — ``(`apps/cloud/server/observability.ts`, cloud repo)``, the form + at `packages/services/service-cluster/src/multi-node-gate-mount.ts:9`; + - the duty is narrowed to what its own words say — **names, not defaults**. + `OS_OBS_SERVICE_NAME` defaults to `objectstack` here and to + `objectstack-cloud` there *deliberately*, because two deployments are two + services; a future reader "tidying" that into one value would merge both + deployments into a single telemetry series. The comment now says so, which is + the point of writing it down rather than leaving it to be rediscovered; + - the canonical home for the variable list is named as + `@objectstack/observability` — the package **both** consumers already import + — instead of two consumers pointing at each other. That mutual pointing is + the decay mechanism itself, and it is still one-sided today: the cloud file + carries no reciprocal sentence, so nobody renaming a name over there is + prompted to come back here. + + ⛔ No behaviour changes, and no observability code path was touched. No env var + is added, removed or renamed; no default moves. + + **This ships, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/cli`'s published `files[]` is + `["dist","README.md","CHANGELOG.md"]`, and this package builds with plain `tsc` + (no `removeComments`), so the block is emitted verbatim into the tarball — + measured on the rebuilt artifact: the new clause is present in + `dist/commands/serve.js` (1 occurrence, and the knob-list line as control + resolves to that one file), the old spelling is absent from all of `dist`, and + `dist/commands/serve.d.ts` carries 0 of it because the block sits above a + non-exported helper. So the published JS bytes move while the declaration + surface does not. +- 9c577c1: fix(platform-objects,cli): the generated i18n staleness predicate judges every section a run generated, not two fixed names + + `os i18n extract --no-objects-only --fill=default --source-hashes` emits + `apps` / `dashboards` / `pages` leaves and fills them from the source locale — + leaves carrying exactly the property the GENERATED staleness predicate exists to + judge — but the population that predicate walked was the fixed + `GENERATED_SECTIONS` list (`['objects', 'metadataForms']`). So no provenance + record was written for such a leaf, none was read back, and a `--fill=default` + copy left behind by a revised source kept being served as a superseded draft + with every i18n gate green. The hand-authored predicate does reach those paths, + but it judges against `LOCALE.source-hashes.ts`, which by construction carries + no entry for a leaf a generator produced. Neither mechanism covered them. + + The population now follows the RUN, at both ends: + + - **write** — `collectFilledFromHashes` takes a new **optional** fourth + parameter, `sections?: readonly string[]`, defaulting to `GENERATED_SECTIONS`. + `collectGeneratedLeaves` takes the same optional second parameter. Every + existing call site compiles and behaves exactly as before; `os i18n extract` + passes the sections it actually built. + - **read** — `findStaleFills` walks the sections the recorded table itself + names. One run wrote that table, so the table is the record of what that run + emitted, and the two ends cannot disagree about it. For every table committed + today this resolves to `['objects', 'metadataForms']`, so no served byte moves. + + Adding `'apps'` to `GENERATED_SECTIONS` was the other available shape and is + deliberately not taken: it would make `collectSourceLeaves` and + `collectGeneratedLeaves` walk one section — two predicates permanently on one + path — and it would assert `apps` is always generated, which is false for every + bundle set that ships. Both constants are unchanged and pinned unchanged. + + Widening the generated population is safe in a way widening the hand-authored + one would not be, because the rule is self-discriminating per leaf: a record is + written only when `value === currentSource` or `previous[path] === hash(value)`, + so a leaf someone actually translated satisfies neither and stays + legacy-trusted however wide the walk. The section list was the only part of the + mechanism that could not tell a fill from a translation. + + No committed bundle or companion byte moves in this repository. All nine + `--source-hashes` configs run the default `--objects-only`, whose commit layer + already narrows the run's table to the sections it emits a bundle for. The 387 + hand-recorded digests across `zh-CN` / `ja-JP` / `es-ES` are neither read, + written, shadowed nor lost — `apps` stays in `HAND_AUTHORED_SECTIONS`, + `collectSourceHashes` still walks it, and the extractor still never writes that + file. Its header now states which table a maintainer keeps for a path that can + appear in both, and why the overlap cannot serve wrong text. +- f721ef0: fix(cli): the boot banner's `🔑 Dev admin` says what that account will and will not see (#17081) + + `--seed-admin` (on by default in `os dev`) prints one credential, and it is the + **only** one a first-run operator is given. It is also, by construction, the + account with every *platform* capability and no *app-declared* one: its standing + is `admin_full_access`, whose `systemPermissions` are `setup.access`, + `studio.access`, `manage_users`, `manage_metadata`, `manage_platform_settings` + and `manage_sharing` — all platform built-ins — plus the `'*'` + view-all/modify-all record bits. + + So in any app that gates its apps, tabs or nav entries on + `requiredPermissions` — the filter `/me/apps` and `/meta/app` apply, and a + first-class platform feature the docs teach — the credential the terminal hands + over is the account that resolves to an **empty navigation**. A downstream + maintainer ran `pnpm dev`, signed in with it, and read the empty shell as a + broken product. The app was correct. The banner had asserted a login and said + nothing about its audience, and it outranks whatever the app's own README says, + because it sits directly under the command that was just run. + + FROM → TO, on a boot that seeds: + + ``` + 🔑 Dev admin: admin@objectos.ai / admin123 + seeded on empty DB · dev only — do not use in production + + platform admin — Setup, Studio and every record, but NO app-declared capability, so + + an app that gates navigation on requiredPermissions may show it an empty menu; grant + + it a permission set under Setup → Users, or sign in as an account your app seeds + ``` + + **Nothing about the seed changes.** What the first run creates — the account, + its address, its password, its promotion to platform admin — is a product-shape + decision and is untouched; only the banner's words move. The three lines print + only inside the branch that already prints the credential, so a boot that seeds + nothing is byte-identical to before. + + Dim continuation lines rather than a warning, deliberately: ADR-0115's + `OS_ALLOW_DEV_PLUGIN` amendment excluded the dev-admin seed from that hazard set + because "a warning about a non-event spends the attention the real ones need". + That exclusion is kept — this qualifies an event that just happened, on the line + that already announces it, and adds no new line where there was none. + + The route the sentence names is asserted against the declarations that make it + reachable, not re-spelled: `SETUP_APP.requiredPermissions` is a subset of what + this account holds, the `Users` entry is ungated, and the `sys_user` detail page + carries the "Grant permission set" related list. A rename on any of those reds + the pin instead of leaving the banner pointing at nothing. +- fce7cd4: The scaffolded `pnpm-workspace.yaml` records the retired `@better-auth/scim>better-call` peer rule instead of advertising it as live + + `objectstack init` wrote a paragraph into every project it scaffolds explaining + an `@better-auth/scim>better-call` suppression that is not in the map it + annotates — the entry retired with objectstack#3653, and `init.test.ts` pins its + absence. All three of its claims were false on today's tree as well: + `@better-auth/scim` is not "held at a release candidate deliberately" (it is + pinned at exact stable `1.7.3`), and stable `@better-auth/scim@1.7.3` declares + `peerDependencies["better-call"]` as the exact string `1.4.0` — the single copy + `better-auth@1.7.3` itself depends on — so the `1.3.7` skew the paragraph + described does not exist. + + It now records the retirement, in the shape `create-objectstack`'s bundled + `blank` template already used, and dates the measurement the way the + neighbouring `better-sqlite3` paragraph in the same block does. Both scaffold + paths previously named `1.7.1` as the current pin; both now name the measured + `1.7.3`, so the two paths tell a user the same thing. + + Comments only — no declaration moves. The rendered `allowedVersions` map is + byte-identical before and after, so no resolution, lockfile or suppression + changes. +- d07fc17: `os i18n extract` reaches a `screen` node nested inside an ADR-0031 flow region + + `walkScreenFlows` (`packages/cli/src/utils/i18n-extract.ts`) iterated + `flow.nodes` flat, so a `type: 'screen'` node inside a region — + `loop.config.body`, `parallel.config.branches[].nodes`, + `try_catch.config.try` / `.catch`, nesting arbitrarily — was never reached. It + emitted **no** `flows.NAME.screens.NODE_ID.title` / `.fields.*` skeleton entry + and **no** coverage row. + + **Why that pairing is the defect and not just a missing translation.** A nested + wizard step is a real screen: the executor pauses on it and the client receives + its `ScreenSpec.nodeId`, so `translateFlow` overlays the bundle onto it and the + key is live. With no entry emitted, a translator was never shown the key AND + `os lint` / `pnpm check:i18n-coverage` had no row to demand — the gap was + invisible to the mechanism built to report gaps. A green i18n gate on a tree + whose nested steps render source-locale text was green because the surface was + unreachable, not because the app was translated. + + The node universe now comes from a region-aware descent that reads the one + shared declaration of WHERE a region lives, `FLOW_REGION_SLOTS_BY_TYPE` from + `@objectstack/spec/automation` — the same table `packages/lint`'s + `walkFlowNodes` reads. No local copy of the slot list is introduced: a second + region table in a fourth package is the very shape this defect is an instance + of. + + **Depth deliberately does not enter the key.** Entries stay + `flows.NAME.screens.NODE_ID.*` at every depth, because `lookupFlowScreenCopy` + is keyed by node id alone and the bundle schema knows nothing about depth; a + region path segment would offer a key nothing resolves. A node id repeated at + two depths therefore addresses one bundle slot and collapses to a single entry + (first emission wins, outer before inner) — one slot can serve only one string, + and the resolver overlays that string onto both nodes. + + Seeding is unchanged and applies at every depth: a screen `title` falls back to + the node `label` (what `ScreenSpec.title` draws), and a field `label` falls back + to its `name` as a *derived* seed, so the skeleton stays usable while the + coverage gate demands no translation of a string nobody authored. + + ⛔ No authorable key, bundle shape or export moves — an author who wrote a + nested screen now gets scaffolding and a coverage row where both were silently + absent. Existing keys are byte-unchanged. +- 56103b7: `os validate`, `os build` and `os info` count the objects an ADR-0130 D4 / option-B project actually declares, so `--strict` stops refusing a conforming stack + + `collectMetadataStats` — the one reader behind the metadata summary all three + commands print — counted every collection at the **top level only**. On an + option-B project (every definition inside `packages[]`, none flattened up) the + summary reported `Data: 0 Objects`, and `os validate` raised + `No objects defined — this stack has no data model` on a stack that declares a + data model. + + Under `--strict` that warning is not cosmetic. Measured through the real + binaries on the card's repro, before: + + ``` + os validate exit 0 Data: 0 Objects + ⚠ No objects defined — this stack has no data model + ⚠ No apps or plugins defined — this stack may not do much + os validate --strict exit 1 ✗ Strict mode: warnings treated as errors + os build exit 0 Data: 0 Objects + os info exit 0 Data: 0 Objects + ``` + + and after, on the same stack: + + ``` + os validate --strict Data: 1 Objects 2 Fields + ⚠ No apps or plugins defined — this stack may not do much + ``` + + A conforming project that also declares an app now exits **0** where it exited + **1**. + + **The fix reuses the existing fold, and that is what keeps the count a union.** + `authoringRuleUnionStack` (`utils/stack-collections.ts`) is this package's one + resolution rule for a package-owned collection, and it is strictly additive: a + key the top level already carries wins, because in today's additive shape that + array already *is* the union. So an object reachable from both the top level and + a `packages[]` entry is counted once, never twice — a corrected number that + over-counts would be the same defect with the opposite sign. + + **One behaviour change beyond the counts, in `os info` only.** The fold resolves + package order through `resolveArtifactPackageOrder`, whose ADR-0112 refusals are + deliberately not swallowed. `os validate` and `os compile` already drove that + seam on the same config above their summary call, so they are unchanged; `os + info` did not, and now reports a stack whose `packages[]` repeats a package id + as a named `422` (`DUPLICATE_ARTIFACT_PACKAGE`) instead of printing + `Data: 0 Objects` for an artifact it could not read. + + ⛔ No authorable key, spec schema or published export moves. A stack whose top + level carries its collections — every stack the platform emits today — gets a + byte-identical summary: the seam returns it by identity. +- 8305ad6: `os lint`'s own rubric and `os lint --score` judge the stack an ADR-0130 D4 / option-B project actually declares, instead of reporting `✓ All checks passed` on a stack they never opened + + `lintConfig` runs two families: the shared author-time rule registry and + `os lint`'s **own** hand-written checks — naming, labels, empty field maps, the + intra-package duplicate advisory, hook-body lowering and the data-model + conventions. The registry learned to resolve `packages[]` earlier; the + hand-written family and `scoreMetadata`, which reaches the same function, still + read the **top level only**. On an option-B project (every definition inside + `packages[]`, none flattened up) they were handed an empty stack. + + Measured through the real binary, on one object authored two ways — the same + metadata, differing only in where it is declared: + + ``` + packages[] os lint exit 0 ✓ All checks passed + Metadata quality: 100/100 (A) + + top level os lint exit 0 ⚠ Label "order" should start with an uppercase letter + convention/label-case at objects[0].label + ℹ Object "ob_order" has no nameField and no name-like field … + object/missing-name-field at objects[0].fields + Metadata quality: 96/100 (A) + ``` + + and after, on the same two projects: + + ``` + packages[] os lint exit 0 ⚠ convention/label-case at objects[0].label + ℹ object/missing-name-field at objects[0].fields + Metadata quality: 96/100 (A) + + top level os lint exit 0 — byte-identical to before + ``` + + The score is the sharper half. `100/100 (A)` with every count at zero is + byte-for-byte the verdict a genuinely clean project gets, on a rubric that had + judged nothing — the same indistinguishability a swallowed linter crash used to + produce, arriving through the input instead. + + **The fix folds once, at `lintConfig`'s entry, with the existing helper.** + `authoringRuleUnionStack` (`utils/stack-collections.ts`) is this package's one + resolution rule for a package-owned collection and it is present-wins: a key the + top level already carries wins, because in today's additive shape that array + already *is* the union. So a multi-package artifact is judged once, never twice, + and a stack whose top level carries its collections — every stack the platform + emits today — is returned by identity and lints byte-identically to before. + + **This does not change what `scoreMetadata` scores.** It already scored the whole + project: its schema half reports `packages.0.manifest.objects.0: …` on an + option-B stack with no fold anywhere, and on today's additive multi-package shape + its lint half already read the flattened union across every package. The fold + makes the option-B shape agree with the additive one. + + ⛔ No authorable key, spec schema, published export or accept set moves. + `os build` rejects and accepts exactly what it did; `os lint`'s own `error` + severity remains a lint verdict, not a publish gate. +- fd8b2c0: `os info`'s **detail** reads now resolve a package-owned collection through the seam the package already has for it, so an ADR-0130 D4 / option-B project (every definition inside `packages[]`, none flattened up) stops contradicting itself. + + Measured through the real binary on the card's own repro, before the change: + + ``` + os info --json exit 0 stats.objects = 1 · objects[] length = 0 + os info exit 0 Data: 1 Objects 2 Fields (no `Objects:` section, no `Apps:` section) + ``` + + `stats` had learned to resolve `packages[]`; the four reads beside it had not, so one `--json` payload asserted `stats.objects: 1` next to `objects: []` — and nothing in the payload distinguished *this project has no objects* from *this reader could not see them*. `--json` is the face a machine reads, so a consumer could not recover from it. + + - **The four reads** — the `--json` `objects` array and the `Objects:` / `Agents:` / `Apps:` text sections in `commands/info.ts` — go through `resolveStackCollection` (`utils/stack-collections.ts`), the one place this package resolves a package-owned collection. + - **Strictly additive.** That seam answers the caller's original expression FIRST and consults `packages[]` only when the top level does not carry the key at all, so **every stack the platform emits today reports exactly what it reported before** — pinned by a control run whose definitions are the same literals, authored at the top level instead. + - **No new failure mode.** `collectMetadataStats` on the line above already resolves the same package list through the same seam, so a malformed `packages` has already answered its ADR-0112 `422` before these reads run. + + ⛔ **Not decided here:** whether an option-B project's detail listing should be this flat union or grouped per package. Each entry keeps the shape and the key set it has always had — no package attribution is added — so that published-output-shape question stays exactly as open as it was. +- b06b2db: `os generate migration` gives the file family — `file` / `image` / `avatar` / `video` / `audio` — the **same column width in both formats**. The typescript format emitted a bare `table.string(name)`, knex's `varchar(255)`, while `--format sql` emitted `VARCHAR(2048)` for the same field, so one command answered one field with two widths depending on the flag (#17883). + + 2048 is not a new number: ADR-0104 ruled the generator's `VARCHAR(2048)` the end-state for this family, `driver-sql` moved to it (`MEDIA_ID_VARCHAR_CHARS`, #15989), and `os migrate files-to-references --apply` retypes the column to `varchar(2048)`. The typescript format was the one producer left at 255 — so a deployment scaffolded from it declared a width the migration it will later run retypes away from. + + ```diff + - table.string('cover_image').nullable(); + + table.string('cover_image', 2048).nullable(); + ``` + + - **No regeneration is required of anyone.** `syncSchema` / `initObjects` are additive and never alter an existing column's type, and a `sys_file` id is far shorter than 255, so nothing stored today is at risk either way. What moves is the **declared** width of tables generated from now on. + - **The width is now read from the sql format's own entry** instead of being retyped beside it, so the two formats cannot drift apart again; `generate-file-reference-width.pin.test.ts` measures both against `driver-sql`'s constant, which is what stops the two halves from "meeting in the middle" at some third value. + - ⛔ **Nothing outside the family moved.** The `text` family, the reference types the file family used to share an arm with (`lookup` / `master_detail` / `user` / `tree`), `autonumber`, and every `--format sql` answer are byte-identical. +- ca9d9d3: `check:app-nav-i18n` now judges the PLATFORM APPS' navigation — Setup **and Account** — instead of narrowing to `setup` at every site. + + The gate is named "every id labelled in every locale" and was structurally blind to one whole app: it printed a byte-identical `OK (10 contributor(s), 54 merged setup nav id(s), 4 locale(s), …)` line before and after the Account app's contributed `nav_connect_agent` label landed, so nothing it printed could tell you it had skipped an app. + + Six sites narrowed it, only three of which were the obvious filters: + + - the contribution filter, the app-shell filter and the merged-app lookup; + - the **locale-file lookup** (`data.apps..navigation`) — widening the first three without this one yields a gate that collects `account` ids and then hunts for their labels under `apps.setup.navigation`; + - the **build prerequisite**, a package path hard-coded to `@objectstack/setup`; + - the **contributor roster**, which booted no package that registers the Account shell — so `account` had no merged app to judge at all. + + Behaviour now: + + - the population is declared with its criterion (an app is judged iff the ADR-0048 platform-app loop registers its shell by default **and** at least one package contributes navigation into it at runtime), which is why `studio` and `crm_app` are out; + - the per-contributor "landed at least one nav id" invariant is applied **per app**, never over a union across apps — a union would let a contributor serving two apps keep passing on one of them after the other silently stopped; + - every verdict, the refusal advisory and the pass line name the app they are actually about, and the pass line carries a per-app id count; + - `--self-test` gains negative controls for the union softening, for a verdict that names the wrong app subtree, and for a pass line that cannot notice an app leaving the population. + + The `setup` judgement is unchanged: the same 54 merged ids, the same verdict, and the same count in the pass line. +- 8fa6b97: `os build` and `os validate` now report a **permission-set name collision** — the compile-time half of the #17516 refusal, raised behind the SAME predicate and the SAME sentence as the runtime door so the two cannot drift (#18024). + + When two packages in one artifact declare a permission set under the same name, `bootstrapDeclaredPermissions` refuses to write into the row the first one owns. That refusal is correct under ADR-0086 D4 and is **unchanged here** — the whole declared set (its object, field, tab and system permissions) is dropped at boot, and nothing else reports it. #17516 gave that drop a runtime door; until now no door said anything at compile time, so the first an author heard of it was a boot warning on a deployed environment. + + Measured on the pre-change tree (`origin/main` 8fe5cb8e5), by grep over `packages/cli/src`, `packages/spec/src` and `packages/metadata/src`: + + ``` + permission-set collision diagnostic, compile time = 0 files + control: `collision|duplicate` in packages/cli/src = 20 files (so the zero is a reading, + not a dead grep) + ``` + + Both commands now compute it, and the findings ride the `warnings` key both payloads already declare — no new top-level key, and no new published export. + + - **Reports; it never refuses.** `severity: 'warning'` is declared at the producer and the failure direction is CLOSED: the set is not installed, so nothing is over-granted. Exiting non-zero would narrow what `os build` accepts, which is the option #14553's ruling weighed for `navigationContributions` and did not take. + - **One derivation, so the two doors cannot drift.** The owner comparison is `permissionSetNameIsForeign` and the sentence is `permissionSetNameCollisionDiagnostic` + `formatPermissionSetNameCollisionDiagnostic`, both consumed from `@objectstack/plugin-security`'s package entry — where #17516 published them for exactly this consumer. No second predicate, no retyped sentence: two doors phrasing one refusal differently is the defect, not the fix. + - **Only the composed case is judged.** A name owned by a package some *other* artifact installed is invisible without a database and stays unreported — the same bound the navigation-contribution check keeps for a contribution aimed at an app no package here ships. + - **A package re-declaring its own set name is not a collision.** That is an idempotent re-seed at runtime, which is why the check asks the shipped ownership predicate rather than counting duplicate names. Ablated on disk: removing that one call leaves the suite at 1 failed / 9 passed, and restoring it returns 10 / 10. +- ecf3e3b: The strict-parse refusal now says WHY a key the author never wrote inside `defineStack()` is being judged as a stack key. + + `objectstack.config.ts` is loaded as a MODULE: `loadConfig()` takes the default export as the base and + then merges every NAMED export onto it as a top-level stack key, under the export's own name. That is + deliberate — `onEnable` and `functions` are declared stack keys an app authors as named exports, and + unwrapping `mod.default` alone dropped them. The consequence nothing stated is that a named export is + legal only when its name is a key `ObjectStackDefinitionSchema` declares, so a helper exported beside + the stack (`export const collectPackageDirs = …`) arrives at the strict parse as a top-level stack key + of that name and is refused there as unrecognised. + + The refusal was already loud and named the key. It is unchanged: same key, same `unrecognized_keys`, + same failing parse, same exit code, same `--json` payload. What `os build` and `os validate` now add, + on the text face only, is the rule it enforces and the fix — move the helper into a sibling module and + import it from the config. `LoadedConfig` gained a `namedExports` reading so that explanation has a + provenance to read instead of guessing; nothing about which configs load has changed. + + The same rule is now on the config-authoring docs page, in the CLI configuration reference, and in the + comment every `os init` template ships at the top of the config it scaffolds. +- 7c8d6d9: `os generate migration` and `os generate types` ask the ONE definition of "is this field multi-valued" — `isMultiValueField` in `@objectstack/spec` — instead of reading `field.multiple` raw, so the DDL they scaffold is the DDL `driver-sql` creates for the same object again (#18199). + + Clause-②: no — no schema key moves, no accept set widens or narrows, no export changes. The generators' inputs and outputs keep their shapes; what changes is which predicate decides one branch inside them. + + The maintainer ruling of 2026-09-13 (decision batch #128 item 5, option 1′) gave "multi-valued" one definition and made storage follow it. #17469 landed the `driver-sql` half — `createColumn` short-circuits on the spec predicate above its own type switch, `isJsonField` and `fieldHasColumn` derive from it — and left `packages/cli` reading the flag. For one release the two answered differently, which is #14829 ("the platform and the GENERATED DDL as two lists") in reverse: + + | declaration | `os generate migration` before | `driver-sql` | now | + |---|---|---|---| + | `{ type: 'text', multiple: true }` | `JSONB` / `table.jsonb` | `TEXT` | `TEXT` / `table.text` | + | `{ type: 'lookup', multiple: true }` | `JSONB` / `table.jsonb` | JSON column | unchanged | + + Two further shapes moved with it, both the same raw read: + + - **`os generate types` stops emitting a nested array for a redundantly-flagged option type.** `multiple: true` is accepted (redundantly) on `multiselect` / `checkboxes` / `tags`, and the generated property type was `string[][]`; it is `string[]` now, which is what the value contract says and what the platform stores. + - **A column DEFAULT is no longer withheld from a single-value field that carries the flag.** `{ type: 'text', multiple: true, defaultValue: 'x' }` emitted a column with no DEFAULT while the driver emits `DEFAULT 'x'`. + + ⚠️ These declarations are refused at the authoring entrance by the same ruling's `FieldSchema` change, so they reach the generators only through the doors that never run it (`registerExternalObject` / `initObjects`, and a hand-written config the generators read unvalidated). Reachable, not authorable — which is why this is a `patch` and not a break. +- 282d0eb: `os package publish --install` installs into the environment `os environments switch` just selected, instead of refusing with ``--install` requires `--env ``. Nobody remembers a UUID. + + The two credential stores are two **identities on two servers**, and the active environment used to live in only one of them. `os environments switch` — and `os environments create --activate` — wrote `activeEnvironmentId` into `~/.objectstack/credentials.json` only (the runtime identity, written by `os login`); `os package publish` reads `~/.objectstack/cloud.json` (the cloud identity, written by `os cloud login`) and never opened the other file — so the environment the CLI had just called active, and that `os environments list` marks with a ★, was invisible to the one command that could install into it. + + - **The id now lives in `cloud.json`, beside `activeOrgId`** — the `CloudConfig` field that was already there for exactly this kind of control-plane scope selector, one level up. + - **`os environments switch` records it there as well** when the control plane it just talked to *is* `cloud.json`'s `url`, and keeps writing `credentials.json` unchanged — that copy is what `createApiClient` reads for the `data` / `meta` / `environments` families. + - **`os environments create --activate` records it too**, through the same helper — it is the *other* writer of an active environment id, and the first half of the flow this fixes: `os environments create --org $ORG --name Dev` then `os package publish --install`, with no `switch` in between. Creation succeeding while the record fails stays a warning, never an exit `1`. + - **`--install` with no `--env` and no `$OS_ENVIRONMENT_ID`** falls back to that value, and only when `cloud.json`'s `url` is the control plane being published to. + - **A value written by an older CLI is migrated once**, and only when both files' `url`s agree. + - ⛔ **Publish never reads `credentials.json` for this.** That is not a purity argument: the files carry *different servers* — `credentials.json`'s url falls back to `http://localhost:3000`, `cloud.json`'s default is `https://cloud.objectos.ai`, and the publish POSTs to the latter. An id taken from the runtime store can therefore name an environment on a **different control plane**, which the server resolves by bare id with no name or short-id rescue. The url gate, not the file name, is the invariant, and it lives in one place (`utils/active-environment.ts`). +- c2815a2: `os build` / `os compile` — the package-docs step line is printed **after** the collection it announces and carries the count, so a build that collected nothing no longer reads identically to one that collected four documents (#18432). + + ``` + → Collecting package docs (ADR-0046)... ← before: every run + → Collecting package docs (ADR-0046)... 0 collected ← after: this run found none + → Collecting package docs (ADR-0046)... 4 collected + ``` + + The sentence was unconditional and was emitted **before** `collectAndLintDocs` ran, so the reassurance it offers — the docs step ran, and it found your docs — was true of every run including the ones that found nothing at all. This is the reassurance half of #18170: an exit-0 build carrying the usual progress line is the shape every reader trusts. #18428 landed the audible half, where an uncollected docs directory speaks for itself. + + - **The docs step now reports what it collected, not what it attempted.** A project whose `src/docs/` is empty, or whose docs directory moved into a package under an ADR-0130 layout, prints `0 collected` here instead of the same sentence a successful collection prints. + - **The printed number is the artifact's `docs` set**, the same `docsResult.docs` the build writes into `dist/objectstack.json` — pinned from both ends (absent directory, empty directory, two docs) in `packages/cli/test/build-docs-step-count.e2e.test.ts`, because a test that only asserted the sentence was printed passes on the defective tree. + - **`--json` is unchanged**: the line has always lived behind `if (!flags.json)` and the machine face still emits one JSON document with no step text. +- 93917d1: `os build` / `os validate` name an artifact package the same way the runtime fold does when its `manifest.id` and `manifest.name` are both empty — `nav-contribution-groups.ts` no longer carries its own copy of the artifact package-id rule and imports the declared owner instead (#18490). + + Clause-②: no + + `packages/cli/src/utils/artifact-packages.ts` declares itself the sole owner of "which package is this", and says why in its own header: *"⛔ A second copy is the one that must not happen. … Two readers computing 'which package is this' slightly differently is how one entry comes to judge a different set of packages than the other while both look right."* `nav-contribution-groups.ts` exported a second implementation, `artifactPackagesOf`, which differed from the owner in one guard — a non-empty check on `manifest.name` — and the two had already drifted on a real input. + + - **The divergent input is reachable, measured rather than assumed.** `ManifestSchema` requires `id` and `name` as strings and constrains neither to be non-empty, so `{ manifest: { id: '', name: '', … } }` parses green through the same `normalizeStackInput` + `ObjectStackDefinitionSchema` chain both commands run. For that package the owner answered `''` and the deleted copy answered `` `packages[]` ``. + - **Importing the owner chose `''`, and `''` is the answer this path needs.** `ObjectQL.registerApp` derives the id it registers a navigation contribution under as `manifest.id || manifest.name`, with no positional fallback, so the read-time fold names that package `''` and prints `Package "" contributes …`. The build used to print `Package "packages[0]" …` for the same artifact — two doors naming one package differently, which is the divergence the shared `checkNavContributionGroups` predicate exists to prevent, one field over. + - **What an author sees change**: for an artifact package with an empty `id` *and* an empty `name`, the `packageId` on a `nav_contribution_group_missing` warning — and the package name inside its message — is now `''` instead of `packages[]`, in both `os build` and `os validate`, matching what the runtime already reports at boot. Every package with a non-empty `id` or `name` is unaffected: both rules answered identically there, measured on the control legs. + - **The id is carried and printed, never keyed on.** Two packages that both resolve to `''` still produce two findings rather than collapsing into one — pinned, because that failure mode would present as a report going quiet rather than as an error. + + `artifactPackagesOf` is removed. It was never reachable through this package's `exports` map (`.`, `./console`, `./hook-body`), so no consumer import can break; the removal is internal to `dist`. +- 031e5fb: The per-package author-time de-duplication key ignores the top-level collection index, so a package-local finding no longer survives as an echo of the union finding it duplicates + + `runPerPackageAuthoringRules` runs the author-time rule table once per + `packages[]` entry and drops anything the union run already reported. Its key + was `rule` + `where` + `path` + `message`, and `path` is **positional**: a + package body re-bases every collection from 0, while the flattened union numbers + that same entry wherever `authoringRuleUnionStack` placed it. + `objects[0].fields.industry` and `objects[1].fields.industry` are ONE finding + under two spellings, so the `Set` never matched them and the echo survived the + filter that exists to remove it. + + Measured on `origin/main` a43b9d0654 over the repo's own two-package fixture + `examples/app-multi-package`, at every door, before and after: + + | | before | after | + |---|---|---| + | `os build --json` | warnings 4, exit 0 | warnings 3, exit 0 | + | `os validate --json` | warnings 4, exit 0 | warnings 3, exit 0 | + | `os lint --json` | total 4, failing 0, exit 0 | total 3, failing 0, exit 0 | + | `os lint --json --strict` | total 4, failing 4, exit 1 | total 3, failing 3, exit 1 | + + The one warning that stops being reported is `field-no-consumers` on + `crm_account.industry` re-reported at the package-local index — the union run's + own finding, printed a second time. Its twin is still reported, which is why no + verdict moves. + + **No input's verdict changes, and that is structural rather than a property of + this fixture.** Every finding the de-duplication drops has, by construction, a + finding carrying the same key already in the reported set: the seed is the union + run's findings, which every door reports, and it grows only with per-package + findings that themselves survived. So a door's refusal cannot flip — `os build` + already exits 1 on a union error before this pass runs, and `os lint --strict` + fails on `errors + warnings`, a count that could only reach zero if the twin + went unreported too. + + Only the **top-level** index is neutralised. Nested positions (`.indexes[1]`, + `.columns[0]`) address the author's own document and read identically in both + views, so they stay in the key and keep discriminating. A finding's own `path` + is never modified — every door still prints the location it always printed. + + What this does **not** buy: the key becomes position-insensitive, not + collision-proof. Two entries that render the same `where` still share a key, + exactly as they already did whenever their indices happened to match. Measured + over every example stack in this repo that parses today (`app-multi-package`'s + built artifact, `app-crm`, `app-showcase`, `app-todo`), 45 registry rules + produced 103 findings and 103 distinct neutralised keys — zero collisions. + + Also corrected: the sentence "what survives the filter is exactly the set the + union could not see", which was false for as long as the key was positional and + had been copied from `compile.ts` into the `os validate` and `os lint` doors as + each was wired. It is now stated at the bound the pass can actually hold, in + every file that carried it. + + Clause-②: no +- c7dc089: `os build`'s text face prints every author-time advisory its own summary line counts — the closing `N author-time warning(s) — see above` no longer stands over a shorter list (#18780). + + Clause-②: no + + `compile.ts` rendered the advisory block at step 3b, inline, straight off the union rule run. Step 3b-ii — the ADR-0130 D4 pass that runs the same rule table once per `packages[]` entry — then appended its survivors to the **same** `ruleAdvisories` binding, and the summary line at the foot of the command counts that binding. So on a multi-package project the count was the complete set and the printed list was the union's alone, and the sentence pointing at it sent the reader back up to find a warning that had never been printed. + + Measured at 17.4.0 on `examples/app-multi-package`, exit 0 on every face: + + ``` + os build 3 advisory entries · ⚠ 4 author-time warning(s) — see above + os build --json warnings: 4 <- the count was already right + os validate 4 advisory entries <- since #18769 + ``` + + - **The list moves, not the count.** #11529 settled this axis one list over: the summary counts the whole set and the printer NAMES what it withheld, because a count quietly shrunk to match a short list is the false-clean direction — it deletes a finding from the text face of the command that ships while `--json` and `os validate` keep reporting it. The fourth advisory now prints. + - **What an author sees change**: on a stack that declares `packages[]`, the advisory block is rendered after the `Running author-time rules per package (N)...` step line instead of before it, and it now carries the per-package findings — the ones whose `where` reads `package '' — …`. A stack with no `packages[]` is unchanged — measured on a single-package fixture, the before/after captures are 2038 bytes each and differ only in the run's two clocks, `Load time: Nms` and `Build complete (Nms)`: its list was already complete, and the block still precedes every later step line. + - **Still ONE printer call.** The block is deferred to the point where the list is complete rather than printed twice, so the 50-entry cap and its `… and N more … not shown` notice keep judging one list. A second `printAuthoringAdvisories` for the survivors alone would have given the cap a second budget and the notice a second, partial total. + - **The author-time rule FAILURE faces keep their advisories.** A union-level failure exits before the per-package pass runs, so its block is byte-for-byte what it was; the per-package failure face now prints the per-package advisories too, which its own `--json` twin has published since #11772. + + No payload key, no exit code and no `--json` byte moves: `warnings` already carried all four, which is how the mismatch was measurable in the first place. +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- f20fe29: chore(spec)!: the metadata migration chain is supported from protocol 16 — `MIGRATION_SUPPORT_FLOOR` 10 → 16, and `step11`–`step16` retire with it (#19056) + + Clause-②: yes (narrowing) + + + + **BREAKING** for a consumer still authored against protocol **10, 11, 12, 13, 14 + or 15**. Landing in the launch window as `minor` under the lockstep convention. + + Maintainer ruling, 2026-09-18, verbatim and untranslated: + + > 升级只需要支持从 16.0版本开始。 + + 「16.0」reads as protocol major 16 — the same unit as the constant + (`PROTOCOL_VERSION` is `17.0.0`, so the package version `17.x` and the protocol + major are not the same number). That reading was put back to the maintainer and + was not contradicted. + + ## What changes for you + + `MIGRATION_SUPPORT_FLOOR` — a published export of `@objectstack/spec` — moves + from `10` to `16`. Two consequences, both at the boundary: + + | you call | before | after | + | --- | --- | --- | + | `applyMetaMigrations(stack, N)` for N ∈ 10..15 | replays the chain from N | throws `MigrationFloorError` | + | `os migrate meta --from N` for N ∈ 10..15 | migrates | refuses, naming the floor | + | `applyMetaMigrations(stack, N)` for N ≥ 16 | unchanged | unchanged | + | `MIGRATION_SUPPORT_FLOOR` as a TS literal type | `10` | `16` | + + The fix, and the only one there is: **reach protocol 16 by another path first, + then re-run.** The refusal says so itself — `Cannot migrate from protocol N: the + chain's support floor is 16 (ADR-0087 D3). Upgrade to protocol 16 by another + path first, then re-run.` A stack already at 16 or above is unaffected, and the + 16 → 17 and 17 → 18 hops are untouched. + + If you pin `MIGRATION_SUPPORT_FLOOR`'s literal type (`const f: 10 = …`), that + annotation stops compiling. The value was always a release-policy knob, so read + the constant rather than restating it. + + ## What this is NOT + + It is **not** a slimming change, and the measurement is the reason to say so. + Counted on `src/migrations/registry.ts` at `e6a03e649` (17,718 lines): + + | block | lines | share | + | --- | ---: | ---: | + | `step11`–`step16` — what leaves | 328 | 1.9% | + | `step17` | 4,699 | 26.5% | + | `step18` | 7,565 | 42.7% | + | the registration map + the two retirement tables | 5,077 | 28.7% | + | file header | 49 | 0.3% | + + Everything but the first row stays. What the raise buys is a **narrower support + promise**: six permanently-replayable chains no longer have to be maintained, + and the CI replay shrinks to the range the project actually promises — 10 of the + 98 conversion fixtures leave the chain-replay gate, because the chain no longer + reaches the major that graduated them. + + ## What was deliberately NOT removed + + `RETIRED_KEYS_BY_MAJOR` and `RETIRED_DEFS_BY_MAJOR` live in the same file and + are keyed by protocol major, which makes them look like chain state. They are + not, and both are kept whole: + + - the chain never reads either table (`chain.ts` imports the steps and the floor + and nothing else); + - their one non-test reader, `packages/spec/scripts/build-schemas.ts` + (`check:authorable-surface`), folds every major into one set and never + mentions `MIGRATION_SUPPORT_FLOOR`. + + So a row below the floor is still the live proof that its retirement was + declared. Measured by ablation: a row planted under major **11** — a major whose + step this change deletes — was still read and judged, reported as *"(registered + at major 11)"*. Both facts are pinned in + `src/migrations/retired-tables-not-floor-scoped.test.ts` so the next floor move + reads them first. Dropping such a row errors nowhere at the moment it is + dropped; the declared retirement simply stops being declared. + + The D2 conversion registry is untouched for the same reason: every rehydration + seam replays the **full** conversion chain over stored `sys_metadata` rows, + retired entries included, so the protocol-11/13/14/15 conversions keep + converting rows at rest long after the source-side chain stops reaching them. + + ## `@objectstack/cli` + + `os migrate meta --help` advertised `--from 10`, `--from 10 --step`, + `--from 11 --to 12` and `--from 10 --out …`. Every one of those refuses after + this change. The examples are now derived from `MIGRATION_SUPPORT_FLOOR`, so the + next floor move cannot leave them advertising commands that throw. +- d7f7e34: Four readers of `FieldSchema.reference` gated the carrier with a truthiness test and then **propagated** it. `FieldSchema.reference` is declared an optional **string**, so the answer a reader owes for a carrier it cannot read is absence — and one of these four did worse than lose the information, it invented a name for it: + + ``` + out.push({ key, reference: String(f.reference) }) // -> reference: '[object Object]' + ``` + + Each site now reads the carrier through the one arbiter, `referenceCarrierOf`, and catches its refusal **at the site** — so the reader answers absence and reports, instead of aborting. That is the deliberate difference from `@objectstack/objectql`'s cascade seams, which let the same refusal propagate: those assert something positive about the schema on a write path, while these four are best-effort display and diagnostic readers whose own failure handling would have turned one unreadable field into a much wider loss. + + - **`@objectstack/plugin-approvals`** — `resolveLookupFields`. The stringified carrier was handed on as an object name to `engine.find()`, where it could never resolve and the failure was swallowed by the caller's `catch`. The field is now left out of the inbox display enrichment and logged; readable targets are unaffected. It is dropped rather than carried with an absent target because the sole consumer uses `reference` as the object name and has nothing to do with an entry carrying none. + - **`@objectstack/service-analytics`** — the ADR-0021 relationship → target-object resolver. An unreadable carrier became the joined table for a dataset's `include`; the resolver now answers `undefined`, which its existing fallback turns into the compiler's own refusal, plus one warning naming the field. + - **`@objectstack/cli`** — `os doctor`'s circular-dependency and unused-object checks, which put the carrier into a graph node and a name set. Both now report the unreadable carrier as a finding rather than skipping it, because "no circular references detected" and "defined but not referenced" are positive claims that an edge nobody could read cannot support. The same file's `collectViewObjectRefs` already narrowed its carrier this way. + + `null`, `undefined` and `''` are absence, not a wrong shape, and still pass silently at every one of these sites — a field is allowed to name no target. Each site's absence answer and its readable-target answer are pinned alongside the refusal. + + Upgrading: nothing conformant changes. A non-string `reference` is refused by `ObjectSchema.safeParse`, so a value in that shape only ever reaches these readers without having passed parse at all. +- d69f7e1: `collect-docs.ts` records what the `docs/duplicate-name` refusal rests on now that ADR-0048 §3.4 retired its older justification (#19248) + + `docs/duplicate-name` refuses two owners declaring one doc name. The claim it + was once explained by — *"one registration overwrites the other"* — was retired + by ADR-0048, and a refusal whose stated justification no longer exists is worth + examining rather than inheriting second-hand. #19248 examined it. + + **The verdict is that no wording change was warranted**, and the ADR text is + quoted into the rule's own docblock so the next reader does not have to + re-derive it. §3.4 retires a RUNTIME throw and nothing else — *"The + cross-package **throw is retired**; two distinct packages coexist on the same + bare name by construction."* — while keeping, in the same clause, the class + this lint belongs to: *"Authoring-time hygiene — an author shipping two + `page/home` in one package — stays covered by the `naming/namespace-prefix` + lint in `os lint`."* Both sentences are quoted verbatim, checked against + `docs/adr/0048-cross-package-metadata-collision.md` on this branch's base + (`13d52947d8`) rather than recalled. + + The message already said `for authoring hygiene` and already declined the + retired claim by name, so what shipped was correct and stays byte-identical. + What the docblock gains is the ADR's own words, the card number the standing + **severity** disagreement is filed under, and the boundary between the two + questions: §3.4 hands authoring hygiene to a warning-only lint while this one + is `severity: 'error'`, which is a live question about the level and not about + the reason. + + ⛔ No behaviour changes. No rule, message, severity or accept set moves; the + only edited bytes are inside one docblock comment. + + **This ships, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/cli`'s published `files[]` is + `["dist","README.md","CHANGELOG.md"]` and the package builds with plain `tsc` + (`tsc -p tsconfig.build.json`, no `removeComments`), so the comment is emitted + into the tarball — measured on the rebuilt artifact: the new clause is present + in `dist/utils/collect-docs.js` (1 occurrence), the replaced spelling is absent + from all of `dist` (0), and `dist/**/*.d.ts` carries 0 of it because the block + sits above a non-exported helper. The rule's own runtime message resolves to + that same file as the positive control. So the published JS bytes move while + the declaration surface does not. +- 8ddefbc: `collect-docs.ts`'s module header no longer gives the retired ADR-0048 claim as the reason for the doc naming lints (#19359) + + The header explained the naming lints with *"the metadata registry key carries + no package coordinate, so a bare-name collision silently overwrites across + packages"*. That is ADR-0048 §1.1 **context**, and the same ADR's §3.3/§3.4 + overturned it: the write is already composite-keyed (§1.2 — *"The silence is in + the read, not the write"*), and *"The cross-package **throw is retired**; two + distinct packages coexist on the same bare name by construction."* The file + already declined that sentence by name 900 lines below, in + `lintDocNamesAcrossOwners` (#19248) — the correction just never reached the top + of the file. + + **That one sentence justified two rules, and they do not rest on the same + thing**, so it was not carried up verbatim: + + - `docs/duplicate-name` rests on **authoring hygiene**, the class ADR-0048 §3.4 + keeps by name. The header now points at `lintDocNamesAcrossOwners` instead of + restating it — a second copy of a justification is how the first one went + stale. + - `docs/namespace-prefix` / `docs/namespace-required` rest on the **flat link + namespace**, which ADR-0048 never touched. A doc link is `[text](./NAME.md)`, + a bare name with nowhere to put a package coordinate, flat on purpose so an + editor or a GitHub preview resolves it natively (ADR-0046 §3.1/§3.3) — and + this module keys on the prefix to tell a same-package link, which it checks, + from a cross-package one, which it defers to publish. ADR-0048 §3.3 repaired + metadata reads by ADDING a package-id argument to `getItem`; the link form has + nowhere to put one, so nothing §3.4 retired was ever load-bearing here. + + ⛔ No behaviour changes. No rule, message, severity or accept set moves; the + only edited bytes are comment bytes. + + **This ships, which is why it carries a changeset rather than + `skip-changeset`** — re-measured on this branch's rebuilt artifact rather than + inherited from #19248. `@objectstack/cli`'s published `files[]` is + `["dist","README.md","CHANGELOG.md"]` and the package builds with plain `tsc` + (`tsc -p tsconfig.build.json`; `removeComments` appears nowhere in the package + or the root configs), so comments are emitted into the tarball. Measured after + `turbo run build --filter=@objectstack/cli`: `npm pack --dry-run` lists + `dist/utils/collect-docs.js` at 50.5 kB among 537 files; the new clause is + present there (1 occurrence); the old bullet spelling is absent from all of + `dist` (0); the retired phrase now occurs exactly once in `dist`, inside the + quotation that declines it. `dist/**/*.d.ts` carries 0 occurrences, because the + block sits above the imports rather than on an exported symbol — so the + published JS bytes move while the declaration surface does not. +- cdc1ae0: fix(cli): `objectstack serve` mounts the always-on `package-registry` capability, so a package created through the API survives a restart on a stock boot (#19387) + + Clause-②: no + + `package-registry` has been on the always-on slate (`PLATFORM_ALWAYS_ON_CAPABILITIES`) since the `marketplace` / `package-registry` split, and `serve` appended it to every app's `requires`. But `Serve.CAPABILITY_PROVIDERS` did not key it, and the resolver's no-provider branch says nothing about a token the app did not declare itself. So an app that did not declare `requires: ['marketplace']` got no `package` service. `POST /api/v1/packages` answered `201`, printed `no 'package' service — '…' registered in-memory only (will not survive a restart)`, and `GET /api/v1/packages/:id` answered `404` after a restart. + + - **`package-registry` now mounts `PackageServicePlugin`** from `@objectstack/service-package`, the provider the spec's `PLATFORM_CAPABILITY_PROVIDERS` row declares for it. A stock boot creates `sys_packages` and replays it at start, so installs and manifest edits made through the API persist. + - **Apps that declare `marketplace` boot as before, with one `PackageServicePlugin`.** `marketplace` resolves to the same provider. The capability resolver now remembers the providers it has mounted itself, so the always-on token does not mount a second copy. Without that change, a declarer's boot would print `Plugin superseded: 'package-service'`. + - **A stock database gains one table, `sys_packages`.** `PackageServicePlugin` creates it with raw DDL, as it already did for `marketplace` declarers. On the in-memory driver (`memory://`), which has no raw SQL, the boot now logs that the DDL was not run and that package hydration was skipped. Packages there last only as long as the process, as before. + - `--preset minimal` still opts out of the whole slate. `protocol.installPackage` keeps its in-memory-only branch as the documented degraded path for hosts that mount no provider. + - **`@objectstack/metadata-protocol`: the `installPackage` docblock no longer says the runtime half is missing.** It used to say that a stock boot still took the in-memory-only branch. It now says that `objectstack serve` mounts `PackageServicePlugin` for `package-registry`, so a stock boot persists, and that the in-memory-only branch is for hosts that mount no provider. The docblock ships in `dist`. No behaviour changes. +- 24162f9: fix(cli): `os data delete`'s human-readable arm reads the server's `success` flag instead of always printing "Record deleted" (#19413) + + Clause-②: no + + `os data delete` renders three output faces from one call. The `--format json` + and `--format yaml` arms lower the server's `DeleteDataResponse.success` into + their own `deleted` key — that is what #5638 landed, and its note is still in + the command. The default human-readable arm did not read it at all: it printed + `Record deleted: ` unconditionally. One command, one call, two output + formats able to state opposite facts about whether a row is gone. + + **What changes.** When the server answers `success: false`, the default arm now + prints a warning instead of a success line: + + ``` + FROM ✓ Record deleted: rec_1 (whatever the server said) + + TO ✓ Record deleted: rec_1 (success: true — unchanged) + ⚠ Not deleted: rec_1 — the server reported the deletion did not happen + (success: false) + ``` + + **The exit code does not move — on either arm, in any format.** The two machine + arms already publish `success: true`, the CLI envelope's *"the command + completed"* flag, beside `deleted: false`, and they exit `0`. Moving only the + human arm off `0` would re-create this very defect one layer down: the same + call exiting `0` under `--format json` and non-zero by default. Moving it on + all three arms would narrow a published CLI accept set — a script that succeeds + today would start failing — which is a contract change and not this fix. So + this is a `patch`, not a `minor` with a breaking banner, and the new pin + asserts `0` in both directions so the next change cannot move it silently. + + **This is reachable on `main`, not hypothetical.** #19411 landed while this was + being written. `MetadataProtocol.deleteData` used to return the literal + `success: true`, so the single-record door could not answer `false` at all and + the unconditional print was merely wrong on its own terms; it now returns + `success: deleted !== 0`, and a package-declared `sys_permission_set` — whose + delete is an ADR-0005 reset that re-projects the row rather than removing it — + answers `success: false` on the live door. From that commit on, + `os data delete sys_permission_set ` printed `Record deleted` for a row the + same command's `--format json` arm reported as `deleted: false`. The + `--format json` and `--format yaml` bytes are untouched in both directions. + + **The flag is read as `=== false`, not as falsiness** — the same reading + `MetadataProtocol.deleteData` takes of the driver contract. `false` is the + protocol's positive *"no row was deleted"* value; an absent or `undefined` flag + from an off-contract server is no signal at all, and turning "no signal" into + "not deleted" would make the CLI deny deletions that really happened. + + **Why this carries a changeset rather than `skip-changeset`.** Measured on the + built tree: `@objectstack/cli`'s published `files[]` ships `dist`, and the new + sentence is present in `dist/commands/data/delete.js` after a build, with the + unchanged success sentence from the same file as the lit control. The new test + file is absent from `dist` entirely, as the negative control. +- 95fb417: **The declared `zod` floor moves from `^4.4.3` to `^4.6.1`**, because on zod below 4.6.1 the three standard error formatters — `z.treeifyError()`, `error.format()` and `error.flatten()` — cannot render a refusal these packages actually emit (#19581). + + Clause-②: no + + **What breaks below the new floor.** All three formatters walked an issue's `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming a member of `Object.prototype` was answered by the prototype and no node was ever created. Two different failures follow: + + | path shape | what happened on `^4.4.3` | + |:---|:---| + | terminal element (`['assignments','__proto__']`, `['x','toString']`) | the inherited member is adopted as the node, then `node._errors.push(...)` runs on it — `TypeError: Cannot read properties of undefined (reading 'push')` | + | non-terminal element (`['__proto__', …]`) | the walk continues **into** `Object.prototype` and writes the next segment onto it — the message is silently dropped from the returned tree and the process gains a global prototype key | + + **Why it reached this platform's consumers.** `@objectstack/spec` refuses a `__proto__` key on its open-key authoring surfaces, and that refusal's issue path is `['assignments','__proto__']` — precisely the terminal shape. Anything that formatted one of these refusals for display crashed on it, and the crash was in the formatter, not in the guard. The guards themselves are unchanged and still necessary: 4.6.1 still drops a `__proto__` key from `z.record()` and `.catchall()` output, which is what they exist to refuse. + + **What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. + + `@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. + + ## The second half the floor move needs: an unknown key refuses TERMINALLY again + + From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no + longer aborts the shape that raised it. Two things follow, and both were + measured on this package with the same bodies on 4.4.3 and 4.6.1: + + 1. **A closed shape's own refinements now run after the refusal**, adding a + second complaint that contradicts the first. + 2. **A union containing that shape loses its envelope.** zod's + `handleUnionResults` returns a single non-aborted member's issues + *unwrapped* instead of raising `invalid_union`, so the union's message + becomes whichever branch zod judged closest. + + At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong + branch's prescription. Writing `type: 'page'` on a ViewItem answered: + + ``` + Unrecognized key(s) on this view container: `viewKind`, `config`. + • `viewKind` belongs to a single VIEW, not to the container. Wrap it: … + ``` + + — naming neither `page` nor its removal. It now answers, as it did before: + + ``` + config.type: 'page' was removed from the list-view `type` enum in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … + ``` + + **What an upgrading consumer must do.** Nothing. No key or value changed + status: everything this package accepted before it accepts now, and everything + it refused it still refuses. What changed is which of several competing + complaints an author reads, and that a refusal behind a union is again + reported as `invalid_union` with its branches, which is what `z.treeifyError()` + and this package's own `formatZodError` expand. + + ⚠️ A closed shape declared with a bare `z.object(…).strict()` or + `z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT + get this and will still collapse its union. Build closed authoring shapes with + `strictObject`, or re-declare an existing one through `closedObject`. +- 0bd1126: `os init` (the `app` and `plugin` templates) and `os generate object` now declare the object they scaffold with `ObjectSchema.create({ … })` — the one authorised shape for a `*.object.ts` — instead of a `Data.ServiceObject`-annotated object literal (#19722). + + The factory parses the declaration against `ObjectSchema` when the file is evaluated, so a mistake surfaces in the file where it was written; the typed literal deferred every check to a build the author might never run. `create-objectstack`'s starter, the data-modeling docs ("Every object definition follows this pattern") and every object file in this repository already used the factory — the two CLI doors were the outliers, and they now write the same shape as each other and as everything else. + + - **What a new scaffold contains**: `import { ObjectSchema } from '@objectstack/spec/data';` (a value import — the factory runs), `const myAppItem = ObjectSchema.create({ … });`, and the unchanged `export default myAppItem;`. The barrel lines both commands write (`export { default as … }`) are unchanged, as are the object's fields, its `sharingModel` and the comment explaining it. + - **Projects you already scaffolded keep working.** Nothing reads the old file differently at runtime, and nothing here renames or rewrites a file you have. + - **Converting an existing file is one mechanical rewrite** — wrap the literal in `ObjectSchema.create( … )`, drop the annotation, and import the factory: + + ```ts + // before + import * as Data from '@objectstack/spec/data'; + const myAppItem: Data.ServiceObject = { name: 'my_app_item', /* … */ }; + export default myAppItem; + + // after + import { ObjectSchema } from '@objectstack/spec/data'; + const myAppItem = ObjectSchema.create({ name: 'my_app_item', /* … */ }); + export default myAppItem; + ``` + + If the converted file now throws when it loads, the factory has found something the literal was carrying unchecked — an unknown top-level key, for example — and the message names it. +- 455dcc0: fix(cli): `os validate` / `os build` / `os lint` now say when the JSX page gate ran at parse level only, and refuse a project `sdui.manifest.json` they cannot use (#20113) + + Clause-②: no + + The gate that checks `kind: 'html'` pages (and the deprecated `kind: 'jsx'`) validates components and props only when an SDUI component manifest resolves: the project's `sdui.manifest.json` in the working directory, then the copy inside `@objectstack/console`. With neither, it falls back to parse-level checking (syntax, tag matching, forbidden constructs). All three commands used to do that silently and report success, so "passed" read as "components and props checked" when they were not. + + A page "to check" below means one the gate actually judges, wherever it is declared: at the top level of the stack, or inside a `packages[]` entry. The per-package pass judges those even when the top level carries its own `pages` key. + + - **No manifest, `kind: 'html'` pages to check: a notice, exit status unchanged.** Each command reports one notice, tagged `sdui/jsx-parse-level-only`. It gives the number of distinct pages checked at parse level only, top-level and package-carried alike, and names every place a manifest was looked for. `os validate` and `os build` print it as an info line plus a hint line; `os lint` lists it under Suggestions. `--json` carries it in the channel each command already publishes. It is an `info` record (the author-time finding shape) at the end of `warnings` on `os validate` / `os build`, and a `suggestion` in `os lint`'s `issues`, which also moves that run's `total` and `suggestions` counts by one. No top-level key is added. `--strict` does not promote it on any command, so a project without its own manifest exits exactly as before. + - **A project `sdui.manifest.json` that exists but cannot be used: refused (exit 1).** This applies when there is a `kind: 'html'` page to check, top-level or package-carried, and the file cannot be read, is not valid JSON, or is not a JSON object with a `components` map. The file and the reason are named on stderr and in the `--json` envelope's `error`. Before, invalid JSON, `null` and an unreadable file were ignored in silence (parse-level checking, exit 0), while `{}` or an array crashed the run with `Cannot convert undefined or null to object` (exit 1). **Fix:** correct the file, or remove it to check those pages at parse level only. + - **Unchanged:** a project with no `kind: 'html'` page to check, at the top level or in any package, says nothing and refuses nothing, whatever its manifest looks like. A resolvable manifest arms full validation exactly as before. `os init`'s scaffold check reads the manifest as it did. +- 805af4f: fix(cli): `os generate` gives object names the project's namespace prefix, so `os init -t app` followed by `os g object order_line` passes `os validate` + + `os init my-app -t app` writes `manifest.namespace: 'my_app'`. Then `os g object order_line` wrote `name: 'order_line'`, and the next `os validate` exited 1 (`os compile` exited 2) with `Object 'order_line' is missing the package namespace prefix. Rename it to 'my_app_order_line'`. The page's own example, `os g object customer`, failed the same way. + + - **Object names are prefixed.** In a project whose manifest declares a `namespace`, every object name a scaffold writes now starts with `_`. That covers the `object` scaffold's `name`, a `view`'s `object`, an `action`'s `objectName`, a `flow` start node's `objectName` and an `app` navigation item's `objectName`. Generated scaffolds now also point at each other: `os g view order_line` binds the object `os g object order_line` wrote. The file name and the exported binding still come from the name you typed (`src/objects/order_line.object.ts`, `orderLine`), and the command prints the object name it wrote. + - **No double prefix.** A name that already carries the prefix (`os g object my_app_order_line`) is written as typed. The "already compliant?" check is the namespace-prefix gate's own `validateObjectNamespacePrefix`, so a `sys_*` name, which the gate exempts, is not prefixed either. A name the gate would still refuse after prefixing (the legacy `NS__SHORT` form) is refused before anything is written. + - **One namespace source.** The namespace is `manifest.namespace` of the config as loaded, the value `os validate` checks against. It is never re-derived from the directory or the `package.json` name. With no config, or a manifest without a `namespace`, nothing is prefixed, as before. If a config exists but does not load, a type that names an object is refused and nothing is written, because the namespace is unknown. `dashboard` and `skill` scaffolds name no object, so a config that does not load does not stop them, but `os g` loads the config after every write, theirs included, to report whether the scaffold reaches the stack. + - **Unchanged:** the names the gate does not check against the namespace. An action's, flow's, dashboard's, app's and skill's own `name`, and an action's flow `target`, are written as before; a view's own `name` now equals the object key it binds to, prefix included. + - `os generate --help` now lists all seven metadata types in the `TYPE` argument. It had omitted `skill`, and the list now comes from the generator table. +- c5dcb3b: fix(cli): what `os generate` writes now reaches the stack, or the command says it does not + + `os init my-app -t app` wrote a config that imported `./src/objects` alone. `os g view`, `action`, `flow`, `dashboard`, `app` and `skill` each wrote a file and a barrel `index.ts` that nothing imported, and `os validate` then exited 0 printing `UI: 0 Apps` and `Logic: 0 Flows`: a green that had judged nothing the command just wrote. + + **What `os init` now writes (`app` and `plugin` templates):** + + - `objectstack.config.ts` imports every directory `os generate` writes into (`src/objects`, `src/views`, `src/actions`, `src/flows`, `src/dashboards`, `src/apps`, `src/skills`) and hands each barrel's exports to `defineStack` under its key (`objects`, `views`, …). A file `os g` writes there is part of the stack with no edit to the config. The keys read the barrels through a small `exportsOf` helper declared in the config, because `Object.values` on an empty barrel does not type-check against `defineStack`'s collection types. + - An `index.ts` containing only `export {};` for each directory the template puts nothing in. An `index.ts` that already exists is kept as it is and never overwritten. + - `requires: ['automation', 'triggers']`. A flow that starts on a record change is fired by `triggers` and run by `automation`. If either one is missing, `defineStack` refuses the config as soon as it holds such a flow. + + **What `os generate` now does:** + + - After writing, it loads the project's config again and reports on the new item. Either the stack carries it, or it is **not wired** (the file is written, the config is left untouched, and the command prints the import and `defineStack` key to add). It never edits the config. + - It refuses a write that makes a config that loaded stop loading, for example an action or app bound to an object nobody declared, or a flow in a stack without `triggers` or without `automation`. It removes what it wrote, exits 1, and prints the stack's own reason. Generate the object first (`os g object customer`), then what binds to it. `dashboard` and `skill` now read the config too, so they can report, and they still generate when the config does not load. + - A view's own `name` is now the object it binds to, prefix included (`my_app_order_line`, not `order_line`). The server registers a view under its object and refused, at boot, a scaffold whose `name` disagreed. That never showed while the views barrel was not loaded. + - The barrel step asks the compiler whether the barrel already exports the name, instead of searching the file's text. `os g view order` after `os g view order_line` had found `order` inside `orderLine` and exported nothing. + - The `flow` scaffold's header states the `requires` it needs. + + **Projects scaffolded by an earlier release** keep their config. `os g` now tells you when a file it wrote is not wired, and prints the lines to add. +- c5d6b2b: fix(cli): `os validate` refuses a `views:` container whose own `name` disagrees with the object it binds to, the stack the server refuses at boot (#20331) + + Clause-②: yes + + A view container is registered under the object it binds to. When its own `name` + is set to something else, for example `{ name: 'order_line', object: 'my_app_order_line', list: { … } }`, + the server refuses the whole stack at boot. `os validate` used to pass that stack + at exit 0, so the first sign of the mistake was a server that would not start. + + `os validate` now runs the same check the server runs at boot and prints the same + message. The text form and `--json` both exit `1`. The `--json` failure payload lists + one `errors` entry per refused container, with `path` (for example `views[0]`, or + `packages[1].manifest.views[0]` in a multi-package stack), `code: 'VALIDATION_ERROR'`, + `httpStatus: 400` and `message`. Every other exit, and the success payload, are + unchanged. + + **Fix:** remove the container's `name`, or set it to the object name the message names. + + **New in `@objectstack/objectql` (the widening):** two new exports on the package's + root entry, `viewContainerNameRefusal(container, sourceLabel, ownerId)` and its + return type `ViewContainerNameRefusal`. The function returns the refusal the boot + registrar throws, or `undefined`. It returns `undefined` for a container whose + derived object key is empty, because the boot registrar skips that entry with a + warning and never refuses it. The boot registrar now calls this function. What it + refuses, its message and its `VALIDATION_ERROR` / `400` envelope are unchanged. + + `os build` runs the same check as well (#20393, its own entry), so it no longer + writes an artifact carrying such a container. +- ab6fb02: docs(cli): give the true reason the `flows` translation group stays author-warned (#20339) + + The doc comment on `authorWarnedTranslationGroups` (published in `dist/` as + `utils/i18n-extract.js` and `.d.ts`) said no shipped runner reads the `flows` + group, so a translated wizard string is stored and never shown. That stopped + being true when the liveness ledger flipped `translation.flows.screens` to + `live`: the console's screen-flow runner reads each screen's `title` and each + field's `label` / `placeholder`. The comment now matches the ledger's `flows` + row: only the flow's own `label` is read by nothing yet (#20318), and the warn + is group-level, so it still covers the whole group. + + No behaviour moves. The `flows` row is still `planned` with `authorWarn`, so + `os lint` and `os i18n extract` still hold back every `flows.*` key exactly as + before; that lifts when the row flips, with no edit to the CLI. + + Clause-②: no +- acd0095: fix(cli): `os build` / `os compile` refuses a `views:` container whose own `name` disagrees with the object it binds to, and writes no artifact the server would refuse at boot (#20393) + + Clause-②: no + + A view container is registered under the object it binds to. When its own `name` + is set to something else, for example `{ name: 'order_line', object: 'my_app_order_line', list: { … } }`, + the server refuses the whole stack at boot. `os validate` has refused that stack + since #20331, but `os build` still exited `0` and wrote `dist/objectstack.json` + carrying the container, so `os serve` then refused the artifact it was handed. + + `os build` now runs the same check `os validate` runs, right after the schema + check and before anything is written, and prints the message the server prints + at boot. The text form and `--json` both exit `1`, and no artifact is written. The + `--json` failure payload is `{ success: false, errors, warnings, conversions }`, + with one `errors` entry per refused container: `path` (for example `views[0]`, or + `packages[1].manifest.views[0]` in a multi-package stack), `code: 'VALIDATION_ERROR'`, + `httpStatus: 400` and `message`, the same rows `os validate --json` reports. A stack + the server accepts builds exactly as before, with the same output. + + **Fix:** remove the container's `name`, or set it to the object name the message names. +- f6b7c53: fix(cli): re-measure the `better-auth` > `better-sqlite3` peer record, correct what it credits, and pin the declaration it justifies (#16813) + + A tree containing `@objectstack/cli` reports an unmet peer on every fresh + resolve — `better-auth` peers `better-sqlite3@^12.0.0`, the CLI declares + `^13.0.3` — and the reading that decides what to do about it lived only inside + the scaffold generator's prose. No range moves here and no resolution moves: + what changes is the recorded reason, which had two measured errors in it, plus + a gate that now holds the declaration to that reason. + + **The declaration is correct and stays at `^13`.** Three readings, taken rather + than inherited: + + - The peer is `optional`, and it governs exactly one configuration — a raw + better-sqlite3 `Database` passed to better-auth's `database` option. + `AuthManager.createDatabaseConfig()` returns an ObjectQL adapter factory, or + `undefined` for better-auth's in-memory adapter. Never a `Database`. + - better-auth cannot be incompatible with better-sqlite3 13, because it never + touches it: of the 464 files in the published `better-auth@1.7.2` tarball, + exactly one names better-sqlite3 — `package.json`, the peer declaration + itself — and no code file references it (positive control: `kysely` names 9). + It accepts a `Database` the caller constructs; its own sqlite test path uses + node's built-in `node:sqlite`. + - Pinning back to `^12` is not a neutral alternative. Measured on a bare + project depending on `@objectstack/cli@17.3.0`, it clears the report only by + resolving a **second** native better-sqlite3 (12.11.1 beside 13.0.3) that + nothing loads. The scaffold's existing `allowedVersions` entry clears the + same report with the lockfile byte-identical. + + **Two corrections to the record.** It credited `@objectstack/driver-sql` for + the 13.x copy; on the chain that actually reports + (`cli` → `runtime` → `plugin-auth` → `better-auth`) the binding copy is the + CLI's own `optionalDependencies` entry, which pnpm names in the warning itself. + And it was measured on better-auth 1.7.1 while the family has been pinned at + 1.7.2 since — re-measured, with the empirical reading replaced by a structural + one. + + The scaffold's rendered `pnpm-workspace.yaml` comment changes wording in both + producers (`objectstack init` and the `create-objectstack` blank template); the + declarations, the widening entry and the resolution are untouched. +- 010c48a: fix(cli): `os register` requires a name, and the request-side `as any` that hid the mismatch is gone (#16932) + + `os register` prompted **"Name (optional)"**, typed its own payload with `name?`, and guarded `email` and `password` but not `name` — three places agreeing the field was optional. The route it actually posts to does not agree: on a fresh environment (no human user yet, so the audience gate's bootstrap bypass admits the request and the route's own validation is the only judge left), `POST /api/v1/auth/sign-up/email` answers `400 VALIDATION_ERROR` — `[body.name] Invalid input: expected string, received undefined`. The same run with a name supplied answers `200` and creates the account. + + So the first-use path failed on exactly the answer the prompt invited, and `RegisterRequestSchema`'s required `name` was right all along. + + - the prompt now reads `Name: `; + - an empty answer is refused by the CLI itself (`Name is required`), beside the existing `Email is required` / `Password is required` guards, before any request goes out; + - the payload is annotated with the declared `RegisterRequest` instead of a hand-written twin; + - the `as any` at the call site is removed, so the next divergence between this command and the declared request type is a compile error rather than a `400` a user meets on their first command. + + No behaviour change for anyone already passing a name, by flag or at the prompt. +- df8a16d: fix(cli): `resolveConfigPath` throws its two refusals so the ten `--json` faces emit their envelopes, and `os verify` gains the catch-all it never had (#15547) + + Every `--json` face in this CLI declares that it answers an error path with a + payload. `resolveConfigPath()` was the one path that bypassed that declaration: + it wrote its refusal and then called `process.exit(1)` **directly**, so nothing + was thrown and the catch-all each command already carries — all of which sit + downstream of a throw — never ran. Ten published faces answered a missing config + file with an empty stdout. + + Measured before this change on the published entry `packages/cli/bin/run.js`, + `NO_COLOR=1`, streams captured separately, exit read before any pipe — ten faces + (`build` · `compile` · `diff` · `i18n check` · `i18n extract` · `info` · `lint` · + `migrate meta` · `validate` · `verify`) across both branches of the helper, 19 + runs: **exit 1, stdout 0 bytes, stderr 296 B (explicit path) / 123 B + (auto-detect)** — and `JSON.parse` on that stdout throws in all 19. After: the + same 19 runs answer **exit 1 with a parseable document on stdout**, stderr + unchanged byte for byte. + + The refusals now throw `ConfigRefusalError`. That is not a new contract — it is + this path being pulled back onto the one its callers had already published, so + it adds **zero** accept-set members and **zero** error codes. + + Three properties hold it in place: + + - **No face becomes a crash dump.** `os verify` had no `try` at all — measured, + a throw through it produced an oclif error line and no payload where every + sibling emitted an envelope — so it gains the catch-all its nine siblings + already had, in this same change rather than after it. + - **The text face does not narrow.** The refusal and both hint lines are still + written by the helper, to stderr, byte-identical: all 19 non-`--json` runs + compare equal before and after on stdout, on stderr and on exit status. The + catch-alls skip re-rendering the sentence a second time on stdout. + - **No error code is minted.** The thrown error carries neither `code` nor + `httpStatus`, so `errorCodeFields()` contributes nothing and each face emits + its own bare `{ error }`. Whether that shape is right is **#15549**'s open + question, and this change deliberately does not answer it. + + The `--json` stdout-purity instrument is widened with the fix rather than after + it: the pre-boot family's discovery moves into a shared module, the pin that + drives it now demands a document (empty stdout no longer passes) and compares + the text face's stderr as a whole string, and `json-stdout-purity.e2e.test.ts` + — whose own discovery is `bootSchemaStack`-based and cannot see a command that + fails above the kernel — reconciles against that population so neither half can + be lost silently. +- 3c5f3c5: fix(cli): `os generate migration` emits the field-level unique index the driver creates (#16317) + + ## What was wrong + + Both migration formats emitted the table and none of the object's declared + uniqueness. Measured on live PostgreSQL 16.13 — one object driven through all + three producers into three schemas, `pg_indexes` read back per schema: + + ```ts + { name: 'probe', fields: { keyed_unique: { type: 'text', unique: true, maxLength: 100 } } } + ``` + + | producer | before | after | + |:--|:--|:--| + | `driver-sql` via `initObjects` | `probe_pkey`, `uniq_probe_keyed_unique` | unchanged | + | `--format sql` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** | + | `--format ts` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** | + + Two rows with the same `keyed_unique` value were refused by the platform's table + (`23505 ... violates unique constraint "uniq_probe_keyed_unique"`) and accepted + by both generated ones, with nothing reporting it: a scaffold that creates the + table for an object silently dropped a uniqueness guarantee the object declares. + After the change the duplicate is refused by all three, each naming the same + constraint. + + The key set was not missing — it was already computed here to size the keyed + text family's columns; only the index it implies was never emitted. + + ## What it does now + + - **`--format sql`** emits an inline `CONSTRAINT "" UNIQUE ()`. + That is what knex's `table.unique(columns, { indexName })` — the driver's own + call — compiles to on PostgreSQL, so a generated table and a platform-created + one agree in `pg_constraint` as well as in `pg_indexes`; and it stays inside + the statement's `IF NOT EXISTS`, which a following `ALTER TABLE ... ADD + CONSTRAINT` has no spelling for. + - **`--format ts`** emits that knex call itself, `indexName` included — which is + what makes the driver recognise the constraint as already present on its first + boot against a generated table, instead of adding a second one under its own + name and then reporting the generated one as an orphan to drop. + - Names come from a transcription of `driver-sql`'s `buildIndexName`, pinned + against the driver's own export (a CLI production module may not statically + value-import a driver package). + + ## What it deliberately still does not emit — and now says so + + Both formats print a `NOT EMITTED:` line naming the index, its key parts and the + reason, instead of dropping it silently: + + - the **organization-scoped composite** (`unique: true` / `'organization'` on an + object with an organization column), whose key part is + `COALESCE(, '__global__')`. Emitting the bare composite + instead would be worse than emitting nothing: under SQL's NULL-distinct + `UNIQUE` it constrains no row that has no organization, which on a + single-tenant deployment is every row. + - an index over a column no field materialises (a virtual `formula` field) — + the same skip the driver performs, where the driver logs a warning. + + Object-level `indexes[]` remains unemitted by both formats; it is normalized by + a different driver-side rule and is not covered by this change. +- 559e531: fix(cli): a generated migration carries the column DEFAULT `driver-sql` puts on the same field (#16294) + + ## What was wrong + + Neither `os generate migration` format read a field's `defaultValue`, so a table + created from a generated migration had no column DEFAULT where the platform's + own table has one. A row inserted out of band — by a database client, a seed + script, anything that does not go through the engine — got NULL where the + declared value belonged. + + Driven on live PostgreSQL 16.13: one object, three schemas, one producer each + (`driver-sql` through `initObjects`, `--format sql` through `db.raw`, + `--format ts` by importing the emitted module and calling `up(db)`), with + `information_schema.columns` read back per schema. + + ``` + field driver sqlgen verdict + f_default null=YES default='hello'::text null=YES default=- DIVERGED + f_default_required null=YES default='hello'::text null=YES default=- DIVERGED + ``` + + After: `diverged: 0 of 6` on the card's probe, and 22 of 23 on a wider one + covering every `defaultValue` shape. + + ## What changed + + Both formats now render one shared verdict, taken from + `SqlDriver.applyDeclaredColumnDefault` — the single place a `defaultValue` + becomes DDL on the platform side: + + - a **literal** is emitted, quoted the way knex binds it (`DEFAULT '42'`, not + `DEFAULT 42` — PostgreSQL keeps those two textually apart forever in + `column_default`, and the driver's column carries the quoted form); + - **`'NOW()'`** becomes the driver's own translation, which is type-branched: + `CURRENT_TIMESTAMP` on a timestamp column, and a UTC-pinned expression on + `date` / `time`, because a bare `CURRENT_TIMESTAMP` resolves those in the + server's timezone; + - **any other runtime token** (`current_user`), an **Expression envelope** and + an **option-level `default: true`** emit nothing, each because the driver + emits nothing — the engine owns those, and a column DEFAULT would override a + decision it makes deliberately; + - a **`multiple: true`** field gets neither, because `createColumn` returns + before both questions. + + No authorable key, export or accepted-input set changes: `defaultValue` was + already declared, already parsed and already honoured by the driver. The + generators simply now read it. +- 0a56d3b: feat(spec,types,triggers)!: `group` runs package-authored scheduled work without a declaration, owning each run's writes per record (#18378) + + + + `Clause-②: yes (widening)` + + **ADR-0087 disposition — `not-required (already-registered)`, not `registered`.** + The ledger entry this change belongs to already exists + (`schedule-flow-acting-organization-required`, entry 18) and predates this diff + at the merge base, so `registered` would assert a registration this PR did not + make. The entry's `surface`, `replacement`, `reason` and `acceptanceCriteria` + each gained their `group` row here, the rejected bootstrap-organization arm + included — recorded because it is the one a later reader will re-propose. + + **Marked breaking (`!`) for the behaviour change, not for a narrowing.** Nothing + that worked stops working and nothing that was admitted becomes refused — the + accept set WIDENS in one cell. What earns the banner is the other direction: on a + `group` deployment with the switch already on, flows that were refused at bind + now arm and run, so clock-driven work appears where an operator had none. That is + worth reading before upgrading even though no consumer has to change anything. + + ## What changes + + With `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` on and tenancy posture `group`, a + time-triggered flow that declares no `config.organization` now **binds and + runs**, where it was previously refused at bind. The organization its writes + carry follows the record: + + | posture | declaration | a bound run's writes act as | + |---|---|---| + | `single` | not read | nothing — the install's one organization resolves beneath each write | + | `group` | **optional** | declared ⇒ the declaration; undeclared ⇒ **the swept record's own organization** | + | `isolated` | **required** | the declaration; undeclared ⇒ not armed, unchanged | + + A `timeRelative` sweep under `group` reads group-wide — inherent to the posture + (ADR-0105 D1) — and stamps each run it launches with that record's organization: + sweep contracts across four plants and each plant's contract yields a run acting + as that plant, whose notifications reach that plant's inboxes. + + ## Why this is not a fallback that guesses + + It is the order `sys_automation_run` was **already** ruled to use. + `ObjectStoreSuspendedRunStore` resolves a run's organization as + `organizationOf() ?? ctx.tenantId` — subject first, acting + context as the fallback and never the primary. Before this change those two + halves disagreed under `group`: the history row was stamped from the record while + the inbox and delivery rows followed an acting context that could not exist + there, so they were refused while the tick summarised itself as healthy. + + ⚠️ With one stated exception, because the two halves ask different questions: + the history row is STAMPED (`tenancy.organizationField` wins there) while the + run's acting organization is a WALL reading that never consults that key. They + agree on every object where the two coincide — which is every ordinary object, + since a declared stamp column is what makes them differ and one shipped object + declares one (`sys_api_key`, deliberately unwalled). Sweeping that object under + `group` stamps its history row while the run itself acts as nothing: the correct + pair of answers, not a residue of the old disagreement, and recorded rather than + smoothed over. + + ⛔ A record-less run under `group` that declared nothing still resolves + **nothing** and is refused at its first tenant-scoped write (`walled-posture`, + ADR-0112), loudly and by name. The rejected alternative was a fallback to the + bootstrap organization (`slug='default'`): under a wall that organization is + minted admin-keyed by the enterprise organizations runtime and may not exist at + all, and where it does it is whichever organization the platform owner + registered under — plausibly one plant of many, not the group's head office. + + ## Upgrading + + **Most deployments: nothing to do.** The switch this depends on is OFF by default + and ships unreleased alongside this change, so the `group`-is-walled behaviour + being amended has never appeared in a published version — no released consumer + can be relying on it. + + If you run posture `group` **and** turn the switch on, read your boot log: each + time-triggered flow's bind line now names which of the three shapes it bound as + ("as organization '…'", "with per-record acting organization", or "with NO + acting organization"). Two things to check: + + - A flow you expected to act as ONE organization but which binds per-record is + missing its `config.organization`. Add it — declaring still narrows, bounding + the sweep's query as well as its identity. + - A plain `schedule` cron flow that binds "with NO acting organization" has no + record to derive one from. If it writes notifications, inbox messages or any + other per-organization row, declare `organization` on its start node; the bind + line says so, and so does the refusal at the first tick. + + ## Which organization a record belongs to — the WALL question, not the stamp one + + `@objectstack/metadata-core` gains a second face on the record→organization + resolver, and the split is the point: `resolveRecordOrganizationField` / + `createRecordOrganizationResolver` answer **"who is this row ABOUT"** (the STAMP + question, whose `tenancy.organizationField` limb stays pinned to the three + sanctioned platform-row writers), while the new + `resolveRecordWallOrganizationField` / `createRecordWallOrganizationResolver` + answer **"what is this row WALLED by"** — `tenancy.enabled: false` ⇒ nothing, + then a declared `tenancy.tenantField`, then the kernel's `organization_id`. + + The sweep uses the WALL face, because "which organization does this run act as" + is a question about the wall. ⛔ It never reads `tenancy.organizationField`: that + key is declared on exactly one shipped object (`sys_api_key`, deliberately + unwalled, #8287), and reading it here would turn "the audit trail should follow + this row's own organization even though nothing walls it" into an acting + identity. A sweep over such an object resolves **nothing** and takes the + `walled-posture` refusal at its first tenant-scoped write, which is the honest + answer. Limbs 1 to 4 are one implementation shared by both faces, pinned as + such, so the half they agree on cannot drift apart. + + **API:** `ScheduledWorkPolicy` gains `runOwnership: 'unscoped' | 'per-record' | + 'declared'`, and `requiresActingOrganization` narrows from "any walled posture" + to `isolated` only. The two are deliberately separate axes: the boolean decides + whether BIND refuses, `runOwnership` decides what a run that DID bind carries. + Inside `@objectstack/trigger-schedule`, both triggers share one bind-line + vocabulary (`describeScheduleRunOwnership`) so they cannot describe one + deployment differently. ⚠️ That helper is module-level, NOT a package export: it + is not re-exported from the package barrel, whose own note says an export whose + only consumers live inside its own package belongs in a non-barrel module. The + new PUBLIC surface in this change is `ScheduledRunOwnership` and the + `runOwnership` key on `@objectstack/types`, plus + `resolveRecordWallOrganizationField` and + `createRecordWallOrganizationResolver` on `@objectstack/metadata-core` — and + those four are what put `Clause-②` at `yes`. Nothing existing is renamed or + re-typed: both stamp-face exports keep their names, their signatures and their + answers, limb 0 included. +- e958468: fix(lint): a hook write-set finding on a handler-authored hook reports `path: hooks[i].handler` — a key the author actually wrote — instead of the lowered `hooks[i].body.source` (#16546) + + `hook-api-update-readonly-field` / `hook-api-update-readonly-when-field` + (`validate-readonly-hook-writes.ts`) and `hook-body-write-unknown-field` / + `hook-body-write-unprovisioned-anchor` / `hook-body-source-unparseable` + (`validate-hook-body-writes.ts`) all report their `path` against `hook.body`, + because that is the shape they parse. For a hook authored as an inline + `handler: async (ctx) => { … }` (39 of 39 hooks in the reference app), + `hooks[i].body` is not something the author wrote at all — `lowerCallables` + mints it from the handler before `os build` / `os lint` hand the stack to + these rules (#16095). The reported `path` therefore named a key that does not + exist in the author's own source file; grepping for `body.source` there finds + nothing. + + **What changed.** `lowerCallables` now records, per `lowerCallables()` call, + which `hooks[*].handler` ref strings got their `body` minted this way (as + opposed to a `body` the author wrote directly). The CLI's four lowering doors + (`os build`, `os lint`, `os validate`, `os init`/`dev`'s scaffold validation) + pass that set through `runAuthoringRules`'s `ctx.loweredHookRefs`, and the two + hook write-set rules use it to redirect a finding on a lowered hook to + `path: hooks[i].handler` — the key that replaced the function the author + wrote — with a message suffix ("judged on the metadata body lowered from the + inline handler") explaining why. A hook whose `body` the author wrote directly + is unaffected: `path` stays `hooks[i].body.source`, unchanged. + + **No verdict changed.** Which hooks are flagged, at what severity, and why is + untouched — #13653 and #4271 are unmoved by a word. Only the location a + finding points at, and the wording explaining it, are different. `os build` + and `os lint` continue to report the identical `path` and message for the + same hook (#16095's "one implementation, both commands agree" — now including + this). + + No `--json` field was added or removed: `path` and `message` keep their + existing shape (string), and this is a within-type value correction for the + one subclass whose old value could never be resolved against the author's + source in the first place. +- 45b90b6: `os lint`: evaluate the `naming/namespace-prefix` duplicate advisory per package. + + The advisory read one flattened array per collection key with no package boundary, so on a + composed multi-package project two packages that each legitimately declare the same bare name + (e.g. `home`) were reported as one package declaring it twice — prescribing a rename of a name + that was already correct, with the OTHER package's namespace as the suggested prefix, under a + closing sentence saying distinct packages may reuse a name freely. Both ADR-0130 D4 stack shapes + were affected (flattened-plus-`packages[]`, and `packages[]`-only). + + ADR-0130 D4/D5 registers artifacts per package, so the advisory now runs once per package — + the same shape `os build` has used for the author-time rule table — and a genuine duplicate + inside one package still warns, with the suggestion taken from that package's own namespace and + a path written whole (`packages[1].manifest.apps[1].name`) so it resolves in either shape. A + single-package project is judged exactly as before. +- f89dd33: `object-reference-unknown` now judges a field's `reference` — the target of `Field.lookup()` / `Field.masterDetail()` / `Field.user()` — with the same four-rung ladder it applies to every other object-name site, and `os build`'s per-package run resolves those names across the artifact's `packages[]` + + `FieldSchema.reference` is `z.string()`: the schema holds it present and non-empty on `lookup` / `master_detail`, and nothing anywhere asked whether the name resolved. So `os validate`, `os lint` and `os build` all exited 0 — no diagnostic of any severity — on `Field.lookup('zzz_object_that_does_not_exist')` (measured on 17.3.0), and the miss surfaced only at runtime: the record picker asking the REST layer for an object that is not registered (404 `OBJECT_NOT_FOUND`), `$expand` failing on the field, the form rendering a control that can never resolve a value. + + The site joins `validateObjectReferences` and rides its existing ladder, so the three commands judge it identically: + + 1. resolves in the stack's own objects, or in the objects an entry of this artifact's `packages[]` provides → ok; + 2. resolves in `PLATFORM_PROVIDED_OBJECT_NAMES` (`sys_user`, the target `Field.user()` writes) → ok; + 3. unresolved and not platform-prefixed → **`error`** — `os validate` / `os build` / `os lint` exit 1; + 4. unresolved, platform-prefixed, registered by nothing (`sys_approval_process`) → the existing `object-reference-unregistered-platform` advisory. + + Judged: `lookup`, `master_detail`, `user`. Not judged, on purpose: `tree` (the object schema already refuses any target but the own name), a `reference` on a non-relationship type (inert), and `objectExtensions[].fields` (an extension targets an object another package owns, routinely one this artifact does not carry). + + ## Migration + + **A build that used to pass can now fail.** Rung 3 is a new `error`-level refusal on a published accept set. Point the field at one of the stack's own objects, at an object another package of the same artifact ships, or at a platform object by its full name (`sys_user`, not `user`); the finding names the objects that resolve and suggests the nearest one. + + **A reference into a sibling package of the same release artifact resolves — it needs no annotation.** ADR-0130 makes the release artifact the co-ownership boundary, so `os build`'s per-package leg now hands each package's stack the artifact's `packages[]` as resolution context (`compile.ts`). A module's `crm_order.account` → its App package's `crm_account` is an ordinary rung-1 resolution on all three commands. This changes what a rule can resolve, never what it judges: the collections judged per package are still that package's own, and a name no entry of `packages[]` provides still errors on the per-package run exactly as it does on the union one. + + **A reference into another RELEASE ARTIFACT still has no rung** — an app naming an object a separate product ships (HotCLM's `clm_contract.crm_contract` → HotCRM). It is unresolved and unprefixed, so rung 3 refuses it. The declared escape for that case resolves against declared manifest dependencies and is its own change; ⛔ it is deliberately not an authored per-field marker, which would be a one-line switch that silences the gate. +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth was + `seed-tenancy-backfill`'s organization probe, which kept a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes recorded `'unknown error'` there rather than `''`; + that fallback was load-bearing — the site read an empty value as "the probe did not fail" — + and #17167 removed it in this same release, so all fourteen sites now record the channel as + is and that site carries its failure fact structurally. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. +- 50bc9c7: Operator-facing text no longer tells an open-source install that multi-organization + operation requires a subscription. + + ADR-0132 moved the `org-scoping` registrar into open core — `@objectstack/organizations` + is Apache-2.0, carries no licence check, and declares both walled postures (`group` and + `isolated`) as its own constant. The messages an operator actually reads had not followed: + + - `os serve`'s install remedy for a walled posture ended "this runtime is closed-source and + is NOT on the public npm registry ... Without one this bullet is not followable" — it now + says the runtime is Apache-2.0 and on the public registry, and notes that a commercial + deployment resolves the same package name to its own private, licence-gated build. + - The `isolated` posture hint rendered by `os serve` and `os doctor` no longer calls the + runtime "enterprise". + - `os verify`'s `--org-scoped` flag description drops the same word. + - The dev stack's degraded-tenancy warning and its stage-2 mount refusal no longer describe + the package as the enterprise runtime. + + Text only — no control flow, no identifiers, no behaviour change. +- 3a2d2b5: `os explain query` now teaches the two keys `QuerySchema` actually declares. + + The entry's example and its two optional-table rows named `filters` and `sort`. + Neither is a key of `BaseQuerySchema`, which is a plain `z.object` — so both + were dropped silently: an author who copied the example got a query that parsed + clean and ran with no filter and no ordering, with nothing in the output saying + so. + + Both faces now read the schema's own spellings: + + - `where` — one condition **tree**, not a `Filter[]`. A field-keyed entry is a + condition on that field (a bare value is implicit equality, an object is a map + of `$` operators), and `$and` / `$or` / `$not` combine conditions. + - `orderBy` — sort nodes, each `{ field, order }`. The direction key is spelled + `order`; `direction` is rejected by name. + + No schema changed, and no accept set moved: the correction is to the catalog + entry only. The `os explain` catalog sweep also gains a key-retention assertion + — an example must parse **and** come back with every key it declares — so the + next entry whose schema strips a key is named instead of passing. +- 6e3462d: `serve`: the multi-org runtime's stage-1 refusal no longer prints its own install remedy for a `declared-unresolvable` failure — it defers to the importer's message, which the same refusal already prints as its `cause:` line. + + Driven on both shapes that kind covers, the minted bullet ("Repair the INSTALL … run `pnpm install`, check that a production prune did not drop it, and that its dist is actually built") was wrong twice over. For a genuinely broken install it repeated, word for word, the three remedies the cause line four lines below already carried. For a location install the finder cannot tie to the declaration, the cause says outright that re-running `pnpm install`, un-pruning a deploy and rebuilding a dist all change nothing — so one screen contradicted itself. + + The arm now says only what it uniquely knows (the app DOES declare the package, so re-reading `package.json` will not help) and names the cause as the authority on the remedy — the same deferral the `declared-no-loadable-entry` arm has had since it landed. +- eadcde6: `os generate schema` can now reach its own `fs.writeFileSync`. + + `runSchemaGeneration` called `z.toJSONSchema(ObjectStackDefinitionSchema, { target: 'draft-2020-12' })` + bare — the one `toJSONSchema` call site in this repository that neither fell back nor used the + `unrepresentable` convention. That call has no JSON form in either io direction on today's tree (a + transform in the output direction, a function type in the authoring direction), so the `catch` below + it printed and exited 1 for every repository and every flag combination: the command could never + write the IDE schema it exists to write. + + It now runs the same three-tier ladder `packages/spec/scripts/build-schemas.ts` already runs for + every schema it publishes — output, then the authoring (`io: 'input'`) direction, then that direction + with `unrepresentable: 'any'` as `packages/metadata-protocol` spells it — and each tier re-raises any + error the known-unsupported predicate does not recognise, so a real conversion failure is still loud. + + No new flag, no new key and no new exported symbol: the change is confined to the body of a + module-private function. + + The published document lands on the third tier today. It is the authoring derivation, so a property + carrying a `default` is not reported as required; the nodes that have no JSON form in any direction — + `onEnable`, and the inline-callable branch of each `handler` under `hooks`, `functions` and + `packages` — are published as unconstrained, which means an IDE validates everything else in + `objectstack.config.ts` and asks nothing about those. +- 776d64c: feat(spec)!: the `@objectstack/spec/cloud` subpath is removed — the cloud control plane's contracts leave the open-source spec, and the package & marketplace format moves to `@objectstack/spec/marketplace` (#16325) + + + + **BREAKING** — a published subpath export of `@objectstack/spec` is deleted, with no + alias and no deprecation window (maintainer, 2026-08-27, verbatim: 「项目在创业阶段, + 用户也很少,短期不考虑渐进。」). Shipped as `minor` under the repo's launch-window + convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness + is carried by this banner plus the ADR-0087 disposition; the hand-migration prescription + is registered under protocol major 18 as `cloud-subpath-retired`. + + ## What moved, and why + + Maintainer direction (2026-09-06, verbatim): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」, + ruled option B "cut by owner" on #16325 (director batch #62, 2026-09-07, 「同意」). + `packages/spec/src/cloud/` held two families with different owners: + + - **The cloud control plane's own contracts** — `environment.zod`, `environment-package.zod`, + `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` (62 JSON-Schema + defs, 2087 lines). Their producer and every consumer live in the closed cloud repo; the + open-source tree read exactly one type from them. They are gone from `@objectstack/spec`: + `environment` and `tenant` are re-declared in the cloud repo (objectstack-ai/cloud#2037), and + the other four are deleted outright — zero consumers in any repo (#16526, ruled A). All of it + is recoverable from git history at `d5d8d50db`. + - **The package & marketplace format** — `package.zod`, `package-version.zod`, `marketplace.zod`, + `package-l10n`, `template-manifest.zod` (30 defs, 1400 lines). A package author needs it and the + open-source CLI's `os package publish` speaks it, so it STAYS, relocated to `src/marketplace/` + and published as `@objectstack/spec/marketplace`. Every def, key and JSON Schema is + byte-identical under the new `$id` category (`RENAMED_DEFS`, 32 entries; nothing left the + author-facing contract). + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `import { PackageSchema, CreatePackageRequestSchema, … } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/marketplace'` — same symbols, same shapes | + | `import { EnvironmentArtifactSchema } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/system'` (it was only ever a re-export of that declaration) | + | `import type { EnvironmentType } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/api'` (re-declared beside the discovery fold table that reads it) | + | `import { EnvironmentSchema, TenantPlanSchema, ProvisionEnvironmentRequestSchema, … } from '@objectstack/spec/cloud'` | no open-source replacement — these are the cloud repo's own declarations now | + | `/docs/references/cloud/` | `/docs/references/marketplace/` for the format pages (redirected); the control-plane pages have no successor | + + Why the mis-binding hazard closes with this: `client.environments.*` keeps its erased `any` + deliberately (#11925/#12036), and the camelCase `Environment` row used to be the obvious-looking + binding for it — it compiled and read `undefined` at runtime against the snake_case wire. That + type no longer exists in the open-source package, so the wrong binding is structurally + impossible rather than warned about in a docblock. + + `@objectstack/cli` and `@objectstack/metadata` change only an import path (`marketplace` and + `system` respectively); no behaviour moves. +- edaf3b2: `os validate` and `os lint` now judge the same stack `os build` judges when a project declares its metadata only in `packages[]`. + + A project in the ADR-0130 D4 artifact shape — every definition inside `packages[]`, no collections at the top level — was handed to the author-time rule table as an **empty stack** by both commands, so all 44 rules reported nothing and both exited 0 having read none of the project. `os build` folds the packages back in first (`authoringRuleUnionStack`) and refuses the same stack. Two of the three authoring gates were certifying an unread project as clean, and `os validate` is the check an author runs before shipping. + + Both commands now hand the rule table the stack that same helper returns — one fold, shared with `os build`, not a second implementation. It is a rule **input** only: neither command's output, `--json` payload nor `os lint`'s metadata score changes, and a stack that still carries its top-level collections is returned by identity, so single-package projects are unaffected by construction. + + ⚠️ **A project that was silently passing may now fail.** That is the defect surfacing, not a new rule: the finding was always there and `os build` was always reporting it. Run `os build` on the same tree to see the identical diagnostic. +- 5865b02: `os create plugin` names the standalone scaffold `plugin-` and marks it `private` + + The default (standalone) emission wrote `"name": "@objectstack/plugin-"` into a + project scaffolded for a developer outside this monorepo — a scope they cannot publish + to — and did not mark the manifest `private`. Nothing failed at scaffold time: the name is + never resolved from a registry inside the project, so `pnpm install`, the type-check and + the scaffold smoke were all green on it, and the cost landed later at `npm publish`. The + emitted README compounded it by instructing `pnpm add @objectstack/plugin-`. + + The standalone default now emits: + + - `"name": "plugin-"` — unscoped, and the same string as the directory the + scaffolder prints and creates; + - `"private": true` — the line that actually stops an accidental publish, whatever the + name says; + - a README whose install instruction is a local reference (`pnpm add link:../plugin-`) + and whose import specifier matches the emitted package name. + + `os create plugin --in-repo` is unchanged: it still emits a publishable + `@objectstack/plugin-` with no `private` flag, because that placement lands under + `packages/plugins/` where every sibling genuinely carries that scope. + + No action is needed for a project already scaffolded. If you generated one with the old + name and have not published it, rename `package.json`'s `name` to `plugin-` (or a + scope you own) and update the README's install line; the exported symbol and the plugin's + runtime `name` are unaffected. +- 8c9bd8f: docs(metadata-protocol,objectql,cli): comments describing the standalone stamp now name `env_local`, the value the tree actually produces + + The v5.0 `project` to `environment` rename reached the two remaining stamps in `@objectstack/runtime` and `@objectstack/metadata` in a previous release: `createStandaloneStack` and `MetadataPlugin` both stamp **`env_local`**. Six comments in three other packages still described that stamp as `'proj_local'`, so they named a value nothing in the tree produces any more. + + No behaviour changes. The reason this is a `patch` rather than a no-publish diff is measured, not assumed: two of the six sites are TSDoc on **exported** interface members (`AssembleMetadataProtocolOptions.runPlatformMigrations`, `ObjectQLPluginOptions.runPlatformMigrations`) and land in the shipped `dist/*.d.ts`, and the `@objectstack/cli` site lands in the shipped `dist/utils/schema-migrate.js` because that package builds with `removeComments` unset. All three packages ship `dist` in `files[]`, so the corrected text is what an author reads on hover after upgrading. + + The sites were judged individually rather than search-and-replaced, because they are not all the same edit: + + - Five sites whose verb describing the stamp is present indicative describe today's tree — two of them point the reader at `runtime/src/standalone-stack.ts` to go and look — and take the current spelling. + - `packages/cli/src/utils/schema-migrate.ts` names `'proj_local'` as the value the historical arming deduction consumed. There the literal is preserved as history and its present-tense relative clause moves into the past, with today's spelling named beside it; rewriting it to `env_local` would have falsified the record in the other direction. + + The causal claim at every site is about **presence**, not spelling: the retired gate read `environmentId === undefined`, so it would have misfired identically under either literal. That reading is preserved at all six. +- Updated dependencies [9a0c0b5] +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [fdeeea0] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [c8a006f] +- Updated dependencies [3c48234] +- Updated dependencies [7843663] +- Updated dependencies [eac58c3] +- Updated dependencies [08b213e] +- Updated dependencies [7851fa3] +- Updated dependencies [ce57857] +- Updated dependencies [2d81e39] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [9fca8eb] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [917b87e] +- Updated dependencies [482d34d] +- Updated dependencies [3c86008] +- Updated dependencies [e526556] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [216b066] +- Updated dependencies [839d1b0] +- Updated dependencies [c88fa2c] +- Updated dependencies [b722547] +- Updated dependencies [ee6fbd7] +- Updated dependencies [1e496f9] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [4f1a56b] +- Updated dependencies [f39ea95] +- Updated dependencies [f19dbcf] +- Updated dependencies [6059b29] +- Updated dependencies [89a652b] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [ca78860] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [a370073] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [fce7cd4] +- Updated dependencies [2d235bc] +- Updated dependencies [4af758d] +- Updated dependencies [86c5052] +- Updated dependencies [c3a95d9] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [dc709b2] +- Updated dependencies [04333d0] +- Updated dependencies [07f93e0] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [7baf04a] +- Updated dependencies [63b6818] +- Updated dependencies [6b2ec3b] +- Updated dependencies [32be735] +- Updated dependencies [c9246fa] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [cea85fd] +- Updated dependencies [cb04f45] +- Updated dependencies [b6471ba] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [0fb6f97] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [5741ff1] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [de62769] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [6ec467b] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [69b5059] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [c54d8d6] +- Updated dependencies [eea7ccc] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [be7382d] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [bea41f6] +- Updated dependencies [2eb4724] +- Updated dependencies [e743fb5] +- Updated dependencies [d46deba] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [690f083] +- Updated dependencies [e7fea46] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [310760d] +- Updated dependencies [7e74af3] +- Updated dependencies [a9096af] +- Updated dependencies [2b6a207] +- Updated dependencies [497655f] +- Updated dependencies [4be4e04] +- Updated dependencies [7c2c5ae] +- Updated dependencies [c5d270a] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [fade3da] +- Updated dependencies [758ac40] +- Updated dependencies [be5c602] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [400167a] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [9ccc417] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [3da78cc] +- Updated dependencies [62a6dc3] +- Updated dependencies [c81e7ff] +- Updated dependencies [17005cc] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [b5cbfef] +- Updated dependencies [d438b3a] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [4d2008c] +- Updated dependencies [abb01f1] +- Updated dependencies [cf39b83] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [5762eaf] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d6137fd] +- Updated dependencies [29a1b3d] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [bce5270] +- Updated dependencies [340b6dc] +- Updated dependencies [3ab1508] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [ad067ad] +- Updated dependencies [a6a1de4] +- Updated dependencies [6e4024c] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [f2044ef] +- Updated dependencies [9be2b59] +- Updated dependencies [922c755] +- Updated dependencies [74832b6] +- Updated dependencies [c17ff70] +- Updated dependencies [df1b275] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [877dc03] +- Updated dependencies [879b512] +- Updated dependencies [6f8d751] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [be7aeb8] +- Updated dependencies [c7448dc] +- Updated dependencies [21b7c12] +- Updated dependencies [21b7c12] +- Updated dependencies [74327d3] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- Updated dependencies [5941246] +- Updated dependencies [939f3ea] +- Updated dependencies [564ac2f] +- Updated dependencies [4efb988] +- Updated dependencies [8015dc8] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [be7763a] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [62bce5c] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [55523fd] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [97466dd] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [b4b83b3] +- Updated dependencies [0318faf] +- Updated dependencies [ef256e6] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [a43b9d0] +- Updated dependencies [4fef271] +- Updated dependencies [554e928] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [b49728f] +- Updated dependencies [2767af8] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [215840f] +- Updated dependencies [75c0dac] +- Updated dependencies [e3b3cdd] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [2b3eb17] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [d7f7e34] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [13d5294] +- Updated dependencies [58644ad] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [841a71e] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [4be0868] +- Updated dependencies [5636641] +- Updated dependencies [b971924] +- Updated dependencies [c9b23cd] +- Updated dependencies [482d584] +- Updated dependencies [2b321a4] +- Updated dependencies [0870fb5] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [c02fa12] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [c3a4c74] +- Updated dependencies [0b4022b] +- Updated dependencies [a227afa] +- Updated dependencies [a60c913] +- Updated dependencies [7ffddfa] +- Updated dependencies [cdc1ae0] +- Updated dependencies [0862063] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [f9977c1] +- Updated dependencies [2306a75] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [a251aaa] +- Updated dependencies [77f54bf] +- Updated dependencies [1f69917] +- Updated dependencies [ccccdcc] +- Updated dependencies [2aac821] +- Updated dependencies [48c91e9] +- Updated dependencies [8dba7aa] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [fd920ca] +- Updated dependencies [3875ae6] +- Updated dependencies [b76aad5] +- Updated dependencies [f77b806] +- Updated dependencies [a754563] +- Updated dependencies [d58b8b6] +- Updated dependencies [4112752] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [3fd3a4f] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [1c8b320] +- Updated dependencies [71ef221] +- Updated dependencies [a90272a] +- Updated dependencies [5dba7f3] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [01df025] +- Updated dependencies [a5afe38] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [bc80e16] +- Updated dependencies [6696056] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [afc3b64] +- Updated dependencies [a6a4361] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [0e90a8d] +- Updated dependencies [ecf90b2] +- Updated dependencies [e07843b] +- Updated dependencies [863a775] +- Updated dependencies [44ce049] +- Updated dependencies [2bbb462] +- Updated dependencies [1f89ba0] +- Updated dependencies [1f0b341] +- Updated dependencies [90ff10a] +- Updated dependencies [e9eb224] +- Updated dependencies [3bd221d] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [467fa76] +- Updated dependencies [3bd28e2] +- Updated dependencies [d1ca874] +- Updated dependencies [b7b6cdd] +- Updated dependencies [beac798] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [d498113] +- Updated dependencies [eee0974] +- Updated dependencies [de091b5] +- Updated dependencies [8490127] +- Updated dependencies [14add48] +- Updated dependencies [6aa3188] +- Updated dependencies [0142415] +- Updated dependencies [a0920b4] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [ae0c90c] +- Updated dependencies [9bfbacb] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [0b866bf] +- Updated dependencies [c839986] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [98f722a] +- Updated dependencies [009da14] +- Updated dependencies [af32cf9] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [55cd8d4] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [aa04ea2] +- Updated dependencies [e8f163f] +- Updated dependencies [61609ed] +- Updated dependencies [172b4cf] +- Updated dependencies [fc6ddb8] +- Updated dependencies [b9e9609] +- Updated dependencies [b81da66] +- Updated dependencies [4463966] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [60fdaa9] +- Updated dependencies [ab82001] +- Updated dependencies [7b76fff] +- Updated dependencies [9d81af7] +- Updated dependencies [26550c6] +- Updated dependencies [8e9a425] +- Updated dependencies [e7f69db] +- Updated dependencies [b373596] +- Updated dependencies [7465eeb] +- Updated dependencies [246314d] +- Updated dependencies [84156c7] +- Updated dependencies [ed3546f] +- Updated dependencies [7e6ca17] +- Updated dependencies [3557f85] +- Updated dependencies [980bc05] +- Updated dependencies [57c2b73] +- Updated dependencies [f09d412] +- Updated dependencies [adbbc5d] +- Updated dependencies [bf37b99] +- Updated dependencies [e0f17a3] +- Updated dependencies [7766b62] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [7ddf396] +- Updated dependencies [8d76c2d] +- Updated dependencies [226e00c] +- Updated dependencies [8a44ce7] +- Updated dependencies [d4c897e] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [2491729] +- Updated dependencies [84880f9] +- Updated dependencies [d624002] +- Updated dependencies [95ab93f] +- Updated dependencies [a8bcce6] +- Updated dependencies [fa00ebf] +- Updated dependencies [7c1039b] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [a08e059] +- Updated dependencies [fe677ae] +- Updated dependencies [6780e34] +- Updated dependencies [55daf89] +- Updated dependencies [536f2d5] +- Updated dependencies [fc646cf] +- Updated dependencies [586934e] +- Updated dependencies [8d1f7ab] +- Updated dependencies [a243cfb] +- Updated dependencies [e01d347] +- Updated dependencies [dbddf02] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [949e99b] +- Updated dependencies [16c5a33] +- Updated dependencies [16c5a33] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [16c5a33] +- Updated dependencies [9401b84] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [329ea2e] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [cfe2387] +- Updated dependencies [cfe2387] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [585c9af] +- Updated dependencies [2dccb7d] +- Updated dependencies [4df101c] +- Updated dependencies [1207baf] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [2bcd5cf] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [5f9d7d7] +- Updated dependencies [0d3ec47] +- Updated dependencies [ae8e3ca] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [cc40033] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [a78f731] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [d3958ba] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [a36a691] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [c74de10] +- Updated dependencies [db74b16] +- Updated dependencies [2b24b8b] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [95f729a] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [c5d6b2b] +- Updated dependencies [2d91c9a] +- Updated dependencies [c577e66] +- Updated dependencies [2f122b6] +- Updated dependencies [15bf186] +- Updated dependencies [b285508] +- Updated dependencies [5049a3c] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [4a1df19] +- Updated dependencies [aeb0557] +- Updated dependencies [70ce802] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [df3ba16] +- Updated dependencies [7fa3e3e] +- Updated dependencies [de8c973] +- Updated dependencies [50e273f] +- Updated dependencies [c745e2b] +- Updated dependencies [9801da1] +- Updated dependencies [fc0db22] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [8cdbe0c] +- Updated dependencies [d753744] +- Updated dependencies [5c7aa46] +- Updated dependencies [7d63088] +- Updated dependencies [87c37ae] +- Updated dependencies [24b7085] +- Updated dependencies [2304b16] +- Updated dependencies [3e8b492] +- Updated dependencies [5b674f5] +- Updated dependencies [4b2d904] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [8e02859] +- Updated dependencies [fb38607] +- Updated dependencies [2b53993] +- Updated dependencies [dc07593] +- Updated dependencies [397572e] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [45f428d] +- Updated dependencies [9449512] +- Updated dependencies [b2b6a06] +- Updated dependencies [8538edf] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [fb194c7] +- Updated dependencies [b057434] +- Updated dependencies [b43a814] +- Updated dependencies [1378ec7] +- Updated dependencies [f6ceddc] +- Updated dependencies [92ea760] +- Updated dependencies [487a784] +- Updated dependencies [40626bd] +- Updated dependencies [e2c55ed] +- Updated dependencies [4c42fd1] +- Updated dependencies [76ddab7] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [40098a4] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [113050e] +- Updated dependencies [5d12b16] +- Updated dependencies [54b3d1d] +- Updated dependencies [634f23d] +- Updated dependencies [f03f6c7] +- Updated dependencies [374d9d3] +- Updated dependencies [86f4246] +- Updated dependencies [4ef8247] +- Updated dependencies [ea4d164] +- Updated dependencies [b8ec127] +- Updated dependencies [ab48938] +- Updated dependencies [cb648cb] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [2c87a48] +- Updated dependencies [a36b526] +- Updated dependencies [dd2fd20] +- Updated dependencies [f6b7c53] +- Updated dependencies [92865f6] +- Updated dependencies [e81c4e5] +- Updated dependencies [01388fe] +- Updated dependencies [d61139f] +- Updated dependencies [1c4270f] +- Updated dependencies [f904e61] +- Updated dependencies [5de9372] +- Updated dependencies [f8fea00] +- Updated dependencies [fb6a2de] +- Updated dependencies [28f9277] +- Updated dependencies [48c91e9] +- Updated dependencies [fbc12be] +- Updated dependencies [3cf6449] +- Updated dependencies [0bf85ea] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [3c557e2] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [b940f32] +- Updated dependencies [e66da5c] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [bdea10a] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [4bd2c60] +- Updated dependencies [71629a1] +- Updated dependencies [7010085] +- Updated dependencies [2266438] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [cefe068] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [e958468] +- Updated dependencies [288fe9c] +- Updated dependencies [611795e] +- Updated dependencies [e77a23f] +- Updated dependencies [f9e16d8] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [a016f08] +- Updated dependencies [b110578] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [522f612] +- Updated dependencies [c86d351] +- Updated dependencies [e07eecf] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3462d] +- Updated dependencies [8fe5cb8] +- Updated dependencies [362dcc3] +- Updated dependencies [31064ca] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [f89dd33] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [3977410] +- Updated dependencies [46cf705] +- Updated dependencies [45c2cf9] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [dfb42c5] +- Updated dependencies [29d00cc] +- Updated dependencies [cca1dc0] +- Updated dependencies [9540590] +- Updated dependencies [ae6dcf6] +- Updated dependencies [7e05b9d] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [bccf311] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [b3f7fdc] +- Updated dependencies [e6965dd] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [f572a7e] +- Updated dependencies [ca31ff6] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [775e5ec] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [f3b28eb] +- Updated dependencies [fd5cff2] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [0ced0aa] +- Updated dependencies [8d4690b] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [cca6991] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [470746a] +- Updated dependencies [b7c792b] +- Updated dependencies [e4fd55d] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [ba17017] +- Updated dependencies [4062aef] +- Updated dependencies [6ff5b56] +- Updated dependencies [777d0c2] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [6465cc0] +- Updated dependencies [4280055] +- Updated dependencies [032452a] +- Updated dependencies [28ce612] +- Updated dependencies [131851f] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [7173d7d] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [43e17b8] +- Updated dependencies [5cdb0db] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [f6189a4] +- Updated dependencies [4ecfd2b] +- Updated dependencies [7cd5874] +- Updated dependencies [a2509d7] +- Updated dependencies [6058cb2] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/plugin-webhooks@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/service-settings@17.5.0 + - @objectstack/service-automation@17.5.0 + - @objectstack/lint@17.5.0 + - @objectstack/metadata-protocol@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/plugin-approvals@17.5.0 + - @objectstack/plugin-hono-server@17.5.0 + - @objectstack/mcp@17.5.0 + - @objectstack/plugin-sharing@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/client@17.5.0 + - @objectstack/service-analytics@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/service-messaging@17.5.0 + - @objectstack/plugin-audit@17.5.0 + - create-objectstack@17.5.0 + - @objectstack/verify@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/service-queue@17.5.0 + - @objectstack/driver-turso@17.5.0 + - @objectstack/plugin-email@17.5.0 + - @objectstack/cloud-connection@17.5.0 + - @objectstack/driver-mongodb@17.5.0 + - @objectstack/observability@17.5.0 + - @objectstack/service-cache@17.5.0 + - @objectstack/service-job@17.5.0 + - @objectstack/service-package@17.5.0 + - @objectstack/service-realtime@17.5.0 + - @objectstack/service-storage@17.5.0 + - @objectstack/trigger-schedule@17.5.0 + - @objectstack/driver-sqlite-wasm@17.5.0 + - @objectstack/trigger-api@17.5.0 + - @objectstack/console@17.5.0 + - @objectstack/service-datasource@17.5.0 + - @objectstack/account@17.5.0 + - @objectstack/setup@17.5.0 + - @objectstack/service-sms@17.5.0 + - @objectstack/trigger-record-change@17.5.0 + - @objectstack/plugin-pinyin-search@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index a57d5b003a4..18365f4a079 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "17.4.0", + "version": "17.5.0", "description": "Command Line Interface for ObjectStack Protocol", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/client-react/CHANGELOG.md b/packages/client-react/CHANGELOG.md index bd557777a0f..1bd5748e49f 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,503 @@ # @objectstack/client-react +## 17.5.0 + +### Patch Changes + +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [3c86008] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [7baf04a] +- Updated dependencies [6b2ec3b] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [cb04f45] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [be7382d] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [400167a] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [62a6dc3] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d6137fd] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [be7aeb8] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [be7763a] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [55523fd] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [7ffddfa] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [b76aad5] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [1c8b320] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [01388fe] +- Updated dependencies [d61139f] +- Updated dependencies [1c4270f] +- Updated dependencies [f904e61] +- Updated dependencies [5de9372] +- Updated dependencies [f8fea00] +- Updated dependencies [fb6a2de] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [bccf311] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [032452a] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/client@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index d4bc7e15ea0..b320e538dbb 100644 --- a/packages/client-react/package.json +++ b/packages/client-react/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client-react", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "React hooks for ObjectStack Client SDK", "main": "dist/index.js", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 1eeb4b07a5e..b20c40db60c 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,1606 @@ # @objectstack/client +## 17.5.0 + +### Minor Changes + +- 6b2ec3b: fix(client): `oauth.applications.register` declares `redirect_uris` optional, matching the body schema of the route it posts to (#17215) + + `ObjectStackClient.oauth.applications.register` declared `redirect_uris` **required**. `POST /api/v1/auth/oauth2/create-client` is mounted verbatim from `@better-auth/oauth-provider`, and that route's body schema declares the member **optional** — so a request the route accepts had no spelling through this SDK. The caller never got a wrong answer; they got a call they could not write. + + ## What changes for a caller + + Nothing they have to do. Every existing call still compiles — this only *adds* spellings: + + ```ts + // now expressible, and accepted by the route: + await client.oauth.applications.register({ client_name: 'My App' }); + + // unchanged, and still the right call when you have redirect URIs: + await client.oauth.applications.register({ + client_name: 'My App', + redirect_uris: ['https://app.example.com/cb'], + }); + ``` + + ⛔ Not breaking in this direction — relaxing a required member to optional keeps every existing call valid. Tightening it back later would be breaking, which is why the parity is now pinned. + + ## Measured at runtime, not read off a `.d.ts` + + The vendor body schema was re-introspected the way the card's original measurement was taken: instantiate `oauthProvider()`, walk `endpoints`, find the endpoint whose `path` is `/oauth2/create-client`, read `options.body`. At the installed **1.7.3** (the card measured 1.7.2; the package has since moved) the object still declares **21 members and every one of them is optional**, and `body.safeParse({ client_name: '…' })` succeeds with `redirect_uris` absent. + + ⚠️ Optional does **not** mean an empty array will do: the vendor refuses `[]`, so when the member is present it must be non-empty. Omitting it and passing `[]` are different requests and only the first is legal. Nor does it mean a client registered without redirect URIs is *usable* — it cannot complete an `authorization_code` flow. The type states what the route accepts, never that every accepted call yields a client fit for every grant; the docblock now says both. + + ## Why it was required, for the record + + Not as a guard. It is residue from the method's first commit, which declared `client_name` required too; the same-day follow-up relaxed `client_name` and left this one behind. No comment, test, ADR or review thread ever asserted a reason for it — which is exactly why it read as a defect to the next auditor. + + Nothing else on the signature moves: the other ten members are byte-identical. +- cb04f45: fix(client): `organizations.invitations.resend` forwards `teamId`, so resending a team invitation keeps its team (#17274) + + `resend` has declared `teamId?: string | null` since the `organizations.*` family's first commit and has never forwarded it. The re-invite it issues carried `email`, `role` and `organizationId` only, so a caller resending a TEAM invitation passed the team, the compiler accepted it, the request succeeded — and the invitation landed with no team. Nothing refused, nothing warned, and the success path carried no trace of the loss. The published type is the contract a caller reads, and it promised a placement the call could not make. + + **Which of the two repairs this is, and what decided it.** The card left the direction open between forwarding the member and deleting it, and required the endpoint to be DRIVEN rather than read off the vendor's types. Driven — a real `AuthManager` (better-auth 1.7.3, organization plugin, `teams: { enabled: true }`, the posture `auth-manager.ts` hard-wires) over a real `SqliteWasmDriver`, with the SDK's own `fetch` handing each `Request` to `AuthManager.handleRequest`: + + | body sent to `POST /organization/invite-member` | answer | + |:--|:--| + | `{ …, teamId: '' }` | `200`, and the invitation's `teamId` is that team | + | `{ …, teamId: 'team_does_not_exist' }` | `400` `Team not found` (`TEAM_NOT_FOUND`) | + | `{ …, teamId: null }` | `400` `[body.teamId] Invalid input` (`VALIDATION_ERROR`) | + | `{ … }` — no `teamId` member | `200`, and the invitation's `teamId` is `null` | + + Row 1 settles it: the endpoint accepts a team on this call, the placement is stored on the invitation row and read back by `invitations.list`. Deleting the member would therefore have removed a capability the wire really has, so it is forwarded. + + **It is not forwarded verbatim, and rows 3 and 4 are why.** `null` is this SDK's own spelling of "no team" — `invitations.list` answers `teamId: string | null`, and handing that object straight back to `resend` is the ordinary way to resend. The vendor's spelling of the same fact is ABSENCE. A bare spread would put `teamId: null` on the wire and convert today's silent drop into a `400` for every round-tripping caller: a second defect wearing the fix's clothes. So `invite` lifts `teamId` out of the spread and sends it only when it is a string; `null` and an omitted member both send no `teamId` at all. ⛔ Nothing else is normalised — an unknown id keeps reaching the vendor, because `TEAM_NOT_FOUND` is the loud refusal that replaces the silent drop. + + **`organizations.invite` gains the same `teamId?: string | null` member.** It is the only route `resend` has to the wire, and declaring the member is what lets the placement be typed rather than smuggled. Purely additive on a published request type: every existing call compiles and sends byte-identical requests, which the sibling byte pins on `invite` assert unchanged. + + `resend` also stops spelling its own `role ?? 'member'` and takes `invite`'s default instead — one family, one substitution, no second copy to drift. Behaviour-neutral: an omitted or explicitly-`undefined` `role` still reaches the wire as `'member'`, in the same position, and a caller-named role still survives. + + Pinned in `packages/client/src/organization-invitation-resend-team-placement.test.ts`: the placement over the real vendor, its read-back through `list()`, the `TEAM_NOT_FOUND` refusal, the `null` round trip that fails on the verbatim forward, and full-string equality on the request bytes for both methods. +- be7382d: fix(client): the four `packages` READ members declare the stage their door is declared at (#17536) + + Clause-②: yes + + **BREAKING** for TypeScript consumers — a published TYPE-surface WIDENING, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition below, never by the level). No runtime behaviour changes, and none is possible here: only a declaration moved, and the values these four methods resolve to are the values they have always resolved to. + + `ObjectStackClient.packages.list` / `.get` and their `ScopedEnvironmentClient` twins returned `InstalledPackage` — the AUTHORING manifest stage, imported from `@objectstack/spec/kernel`. Since PR #17517 both read doors have been declared at EITHER stage: `ListInstalledPackagesResponseSchema.packages` is `z.array(InstalledPackageAtEitherStageSchema)` and `GetInstalledPackageResponseSchema.data` is that same schema. ⇒ A response the server is declared able to send was one this SDK's own types said could not arrive. + + `packages/spec` is the one contract between producers and consumers, and `packages/client` is a consumer of it, so the consumer's declaration is what moves. All four now declare `InstalledPackageAtEitherStage` from `@objectstack/spec/api`. + + **What the union is.** It is a union over the two manifest stages — `InstalledPackageSchema` and `AssembledInstalledPackageSchema` — which differ in exactly one key, `manifest`. Every other member of the row (`id`, `name`, `version`, `status`, `enabled`, `installedAt`, …) is common to both branches and reads exactly as it did, so code that reads only those members needs no change at all. + + **Where the RUNTIME and the TYPE disagree, measured at this head.** The runtime schema is the strict half: `InstalledPackageAtEitherStageSchema.safeParse(row)` answers `success: false` for a row whose `manifest` belongs to neither stage — measured on a `{ bogus: 1, objects: 'not-even-an-array' }` manifest and on an empty `{}` one. The published TYPE is NOT that strict: on the assembled branch `manifest` is declared `Record`, so both of those same rows COMPILE against the declared return type. ⛔ Do not read this widening as a type-level guarantee about `manifest` — the guarantee is the parse's. The type-level tolerance is a known gap, tracked as **#19324**; its root cause is the deliberate `z.ZodType, …>` annotation at `packages/spec/src/stack.zod.ts:1283` (#14513 — TS7056 and a declaration-chunk ceiling), and it is ⛔ not this change's to fix. It is recorded as a pin in `packages/client/src/return-type-precision.test.ts`, which reddens the day the gap closes. + + **What a consumer does, concretely.** A member read off `manifest` on the union arrives as `unknown` (measured: `pkg.manifest.objects` is `unknown`). ⇒ a caller that reaches INTO `manifest` narrows by PARSING the row with a `packages/spec` schema and reading the parse's output: + + ```ts + import { AssembledInstalledPackageSchema } from '@objectstack/spec/api'; + import { InstalledPackageSchema } from '@objectstack/spec/kernel'; + + const row = await client.packages.get(id); + const parsed = AssembledInstalledPackageSchema.safeParse(row); + if (parsed.success) { + // parsed.data.manifest — the ASSEMBLED stage, object definitions + } else { + const authoring = InstalledPackageSchema.parse(row); + // authoring.manifest.objects — the AUTHORING stage, glob strings + } + ``` + + ⛔ Do NOT narrow with `Array.isArray(pkg.manifest.objects)`, or with any other structural guess. It separates the stages on NEITHER level: at the type level `pkg.manifest` is the same union inside both branches of that `if`, and at runtime BOTH stages' `objects` are arrays — `z.array(z.string())` at the authoring stage against `z.array(ObjectSchema)` at the assembled one. (Measured: a row carrying no `objects` at all parses as either stage, which is the right answer for it — the key such a guess would read is not there.) + + In this repository the whole consumer cost is zero sites outside `packages/client` itself: no other workspace package calls either read member. + + **The WRITE members did not move** and stay declared at the authoring stage — all four of them: `install`, `enable`, `disable`, `update` still answer `Promise`. `install` answers the row its own request contract produced (`PackageInstallRequestSchema` declares `manifest: ManifestSchema`), and PR #17517 moved the read doors alone. That asymmetry is the measurement, not an oversight, and it is pinned. + + The type is reached the same way `InstalledPackage` always was, from `@objectstack/spec` rather than re-exported here: this SDK has never re-exported the package row, and this change does not start. + + +- 62a6dc3: **BREAKING** — `client.environments.updateVisibility(id, visibility)` is REMOVED from the published SDK surface. + + Clause-②: no + + A `major`-class change, recorded as `minor` under the launch-window convention. Director-seat decision batch #132 item 1, maintainer 「同意」, 2026-09-13; ADR-0049 enforce-or-remove. + + **Why.** The method's only behaviour was a write the control plane refuses. It PATCHed the generic `/api/v1/cloud/environments/:id` route with `{ visibility }`, and `visibility` is one of the server-owned columns that route rejects — the same accept-set (`display_name`, `is_default`, `metadata`) the `update` docblock already records. Its own docblock described a whole capability ("`public` lists the environment and freely exposes all revisions") that does not exist. The 2026-09-12 maintainer ruling keeps `visibility` server-owned and forced to `private` until the public-listing feature ships, at which point that capability arrives on its **own** endpoint rather than on this generic update — so this method was never going to be its carrier, even once it lands. A published method that is known never to be implemented is removed rather than left throwing forever. + + ## No FROM → TO mapping, and why this section is not one + + There is no replacement to rewrite a call into, and stating one would be false. **Delete the call.** No behaviour is lost: the write it issued was already refused. The channel that reaches every affected consumer is the compiler, at their own call site — strictly more precise than any prose here. When the public-listing endpoint ships, a NEW method is written against it; ⛔ restoring this signature would re-declare the refused generic-update write. + + `objectstack migrate meta` has nothing to reach: an SDK call site is source code, not stored metadata, so no ADR-0087 conversion entry and no migration-chain step can act on it. + + Also in the same change: `packages/runtime/src/http-dispatcher.ts` loses an orphaned control-plane route-table docblock that documented routes that file does not serve — `/cloud/*` is skipped there, and the table repeated the corrected accept-set. Comment-only; no runtime byte moves. + + +- e6c34f6: The identity read routes now serve what `@objectstack/spec/identity` declares: `metadata` arrives DECODED on every organization route that reads the row back, and `updatedAt` is declared optional on `Organization` / `Member` / `Invitation` — the shape better-auth's own serializer documents (#18728). + + Clause-②: yes (widening) — `updatedAt` moves from required to optional on three published schemas, so the set a consumer may hand to `OrganizationSchema` / `MemberSchema` / `InvitationSchema` grows by exactly one shape: the key being absent. Nothing previously admitted is refused, nothing is renamed, and no producer is required to write it. Contract-review tier. + + Three published schemas could not parse a served response. `OrganizationSchema` declared `updatedAt` required and `metadata` an object; the four organization read routes (`setActive`, `get`, `delete`, `list`) carried no `updatedAt` at all and served `metadata` as the stored JSON text. `@objectstack/client` had recorded that as three 「not relayed」 notes rather than as a defect, and with zero in-repo consumers nothing went red — the audience was entirely external. Maintainer ruling C (batch #158 item 4) fixed the producer and made the one remaining key conditional on a measurement, which is what decided each half: + + - **`metadata` is decoded at the producer, unconditionally** — it is our column. plugin-auth's data adapter decodes `sys_organization.metadata` out of its stored JSON text on its READ verbs, so all four routes serve the object the spec declares, and an unset column is OMITTED rather than sent as `null`. ⛔ The write verbs are deliberately untouched: better-auth's own organization adapter decodes the `create` / `update` echoes itself and discriminates on the value still being a string, so decoding there would fold the create echo's `metadata` to `undefined`. Both directions are pinned. + - **`updatedAt` aligns to the documented wire** — ruling C's own fallback A, and its two conditions were measured against the installed better-auth 1.7.3 rather than assumed. The routes are better-auth's endpoints mounted through a single catch-all, each answering `ctx.json(...)` with no ObjectStack post-processing; and the vendor's `organization`, `member` and `invitation` models declare no `updatedAt` field, while its adapter factory's output transform iterates the declared fields only, so an undeclared column is dropped before any route sees it. Control, in the same file: the vendor's `team` and `organizationRole` models DO declare `updatedAt`, so the absence is a reading. For `member` and `invitation` there is additionally no column to serve — `sys_member` and `sys_invitation` are `managedBy: 'better-auth'`, the one disposition under which the platform injects no audit family, and neither declares `updated_at` itself. + - **`@objectstack/client` relays the schemas.** `OrganizationWire` is the spec's `Organization`, `OrganizationMemberWire` is `Member`, and `OrganizationInvitationWire` is `Invitation` with `status` narrowed per route plus the three members the platform adds on top (`teamId` and the two ADR-0105 D8 placement fields, which the non-strict schema strips). The three 「not relayed」 notes are gone. + - **The negative controls are the point.** "The client relays the spec schemas" and "the client stopped validating" look identical from a green positive test, so every accepted body is paired with a refused one — a required field genuinely missing, `metadata` still arriving as the stored JSON TEXT, and a `createdAt` or `updatedAt` present but not a datetime. `.optional()` widened the accept set by absence ONLY; a value that is there is still held to `z.string().datetime()`. + + **Not declared breaking, and the reason is the repo's own criterion** rather than the level being convenient. AGENTS.md binds the breaking class to removing or renaming something an author can write, and to the `(narrowing)` arm of the clause-② pair. Neither holds here: nothing is removed, renamed or retired; the one `packages/spec` edit only widens an accept set; and the `metadata` half is a producer brought into line with a contract this package has published all along — `OrganizationSchema.metadata` has declared an object since it was written, and the client's own comment called the served text 「not relayed」 rather than a shape anyone was promised. No ADR-0087 disposition is claimed because no breaking change is declared: no authored metadata moves, so `objectstack migrate meta` has nothing to visit, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. These three schemas are not metadata types — not in `DEFAULT_METADATA_TYPE_REGISTRY`, no authorable surface. ⚠️ Stated here rather than assumed silently, because it is the one judgement in this diff that the contract review the `Clause-②: yes` declaration commissions should confirm. + + **What a consumer notices**, and where it is delivered: `organization.metadata` was the stored JSON text and is now the decoded object, so a caller that decoded it itself drops that step. + + ```ts + // before — the caller decoded what the route sent + const meta = JSON.parse(org.metadata ?? '{}'); + // after — the producer decoded it; the key is ABSENT when unset + const meta = org.metadata ?? {}; + ``` + + The channel that reaches that caller is the compiler, on the line that used to work: `JSON.parse` no longer accepts the value. `updatedAt` needs nothing in either direction — it was never on this family's wire, so no caller can have been reading a value, and the declaration now says so out loud instead of promising one. +- 0b4022b: feat(automation): `GET /automation/:name/runs` retires `cursor` and computes `hasMore` (#19543) + + This door declared a pagination parameter it never spent and then reported, as a + literal, that there was nothing more to fetch. Both halves are closed here, per + the maintainer-approved ruling of 2026-09-21 (decision batch #204 item 2, + letter C of three). + + **BREAKING** — `cursor` no longer parses on `ListRunsRequestSchema`, its slot + is gone from `IAutomationService.listRuns`, and `@objectstack/client` no longer + declares or sends it on any of the three run-list surfaces + (`automation.runs.list`, `automation.listRuns`, + `client.environment(id).automation.listRuns`). It was declared on the wire, + *validated* at the boundary, forwarded into the service contract, appended by + the SDK, and read by no implementation. No emit site has ever written the + response half `nextCursor`, and the only ordering this door has is a required + but non-unique `startedAt` timestamp that nothing ever minted a resume point + from — so a caller looping "until the cursor runs out" re-read the first and + only window forever, with no error. + + ``` + FROM ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' }) + -> { name: 'f', limit: 20, cursor: 'n_007' } // forwarded, then dropped + + TO ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' }) + -> throws: '`cursor` was removed from GET /api/v1/automation/:name/runs in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) …' + ``` + + `cursor` is a `retiredKey()` tombstone rather than a deletion: the request + schema is not `.strict()`, so a bare deletion would have made Zod silently strip + whatever a generated client kept sending — a clean parse and a parameter that + never takes effect, which is this defect re-created one layer down (ADR-0104). + Writing the key is now a `tsc` error and a parse error carrying the + prescription. + + **The SDK is retired in the same stroke, and that is what makes the sentence + above true.** Retiring the key in the schema alone would have left the one + generated client this repo ships typing it `string` and sending it into a route + that no longer reads it — the exact ADR-0104 shape the tombstone exists to + prevent, re-created one layer down, for the channel most callers actually reach + this door through. So the option is gone from all three surfaces and no + `?cursor=` is appended on any of them; an untyped caller cannot smuggle it past + the retired schema either, which is pinned. Same call as when #6361 retired the + notifications `cursor`: the client dropped the option and recorded the removal + in its docblock. + + ``` + FROM client.automation.runs.list('f', { limit: 5, cursor: 'abc' }) + -> GET …/automation/f/runs?limit=5&cursor=abc // the key is dropped server-side + + TO client.automation.runs.list('f', { limit: 5 }) + -> GET …/automation/f/runs?limit=5 + // `{ cursor }` is now a TS2353 excess-property error; widen `limit` + // (1..100) and read `hasMore` instead. + ``` + + **⛔ `limit` is NOT retired, and its `.default(20)` stays.** The sibling + `/packages` door retired *its* `limit` alongside `cursor` (#17667) because + nothing read it. That does not transfer, and the ruling says so explicitly: here + `limit` is read end to end — the HTTP boundary enforces the declared `1..100` + range read off the schema itself, the service takes it as an option, and the + engine spends it as the run store's history window. Retiring it would have been + a regression, not a narrowing. + + **`hasMore` is now computed, and this is a behaviour change callers can see.** + The door shipped `{ runs, hasMore: false }` with the `false` written as a + literal, beside a list the engine had already cut with `.slice(0, limit)`. A + caller asking for one row of a thousand was handed one row and told that was all + of them. A request whose window is shorter than the matching run set now + receives `hasMore: true` where it previously received `false`; a caller that + read `false` as "this is the whole history" was always wrong and is now told so. + `nextCursor` stays absent — nothing mints one. + + Read the new `false` with **one qualification**: unfiltered it is exact, but + under `?status=` it means "no further match inside the window that was scanned" + rather than "none exists", because the durable history source has no status slot + and the window is taken before the filter is applied. Pushing the filter down is + a `RunStore` contract change this card did not scope. The published + `RunListResult.hasMore` docblock and the response schema's own description both + carry that qualification, so a consumer meets it where they meet the field. + + **How truncation is established, because the obvious signal is wrong.** + `runs.length === limit` cannot tell a flow holding exactly `limit` runs from one + holding ten thousand; the two windows are byte-identical. So + `AutomationEngine` over-reads its history source by exactly one row and compares + the merged, filtered, ordered set against the caller's window. + `RunStore.listHistory`'s signature is deliberately unchanged — over-reading is + expressible in the `limit` it already takes. + + **New:** `IAutomationService.listRunsPage`, an optional member returning + `{ runs, hasMore }` (the shape `IExportService.listExportJobs` already uses, + minus the cursor nothing mints), plus the exported `RunListResult`. The engine + implements it and `listRuns` is its `runs` half, so there is one implementation + and no second copy to rot. A deployment whose automation service does not + implement it answers `501` naming the member, never a `200` carrying a guessed + `hasMore`. + + **One strictness regression, stated because it reverses a recorded decision.** + `?cursor=a&cursor=b` used to answer `400 VALIDATION_FAILED` and now answers + `200` with the key ignored, like any other unrecognised query name. #7300 + validated the key rather than deciding it, so that a future cursor + implementation would not be the one to discover the type was unenforced; this + ruling decides it instead — there will be no cursor implementation on this + door — so the refusal would be validating a key the contract no longer has. + This route declares no closed query-parameter set, so an unrecognised name has + never been refused here on its own account. + + Clause-②: yes + + +- 3875ae6: feat!: retire the `GET /api/v1/automation` flow list in favour of `GET /api/v1/meta/flow`; `ListAiConversationsResponse` declares `hasMore` (#19543) + + **BREAKING** — two sibling list doors that declared paging nobody honoured. + + **The flow list is retired, with no alias and no transition window** (maintainer + ruling: 「退役,统一走 /meta/flow」). Its contract described a capability no build + ever delivered: the request declared `status`, `type`, `limit` (default 50) and + `cursor`, and the route read none of them; the response declared `FlowSummary` + rows with `total`, `nextCursor` and `hasMore`, and the route answered bare flow + names beside a literal `hasMore: false`. Measured before removal on the main branch + of this repository and cloud, and on objectui at its pinned commit and at main: + zero callers of the route or of `client.automation.list` outside their own tests, + while the Console flow-runs page and the Setup packaged-automation page already + read `GET /api/v1/meta/flow`. + + FROM → TO, per surface: + + - `GET /api/v1/automation` (and its environment-scoped twin) → no longer mounted + for `GET`. `POST /api/v1/automation` (create a flow) still lives at that path, so + on the default Hono host a `GET` there answers the host's standard method + mismatch — `405 METHOD_NOT_ALLOWED` with `Allow: POST` — the same answer any + POST-only path gets. A transport that forwards every automation path to the + dispatcher (the `@objectstack/hono` catch-all) is told the domain does not handle + it and answers its own not-found `404`. Fix: read `GET /api/v1/meta/flow`; + flows are metadata (ADR-0106), and it answers full definitions, so map each item + to its `name` if you only need names. Per-flow runtime enablement and trigger + binding is `GET /api/v1/automation/_status`, unchanged. + - `client.automation.list` (`@objectstack/client`) → removed; calling it is a + compile error. Fix: `client.meta.getItems('flow')`, or + `client.automation.getRuntimeStatus()` for the enabled/bound state. + - `ListFlowsRequestSchema`, `ListFlowsResponseSchema`, `FlowSummarySchema` and the + types `ListFlowsRequest`, `ListFlowsRequestParsed`, `ListFlowsResponse`, + `ListFlowsResponseParsed`, `FlowSummary` (`@objectstack/spec/api`) → removed, + no replacement export (TS2305 on import). Fix: delete the import; the flow + definition type is `Flow` from `@objectstack/spec/automation`. + - `AutomationApiContracts.listFlows` → removed; the map has eight entries, none of + them a `GET` at the bare path. Every other automation route is unchanged. + + **`ListAiConversationsResponseSchema` gains a required `hasMore`** (the spec half + of the same card; the server half is objectstack-ai/cloud#2426). The list is + declared **newest first** and pages by keyset: `cursor` is the `id` of the last + conversation the caller already holds, and `hasMore` says whether another page + follows. `hasMore` is required rather than optional so a server that does not + compute it is off-contract instead of silently spec-valid; no `nextCursor` is + declared, because the next cursor is the last conversation's id, already on the + page. Who notices: code that constructs a `ListAiConversationsResponse` must now + set `hasMore`, and a response parsed with the schema is refused without it. + `client.ai.conversations.list()` is unchanged — it still resolves to the + conversation array. + + Breaking ships as `minor` per the launch-window convention + (`scripts/check-changeset-no-major.mjs`). + + **Clause-②: yes (narrowing)** — the conversation list's response surface gains a + declared `hasMore`; a route, an SDK method, three published schemas with their five + types and a contract entry are removed, and a conversation-list response without + `hasMore` is now refused. + + +- b76aad5: fix(client)!: every `limit` query-parameter emitter sends what the caller wrote, so `{ limit: 0 }` is no longer silently swapped for the server's default window on three methods (#19567) + + Clause-②: no (narrowing) + + + + **BREAKING** — a narrowing, shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; the breaking-ness is carried by + this banner and the ADR-0087 disposition above, not by the level). A call that used + to answer `200` can now answer `400`. + + **What changed.** The SDK set the `limit` query parameter behind three different + guards, so one input got a different answer depending on the method. Seven methods + already sent every value except `undefined`. Three used a truthy test, so `0` and + `NaN` never left the client and the server answered `200` with its default window — + rows the caller did not ask for. Four used `!= null`, which dropped an untyped `null` + as the truthy three did, while the seven others sent it as the text `null`. All + fourteen emitters now leave only an absent (`undefined`) `limit` off the wire and send + everything else as written; the door that declares the bound decides. The SDK itself + still does not validate `limit`. + + | method | what changes on the wire | + |:--|:--| + | `automation.runs.list` | `0`, `NaN` and `null` are now sent; the door declares `1..100` and refuses all three with `400 VALIDATION_FAILED` | + | `notifications.list` | `0`, `NaN` and `null` are now sent; the inbox clamps `0` to one row (its declared clamp into `1..200`) and refuses `NaN` / `null` with `400` | + | `environments.listRevisions` | `0`, `NaN` and `null` are now sent to the control-plane door | + | `automation.listRuns`, `environment(id).automation.listRuns` | an untyped `null` is now sent and refused with `400` (`0` and `NaN` were already sent) | + | `data.listImportJobs`, `environment(id).data.listImportJobs` | an untyped `null` is now sent; the door reads it as its default of 50, so the answer does not move | + + `meta.getHistory`, `meta.getAudit`, `search`, `ai.conversations.list`, + `ai.pendingActions.list`, `data.export` and `environment(id).meta.getHistory` + already sent every value except `undefined`, and are unchanged. + + **If you relied on the old behaviour:** a call that passed `limit: 0` (or `null`) to + mean "the server's default window" should leave `limit` out instead. Every `limit` + here is typed `number | undefined`, so `null` only reaches these methods through an + untyped caller. +- 8d1f7ab: feat!: retire the saved-report stack — `sys_saved_report` / `sys_report_schedule`, `/api/v1/reports`, `client.reports`, `IReportService`, the `reports` capability and `@objectstack/plugin-reports` (#20102) + + **BREAKING** — the saved-report stack is removed whole, with no deprecation window + (maintainer ruling 2026-09-25, 「A. 退役」). It persisted a raw object query + (`object_name` + `{ filter, fields, orderBy, limit, groupBy }`) with a render format + and an owner, and could e-mail it on a schedule. Measured on the main branch of this + repository, objectui and cloud before removal: zero callers of the routes, the SDK + namespace or the service contract outside their own tests, and no app declaring the + capability. + + **NOT affected: the `report` metadata kind.** `ReportSchema`, `defineReport`, + `/meta/report`, datasets and the analytics service are unchanged. The two shared the + word "report" and nothing else. + + FROM → TO, per surface: + + - `requires: ['reports']` → **refused** by `defineStack` (`STACK_CAPABILITY_UNKNOWN`, + 422) with the prescription "requires: 'reports' was removed in @objectstack/spec + 17.5.0 … Delete the token." Fix: delete the token. `os serve` on an older artifact + that still carries it warns with the same prescription and ignores it; `os validate` + and `os build` over a plain-object config (no `defineStack` call, so no parse-time + vocabulary check) report it as a non-fatal capability advisory carrying the same + prescription, never "check for a typo". The token is + gone from `PLATFORM_CAPABILITY_TOKENS` and `PLATFORM_CAPABILITY_PROVIDERS`; the new + `RETIRED_PLATFORM_CAPABILITY_GUIDANCE` (`@objectstack/spec/kernel`) carries the + prescription. + - `IReportService`, `SavedReport`, `ReportSchedule`, `ReportQuery`, `ReportFormat`, + `ReportRunResult`, `SaveReportInput`, `ScheduleReportInput` + (`@objectstack/spec/contracts`) → removed, no replacement export. Fix: delete the + import. + - `SysSavedReport`, `SysReportSchedule` (`@objectstack/platform-objects/audit`) and + the names `sys_saved_report` / `sys_report_schedule` in + `PLATFORM_PROVIDED_OBJECT_NAMES` → removed. A stack referencing either name is now + flagged as a probable typo instead of resolving. + - `GET|POST /api/v1/reports`, `GET|DELETE /api/v1/reports/:id`, + `POST /api/v1/reports/:id/run`, `POST /api/v1/reports/:id/schedule`, + `GET /api/v1/reports/:id/schedules`, `DELETE /api/v1/reports/schedules/:scheduleId` + → unmounted: each answers the standard unmatched-route `404`, byte-identical to a + path that never existed. Their nine error codes (`REPORTS_LIST_FAILED`, + `REPORT_DELETE_FAILED`, `REPORT_GET_FAILED`, `REPORT_NOT_FOUND`, + `REPORT_RUN_FAILED`, `REPORT_SAVE_FAILED`, `REPORT_SCHEDULE_FAILED`, + `SCHEDULES_LIST_FAILED`, `SCHEDULE_DELETE_FAILED`) leave `ERROR_CODE_LEDGER` with + their only emitter. + - `client.reports.*` (`list`, `save`, `get`, `delete`, `run`, `schedule`, + `listSchedules`, `unschedule`) → removed. Fix: delete the call. A report is `report` + metadata, read through `meta.*` and queried through `analytics.*`; a saved ad-hoc + object query is a ListView on that object. + - `RestServer`'s constructor keeps the position of the retired saved-report provider, + typed `undefined`, so no later positional argument re-binds. Pass `undefined` there; + passing a provider is a compile error. + - `@objectstack/plugin-reports` → no longer built or published from this repository, + and `@objectstack/cli` no longer depends on it or mounts it. Fix: remove the + dependency. There is no successor package and no scheduled-delivery replacement. + + **Existing databases.** `sys_saved_report` / `sys_report_schedule` tables in a deployed + database are left in place, untouched — no backfill, no reaper, no drop — under the + repository's convention for a retired platform object: the platform never drops a + table that metadata stops declaring, and `os migrate plan` lists such a table in its + informational unmanaged-tables section so an operator can decide. + + `@objectstack/metadata-protocol` (patch): the `INVALID_SORT` hint for a sort node + spelled `{ field, direction }` no longer names the retired saved-report contract as + the source of that vocabulary; it names the better-auth adapter's `sortBy`, which + still uses it. Code and status are unchanged. + + Breaking ships as `minor` per the launch-window convention + (`scripts/check-changeset-no-major.mjs`). + + **Clause-②: yes (narrowing)** — a published capability token, a service contract and + its types, two platform objects, eight routes, nine registered error codes and an SDK + namespace are removed; nothing previously refused is now accepted. + + +- d61139f: feat(client): a bearer-mode `ObjectStackClient` keeps the session the server rotates it onto (#16534) + + Three better-auth routes ROTATE the caller's session on success — they mint a new session, install it in `Set-Cookie` (and, through `bearer()`, in the `set-auth-token` response header), and DELETE the row the caller was presenting: + + | route | where the new credential is | + | --- | --- | + | `auth.twoFactor.verifyTotp()` on the enrolment lane | body — `token`, and it is the LIVE one (plugin-auth's `two-factor-rotated-token-echo` repairs the vendor's stale echo) | + | `auth.changePassword({ revokeOtherSessions: true })` | body — `token` | + | `auth.twoFactor.disable()` | **response header only** — the body is `{ status: true }` | + + A browser is carried across all three by its own cookie. A bearer client — this SDK's own mode — kept presenting the DELETED session's token, so its very next call answered `401 UNAUTHORIZED`. Measured against a real `AuthManager` (better-auth 1.7.2) over a real driver, driven through the real `ObjectStackClient`, `login → enable → verifyTotp → disable → deleteUser` could not run to the end without the caller re-seating `client.token` by hand between the steps. + + The three methods now adopt the rotated credential themselves, the way `login()` already adopts the token it is handed. The `token` members stay on the wire and stay declared, so a caller that keeps its own credential store is unaffected; what changes is that it no longer has to. + + **No public surface moves.** No new export, no new option or flag, no new key on any declared request or response type — the SDK stores a token the server already sends and this package already declares. Graded `minor` rather than `patch` because the published runtime behaviour of three methods moves for existing callers. + + ## What does NOT change, deliberately + + The adoption is on those three routes only, never in the shared `fetch` wrapper. `set-auth-token` rides **every** response that stages a session cookie — `POST /update-user` stages one to carry the updated user without rotating anything — and it carries the SIGNED `.` spelling while every JSON `token` echo carries the UNSIGNED one. A wrapper-level read would therefore rewrite the stored credential into a different spelling of the SAME session on ordinary traffic. `auth.me()`, `auth.sessions.list()`, `auth.updateUser()` and `auth.twoFactor.verifyBackupCode()` (which does not rotate — the vendor echoes the session it resolved at entry) all leave the stored credential byte-identical, and that is pinned. + + A cookie-only deployment sends no `set-auth-token`; there is then nothing to adopt and `twoFactor.disable()` leaves the stored credential exactly as it was. `changePassword` without `revokeOtherSessions` answers `token: null` and likewise stores nothing. + + The three TSDoc warnings that told bearer callers "this SDK does not store it" are updated in the same change. +- 1c4270f: feat(client): `environments.delete` gains `purge` and documents the hosted control plane's two-step delete (#17636) + + The hosted control plane's `DELETE /api/v1/cloud/environments/:id` follows cloud ADR-0014: a live environment is **archived**, and only a second call with `?purge=1` on the now-archived environment tears it down. `?force=1` confirms a production environment and is never a purge. The SDK sent `force` only, so an SDK caller could archive an environment but never purge one. + + - `opts.purge?: boolean` sends `?purge=1`. It combines with `force`: a production environment is torn down with `{ force: true }`, then `{ force: true, purge: true }`. Calls that pass no options, or `force` alone, build exactly the URL they built before. + - The return type declares the two answers the route actually sends, discriminated by `deleted`: + - archive: `{ environmentId, deleted: false, archived: true, purgeDeferred, retentionDays, warnings, message }` + - teardown: `{ environmentId, deleted: true, purged: true, warnings }` + + Both members carry every key the old declaration named (`deleted`, `environmentId`, `warnings`), so existing reads still compile. + - The JSDoc no longer describes a one-call cascade delete: a live environment is archived, `purge` acts only on an archived environment, `force` is the production confirmation, and a `failed` environment is torn down in one call. + - `organizations.delete`'s JSDoc no longer claims that server-side hooks tear down the organization's environments. No hook does; delete each environment first. + + Graded `minor`: a purely additive widening of a published method's accepted options and declared answer (the "WHICH LEVEL" rule in `.github/workflows/pr-automation.yml`). Nothing is removed or renamed. +- f904e61: fix(client): `organizations.getActiveMember(organizationId)` answers the organisation the caller NAMES, not whichever one the session has active (#16568) + + **BREAKING** — the answer this published method gives moves for existing inputs. The signature, the declared return type and the export are byte-identical; what changes is the response an existing call observes, stated below as a before/after pair per input. + + The method built `GET /organization/get-active-member?organizationId=…`, and better-auth 1.7.2's handler for that path reads `session.session.activeOrganizationId` and never looks at `ctx.query`. The query string was dead on arrival: a client doing a permission check for organisation B while A was active got **A's** membership row back, with a 200 and no diagnostic — the wrong-but-plausible answer, silently. The SDK's own JSDoc promised "the calling user's membership row in the given organisation", so this was a declared capability the runtime did not deliver. + + It now asks the question honestly, in two requests: + + 1. `GET /get-session` — the caller's own user id; + 2. `GET /organization/list-members?organizationId=…&filterField=userId&filterValue=&limit=1` — the row, unwrapped from the one-entry page. + + `list-members` reads `ctx.query.organizationId`, and its rows carry the identical shape (`OrganizationMemberWithUserWire`, user projection included), so the signature and the declared return type are unchanged and no caller's types move. + + ## What an existing call observes, before and after + + Everything here is measured against a real `AuthManager` (better-auth 1.7.2, organization plugin) over a real `SqlDriver`. Each bullet is one input, with the response it drew before and the response it draws now. + + - **An organisation id other than the session's active one.** Before: a 200 carrying the **active** organisation's membership row, whatever id was named. After: a 200 carrying the **named** organisation's row. An input that named the active organisation's own id drew that organisation's row before and draws the same row after — `auth.me()` is where that id is readable, on `session.activeOrganizationId`. + - **An organisation the caller is not a member of.** Before: the named organisation was never consulted, so the answer was about the **active** one — a 200 carrying the active organisation's row, or `400 MEMBER_NOT_FOUND` when the caller had no row there either. After: `403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION`, the server's own refusal, about the organisation that was actually named. + - **Any id, on a session with no active organisation.** Before: `400 NO_ACTIVE_ORGANIZATION`. After: a 200 carrying the caller's row in the named organisation. `setActive` has stopped being a precondition, which is the point of naming the organisation. + - **An empty `organizationId`.** Before: a 200 carrying the **active** organisation's row — better-auth resolves `ctx.query.organizationId || session.activeOrganizationId`, so an empty string fell through to session state and the wrong-but-plausible answer survived on that one input. After: the SDK refuses it before the wire, with a thrown `[ObjectStack] organizations.getActiveMember: organizationId is required`. + + Two things do not move: an anonymous caller still draws `401 UNAUTHORIZED`, thrown by the same session middleware that guarded the old route; and the row's shape is the same on both sides. The method now makes two HTTP requests where it made one. + + Graded `minor` rather than `patch`: the method's published behaviour moves for existing callers, which is the same clause-② judgement this PR declares, and the maintainer's ruling of 2026-09-04 (decision batch #35) holds that a change to a published package's public surface takes at least `minor` — a commit type may raise a bump, never lower it below what the act requires. The banner above carries the breaking-ness that the level cannot, per the ruling recorded on #16568 on 2026-09-08. + + The auth route ledger's `GET /api/v1/auth/organization/get-active-member` row is rebooked from `sdk` to `server-only` in the same change: `sdk` means "expressed by the SDK", and no SDK method builds that URL any more. The `get-session` and `list-members` rows gain the method in their notes, since it now builds both. Ledger-internal, nothing published moves with it. + + +- fb6a2de: fix(client): `packages.get` binds the bare `InstalledPackage` row on both the global and the environment-scoped client, replacing a `{ package }` envelope no surface emits (#12034) + + `client.packages.get(id)` and `ScopedEnvironmentClient.packages.get(id)` now resolve to **`InstalledPackage`** — the row itself — instead of an object wrapping it. + + **Migration — read the row directly, not `.package`:** + + ```ts + // before + const { package: pkg } = await client.packages.get('com.acme.crm'); + const pkg2 = (await scoped.packages.get('com.acme.crm')).package; + + // after + const pkg = await client.packages.get('com.acme.crm'); + const pkg2 = await scoped.packages.get('com.acme.crm'); + ``` + + FROM `{ package: any }` (global) and `{ package: InstalledPackage }` (scoped) TO `InstalledPackage` on both. + + This is a **narrowing**: a `.package` read compiles today and stops compiling after this change. That is the point of the change rather than a side effect of it — the wrapper was never what the wire sent, so every one of those reads was already `undefined` at runtime, and on the global method the `any` member is what kept the falsehood invisible. Nothing about the request or the wire changes; only the declaration moves to match what the server has been sending. + + Why it can be bound now, when #11925 deliberately left it erased: this route used to be served by two implementations that disagreed — the runtime dispatcher sent the bare row, the `@objectstack/rest` registrar sent `{ package }` — so no declaration was true on both. The registrar's read routes were removed in #16628, leaving the dispatcher's `/packages` domain as the single implementation. It builds the detail body with the same expression it maps over every `list` row, which is why this type now agrees with the `InstalledPackage[]` that `packages.list` has already declared, and with `GetInstalledPackageResponseSchema` in `@objectstack/spec`, which has declared `data: InstalledPackageSchema` all along. + + The environment-scoped method is the sharper half of the change: its member was a real `InstalledPackage`, not `any`, so `.package` reads there looked type-safe while returning `undefined` against every surface that has served that path since #16628. +- bccf311: fix(client): `oauth.applications.register` declares only the members `/oauth2/create-client` accepts — `name`, `scopes` and `metadata` are removed (#15447) + + **BREAKING** — three members leave a published request type. A caller who sets one compiles today and gets a type error after this release. That is the point: the route never honoured any of them, so what the compiler now refuses is code that was already having its value thrown away. + + ## What a caller passing these members should do instead + + | you were passing | pass instead | why | + |---|---|---| + | `name: 'My App'` | `client_name: 'My App'` | same `string`, and `client_name` is the member the route reads | + | `scopes: ['openid', 'profile']` | `scope: ['openid', 'profile'].join(' ')` | ⚠️ **not** a rename — `scope` is one space-delimited string; posting an array is refused with `400 [body.scope] Invalid input: expected string, received array` | + | `metadata: { tenant: 'acme' }` | nothing — delete the member | no door this SDK can reach accepts it (see below) | + + ## ⚠️ These were the vendor's RECORD vocabulary, not typos + + `client_name` writes the DB column literally named **`name`**; `scope` writes the DB column literally named **`scopes`**, as a JSON array. The removed members were the *column* names offered next to the *wire* names in the same declared type — an author picking the adjacent one of two got a success receipt and no value. Treating them as misspellings would be the wrong reading of what they were; the prescription above is still the wire member either way. + + ## Why they had to go rather than be honoured here + + `POST /api/v1/auth/oauth2/create-client` is mounted verbatim from `@better-auth/oauth-provider@1.7.2`. Its body schema declares 21 members and sets no `catchall`, so it is zod's default **strip**: an unknown key is dropped, not refused, and the caller gets **HTTP 201 and a client that quietly does not have the value**. Driven end to end against a real `betterAuth` + `oauthProvider` over a real ObjectQL engine on a real socket, through this client: each of the three came back absent from the response, absent from `oauth.applications.get`, absent from `oauth.applications.list`, and `null` in the `sys_oauth_application` row. + + A second, independent barrier stands behind that strip — the handler funnels the parsed remainder into the opaque-metadata envelope, and all three names sit in `OPAQUE_METADATA_RESERVED_FIELDS` — so no amount of loosening on the SDK side could ever have made them arrive. `metadata` in particular is honoured only by `PATCH /admin/oauth2/update-client`, which is `SERVER_ONLY` and therefore not an HTTP route at all: over the wire it answers 404 with a zero-byte body. + + Nothing else on the method moves. The two members the route does honour, `client_name` and `scope`, are declared exactly as before and still reach the server byte for byte; the method's return type, its URL and its request-building step are unchanged. + + Graded `minor` rather than `patch` because a published package's public surface moves, per the maintainer's ruling of 2026-09-04 (decision batch #35) that such a change takes at least `minor`; the banner above carries the breaking-ness the level cannot. + + +- 9bd4344: feat(auth)!: adopt better-auth's account-issuer rollback — drop `sys_account.issuer`, retire the backfill, lift the `@better-auth/*` family to an exact `1.7.3` (#17440) + + + + **BREAKING** — a platform object drops a declared field and `@objectstack/plugin-auth` + drops six published symbols. Shipped as `minor` under the launch-window convention + (`major` is refused by `check-changeset-no-major`; breaking-ness is carried by this + banner plus the ADR-0087 disposition above). The hand-migration prescription is + registered under protocol major 18 as `sys-account-issuer-retired`. + + better-auth `1.7.3` removed the issuer-scoped account identity outright + (`better-auth/better-auth#10909`): `createLocalAccountIssuer` is deleted, + `accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the + `account.issuer` column and its unique index are gone from `get-tables`. There is no + drop-in replacement. `#16186` pinned the family at an exact `1.7.2` as a stopgap; this is + the durable half, per the maintainer ruling of 2026-09-10 on `#16629`. + + ## 迁移:FROM → TO + + | FROM | TO | the one-line fix | + |:--|:--|:--| + | `sys_account.issuer` (column + `{ fields: ['issuer','account_id'], unique: true }`) | — | nothing replaces it; identity is `(provider_id, account_id)`, declared UNIQUE on `sys_account` since the object was created | + | reading `account.issuer` off a row or off `client.accounts.list()` | `sys_sso_provider.issuer`, resolved through the account's `provider_id` | `provider_id` is unique per environment, so it names the authority on its own | + | `backfillAccountIssuer(ql, …)` | — | delete the call; there is no successor pass | + | `CREDENTIAL_ISSUER` / `oauthIssuerFor(id)` | — | drop the argument; `internalAdapter.createAccount({ userId, providerId, accountId, password })` takes no `issuer` | + | `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult` | — | delete the import; the compiler names every site | + | `@better-auth/*` at an exact `1.7.2` (eleven members) | an exact `1.7.3` (eleven members) | the family moves as ONE line — `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions | + + ## ⭐ Existing deployments: run the pre-flight BEFORE the column is dropped + + Uniqueness moves from `(issuer, account_id)` to `(provider_id, account_id)` — a + **narrower** key. Two rows sharing `provider_id` + `account_id` and differing only in + `issuer` are legal under the old key and are ONE account under the new one. + + ``` + os migrate account-issuer # read-only; exits non-zero when the drop must not proceed + # … take a backup (the operator's act, and the apply step's precondition) … + os migrate apply --allow-destructive + os migrate account-issuer # post-check: reads zero + ``` + + The pre-flight reads **rows**, never the index declaration. `syncDeclaredIndexes` logs a + plain UNIQUE whose CREATE failed on existing duplicates onto the durability channel and + lets the boot continue (`#14902` / `#15479`), so a database can carry the declaration + without the constraint — and on such a database the drop does not fail loudly, it + degrades silently: the rows become indistinguishable and a sign-in can resolve onto the + wrong user's account. `os migrate apply --allow-destructive` re-runs the same pre-flight + and refuses the drop before writing any DDL. A read that throws, or a scan that + truncates, refuses too — an unread table is not a clean one. + + ⛔ Colliding rows are never merged or dropped for you: which row survives is application + knowledge, and two different people can be behind one colliding key. Keep the row whose + provider account is live, delete the rest so a fresh sign-in re-links, and re-run. + + The boot refusal is unchanged and needs no new machinery: a runtime already refuses to + start against unapplied destructive drift, naming the command to run, and never + auto-migrates. + + ## ⚠️ A `provider_id` re-pointed at a different IdP must have its bindings REBUILT + + This is the one case `issuer` still discriminated. After the drop no column records which + IdP vouched for a row, so if a re-pointed provider's new IdP mints a subject the old one + had already issued to somebody else, the key resolves that sign-in onto the other + person's account. Under the old key that failed loudly (`unable_to_link_account`); under + the new one it is silent. + + ⇒ `sys_sso_provider` now **refuses an `issuer` change while `sys_account` rows are still + bound to that `provider_id`** (`RESOURCE_CONFLICT` / 409). Delete the provider's account + bindings first; each user re-links on their next sign-in. + + ## Why the column was a liability, not an asset + + A credential row whose `issuer` was not the local credential issuer was invisible to + `findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not + found" warn pointing at the `sys_user` row rather than at the account. **Four checklist + items had that recorded as a knownGap, each rediscovering it.** Its discriminating power + here was near zero anyway: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: + true }`, so `provider_id → issuer` is a function within an environment. + + ## Also in this change + + `pnpm check:vendor-export-contract` (from `#16186`) keeps its exactness requirement and + still resolves every named symbol — its self-test re-anchors from the now-retired + `@better-auth/core/db` specimen onto a live edge, and gains a case asserting the two + deleted names are imported nowhere. `#11627`'s hash-shadow-key machinery is untouched: it + is a generic driver capability serving five UNIQUE members of the >768-char class. + +### Patch Changes + +- 3c86008: `client.ai`'s docblock says the AI slot answers 501, names the 401-first and `GET /ai/agents` arms, and stops promising a 404 + + The `ai` namespace docblock described the pre-`capabilityUnavailable` + behaviour: that this repo's dispatcher *"404s `AI service is not configured` + when the service is absent (the open-source default)"*. The dispatcher has + answered **501** since the shared exit landed. `/ai/*` is registered + **unconditionally** (`createAiDomain`, plus the host wildcard across four + methods in every branch of the scoping conditional), so a request reaches a + handler with nothing behind it — which is 501 Not Implemented, not 404. + `packages/runtime/src/domains/unavailable.ts` exists to draw exactly that line: + 404 means *the route is not there*, and for `/ai/*` that is false. + + **Why the replacement is narrower than "`/ai/*` answers 501".** That sentence + is not true either, and a caller branching on status needs both exceptions. + Verified against the unserveable-slot branch in + `packages/runtime/src/domains/ai.ts`, in its own evaluation order: + + ``` + FROM any /ai/* with no AI service -> 404 `AI service is not configured` + + TO anonymous caller -> 401 (ANONYMOUS_DENY_STATUS; the 501 and + the courtesy below are capability + disclosures, owed to nobody who has + not authenticated) + GET /ai/agents -> 200 { agents: [] } under the envelope's + `data` — a console polls it on every + navigation to decide whether to show + AI affordances + every other /ai/* route -> 501 serviceUnavailableMessage('ai') + ``` + + All three arms are already test-pinned in + `domains/ai-anonymous-deny-ordering.test.ts` — this changeset moves no + behaviour, only the sentence describing it. + + **The `GET /ai/agents` courtesy was mentioned nowhere in this docblock**, which + is the one an SDK reader actually opens, so it is added rather than merely + corrected. Also stated now: the 501 body is not a local string — it comes from + the shared `serviceUnavailableMessage`, the same sentence + `discovery.services.ai` reports for the slot, so the two cannot drift into + naming different remedies. + + ⛔ No behaviour changes. This is a docblock; no export, authorable key, accept + set or response byte moves. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/client`'s published `files[]` ships `dist`, + and this TSDoc is emitted into all four built artifacts — `dist/index.d.ts`, + `dist/index.d.mts`, `dist/index.js` and `dist/index.mjs` — measured on the + built tree, with the stale `AI service is not configured` sentence absent from + every built file afterwards and the docblock's own neighbouring sentence + present as the lit control. The declarations are what a consumer's editor shows + on hover and what an upgrading agent greps, and they change. + + The two sibling corrections in the same change do **not** publish and are not + named here: `packages/runtime/src/route-ledger.ts` is a CI-audit ledger that is + not exported from the runtime entry (`ROUTE_LEDGER` is absent from + `packages/runtime/dist` entirely), and the `domains/ai.ts` implementation + comment is not emitted — three pre-existing comments from that same file were + probed as controls and none appears in the built output. +- 7baf04a: `oauth.applications.register`'s docblock says where a plain `name` IS honoured, and that it is not this route + + A caller who wants to name an OAuth client reaches for `name`. On the route this + method posts — the provider's `/oauth2/create-client` — that member is not in + the body schema and is stripped: driven on a real socket, the call answered + **201** and the value was absent from the response, from `applications.get`, + from `applications.list`, and `null` in the `sys_oauth_application` row's `name` + column. Nothing in the answer says so. + + The spelling is not wrong everywhere, which is what made it worth writing down: + `POST /api/v1/auth/sys-oauth-application/register` — the session-required + ObjectStack mount behind the Console's *Setup → OAuth Applications* form — + answered **200** to the same body, mapped `name` onto `client_name`, and set + that column. That mount is `disposition: 'server-only'` in the auth route ledger + and objectstack#17210 ruled it stays that way, so no SDK method builds its URL. + + The docblock now states both halves where the caller reads them: post + `client_name` to name a client from here, and `redirect_uris` must arrive + pre-split — the newline-separated-textarea split is the Console wrapper's, not + this route's. + + Docblock only. No method is added, no request or response type changes, and the + ledger row is untouched — but the text ships inside `dist/*.d.ts` as editor + hover, so it is a `patch` rather than a no-publish change. +- 400167a: `environments.update`'s JSDoc no longer advertises writes the control plane refuses, and `environments.updateVisibility` carries a current-state note. + + The `update` comment listed `display_name, plan, status, is_default, metadata` as the updatable set, and the namespace route table listed `plan` and `status` too. `plan` and `status` are read-only columns on the control plane; the generic `PATCH /api/v1/cloud/environments/:id` route answers an unknown or read-only key with a **400** rather than dropping it, so the comment was actively teaching a call that fails. The same prose implied `visibility` was writable while it is server-owned. + + Three prose sites move, all in `packages/client/src/index.ts`: + + - **The namespace route-table docblock.** The PATCH accept-set now reads `display_name, is_default, metadata`, with the redirects stated: plan changes go through the billing routes, status changes through the lifecycle actions (archive / restore / suspend / resume), and `visibility` is server-owned. + - **`update`'s JSDoc.** The same accept-set with per-field detail, and — the sentence that matters most to a caller — that an unknown or read-only key is answered with a 400 and is **not** dropped silently. Silent-drop is the assumption a caller reasonably makes today, and it is the wrong one. + - **`updateVisibility`'s JSDoc.** A note that the call is refused today, so the paragraph describing what `public` does describes a capability that does not exist yet. The 2026-09-12 maintainer ruling keeps `visibility` server-owned and forced to `private` until the public-listing feature ships, at which point it gets its own endpoint rather than this generic update. + + ⛔ **No signature, type or runtime byte moves.** `patch` stays `Record` and `updateVisibility`'s signature and body are byte-for-byte unchanged (verified by hash, before and after). Narrowing a published accept-set is as much a breaking change as widening one, and retiring, re-signing or throwing from a published SDK method is a maintainer ruling — neither is a doc fix's to make. What reaches consumers is the hover text in `dist/index.d.ts`. + + ⚠️ The 400 is an **inherited reading**, not one measured from this repo: `/api/v1/cloud/*` is served by `objectstack-ai/cloud`, which is not readable from here, so no gate here can check it. The comments say so at the point of the claim rather than leaving a later reader to try. +- 156792e: The package-install request contract now names the door that actually serves it, declares the two body forms that door accepts, and the door honours `enableOnInstall` instead of ignoring it (#18058). + + `PackageInstallRequestSchema` was declared, published and bound to `POST /api/v1/packages/install` — a path the composed runtime mounts nowhere: the dispatcher answers `handled=false` and `@objectstack/rest`'s registrar mounts only `POST /api/v1/packages/publish`. Meanwhile `POST /api/v1/packages`, the door that answers `201`, had no declared request contract at all, so the read contract was strictly more truthful than the write contract producing the rows it describes. + + Clause-②: yes (widening) + + **What moved on the published surface** + + - `PackageApiContracts.installPackage.path` — `'/api/v1/packages/install'` → `'/api/v1/packages'`. A caller that read the constant to build a URL was building one nothing serves; a caller that hard-coded the old string gets a `404` today and should send `POST /api/v1/packages`. The method (`POST`) is unchanged and is what distinguishes this entry from `listPackages`. + - `PackageApiContracts.installPackage.input` — `PackageInstallRequestSchema` → the new `PackageInstallBodySchema`. The wrapped schema is still exported and still parses the wrapped form; the new export is a union that also parses a bare manifest. + - `PackageInstallRequestSchema` gains **`overwrite?: boolean`**. This is a declaration of behaviour that already shipped: the door reads `overwrite` from the body (or `?overwrite=true`) to opt back in to replacing an already-installed id instead of answering `409 Conflict`, the first-party SDK sends it, and no schema declared it — so any parse at that door would have silently stripped it and turned a deliberate re-install into a conflict. + - **`PackageInstallBodySchema`** / `PackageInstallBody` / `PackageInstallBodyParsed` are new. The door reads `body.manifest || body`, and first-party callers really do post a bare manifest as the whole body, so the contract declares both forms as a union — every parse is a full parse of one coherent form, never a tolerant shape. The two branches are disjoint, but only the BARE one is CLOSED: `PackageInstallRequestSchema` is a plain `z.object`, so an unknown key on the wrapped form is DROPPED (`{ manifest, bogus: 1 }` parses and `bogus` is gone) while the same key on a bare manifest is refused by name. That asymmetry matches the door, which reads four keys off the wrapper and ignores the rest — closing the wrapped branch would refuse bodies the door answers `201` to. The bare form carries no install options: `settings`, `enableOnInstall` and `overwrite` are not manifest keys and the manifest surface is closed, so a bare-form caller reaches `overwrite` through the query string alone. + + **What moved at the runtime** + + `POST /api/v1/packages` now honours `enableOnInstall: false` in the wrapped body: the package installs `disabled`, through the same registry flip and durable state write `PATCH /packages/:id/disable` uses, so a restart does not re-enable what the caller switched off. `true` and absent install enabled, which is the declared default. Previously the key was declared in three schemas, sent by the SDK, and read by no handler at all. + + The durable write happens on **both** arms, not just the disable. `POST /packages` is a create that an already-installed id reaches through `overwrite`, and `DELETE /packages/:id` does not clear this record either, so an install could answer `201` with `enabled: true` while the state file still listed the id as disabled — and `SchemaRegistry.installPackage` reads that file at boot, re-installing the package DISABLED one restart later with nothing red in between. The mirror of that risk is why the write follows the ROW this door returned rather than the request's intent: `SchemaRegistry.installPackage` lands an id in the boot-seeded `initialDisabledPackageIds` DISABLED whatever the request says, and `enableOnInstall` defaults to `true`, so persisting the request would clear an operator's earlier disable off disk on the SDK's default call while the row being served says `enabled: false`. A flag-absent install of a seeded id therefore answers `enabled: false` and records it disabled — wire, registry and disk agree, and the next boot reads the same. Every install now persists the state it returned. + + **What the declaration does NOT cover — the measured residual** + + This is a subset description of the live door, deliberately, and it is recorded rather than implied. Measured through `HttpDispatcher.handlePackages`, the door also answers `201` to: a manifest missing `type` and/or `version` (both of the runtime's own door drives post one); unknown keys on either form (refused by name on the bare branch, dropped on the wrapped one, `201` either way); a string-typed `enableOnInstall` / `overwrite`, which is compared against `true`/`false`/`'true'` and therefore treated as absent — `enableOnInstall: 'false'` installs ENABLED; and install options spelled on the bare form, which are ignored. In the opposite direction the door answers `400` to a whitespace-only `id` this declaration admits. `ManifestSchema` is not relaxed to close any of that. + + **Documentation** + + `packages/client`'s README install example could not parse against the manifest contract — no `id`, no `type`, and a `label` key the closed manifest surface refuses by name — and the live door answered it `400 Package id is required`. It is now a manifest that parses, and the example names the `overwrite` opt-in beside it. +- d6137fd: `auth.me()` and the `/auth/*` wire table say what `/get-session` answers an anonymous caller TODAY: `401 UNAUTHENTICATED`, not `200 null` + + objectstack#17881 (`374d9d3afa`) landed `plugin-auth`'s + `refuseAnonymousSession`, which converts better-auth's `200` + the literal JSON + `null` on `GET /api/v1/auth/get-session` into the declared ADR-0112 refusal + envelope — HTTP `401`, `code: UNAUTHENTICATED` — before it leaves the process. + `@objectstack/client` reaches the server over the wire, so that is exactly what + it sees. Three present-tense statements in the SDK still described the retired + shape, none of them carrying a rev or a date, so none of them read as history. + + **FROM → TO for a caller.** An anonymous `auth.me()` no longer RESOLVES with + the literal `null`; it REJECTS. The SDK's shared `fetch` wrapper throws on the + non-2xx, so: + + | you wrote | write instead | + |:--|:--| + | `const s = await client.auth.me(); if (s === null) …` | `try { await client.auth.me() } catch (e) { if (e.code === 'UNAUTHENTICATED') … }` | + + That is the behaviour objectstack#17881 shipped; what moves here is only the + SDK's description of it. A reader coding against the old table wrote a `null` + branch that can never be taken and omitted the rejection branch that now fires. + + **What changed** + + - `normalizeSessionResponse`'s `/auth/*` transcript no longer lists the + anonymous `200 null` row among the bodies that helper is handed — it is not + handed that body at all, because the rejection happens one frame out. The + current answer is stated separately, anchored to the producer. + - The closing `!body`-guard paragraph no longer claims that guard carries the + anonymous answer, and no longer says closing the gap needs the published + return annotation to widen. objectstack#17238 ruled the opposite: the + producer moved and `SessionResponseSchema` is untouched. + - `auth.me()`'s docblock says the anonymous call rejects rather than resolving + outside its declared type. + + ⛔ No behaviour changes. `SessionResponseSchema`, every published return + annotation and the `!body` guard's own code are byte-identical; only what the + SDK says about them moves. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/client`'s published `files[]` is + `["dist","README.md","CHANGELOG.md"]`, and `auth.me()` is a member of the + exported `ObjectStackClient`, so its TSDoc is emitted into the shipped + declarations — measured on the built artifact: the corrected sentence is + present in `dist/index.d.ts`, `dist/index.d.mts`, `dist/index.js` and + `dist/index.mjs`, the retired sentence is absent from `dist` afterwards, and + `getActiveMember` was carried as the lit control, found in the same four files. + + Clause-②: no — no schema key moves, no accept set widens or narrows, no export + changes, and `ERROR_CODE_LEDGER` / `StandardErrorCode` are untouched + (`UNAUTHENTICATED` is an existing standard member that objectstack#17881 + already derives via `standardErrorCodeForHttpStatus`). The direction is a + pull-back: the runtime already answers 401 and the SDK's self-description was + lagging. +- be7aeb8: fix(client): `normalizeSessionResponse`'s JSDoc records the `data.user.image` gap as closed, not as tracked (#18510) + + Clause-②: no + + No declaration, accept set, export or runtime behaviour moves. What moves is one + sentence of developer commentary and the pin that now holds it honest. + + The block above `normalizeSessionResponse` says what the lift compensates for, + so it names the cards that opened and closed each compensation. One clause was + still in the present tense: + + > … with one gap that is NOT this: `data.user.image` served `null` against a + > declared `string | undefined` (#17235, tracked separately). + + Both halves went false when `SessionUserSchema.image` widened to + `z.string().nullish()` and #17235 closed — the sentence described a live gap + that no longer existed and pointed the next reader at a closed card as somewhere + to go look. It now reads in the past tense, naming the widening that closed it + and the residue list in `auth-login-register-envelope.test.ts` that is pinned + empty. Nothing else in the block moves. + + **Why this is a `patch` and not `skip-changeset`, measured rather than + assumed.** "Only comments changed" is not "nothing published moves", and on this + package the two answers differ. `@objectstack/client` ships `dist`, `README.md` + and `CHANGELOG.md`; `dist` is six files (`index.js`, `index.mjs`, `index.d.ts`, + `index.d.mts` and a `.map` beside each of the two bundles — this package emits no + `*.cjs` and no `*.d.cts`). Built from the same tree before and after the change: + + - the comment text reaches **none** of the six (`no longer residue`, + `data.user.image` and `tracked separately` each 0 hits), while the positive + controls land — `{@link normalizeSessionResponse}` appears 3× in each bundle + and 3× in each `.d.ts`, carried there by the JSDoc of the **exported** + `auth.login` / `auth.register` / `auth.me` that link to it, and `set-auth-token` + 4× / 3×. So comment text from this file does reach the published types; this + block's own text does not, because the function it documents is not exported; + - `index.js`, `index.mjs`, `index.d.ts` and `index.d.mts` are **byte-identical** + across the change (sha256, same build, reproducibility control re-run); + - both `.map` files **differ**. Neither carries `sourcesContent`, so no comment + text ships inside them either — the position table shifts because the rewritten + comment is two lines longer than the one it replaced. + + So the published tarball's bytes do move, and a released package whose shipped + bytes move takes a changeset. +- c23cfb3: fix(client): take `InstalledPackageAtEitherStage` from `@objectstack/spec/api-assembled` (#18576) + + `@objectstack/spec` moved the declarations that embed the assembled package body — `InstalledPackageAtEitherStage` among them — off `@objectstack/spec/api` into the new `@objectstack/spec/api-assembled` entry. The client's `packages.list` / `packages.get` return types (and their scoped twins) name that type, so the published declarations now import it from the new entry. The return types are the same type as before; nothing a caller writes changes. It is a type-only import, erased from the client's bundle. +- be7763a: docs(client): the published README's `packages.install` example is a manifest `ManifestSchema` actually accepts (#18607) + + The example shipped in the `@objectstack/client` npm tarball was refused on three + counts when parsed against the contract its own call site declares + (`PackageInstallRequestSchema`, whose `manifest` key is `ManifestSchema`): + `invalid_type` at `[manifest, id]`, `invalid_value` at `[manifest, type]` — both + required and absent — and `unrecognized_keys` at `[manifest]` for a `label` key + that `ManifestSchema`'s `strictObject` close refuses by name. + + ```diff + await client.packages.install({ + - name: 'vendor_plugin', + - label: 'Vendor Plugin', + + id: 'com.vendor.plugin', + + type: 'plugin', + + name: 'Vendor Plugin', + version: '1.0.0', + }); + ``` + + `label` is not a root manifest key and never was: the root shape declares `name` + for the human-readable string (measured — `ManifestSchema` declares 25 root keys + and `label` is not among them), so the example's `label` value moves to `name` + and the machine identifier becomes the reverse-domain `id` the key documents. + `type: 'plugin'` is the enum member the example's own subject names — a + general-purpose functionality extension, not the consumer-installable `app` + bundle. Required root keys, read off the schema rather than the prose: `id`, + `name`, `type`, `version`. + + Nothing parses that contract at the install door today, so the example "worked" + by being posted unvalidated — which is what made it a timed charge rather than a + live outage: closing the door turns a silently-wrong published example into a + loudly-broken one for every reader who copied it. + + Pinned in `packages/client/src/readme-package-install-example.test.ts`, which + parses every `packages.install` manifest literal in this README against that + schema and fails if the corpus is ever empty. + + Clause-②: no + + No schema, export, type or runtime behaviour changes. It ships because the README + is listed in this package's `files[]` and is the first thing a new integrator + copies. +- 55523fd: `organizations.getActiveMember`'s own prose says what an anonymous caller gets TODAY: `401 UNAUTHENTICATED` on request ONE — not `200 null` and then a `401 UNAUTHORIZED` from `list-members` + + objectstack#17881 (`374d9d3afa`) landed `plugin-auth`'s + `refuseAnonymousSession`, which converts better-auth's `200` + the literal JSON + `null` on `GET /api/v1/auth/get-session` into the declared ADR-0112 refusal + envelope — HTTP `401`, `code: UNAUTHENTICATED` — before it leaves the process. + `@objectstack/client` reaches the server over the wire, so that is what it + sees. Three present-tense statements in and around `getActiveMember` still + described the retired shape, and they were wrong on two axes at once: the CODE + (`UNAUTHORIZED` vs `UNAUTHENTICATED`) and the REQUEST the refusal arrives on + (the second one, `list-members`, vs the first, `/get-session` itself). + + **FROM → TO for a caller.** `getActiveMember` makes two requests for a + signed-in caller. For an anonymous one it now makes ONE, and rejects: + + | you wrote | write instead | + |:--|:--| + | `try { await c.organizations.getActiveMember(id) } catch (e) { if (e.code === 'UNAUTHORIZED') … }` | `… catch (e) { if (e.code === 'UNAUTHENTICATED') … }` | + + The behaviour is objectstack#17881's and shipped then; what moves here is only + the SDK's description of it. A reader coding against the old prose caught the + wrong code, and expected the refusal on a request that is never put on the + wire. + + **What changed** + + - Step 1 of the two-request list no longer says `/get-session` serves "the + literal `null` for an anonymous one". The signed-in arm keeps its + `(measured)` tag, which is still the 2026-09-09 drive's; the anonymous + answer is stated separately and anchored to the producer, including that + step 2 never reaches the wire. + - The anonymous bullet of that drive's delta list no longer says an anonymous + caller "still gets `401 UNAUTHORIZED`, thrown from the `list-members` + request". It is RE-ANCHORED rather than restamped — the drive's own row is + kept in the past tense and today's answer is stated from the producer, the + same disposition objectstack#18642 used on this family's sibling statements. + - The inline comment on the `userId` read no longer says `Anonymous → null`. + It says an anonymous caller never reaches that line, and says why the + `| null` annotation and the `?? ''` fallback stay as the defensive branch + they always were. + + ⛔ No behaviour changes. `packages/client/src/index.ts` changes COMMENTS ONLY — + verified mechanically: of every line the diff touches in that file, zero are + outside a comment. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/client`'s published `files[]` is + `["dist","README.md","CHANGELOG.md"]` and `getActiveMember` is a member of the + exported `ObjectStackClient`, so its TSDoc is emitted into the shipped + artifacts. Measured on the built `dist` at `13e09a5e3c`: the corrected sentence + is present exactly once in `dist/index.d.ts`, `dist/index.d.mts`, + `dist/index.js` and `dist/index.mjs`; the retired sentence is absent from all + four; and `getActiveMember` was carried as the lit control, found in every one + of them. ⚠️ This package emits no `.d.cts` and no `.cjs` — its CJS pair is + `index.js` + `index.d.ts` and its ESM pair is `index.mjs` + `index.d.mts`, so + a `*.d.cts` check here would have measured an absent file. + + Clause-②: no — no schema key moves, no closed set gains or loses a member, no + published export changes and no registry row is touched. `UNAUTHENTICATED` is + an existing `StandardErrorCode` that objectstack#17881 already derives through + `standardErrorCodeForHttpStatus(401)`; nothing is minted here. The direction is + a pull-back: the runtime has answered `401` since objectstack#17881 and the + SDK's self-description was lagging. +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- 6cc8dcd: `RecordStagePackageBodySchema`, `AssembledInstalledPackageSchema` and `ObjectStackClient.packages.list` now say, in their published docblocks, that `manifest`'s static type is deliberately an index signature and that the runtime schema is the enforced contract (#19324) + + Clause-②: no + + `AssembledInstalledPackage['manifest']` is `RecordStagePackageBodySchema`, declared `z.ZodType, Record>`. So its published type is an index signature: an authoring-stage `InstalledPackage` assigns to `AssembledInstalledPackage`, and a row whose `manifest` belongs to neither stage type-checks as an `InstalledPackageAtEitherStage`. The maintainer ruled that this is the accepted static contract (#19324, letter 丙). The three declarations now say so where a TypeScript reader meets them: + + - **The runtime schema is the enforced contract.** `InstalledPackageAtEitherStageSchema.safeParse()` refuses a `manifest` that belongs to neither stage. Tell the two stages apart by parsing, never by the static type. + - **Why the type is not inferred.** `tsc` refuses to print the whole metadata vocabulary into the declarations that embed it (TS7056). Dropping the record and artifact stages' annotations and the `ZodRawShape` cast fails the declaration build with TS7056 at `PackageApiContracts`. A named alias would turn `stack.zod` into a shared declaration chunk, the heap failure #14513 recorded. + - **The precise form, if the schema depth ever allows it,** is the one #19324 measured as A2, with its cost recorded at `RecordStagePackageBodySchema`. + + **`@objectstack/client`**: the `packages.list` TSDoc used to call this asymmetry "a KNOWN GAP rather than a design", tracked on #19324, and cited a `stack.zod.ts` line number. It now calls it the accepted static contract, cites `RecordStagePackageBodySchema` by name, and keeps its advice unchanged: narrow a row by parsing it with a `@objectstack/spec` schema, and never by `Array.isArray(pkg.manifest.objects)`. + + This settles what the `@objectstack/client` read-door changeset (#17536) calls "a known gap, tracked as #19324". The gap is not closing under #19324: it is the accepted static contract, and the client pin that records it stays. + + ⛔ No behaviour changes. No type, schema, accept set, authorable key or export moves. Only TSDoc and source comments change, and they ship: + + - `@objectstack/spec`'s published `files[]` carries `dist`, where the TSDoc is emitted into the `.d.ts` / `.d.mts` declarations, and `src/**/*.zod.ts`, so both edited files also ship as source. + - `@objectstack/client`'s published `files[]` carries `dist`, where the rewritten paragraph lands in `index.d.ts`, `index.d.mts`, `index.js` and `index.mjs`. +- 7ffddfa: `ObjectStackClient.environments` — the docblock that licenses the namespace's erased `any` now names where the family is enumerated, and the enumeration exists (#19383). + + Fourteen methods on `environments.*` and the nested `environments.packages.*` return types that **contain** `any` and carry **no return annotation**. They are deliberate: the `/api/v1/cloud/*` control plane speaks snake_case, its row contracts left this repo with `@objectstack/spec/cloud`, and binding them here would typecheck and be false. Nothing mechanical held the family, though — `check:exported-any-returns` asks whether an awaited return type **IS** `any` and never whether it **CONTAINS** one (a documented scope that buys the gate zero false positives), and with no annotation on any signature line there is no text for a search to find. A 15th such method landed silently green under a paragraph that licensed it in advance. + + - **What changed for a consumer**: one paragraph of published TSDoc on `environments`. It bounds the licence — the family is enumerated by name in the package's own `environments-any-family.pin.test.ts`, and a method that pin does not list is not covered by the paragraph. No export, signature, envelope key or runtime behaviour moves; the emitted declarations are otherwise byte-identical. + - **Pinned by membership, not by a CONTAINS-any detector.** A `ts.createProgram` + `TypeChecker` census over the SDK surface shows CONTAINS-any has no canonical boundary here: the population is a function of how many hops the walk is allowed (24 at three, 43 at four, 57 at five and six), an unbounded walk does not terminate, and 144 callables are still unexplored at six hops — so such a gate's green would mean "no `any` within N hops", never "no `any`". + - **And a package-wide rule would refuse the protected class.** The only other unannotated `any`-containing sites on the surface are `organizations.list` (better-auth organisation `metadata`) and `oauth.applications.list` (`Record[]`), which is precisely the caller-shaped class the ratchet's ledger protects by name. +- 1c8b320: `publishItem`'s JSDoc, which ships into `dist/index.d.ts`, is corrected to the refusal spelling the runtime has emitted since PR #19683 (#16245): 404 `NO_DRAFT` on the `code` axis, instead of the retired bracketed lowercase opener `[no_draft]` that PR removed from the message. No behaviour change — the SDK method, its request and its return type are untouched; only the doc comment's stale prose is corrected (#19704). +- 01388fe: `auth.login` and `auth.register` now deliver the `SessionResponse` envelope they declare. + + Both methods annotate their return as `SessionResponse`, whose base `BaseResponseSchema` declares + `success` as a required boolean. Both carried an inline lift that filled `data` and never wrote + `success`, so neither delivered the type it advertises and every consumer keying on the envelope + flag — `ObjectStackClient.unwrapResponse` keys on exactly this — read `undefined` rather than + `true` or `false`. They now run the same lift `auth.me` / `auth.refreshToken` use, so the family + cannot deliver two different envelopes again. + + The credential is unchanged: `data.token` is still the token the route puts in the response body, + byte-identical, and `login` / `register` still arm the client's bearer token from it. + + Known residue, unchanged by this release: `data.session` is still absent from what these two + methods return. `POST /sign-in/email` and `POST /sign-up/email` serve no session object, id or + expiry in the body or in any header, so the member is not obtainable without a second + `GET /get-session` call — read it from `auth.me()`. Nothing is synthesized in its place. +- 5de9372: fix(client): `auth.me` / `auth.refreshToken` deliver the `SessionResponse` envelope they declare, and `refreshToken` reads the token the route actually serves (#16760) + + Both methods annotate their return as `SessionResponse` — ObjectStack's REST + `{ success, data }` envelope — for `GET /api/v1/auth/get-session`. better-auth + owns those bytes and answers **bare**. Measured against a real `AuthManager` + (better-auth 1.7.2, organization plugin) over a real driver: + + ``` + GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}} + GET /api/v1/auth/get-session (anonymous) -> 200 null + ``` + + So `(await client.auth.me()).data.user` type-checked and was `undefined` at + runtime, while `.user` — the real payload — did not type-check. The annotation + pointed every caller at the wrong key. + + ## What changed + + - The bare answer is now lifted into the declared envelope, the same lift + `auth.login` has always carried for `/sign-in/email`. `SessionResponse` is + **unchanged** and so is each method's published return annotation: the fix is + in what the methods produce, not in what they promise. + - The lift fills `success` as well as `data`. `SessionResponseSchema` is + `BaseResponseSchema.extend(…)` and that base declares `success` as a required + boolean, so a body carrying `data` alone still would not parse as the declared + type. + - The raw `.user` / `.session` keys are **kept** alongside `data`. They are what + callers were pushed onto while the declared shape was unreachable; dropping + them would trade one silent breakage for another. + - `auth.refreshToken` now reads `data.session.token`. It used to read + `data.data?.token` — a field this route does not produce at any nesting, so + the method returned successfully having captured nothing. A bearer-mode client + calling it to refresh kept whatever credential it already had, silently. + + ## The read was not a consequence of the envelope + + Worth stating because the reverse is the natural assumption: enveloping the body + does **not** put a token at `data.token`, because the route serves no top-level + `token` to lift. The only credential in the body is `session.token`, and that is + now the read. Fixing the shape alone would have left `refreshToken` exactly as + inert as it was. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `(await client.auth.me()).user` | still works — kept deliberately | + | `(await client.auth.me()).data.user` | now populated (was `undefined`) | + | `(await client.auth.refreshToken(t)).data.token` | `.data.session.token` | + + `refreshToken` stores the **unsigned** session token, which is the spelling + `/get-session` serves; `bearer()` accepts it and the signed + `token.signature` form interchangeably, so a client that held the signed form + stays signed in across the call. + + Three answers sat outside the declared type when this change was written and + are **not** addressed by it. Each has since been answered on its own card, so a + caller reading this entry does not have to code around any of them: + + - the **anonymous** `/get-session` answer, recorded above as `200 null`. It no + longer needs the published return annotation to widen, because the producer + moved instead: since #17881 `plugin-auth`'s `refuseAnonymousSession` converts + better-auth's `200` plus the literal JSON `null` into the declared ADR-0112 + refusal — HTTP `401` with `code: UNAUTHENTICATED` — before it leaves the + process. The SDK's shared `fetch` wrapper throws on any non-2xx, so an + anonymous `auth.me()` **rejects** rather than resolving outside its own type. + Ruled by #17238: the producer moved and `SessionResponseSchema` is untouched. + - `SessionUser.image`, then declared `z.string().optional()` against a route + that serves `null` (#17235). It is now declared `z.string().nullish()`, so + the `"image": null` every `/auth/*` session body carries parses. + - the sibling `auth.login` / `auth.register`, which then normalized into `data` + but set no `success` (#17234). They now run this entry's own lift, which + fills `success` as well as `data`. +- f8fea00: fix(client): `organizations.invite` defaults `role` to `'member'`, so the shorter call it declares actually works (#16582) + + `organizations.invite` declares `role?` as **optional** and forwarded the caller's object to better-auth verbatim. better-auth 1.7.2's body schema for `POST /organization/invite-member` makes `role` **required**, so the documented-looking minimal call was refused before it reached any ObjectStack code: + + ``` + client.organizations.invite({ email, organizationId }) -> 400 [body.role] Invalid input (VALIDATION_ERROR) + ``` + + Omitting `role` now sends `'member'`. **No published type moves** — `role` stays optional, and a caller who names a role still gets exactly that role on the wire (including `role: undefined`, which is treated as omission rather than dropped). + + The default is `'member'` because the sibling `organizations.invitations.resend` has always substituted exactly that over the **same** vendor endpoint. That asymmetry is why the gap stayed invisible: one member of the family papered over the vendor's requirement and the other did not, so only the shorter form ever failed. It is also the least-privileged name in the closed membership vocabulary (ADR-0108 D1 — `orgRoleGrade` floors at `member` and rises only for `owner`/`admin`), and an invitation is a pending row the invitee must still accept, so the implicit choice cannot confer reach the caller did not ask for. + + Measured against a real `AuthManager` (better-auth 1.7.2, organization plugin, `teams: { enabled: true }`) over a real `SqlDriver` (better-sqlite3), before and after: + + ``` + before: POST /organization/invite-member -> 400 {"message":"[body.role] Invalid input","code":"VALIDATION_ERROR"} + after: POST /organization/invite-member -> 200 {"role":"member","status":"pending", ...} + ``` + + No caller had to change: the census found no in-repo or Console caller using the two-argument form, so this repairs a path that was declared and unreachable rather than one that was in use. +- 032452a: fix(client): the scoped SDK reads `metadata.prefix` off the advertised routes instead of restating `/meta` + + `metadata.prefix` is a live `RestServerConfig` key: REST mounts every metadata + route under `metaPath = ${basePath}${metadata.prefix}` and the discovery handler + advertises the same value as `routes.metadata = ${realBase}${metadata.prefix}`. + Three surfaces describe one set of paths — the mounts, the discovery document, + and this SDK. + + `ScopedEnvironmentClient` restated `/meta` as a literal in all six of its + metadata methods — `getTypes`, `getItems`, `getItem`, `saveItem`, `deleteItem`, + `getHistory` — so on a deployment that moved the prefix, every one of them + called a path the server does not mount. The unscoped twin of each method was + already correct (it builds `${baseUrl}${getRoute('metadata')}`), so one SDK + disagreed with itself: the unscoped half read the advertised value while the + scoped half guessed. Measured on a live server booted at + `metadata: { prefix: '/metadata' }`, all six went to + `/api/v1/environments//meta`, which that deployment answers 404. + + The six now build through `metaUrl()`, which takes its base from `_apiBase()` + and its prefix from the new `_metaPrefix()` — the exact sibling of the + `_dataPrefix()` derivation that fixed `crud.dataPrefix`, fallback discipline + included. `_metaPrefix()` prefers the advertised `routes.metadata`, recovers the + prefix from `routes.data` as a second equation over the same `realBase` when the + advertised value is not the conventional one, and **declines to `/meta`** + whenever the document does not determine the answer: an SDK must not become + unusable because a server's discovery document is missing a key. + + Deployments on the default prefix are unaffected, by construction and by + measurement: the conventional-suffix rule is taken first, so a default + deployment is answered from `routes.metadata` alone, and a client that never + connected never reaches a rule at all. The pinned negative control asserts the + six request URLs of a default deployment byte for byte, for a connected client + and for an unconnected one, and that the unconnected client puts no discovery + request on the wire. + + The unscoped metadata methods are untouched. +- ab1c585: `POST /two-factor/verify-totp` and `/two-factor/verify-otp` now echo the user row as it stands when the response is written, instead of the pre-rotation snapshot the vendor closes over. + + On the enrolment lane — a signed-in caller confirming a new factor — better-auth writes `twoFactorEnabled: true`, rotates the session, and only then calls the `valid(ctx)` closure it built at entry. That closure still holds the pre-rotation session, so a successful verification answered `user.twoFactorEnabled: false` to the very caller who had just switched 2FA on. An account portal reading that body renders the factor as still OFF right after enrolment, and a bearer client that caches the echoed user carries the wrong flag until its next `get-session`. + + `two-factor-rotated-token-echo` already repaired the body's other stale member, `token`, on exactly these routes and on exactly this predicate — the response staged a session cookie whose token differs from the one echoed. The `user` member is stale for the same reason, so it is repaired under the same predicate rather than a new one. + + - **Two narrowings, both load-bearing.** Only the members the vendor already echoed are written, so the published payload shape (`AuthWireUser`) cannot widen — better-auth's own output filter is a deny-list, and forwarding a raw row would put every column it happens to carry on the wire. And the row is re-read through `internalAdapter` by the id the response itself published, so the repair travels the same output transform that produced the echo (a driver that stores booleans as `1`/`0` cannot change a member's wire type) and can never substitute a different principal into a response. + - **`/two-factor/verify-backup-code` is untouched.** It does not rotate and already echoed the live row; it is in neither path list, its row is not read, and it is pinned as a negative control on both the in-memory engine and a real `SqlDriver` — an unconditional re-read would have "fixed" the broken lane and quietly rewritten one that was already right. + - **The failure posture is inherited.** A row read that throws or answers nothing degrades to the vendor's own echo, never to a failed verification and never to a lost `token` repair, which is written first for that reason. + + `@objectstack/client` drops the `AuthTwoFactorVerificationResult.user` warning that told callers to re-read the session for the live flag; the wire shape it declares is unchanged. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index cdcd20c91aa..e4c73ff43df 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Official Client SDK for ObjectStack Protocol", "main": "dist/index.js", diff --git a/packages/cloud-connection/CHANGELOG.md b/packages/cloud-connection/CHANGELOG.md index 47ccef29003..6fb8427ed27 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,660 @@ # @objectstack/cloud-connection +## 17.5.0 + +### Minor Changes + +- d58b8b6: fix(cloud-connection): `POST /api/v1/marketplace/install-local` parses the package id it installs through the manifest declaration (#19576) + + Clause-②: no (narrowing) + + **BREAKING for callers of the install-local door** — a manifest whose `id` is + not reverse-domain notation is now refused `PLUGIN_MANIFEST_INVALID` (`400` for + an inline manifest, `502` for a cloud-fetched snapshot) before anything is + registered or written. It used to install and answer `200`. + + The accept set only shrinks back to what the published declaration has always + said. `MANIFEST_ID_PATTERN` (`@objectstack/spec/kernel`) is the one declaration + of a package id, and the other two package-install doors — `POST + /api/v1/packages` and the protocol install primitive — already refuse the ids it + refuses. This door is a separate path: it never calls the protocol primitive, + so neither gate covered it. It derived the id as `manifest.id ?? manifest.name` + and parsed nothing, so `late-app`, `com.example.my_erp`, a number, or a manifest + carrying only a `name` installed cleanly and became the key for the on-disk + ledger entry and for every `:manifestId` route. + + The door now asks the declaration **by reference** — `ManifestSchema.shape.id` + — at the one point where the inline branch (after a compiled bundle is + flattened) and the cloud branch have converged, ahead of the `409 + MANIFEST_CONFLICT` collision check, the posture gate, the hot-register and the + ledger write. The sentence the caller reads is the declaration's own + (`manifestIdRefusal`), surfaced rather than reworded. Posting `id: 'late-app'` + now answers, in `error.message`: + + ```text + Invalid package id 'late-app' on `manifest.id`. Expected reverse-domain notation + ('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments + of letters, digits and inner hyphens; a segment may not open with a hyphen; + underscores are not admitted. Did you mean 'com.example.late-app'? + ``` + + **`manifest.name` is no longer read as an id.** `ManifestSchema` declares `id`; + `name` is a display label with no pattern. A manifest with no `id` is refused + with the same sentence, naming `manifest.id`. The inline branch's earlier + message for that case — which said the manifest needed an `"id"` or a `"name"` + — is gone with the fallback it described. + + **What is not affected.** A conforming id installs exactly as before, on both + branches and for both the flat and the compiled-bundle shape. Ledger entries + already on disk are not re-judged: an entry an older build installed under an + id the declaration refuses still rehydrates at boot and can still be removed + with `DELETE /api/v1/marketplace/install-local/:manifestId`; only a fresh + install under that id is refused. Boot-time and in-process registration + (`manifest.register`, `AppPlugin`) never passes through this door. + + **If you are refused.** Give the manifest an `id` in reverse-domain notation — + lowercase dot-separated segments, hyphens allowed inside a segment, underscores + not. The refusal names the key, echoes what was sent and, where a mechanical + repair exists, offers one it has already checked against the rule. Artifacts + built by `os build` from `defineStack()` already carry a conforming id, because + the same declaration refuses anything else at build time. + + + +### Patch Changes + +- b4b83b3: docs(cloud-connection): cite the cloud control-plane decisions as `cloud ADR-NNNN` instead of bare numbers that resolve to this repo's own records (#18762) + + AGENTS.md Prime Directive 13 is explicit — an ADR "lives in the repository whose + code it governs", and a cloud decision is cited as `cloud ADR-NNNN`, "never as a + bare number, which `scripts/check-adr-anchors.mjs` resolves against *this* + registry (the two number independently)". The rule landed; the stock this + package already carried was never swept. + + Read against this repository's registry, the bare numbers pointed at real but + unrelated records: + + - `ADR-0008` → `docs/adr/0008-metadata-repository-and-change-log.md`, *Metadata + Repository, Change Log & Subscription (M0 → M4)* — zero occurrences of + "control plane", "cloud-connection" or "Phase 1"/"Phase 2". + - `ADR-0007` → `docs/adr/0007-settings-manifest-and-kv-store.md`, *Settings — + Manifest + K/V Store + Resolver*. The cloud ADR-0007 these lines mean is the + one this repo's own ADR-0003 status line already names: the decision that + redefined `sys_package_installation` as management-plane desired state and put + runtime truth in the `LocalManifestSource` ledger. + - `ADR-0009` → `docs/adr/0009-execution-pinned-metadata.md`, *Execution-Pinned + Metadata* — not the marketplace Setup-navigation ownership decision the lines + describe. + + That is worse than citing a number nobody has. A dangling id stops a reader; an + id that resolves lets them believe they read the right page and walk away with + the wrong decision. + + 18 citations now carry the `cloud` qualifier, in the spelling this package + already used elsewhere for the very same numbers — `cloud ADR-0008` in + `connection-credential-store.ts`, `cloud ADR-0007 step ⑤` in + `local-manifest-source.ts`, `cloud ADR-0009 P2a` in `marketplace-ui.ts`'s own + header. All three numbers already carried both spellings inside this one + package, and `marketplace-ui.ts` carried both inside a single file — qualified in + its header on line 4, bare on lines 16 and 43. + + What actually reaches a consumer of this package: + + - The npm `description` field, which is the sentence shown on the package page. + - `README.md`, including the closing pointer that already said "in the cloud + repository" while writing the number bare. + - The published `.d.ts`, which carries the module and plugin docblocks. + + No behaviour moves. No type, export, route, schema or runtime path is touched — + this is citation spelling and prose only, which is why it ships as a patch rather + than silently. No ADR record is written or edited. `packages/cloud-connection/CHANGELOG.md` + is deliberately untouched: it is published history, and a released entry is + amended in a dedicated docs-only PR, never as a rider on code changes. +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [fdeeea0] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [08b213e] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [4af758d] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [cea85fd] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [310760d] +- Updated dependencies [2b6a207] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [c17ff70] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [74327d3] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [4fef271] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [13d5294] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [c02fa12] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [0862063] +- Updated dependencies [5c5b67f] +- Updated dependencies [f9977c1] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [3fd3a4f] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [b7b6cdd] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [b81da66] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [fa00ebf] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [2bcd5cf] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [cc40033] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [95f729a] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [5049a3c] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [5c7aa46] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [45f428d] +- Updated dependencies [9449512] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [76ddab7] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [ea4d164] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [e77a23f] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [e6965dd] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [777d0c2] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/runtime@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index a793fb25bd3..9e3532c3507 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cloud-connection", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Runtime-side client for an ObjectStack cloud control plane — marketplace browse proxy, install-local, device-code binding, org catalog and installed views, and the /api/v1/runtime/config discovery endpoint. Open mechanism (cloud ADR-0008): the hub service, plan policy, and entitlements stay server-side.", "type": "module", diff --git a/packages/connectors/connector-mcp/CHANGELOG.md b/packages/connectors/connector-mcp/CHANGELOG.md index a27c6f133b6..513405a925c 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,681 @@ # @objectstack/connector-mcp +## 17.5.0 + +### Patch Changes + +- fc29c74: feat(spec)!: retire `connector.connectionTimeoutMs` — declared, bounded, defaulted, served back, and never applied as a deadline + + **BREAKING** — `connector.connectionTimeoutMs` is removed. ADR-0049 + enforce-or-remove; maintainer ruling 2026-09-22, letter A. It is the narrower + **second** decision this key was owed: the earlier ruling that made its nine + liveness siblings live (`retryConfig.*`, `requestTimeoutMs`) left this one dead + on a stated reason rather than by oversight, and `packages/spec/liveness/connector.json` + has been asking for this decision since. + + The key was bounded (`min(1000).max(300000)`), defaulted (`30000`), + `.describe()`d, authorable on both carriers and served back by + `/meta/connector`. Every signal an authoring surface can give said it worked. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `connector.connectionTimeoutMs` (on `Connector` and on `DeclarativeConnectorEntry`, so `stack.connectors[]` and `PUT /meta/connector/:name`) | `requestTimeoutMs` — the deadline the platform keeps, applied as `resilientFetch`'s per-attempt timeout. For a connect-only bound, configure it at a connector provider or upstream gateway on a transport that can separate the phases. | + | `ConnectorProviderContext.connectionTimeoutMs` (handed to every `ConnectorProviderFactory` — added after `@objectstack/spec@17.4.0` and never in a release, see below) | `ctx.requestTimeoutMs`, or the factory's own `providerConfig` where the provider owns the vocabulary. | + | The `ZodObject` combinators on `ConnectorSchema` and `DeclarativeConnectorEntrySchema` — `.extend()`, `.omit()`, `.pick()`, `.partial()`, `.merge()`, `.strict()`, `.keyof()`, `.safeExtend()` | Both exports are now `z.preprocess` **pipes** (the residue stage below), so those methods no longer exist on them. **Build on the object and re-wrap:** `acceptRetiredDefaultResidue(, { connectionTimeoutMs: 30000 })`, the `EffectiveObjectPermissionSchema` route. ⚠️ `.superRefine()` still *exists* on a pipe but returns a schema with no read-through `shape`, so refine before wrapping, not after. Parsing, `z.input` / `z.infer`, and the read-through `.shape` are unchanged. | + + **The one-line fix: delete the key.** `os migrate meta --from 17` lists the + mechanical edits for existing sources; apply them by hand. + + The three interface members withdrawn with it were **never in a release**: + `ConnectorProviderContext.connectionTimeoutMs`, + `RestConnectorOptions.connectionTimeoutMs` and + `OpenApiConnectorConfig.connectionTimeoutMs` all entered with `b929e0a662`, + after the `@objectstack/*@17.4.0` tag, and leave in this same release. A factory + or caller built against a released version never saw them; only code written + against an unreleased `main` in between can read them, and it stops. + + ⚠️ Runtime behaviour is **unchanged for every shipped provider**, because none + ever applied the value: a connector that authored `connectionTimeoutMs: 1000` + made exactly the same calls, with exactly the same deadlines, as one that did + not. What does change is observable and intended: the def served by + `GET /connectors` no longer echoes a connect deadline nobody keeps. + + ### ⭐ This is NOT the zero-mention retirement shape + + Measured with `git grep -n connectionTimeoutMs SHA -- . ':!packages/spec'` at + `e07843b5a6`, the tree this retirement landed on: **thirteen** non-test source + occurrences over seven files in five + packages — **six reads** (`openapi-connector.ts:242`, `openapi-provider.ts:193`, + `rest-connector.ts:134`, `rest-provider.ts:64`, `plugin.ts:307`, + `plugin.ts:1589`), **four type declarations**, and **three** surviving hardcoded + `30000` writes. Reading the retirement as "nothing referenced it" loses the + finding. Measured across all six reads, every one is a **pass-through**: the + value's only termini were the def `GET /connectors` echoes and the fingerprint + that decides whether to re-materialize. `connectorFetchOptions()` — the one + mapping from authored policy onto the platform's outbound `fetch` — was handed + `{ retryConfig, requestTimeoutMs }` only. Carrying a number is not honouring it, + and ADR-0049 forbids the parsed-unmarked-unenforced state whether the inert + value travels or sits still. + + Nor was the `实现` arm available. A connector's outbound call is a WHATWG + `fetch`, whose only cancellation surface is ONE `AbortSignal` covering the whole + operation; nothing in that interface observes the connection phase. Bounding + "time until the response arrives" with this key would kill a slow-but-connected + upstream the author meant to allow with a large `requestTimeoutMs` — breaking + the very promise the key makes. (undici's `connectTimeout` needs a custom + dispatcher: Node-only, and a new subsystem underneath every connector, which the + ruling that made the siblings live forbids.) + + ### The retirement kit + + - The **authorable key** is a `retiredKey()` tombstone on `ConnectorSchema`, + registered as `integration/Connector:connectionTimeoutMs` and + `integration/DeclarativeConnectorEntry:connectionTimeoutMs` in + `RETIRED_KEYS_BY_MAJOR[18]`. The schema is not `.strict()`, so a bare deletion + would strip an authored key in silence (ADR-0104): the tombstone is audible in + both channels — `tsc` (input type `never`) and the parse, which raises the + prescription itself. `DeclarativeConnectorEntrySchema` carries it too — both + published carriers wrap the same private `ConnectorBaseSchema` — so + `stack.connectors[]` and the `/meta/connector` door refuse it too: every value + but the retired default `30000`, which the residue stage below strips first. + - **A D2 conversion, `connector-connection-timeout-ms-removed`** — one strip per + `connectors[]` entry, a pure lossless delete. ⭐ The ruling left whether one was + owed to be **measured** ("a D2 conversion only if a stored connector row can + carry the key"). It can, and both legs were measured before the tombstone + landed: `getMetadataTypeSchema('connector')` — what `PUT /meta/connector/:name` + validates against — parsed a body carrying the key and its output **retained** + the authored value, so the number reached `sys_metadata`; and + `applyConversionsToStoredItem('connector', …)` is live for this type. Rows + written on 17.x therefore replay clean. + - **A D3 semantic entry, + `connector-provider-context-connection-timeout-ms-retired`**, for the withdrawn + `ConnectorProviderContext` member (never in a release, above). A provider + factory is code: there is no authored source and no `sys_metadata` row for a + conversion to rewrite, so the removal reaches a factory author who read it — + possible only against an unreleased `main` — as a `tsc` error and as that + entry. + - **No def leaves.** The key was a bare `z.number()`, never a `ConfigSchema` + shape, so `RETIRED_DEFS_BY_MAJOR[18]` gains nothing — and `api-surface/` and + `json-schema.manifest/` are byte-identical, which is the correct reading for a + key-only tombstone rather than a missed regeneration. + - `authorable-surface/integration.json` gains two `[RETIRED]` rows; + `authorable-defaults/integration.json` loses the two `= 30000` rows. + - The liveness row **stays** `dead` with a `REMOVED` note, because `retiredKey()` + keeps the key in the walked shape. Its previous note claimed "every occurrence + outside `packages/spec` is a WRITE". That reading was **correct at the SHA the + card cited and dated** (`0870fb5418` — exactly five non-spec source hits, all + five `connectionTimeoutMs: 30000,`) and was superseded by `b929e0a662`, the PR + the card itself flagged as pending. It is **stale, not false**, and the row now + carries both readings with their trees rather than one undated claim. + - **An `acceptRetiredDefaultResidue` stage** (#12840), `{ connectionTimeoutMs: 30000 }` + on both carriers. The key was `.optional().default(30000)`, so a 17.x parse + materialized it into **every** connector — measured on both sides of the + retirement: the released + `@objectstack/spec@17.4.0` emits `connectionTimeoutMs: 30000` for an entry that + authored only `name`/`label`/`type`, and the tombstone **without the stage** + refuses that exact object at `connectionTimeoutMs`. With the stage, as it + ships, that object is **accepted and the key stripped** before the tombstone + reads it — on `ConnectorSchema`, `DeclarativeConnectorEntrySchema`, the + `/meta/connector` schema and `stack.connectors[]` alike. + The D2 does **not** discharge the obligation, and the precedent shows it: + `ObjectPermission:allowPurge` carries a D2 **and** the residue stage, for its + own reason (a released toolchain materialized its default into every built + artifact's entries). The reason *here* is a different one — this schema has a + second door: `AutomationEngine.registerConnector` parses `ConnectorSchema` for + a def a plugin or provider factory builds **in code**, where no conversion + ever runs, and in 17.4.0 all four shipped connector packages put that `30000` + straight into the def literal. So the emitted `30000` is accepted-and-stripped, + while every other value (`15000`, `1000`, the string `"30000"`) keeps the + tombstone's refusal — at `connectionTimeoutMs`, or at + `connectors.0.connectionTimeoutMs` inside a stack — and nothing is un-retired: + `z.input` stays `never` and the `[RETIRED]` row stays. + - **No deprecation window** (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」), + and no staged retirement. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no download, dependent or source + telemetry was consulted for. The pinned sibling checkout **was** measured: zero + occurrences of the name at objectui `87af769e`, against a lit control on the same + command and scope, so no sibling fix or pin bump rides with this. + + `Clause-②: yes (narrowing)` — a published authorable key is removed on two + carriers, so the accept set a consumer writes against narrows. Nothing is + widened and nothing is renamed. Contract-review tier. + + +- 40b315b: feat(spec)!: retire the connector resilience family — `health` (health probe + circuit breaker), `status` and the nested `webhooks`, sixteen keys nothing read (#20273) + + **BREAKING** — `connector.health` (the `healthCheck` probe, eight keys, and the + `circuitBreaker`, six keys), `connector.status` and the connector-nested + `webhooks` are removed from `ConnectorSchema` and `DeclarativeConnectorEntrySchema` + — so from `defineConnector`, `stack.connectors[]`, the `PUT /api/v1/meta/connector/:name` + door and `AutomationEngine.registerConnector`. ADR-0049 enforce-or-remove, one + batch for the family, by the maintainer's criterion: does the mainstream platform + offer this capability? Author-configured health probes and circuit breakers are + not connector metadata in the mainstream (breakers live in API-gateway + infrastructure), and an authored status and a nested webhook list duplicate what + is already delivered here by other keys. + + Measured before removal, each against a lit control: zero reads of any of the + sixteen keys outside `packages/spec`. No loop ever polled a connector endpoint, + counted consecutive failures or tripped a breaker, and none of the four + `fallbackStrategy` behaviours existed. Nothing read an authored `status`: the + runtime's dispatchability answer is the COMPUTED `state` (`ready` / `degraded`) + on `GET /api/v1/automation/connectors`, which no authored value sets. A webhook + nested in a connector was never registered as a `webhook` item, so it was never + materialized into `sys_webhook` and never delivered. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `connector.health` (`healthCheck.*`, `circuitBreaker.*`, including `monitoringWindowMs` and the pre-rename `monitoringWindow`) | delete the block. Put health probes and circuit breaking in the connector provider or an upstream gateway. | + | `connector.status` | delete the key. `enabled: false` on a declarative entry is what withdraws a materialized instance or marks a catalog-only descriptor; whether a registered connector can be dispatched is the computed `state`. | + | `connector.webhooks` | delete the array. A webhook that is actually delivered is declared in the stack's top-level `webhooks:` collection — moving one there STARTS deliveries this connector never made, so decide per webhook. `events` and `signatureAlgorithm` have no counterpart there. | + | `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` (schemas, types, `…Parsed` types) | no replacement — nothing parsed or constructed them. | + + **The one-line fix: delete `health:`, `status:` and `webhooks:` from every connector.** + `os migrate meta --from 17` lists the mechanical edits for existing sources. + + ⚠️ Runtime behaviour is deliberately **unchanged**: none of the sixteen keys ever + changed what a connector did. What changes is the answer an author gets — each + key is refused at parse with a prescription, and in `tsc` (its input type is + `never`), instead of being saved with no effect. + + ### The retirement kit + + - **Tombstones.** `health`, `status` and `webhooks` are `retiredKey()` tombstones + on the private `ConnectorBaseSchema` both published carriers wrap (the schema + is not `.strict()`, so a bare deletion would be a silent strip, ADR-0104). + `RETIRED_KEYS_BY_MAJOR[18]`: `integration/Connector:{health,status,webhooks}` + and `integration/DeclarativeConnectorEntry:{health,status,webhooks}`. + - **Retired-default residue.** `status` was `.default('inactive')`, so every 17.x + parse emitted `status: 'inactive'` into every connector; that exact value joins + `connectionTimeoutMs: 30000` in the residue stage (accepted and stripped, so a + def a 17.x toolchain built still registers). Every other value is refused. + - **Seven defs leave whole** (`RETIRED_DEFS_BY_MAJOR[18]`): the four + `integration/` schemas and three enums listed above. + - **D2 conversion `connector-resilience-keys-removed`** (step 18, retired from + the load path): strips the three keys from `connectors[]` and from stored + `sys_metadata` connector rows (the rehydration seam replays it), one notice per + key, as a lossless delete. Nested webhooks are stripped, never moved. + - **The chain.** In the same step, `connector-health-and-trigger-durations-unit-in-key` + renamed `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs`. That + breaker half is absorbed by this removal: the renamed key is itself removed, so + an author holding either spelling ends with no `health` block. The + conversion's `triggers[].interval` → `intervalSeconds` rename is unaffected. + - **D3 entry `connector-resilience-keys-retired`** carries the family's + judgement: which probe, breaker or nested webhook the author actually relied + on, and where it goes now. + - **Writers deleted.** The four shipped connector packages wrote + `status: 'active'` and the automation service's degraded husk wrote + `status: 'error'`; nothing read either back, and both writes are gone. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no telemetry was consulted for. + + Clause-②: no (narrowing) + + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index 8e7ce78cebe..9caf0ad15f0 100644 --- a/packages/connectors/connector-mcp/package.json +++ b/packages/connectors/connector-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-mcp", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Model Context Protocol (MCP) connector for ObjectStack — a generic adapter that turns any MCP server's tools into a connector's actions on the automation engine's connector registry (ADR-0024).", "main": "dist/index.js", diff --git a/packages/connectors/connector-openapi/CHANGELOG.md b/packages/connectors/connector-openapi/CHANGELOG.md index de7a065683f..a58306aa00f 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,752 @@ # @objectstack/connector-openapi +## 17.5.0 + +### Minor Changes + +- b929e0a: feat(connectors): a connector's declared `retryConfig` and `requestTimeoutMs` are executed, not just parsed (#18975) + + Clause-②: yes (widening) + + `ConnectorSchema.retryConfig` (eight sub-keys) and the two timeouts beside it + parsed, stored, and reached nothing. An author who wrote a retry policy — the + one `packages/spec/docs/SYNC_ARCHITECTURE.md` points at for a rate-limited + upstream, whose `retryableStatusCodes` default includes `429` — got + configuration that looked applied and did nothing, with no error and no + warning. ADR-0049 owed these keys a decision and ruled **implement**. + + **Where it landed: one wrapper, not a gateway.** `resilientFetch` + (`@objectstack/spec/shared`) already was the platform's outbound-HTTP call for + connectors — it gave every attempt a 30s timeout and a fixed exponential + backoff. What it could not express was the declared policy, so it gains exactly + the knobs that were missing (`strategy`, `backoffMultiplier`, `maxDelayMs`, + `jitter`, `retryOnNetworkError`), each defaulting to the behaviour it already + had. One new function, `connectorFetchOptions()` + (`@objectstack/spec/integration`), is the single mapping from a connector's + declared policy onto those options — one execution site, not one per connector + package. + + **How the authored value gets there.** `ConnectorProviderContext` gains + `retryConfig` and `requestTimeoutMs`, read-only and + resolved from the entry (the automation service parses `retryConfig` so a + factory reads real values instead of re-deriving the schema's defaults), so a + custom provider that does its own I/O can honour them. The built-in HTTP + providers — `rest` and `openapi` — honour them by construction. + + What an author now gets from each key: `strategy` picks the growth shape + (`exponential_backoff` / `linear_backoff` / `fixed_delay` / `no_retry`); + `maxAttempts` bounds the calls (it counts TOTAL attempts with the first + included, the contrast `content/docs/automation/flows.mdx` already draws against + `maxRetries`, and `maxAttempts: 0` still makes the one call and never retries); + `initialDelayMs` and `backoffMultiplier` shape the delay; `maxDelayMs` caps it, + applied after jitter so the declared ceiling is a real one — and an upstream + `Retry-After` longer than that ceiling ends the retry loop and returns the + response, rather than sleeping past a maximum the author declared; + `retryableStatusCodes` both widens and narrows what is retried; + `retryOnNetworkError` governs a thrown attempt; `jitter` can now be turned off; + `requestTimeoutMs` becomes the per-attempt deadline. + + **Two behaviour changes to know about.** A connector that declares a policy now + retries per that policy where it previously did not retry at all — that is the + fix, and a connector that declares none is on exactly its prior behaviour. + Separately, `connector-openapi`'s generated actions went through a naked + `fetch`: unbounded, never retried, and the one built-in HTTP path an authored + policy could never reach. They now go through the same wrapper as + `connector-rest` and `connector-slack`, which gives them the 30s per-attempt + timeout and bounded retry those two already had. + + **⚠️ `connectionTimeoutMs` is NOT made live, deliberately, and is the one thing + the ruling assumed that measurement refused.** A connector's call is a WHATWG + `fetch`, whose only cancellation surface is one `AbortSignal` over the whole + operation; nothing in that interface observes the connection phase separately. + Bounding time-to-response with it would kill a slow-but-connected upstream the + author meant to allow with a large `requestTimeoutMs` — breaking the very + promise the key makes. So this change leaves it unenforced, with the reason + recorded at the mapping and in `packages/spec/liveness/connector.json`, whose + row for it stays `dead`. That left it owed a second, narrower ADR-0049 + decision, and this same release takes it: `connector.connectionTimeoutMs` is + **retired**, and its own entry in this release says what to write instead. The + key never reaches `ConnectorProviderContext` in any release. + + Nine of the ten ledger rows flip `dead` → `live` with the consumer site named; + the tenth is `connectionTimeoutMs`, above. This change itself moves no + declaration: it leaves every key, every bound and every default on the + connector schema as it found them. + +### Patch Changes + +- fc29c74: feat(spec)!: retire `connector.connectionTimeoutMs` — declared, bounded, defaulted, served back, and never applied as a deadline + + **BREAKING** — `connector.connectionTimeoutMs` is removed. ADR-0049 + enforce-or-remove; maintainer ruling 2026-09-22, letter A. It is the narrower + **second** decision this key was owed: the earlier ruling that made its nine + liveness siblings live (`retryConfig.*`, `requestTimeoutMs`) left this one dead + on a stated reason rather than by oversight, and `packages/spec/liveness/connector.json` + has been asking for this decision since. + + The key was bounded (`min(1000).max(300000)`), defaulted (`30000`), + `.describe()`d, authorable on both carriers and served back by + `/meta/connector`. Every signal an authoring surface can give said it worked. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `connector.connectionTimeoutMs` (on `Connector` and on `DeclarativeConnectorEntry`, so `stack.connectors[]` and `PUT /meta/connector/:name`) | `requestTimeoutMs` — the deadline the platform keeps, applied as `resilientFetch`'s per-attempt timeout. For a connect-only bound, configure it at a connector provider or upstream gateway on a transport that can separate the phases. | + | `ConnectorProviderContext.connectionTimeoutMs` (handed to every `ConnectorProviderFactory` — added after `@objectstack/spec@17.4.0` and never in a release, see below) | `ctx.requestTimeoutMs`, or the factory's own `providerConfig` where the provider owns the vocabulary. | + | The `ZodObject` combinators on `ConnectorSchema` and `DeclarativeConnectorEntrySchema` — `.extend()`, `.omit()`, `.pick()`, `.partial()`, `.merge()`, `.strict()`, `.keyof()`, `.safeExtend()` | Both exports are now `z.preprocess` **pipes** (the residue stage below), so those methods no longer exist on them. **Build on the object and re-wrap:** `acceptRetiredDefaultResidue(, { connectionTimeoutMs: 30000 })`, the `EffectiveObjectPermissionSchema` route. ⚠️ `.superRefine()` still *exists* on a pipe but returns a schema with no read-through `shape`, so refine before wrapping, not after. Parsing, `z.input` / `z.infer`, and the read-through `.shape` are unchanged. | + + **The one-line fix: delete the key.** `os migrate meta --from 17` lists the + mechanical edits for existing sources; apply them by hand. + + The three interface members withdrawn with it were **never in a release**: + `ConnectorProviderContext.connectionTimeoutMs`, + `RestConnectorOptions.connectionTimeoutMs` and + `OpenApiConnectorConfig.connectionTimeoutMs` all entered with `b929e0a662`, + after the `@objectstack/*@17.4.0` tag, and leave in this same release. A factory + or caller built against a released version never saw them; only code written + against an unreleased `main` in between can read them, and it stops. + + ⚠️ Runtime behaviour is **unchanged for every shipped provider**, because none + ever applied the value: a connector that authored `connectionTimeoutMs: 1000` + made exactly the same calls, with exactly the same deadlines, as one that did + not. What does change is observable and intended: the def served by + `GET /connectors` no longer echoes a connect deadline nobody keeps. + + ### ⭐ This is NOT the zero-mention retirement shape + + Measured with `git grep -n connectionTimeoutMs SHA -- . ':!packages/spec'` at + `e07843b5a6`, the tree this retirement landed on: **thirteen** non-test source + occurrences over seven files in five + packages — **six reads** (`openapi-connector.ts:242`, `openapi-provider.ts:193`, + `rest-connector.ts:134`, `rest-provider.ts:64`, `plugin.ts:307`, + `plugin.ts:1589`), **four type declarations**, and **three** surviving hardcoded + `30000` writes. Reading the retirement as "nothing referenced it" loses the + finding. Measured across all six reads, every one is a **pass-through**: the + value's only termini were the def `GET /connectors` echoes and the fingerprint + that decides whether to re-materialize. `connectorFetchOptions()` — the one + mapping from authored policy onto the platform's outbound `fetch` — was handed + `{ retryConfig, requestTimeoutMs }` only. Carrying a number is not honouring it, + and ADR-0049 forbids the parsed-unmarked-unenforced state whether the inert + value travels or sits still. + + Nor was the `实现` arm available. A connector's outbound call is a WHATWG + `fetch`, whose only cancellation surface is ONE `AbortSignal` covering the whole + operation; nothing in that interface observes the connection phase. Bounding + "time until the response arrives" with this key would kill a slow-but-connected + upstream the author meant to allow with a large `requestTimeoutMs` — breaking + the very promise the key makes. (undici's `connectTimeout` needs a custom + dispatcher: Node-only, and a new subsystem underneath every connector, which the + ruling that made the siblings live forbids.) + + ### The retirement kit + + - The **authorable key** is a `retiredKey()` tombstone on `ConnectorSchema`, + registered as `integration/Connector:connectionTimeoutMs` and + `integration/DeclarativeConnectorEntry:connectionTimeoutMs` in + `RETIRED_KEYS_BY_MAJOR[18]`. The schema is not `.strict()`, so a bare deletion + would strip an authored key in silence (ADR-0104): the tombstone is audible in + both channels — `tsc` (input type `never`) and the parse, which raises the + prescription itself. `DeclarativeConnectorEntrySchema` carries it too — both + published carriers wrap the same private `ConnectorBaseSchema` — so + `stack.connectors[]` and the `/meta/connector` door refuse it too: every value + but the retired default `30000`, which the residue stage below strips first. + - **A D2 conversion, `connector-connection-timeout-ms-removed`** — one strip per + `connectors[]` entry, a pure lossless delete. ⭐ The ruling left whether one was + owed to be **measured** ("a D2 conversion only if a stored connector row can + carry the key"). It can, and both legs were measured before the tombstone + landed: `getMetadataTypeSchema('connector')` — what `PUT /meta/connector/:name` + validates against — parsed a body carrying the key and its output **retained** + the authored value, so the number reached `sys_metadata`; and + `applyConversionsToStoredItem('connector', …)` is live for this type. Rows + written on 17.x therefore replay clean. + - **A D3 semantic entry, + `connector-provider-context-connection-timeout-ms-retired`**, for the withdrawn + `ConnectorProviderContext` member (never in a release, above). A provider + factory is code: there is no authored source and no `sys_metadata` row for a + conversion to rewrite, so the removal reaches a factory author who read it — + possible only against an unreleased `main` — as a `tsc` error and as that + entry. + - **No def leaves.** The key was a bare `z.number()`, never a `ConfigSchema` + shape, so `RETIRED_DEFS_BY_MAJOR[18]` gains nothing — and `api-surface/` and + `json-schema.manifest/` are byte-identical, which is the correct reading for a + key-only tombstone rather than a missed regeneration. + - `authorable-surface/integration.json` gains two `[RETIRED]` rows; + `authorable-defaults/integration.json` loses the two `= 30000` rows. + - The liveness row **stays** `dead` with a `REMOVED` note, because `retiredKey()` + keeps the key in the walked shape. Its previous note claimed "every occurrence + outside `packages/spec` is a WRITE". That reading was **correct at the SHA the + card cited and dated** (`0870fb5418` — exactly five non-spec source hits, all + five `connectionTimeoutMs: 30000,`) and was superseded by `b929e0a662`, the PR + the card itself flagged as pending. It is **stale, not false**, and the row now + carries both readings with their trees rather than one undated claim. + - **An `acceptRetiredDefaultResidue` stage** (#12840), `{ connectionTimeoutMs: 30000 }` + on both carriers. The key was `.optional().default(30000)`, so a 17.x parse + materialized it into **every** connector — measured on both sides of the + retirement: the released + `@objectstack/spec@17.4.0` emits `connectionTimeoutMs: 30000` for an entry that + authored only `name`/`label`/`type`, and the tombstone **without the stage** + refuses that exact object at `connectionTimeoutMs`. With the stage, as it + ships, that object is **accepted and the key stripped** before the tombstone + reads it — on `ConnectorSchema`, `DeclarativeConnectorEntrySchema`, the + `/meta/connector` schema and `stack.connectors[]` alike. + The D2 does **not** discharge the obligation, and the precedent shows it: + `ObjectPermission:allowPurge` carries a D2 **and** the residue stage, for its + own reason (a released toolchain materialized its default into every built + artifact's entries). The reason *here* is a different one — this schema has a + second door: `AutomationEngine.registerConnector` parses `ConnectorSchema` for + a def a plugin or provider factory builds **in code**, where no conversion + ever runs, and in 17.4.0 all four shipped connector packages put that `30000` + straight into the def literal. So the emitted `30000` is accepted-and-stripped, + while every other value (`15000`, `1000`, the string `"30000"`) keeps the + tombstone's refusal — at `connectionTimeoutMs`, or at + `connectors.0.connectionTimeoutMs` inside a stack — and nothing is un-retired: + `z.input` stays `never` and the `[RETIRED]` row stays. + - **No deprecation window** (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」), + and no staged retirement. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no download, dependent or source + telemetry was consulted for. The pinned sibling checkout **was** measured: zero + occurrences of the name at objectui `87af769e`, against a lit control on the same + command and scope, so no sibling fix or pin bump rides with this. + + `Clause-②: yes (narrowing)` — a published authorable key is removed on two + carriers, so the accept set a consumer writes against narrows. Nothing is + widened and nothing is renamed. Contract-review tier. + + +- 40b315b: feat(spec)!: retire the connector resilience family — `health` (health probe + circuit breaker), `status` and the nested `webhooks`, sixteen keys nothing read (#20273) + + **BREAKING** — `connector.health` (the `healthCheck` probe, eight keys, and the + `circuitBreaker`, six keys), `connector.status` and the connector-nested + `webhooks` are removed from `ConnectorSchema` and `DeclarativeConnectorEntrySchema` + — so from `defineConnector`, `stack.connectors[]`, the `PUT /api/v1/meta/connector/:name` + door and `AutomationEngine.registerConnector`. ADR-0049 enforce-or-remove, one + batch for the family, by the maintainer's criterion: does the mainstream platform + offer this capability? Author-configured health probes and circuit breakers are + not connector metadata in the mainstream (breakers live in API-gateway + infrastructure), and an authored status and a nested webhook list duplicate what + is already delivered here by other keys. + + Measured before removal, each against a lit control: zero reads of any of the + sixteen keys outside `packages/spec`. No loop ever polled a connector endpoint, + counted consecutive failures or tripped a breaker, and none of the four + `fallbackStrategy` behaviours existed. Nothing read an authored `status`: the + runtime's dispatchability answer is the COMPUTED `state` (`ready` / `degraded`) + on `GET /api/v1/automation/connectors`, which no authored value sets. A webhook + nested in a connector was never registered as a `webhook` item, so it was never + materialized into `sys_webhook` and never delivered. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `connector.health` (`healthCheck.*`, `circuitBreaker.*`, including `monitoringWindowMs` and the pre-rename `monitoringWindow`) | delete the block. Put health probes and circuit breaking in the connector provider or an upstream gateway. | + | `connector.status` | delete the key. `enabled: false` on a declarative entry is what withdraws a materialized instance or marks a catalog-only descriptor; whether a registered connector can be dispatched is the computed `state`. | + | `connector.webhooks` | delete the array. A webhook that is actually delivered is declared in the stack's top-level `webhooks:` collection — moving one there STARTS deliveries this connector never made, so decide per webhook. `events` and `signatureAlgorithm` have no counterpart there. | + | `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` (schemas, types, `…Parsed` types) | no replacement — nothing parsed or constructed them. | + + **The one-line fix: delete `health:`, `status:` and `webhooks:` from every connector.** + `os migrate meta --from 17` lists the mechanical edits for existing sources. + + ⚠️ Runtime behaviour is deliberately **unchanged**: none of the sixteen keys ever + changed what a connector did. What changes is the answer an author gets — each + key is refused at parse with a prescription, and in `tsc` (its input type is + `never`), instead of being saved with no effect. + + ### The retirement kit + + - **Tombstones.** `health`, `status` and `webhooks` are `retiredKey()` tombstones + on the private `ConnectorBaseSchema` both published carriers wrap (the schema + is not `.strict()`, so a bare deletion would be a silent strip, ADR-0104). + `RETIRED_KEYS_BY_MAJOR[18]`: `integration/Connector:{health,status,webhooks}` + and `integration/DeclarativeConnectorEntry:{health,status,webhooks}`. + - **Retired-default residue.** `status` was `.default('inactive')`, so every 17.x + parse emitted `status: 'inactive'` into every connector; that exact value joins + `connectionTimeoutMs: 30000` in the residue stage (accepted and stripped, so a + def a 17.x toolchain built still registers). Every other value is refused. + - **Seven defs leave whole** (`RETIRED_DEFS_BY_MAJOR[18]`): the four + `integration/` schemas and three enums listed above. + - **D2 conversion `connector-resilience-keys-removed`** (step 18, retired from + the load path): strips the three keys from `connectors[]` and from stored + `sys_metadata` connector rows (the rehydration seam replays it), one notice per + key, as a lossless delete. Nested webhooks are stripped, never moved. + - **The chain.** In the same step, `connector-health-and-trigger-durations-unit-in-key` + renamed `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs`. That + breaker half is absorbed by this removal: the renamed key is itself removed, so + an author holding either spelling ends with no `health` block. The + conversion's `triggers[].interval` → `intervalSeconds` rename is unaffected. + - **D3 entry `connector-resilience-keys-retired`** carries the family's + judgement: which probe, breaker or nested webhook the author actually relied + on, and where it goes now. + - **Writers deleted.** The four shipped connector packages wrote + `status: 'active'` and the automation service's degraded husk wrote + `status: 'error'`; nothing read either back, and both writes are gone. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no telemetry was consulted for. + + Clause-②: no (narrowing) + + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index e1b630c6830..7a330dab9c9 100644 --- a/packages/connectors/connector-openapi/package.json +++ b/packages/connectors/connector-openapi/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-openapi", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "OpenAPI 3.x connector generator for ObjectStack — turns a declarative OpenAPI document into connector actions on the automation engine's registry, with a self-contained static-auth HTTP transport (ADR-0023).", "main": "dist/index.js", diff --git a/packages/connectors/connector-rest/CHANGELOG.md b/packages/connectors/connector-rest/CHANGELOG.md index 195702e5c39..92e9b5abd9c 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,752 @@ # @objectstack/connector-rest +## 17.5.0 + +### Minor Changes + +- b929e0a: feat(connectors): a connector's declared `retryConfig` and `requestTimeoutMs` are executed, not just parsed (#18975) + + Clause-②: yes (widening) + + `ConnectorSchema.retryConfig` (eight sub-keys) and the two timeouts beside it + parsed, stored, and reached nothing. An author who wrote a retry policy — the + one `packages/spec/docs/SYNC_ARCHITECTURE.md` points at for a rate-limited + upstream, whose `retryableStatusCodes` default includes `429` — got + configuration that looked applied and did nothing, with no error and no + warning. ADR-0049 owed these keys a decision and ruled **implement**. + + **Where it landed: one wrapper, not a gateway.** `resilientFetch` + (`@objectstack/spec/shared`) already was the platform's outbound-HTTP call for + connectors — it gave every attempt a 30s timeout and a fixed exponential + backoff. What it could not express was the declared policy, so it gains exactly + the knobs that were missing (`strategy`, `backoffMultiplier`, `maxDelayMs`, + `jitter`, `retryOnNetworkError`), each defaulting to the behaviour it already + had. One new function, `connectorFetchOptions()` + (`@objectstack/spec/integration`), is the single mapping from a connector's + declared policy onto those options — one execution site, not one per connector + package. + + **How the authored value gets there.** `ConnectorProviderContext` gains + `retryConfig` and `requestTimeoutMs`, read-only and + resolved from the entry (the automation service parses `retryConfig` so a + factory reads real values instead of re-deriving the schema's defaults), so a + custom provider that does its own I/O can honour them. The built-in HTTP + providers — `rest` and `openapi` — honour them by construction. + + What an author now gets from each key: `strategy` picks the growth shape + (`exponential_backoff` / `linear_backoff` / `fixed_delay` / `no_retry`); + `maxAttempts` bounds the calls (it counts TOTAL attempts with the first + included, the contrast `content/docs/automation/flows.mdx` already draws against + `maxRetries`, and `maxAttempts: 0` still makes the one call and never retries); + `initialDelayMs` and `backoffMultiplier` shape the delay; `maxDelayMs` caps it, + applied after jitter so the declared ceiling is a real one — and an upstream + `Retry-After` longer than that ceiling ends the retry loop and returns the + response, rather than sleeping past a maximum the author declared; + `retryableStatusCodes` both widens and narrows what is retried; + `retryOnNetworkError` governs a thrown attempt; `jitter` can now be turned off; + `requestTimeoutMs` becomes the per-attempt deadline. + + **Two behaviour changes to know about.** A connector that declares a policy now + retries per that policy where it previously did not retry at all — that is the + fix, and a connector that declares none is on exactly its prior behaviour. + Separately, `connector-openapi`'s generated actions went through a naked + `fetch`: unbounded, never retried, and the one built-in HTTP path an authored + policy could never reach. They now go through the same wrapper as + `connector-rest` and `connector-slack`, which gives them the 30s per-attempt + timeout and bounded retry those two already had. + + **⚠️ `connectionTimeoutMs` is NOT made live, deliberately, and is the one thing + the ruling assumed that measurement refused.** A connector's call is a WHATWG + `fetch`, whose only cancellation surface is one `AbortSignal` over the whole + operation; nothing in that interface observes the connection phase separately. + Bounding time-to-response with it would kill a slow-but-connected upstream the + author meant to allow with a large `requestTimeoutMs` — breaking the very + promise the key makes. So this change leaves it unenforced, with the reason + recorded at the mapping and in `packages/spec/liveness/connector.json`, whose + row for it stays `dead`. That left it owed a second, narrower ADR-0049 + decision, and this same release takes it: `connector.connectionTimeoutMs` is + **retired**, and its own entry in this release says what to write instead. The + key never reaches `ConnectorProviderContext` in any release. + + Nine of the ten ledger rows flip `dead` → `live` with the consumer site named; + the tenth is `connectionTimeoutMs`, above. This change itself moves no + declaration: it leaves every key, every bound and every default on the + connector schema as it found them. + +### Patch Changes + +- fc29c74: feat(spec)!: retire `connector.connectionTimeoutMs` — declared, bounded, defaulted, served back, and never applied as a deadline + + **BREAKING** — `connector.connectionTimeoutMs` is removed. ADR-0049 + enforce-or-remove; maintainer ruling 2026-09-22, letter A. It is the narrower + **second** decision this key was owed: the earlier ruling that made its nine + liveness siblings live (`retryConfig.*`, `requestTimeoutMs`) left this one dead + on a stated reason rather than by oversight, and `packages/spec/liveness/connector.json` + has been asking for this decision since. + + The key was bounded (`min(1000).max(300000)`), defaulted (`30000`), + `.describe()`d, authorable on both carriers and served back by + `/meta/connector`. Every signal an authoring surface can give said it worked. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `connector.connectionTimeoutMs` (on `Connector` and on `DeclarativeConnectorEntry`, so `stack.connectors[]` and `PUT /meta/connector/:name`) | `requestTimeoutMs` — the deadline the platform keeps, applied as `resilientFetch`'s per-attempt timeout. For a connect-only bound, configure it at a connector provider or upstream gateway on a transport that can separate the phases. | + | `ConnectorProviderContext.connectionTimeoutMs` (handed to every `ConnectorProviderFactory` — added after `@objectstack/spec@17.4.0` and never in a release, see below) | `ctx.requestTimeoutMs`, or the factory's own `providerConfig` where the provider owns the vocabulary. | + | The `ZodObject` combinators on `ConnectorSchema` and `DeclarativeConnectorEntrySchema` — `.extend()`, `.omit()`, `.pick()`, `.partial()`, `.merge()`, `.strict()`, `.keyof()`, `.safeExtend()` | Both exports are now `z.preprocess` **pipes** (the residue stage below), so those methods no longer exist on them. **Build on the object and re-wrap:** `acceptRetiredDefaultResidue(, { connectionTimeoutMs: 30000 })`, the `EffectiveObjectPermissionSchema` route. ⚠️ `.superRefine()` still *exists* on a pipe but returns a schema with no read-through `shape`, so refine before wrapping, not after. Parsing, `z.input` / `z.infer`, and the read-through `.shape` are unchanged. | + + **The one-line fix: delete the key.** `os migrate meta --from 17` lists the + mechanical edits for existing sources; apply them by hand. + + The three interface members withdrawn with it were **never in a release**: + `ConnectorProviderContext.connectionTimeoutMs`, + `RestConnectorOptions.connectionTimeoutMs` and + `OpenApiConnectorConfig.connectionTimeoutMs` all entered with `b929e0a662`, + after the `@objectstack/*@17.4.0` tag, and leave in this same release. A factory + or caller built against a released version never saw them; only code written + against an unreleased `main` in between can read them, and it stops. + + ⚠️ Runtime behaviour is **unchanged for every shipped provider**, because none + ever applied the value: a connector that authored `connectionTimeoutMs: 1000` + made exactly the same calls, with exactly the same deadlines, as one that did + not. What does change is observable and intended: the def served by + `GET /connectors` no longer echoes a connect deadline nobody keeps. + + ### ⭐ This is NOT the zero-mention retirement shape + + Measured with `git grep -n connectionTimeoutMs SHA -- . ':!packages/spec'` at + `e07843b5a6`, the tree this retirement landed on: **thirteen** non-test source + occurrences over seven files in five + packages — **six reads** (`openapi-connector.ts:242`, `openapi-provider.ts:193`, + `rest-connector.ts:134`, `rest-provider.ts:64`, `plugin.ts:307`, + `plugin.ts:1589`), **four type declarations**, and **three** surviving hardcoded + `30000` writes. Reading the retirement as "nothing referenced it" loses the + finding. Measured across all six reads, every one is a **pass-through**: the + value's only termini were the def `GET /connectors` echoes and the fingerprint + that decides whether to re-materialize. `connectorFetchOptions()` — the one + mapping from authored policy onto the platform's outbound `fetch` — was handed + `{ retryConfig, requestTimeoutMs }` only. Carrying a number is not honouring it, + and ADR-0049 forbids the parsed-unmarked-unenforced state whether the inert + value travels or sits still. + + Nor was the `实现` arm available. A connector's outbound call is a WHATWG + `fetch`, whose only cancellation surface is ONE `AbortSignal` covering the whole + operation; nothing in that interface observes the connection phase. Bounding + "time until the response arrives" with this key would kill a slow-but-connected + upstream the author meant to allow with a large `requestTimeoutMs` — breaking + the very promise the key makes. (undici's `connectTimeout` needs a custom + dispatcher: Node-only, and a new subsystem underneath every connector, which the + ruling that made the siblings live forbids.) + + ### The retirement kit + + - The **authorable key** is a `retiredKey()` tombstone on `ConnectorSchema`, + registered as `integration/Connector:connectionTimeoutMs` and + `integration/DeclarativeConnectorEntry:connectionTimeoutMs` in + `RETIRED_KEYS_BY_MAJOR[18]`. The schema is not `.strict()`, so a bare deletion + would strip an authored key in silence (ADR-0104): the tombstone is audible in + both channels — `tsc` (input type `never`) and the parse, which raises the + prescription itself. `DeclarativeConnectorEntrySchema` carries it too — both + published carriers wrap the same private `ConnectorBaseSchema` — so + `stack.connectors[]` and the `/meta/connector` door refuse it too: every value + but the retired default `30000`, which the residue stage below strips first. + - **A D2 conversion, `connector-connection-timeout-ms-removed`** — one strip per + `connectors[]` entry, a pure lossless delete. ⭐ The ruling left whether one was + owed to be **measured** ("a D2 conversion only if a stored connector row can + carry the key"). It can, and both legs were measured before the tombstone + landed: `getMetadataTypeSchema('connector')` — what `PUT /meta/connector/:name` + validates against — parsed a body carrying the key and its output **retained** + the authored value, so the number reached `sys_metadata`; and + `applyConversionsToStoredItem('connector', …)` is live for this type. Rows + written on 17.x therefore replay clean. + - **A D3 semantic entry, + `connector-provider-context-connection-timeout-ms-retired`**, for the withdrawn + `ConnectorProviderContext` member (never in a release, above). A provider + factory is code: there is no authored source and no `sys_metadata` row for a + conversion to rewrite, so the removal reaches a factory author who read it — + possible only against an unreleased `main` — as a `tsc` error and as that + entry. + - **No def leaves.** The key was a bare `z.number()`, never a `ConfigSchema` + shape, so `RETIRED_DEFS_BY_MAJOR[18]` gains nothing — and `api-surface/` and + `json-schema.manifest/` are byte-identical, which is the correct reading for a + key-only tombstone rather than a missed regeneration. + - `authorable-surface/integration.json` gains two `[RETIRED]` rows; + `authorable-defaults/integration.json` loses the two `= 30000` rows. + - The liveness row **stays** `dead` with a `REMOVED` note, because `retiredKey()` + keeps the key in the walked shape. Its previous note claimed "every occurrence + outside `packages/spec` is a WRITE". That reading was **correct at the SHA the + card cited and dated** (`0870fb5418` — exactly five non-spec source hits, all + five `connectionTimeoutMs: 30000,`) and was superseded by `b929e0a662`, the PR + the card itself flagged as pending. It is **stale, not false**, and the row now + carries both readings with their trees rather than one undated claim. + - **An `acceptRetiredDefaultResidue` stage** (#12840), `{ connectionTimeoutMs: 30000 }` + on both carriers. The key was `.optional().default(30000)`, so a 17.x parse + materialized it into **every** connector — measured on both sides of the + retirement: the released + `@objectstack/spec@17.4.0` emits `connectionTimeoutMs: 30000` for an entry that + authored only `name`/`label`/`type`, and the tombstone **without the stage** + refuses that exact object at `connectionTimeoutMs`. With the stage, as it + ships, that object is **accepted and the key stripped** before the tombstone + reads it — on `ConnectorSchema`, `DeclarativeConnectorEntrySchema`, the + `/meta/connector` schema and `stack.connectors[]` alike. + The D2 does **not** discharge the obligation, and the precedent shows it: + `ObjectPermission:allowPurge` carries a D2 **and** the residue stage, for its + own reason (a released toolchain materialized its default into every built + artifact's entries). The reason *here* is a different one — this schema has a + second door: `AutomationEngine.registerConnector` parses `ConnectorSchema` for + a def a plugin or provider factory builds **in code**, where no conversion + ever runs, and in 17.4.0 all four shipped connector packages put that `30000` + straight into the def literal. So the emitted `30000` is accepted-and-stripped, + while every other value (`15000`, `1000`, the string `"30000"`) keeps the + tombstone's refusal — at `connectionTimeoutMs`, or at + `connectors.0.connectionTimeoutMs` inside a stack — and nothing is un-retired: + `z.input` stays `never` and the `[RETIRED]` row stays. + - **No deprecation window** (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」), + and no staged retirement. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no download, dependent or source + telemetry was consulted for. The pinned sibling checkout **was** measured: zero + occurrences of the name at objectui `87af769e`, against a lit control on the same + command and scope, so no sibling fix or pin bump rides with this. + + `Clause-②: yes (narrowing)` — a published authorable key is removed on two + carriers, so the accept set a consumer writes against narrows. Nothing is + widened and nothing is renamed. Contract-review tier. + + +- 40b315b: feat(spec)!: retire the connector resilience family — `health` (health probe + circuit breaker), `status` and the nested `webhooks`, sixteen keys nothing read (#20273) + + **BREAKING** — `connector.health` (the `healthCheck` probe, eight keys, and the + `circuitBreaker`, six keys), `connector.status` and the connector-nested + `webhooks` are removed from `ConnectorSchema` and `DeclarativeConnectorEntrySchema` + — so from `defineConnector`, `stack.connectors[]`, the `PUT /api/v1/meta/connector/:name` + door and `AutomationEngine.registerConnector`. ADR-0049 enforce-or-remove, one + batch for the family, by the maintainer's criterion: does the mainstream platform + offer this capability? Author-configured health probes and circuit breakers are + not connector metadata in the mainstream (breakers live in API-gateway + infrastructure), and an authored status and a nested webhook list duplicate what + is already delivered here by other keys. + + Measured before removal, each against a lit control: zero reads of any of the + sixteen keys outside `packages/spec`. No loop ever polled a connector endpoint, + counted consecutive failures or tripped a breaker, and none of the four + `fallbackStrategy` behaviours existed. Nothing read an authored `status`: the + runtime's dispatchability answer is the COMPUTED `state` (`ready` / `degraded`) + on `GET /api/v1/automation/connectors`, which no authored value sets. A webhook + nested in a connector was never registered as a `webhook` item, so it was never + materialized into `sys_webhook` and never delivered. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `connector.health` (`healthCheck.*`, `circuitBreaker.*`, including `monitoringWindowMs` and the pre-rename `monitoringWindow`) | delete the block. Put health probes and circuit breaking in the connector provider or an upstream gateway. | + | `connector.status` | delete the key. `enabled: false` on a declarative entry is what withdraws a materialized instance or marks a catalog-only descriptor; whether a registered connector can be dispatched is the computed `state`. | + | `connector.webhooks` | delete the array. A webhook that is actually delivered is declared in the stack's top-level `webhooks:` collection — moving one there STARTS deliveries this connector never made, so decide per webhook. `events` and `signatureAlgorithm` have no counterpart there. | + | `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` (schemas, types, `…Parsed` types) | no replacement — nothing parsed or constructed them. | + + **The one-line fix: delete `health:`, `status:` and `webhooks:` from every connector.** + `os migrate meta --from 17` lists the mechanical edits for existing sources. + + ⚠️ Runtime behaviour is deliberately **unchanged**: none of the sixteen keys ever + changed what a connector did. What changes is the answer an author gets — each + key is refused at parse with a prescription, and in `tsc` (its input type is + `never`), instead of being saved with no effect. + + ### The retirement kit + + - **Tombstones.** `health`, `status` and `webhooks` are `retiredKey()` tombstones + on the private `ConnectorBaseSchema` both published carriers wrap (the schema + is not `.strict()`, so a bare deletion would be a silent strip, ADR-0104). + `RETIRED_KEYS_BY_MAJOR[18]`: `integration/Connector:{health,status,webhooks}` + and `integration/DeclarativeConnectorEntry:{health,status,webhooks}`. + - **Retired-default residue.** `status` was `.default('inactive')`, so every 17.x + parse emitted `status: 'inactive'` into every connector; that exact value joins + `connectionTimeoutMs: 30000` in the residue stage (accepted and stripped, so a + def a 17.x toolchain built still registers). Every other value is refused. + - **Seven defs leave whole** (`RETIRED_DEFS_BY_MAJOR[18]`): the four + `integration/` schemas and three enums listed above. + - **D2 conversion `connector-resilience-keys-removed`** (step 18, retired from + the load path): strips the three keys from `connectors[]` and from stored + `sys_metadata` connector rows (the rehydration seam replays it), one notice per + key, as a lossless delete. Nested webhooks are stripped, never moved. + - **The chain.** In the same step, `connector-health-and-trigger-durations-unit-in-key` + renamed `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs`. That + breaker half is absorbed by this removal: the renamed key is itself removed, so + an author holding either spelling ends with no `health` block. The + conversion's `triggers[].interval` → `intervalSeconds` rename is unaffected. + - **D3 entry `connector-resilience-keys-retired`** carries the family's + judgement: which probe, breaker or nested webhook the author actually relied + on, and where it goes now. + - **Writers deleted.** The four shipped connector packages wrote + `status: 'active'` and the automation service's degraded husk wrote + `status: 'error'`; nothing read either back, and both writes are gone. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no telemetry was consulted for. + + Clause-②: no (narrowing) + + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index 8813fc217f9..cd700b8a442 100644 --- a/packages/connectors/connector-rest/package.json +++ b/packages/connectors/connector-rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-rest", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Generic REST connector for ObjectStack — the reference concrete connector that registers a `request` action on the automation engine's connector registry (ADR-0018 §Addendum).", "main": "dist/index.js", diff --git a/packages/connectors/connector-slack/CHANGELOG.md b/packages/connectors/connector-slack/CHANGELOG.md index 192dbc70c7c..0162f9c2397 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,681 @@ # @objectstack/connector-slack +## 17.5.0 + +### Patch Changes + +- fc29c74: feat(spec)!: retire `connector.connectionTimeoutMs` — declared, bounded, defaulted, served back, and never applied as a deadline + + **BREAKING** — `connector.connectionTimeoutMs` is removed. ADR-0049 + enforce-or-remove; maintainer ruling 2026-09-22, letter A. It is the narrower + **second** decision this key was owed: the earlier ruling that made its nine + liveness siblings live (`retryConfig.*`, `requestTimeoutMs`) left this one dead + on a stated reason rather than by oversight, and `packages/spec/liveness/connector.json` + has been asking for this decision since. + + The key was bounded (`min(1000).max(300000)`), defaulted (`30000`), + `.describe()`d, authorable on both carriers and served back by + `/meta/connector`. Every signal an authoring surface can give said it worked. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `connector.connectionTimeoutMs` (on `Connector` and on `DeclarativeConnectorEntry`, so `stack.connectors[]` and `PUT /meta/connector/:name`) | `requestTimeoutMs` — the deadline the platform keeps, applied as `resilientFetch`'s per-attempt timeout. For a connect-only bound, configure it at a connector provider or upstream gateway on a transport that can separate the phases. | + | `ConnectorProviderContext.connectionTimeoutMs` (handed to every `ConnectorProviderFactory` — added after `@objectstack/spec@17.4.0` and never in a release, see below) | `ctx.requestTimeoutMs`, or the factory's own `providerConfig` where the provider owns the vocabulary. | + | The `ZodObject` combinators on `ConnectorSchema` and `DeclarativeConnectorEntrySchema` — `.extend()`, `.omit()`, `.pick()`, `.partial()`, `.merge()`, `.strict()`, `.keyof()`, `.safeExtend()` | Both exports are now `z.preprocess` **pipes** (the residue stage below), so those methods no longer exist on them. **Build on the object and re-wrap:** `acceptRetiredDefaultResidue(, { connectionTimeoutMs: 30000 })`, the `EffectiveObjectPermissionSchema` route. ⚠️ `.superRefine()` still *exists* on a pipe but returns a schema with no read-through `shape`, so refine before wrapping, not after. Parsing, `z.input` / `z.infer`, and the read-through `.shape` are unchanged. | + + **The one-line fix: delete the key.** `os migrate meta --from 17` lists the + mechanical edits for existing sources; apply them by hand. + + The three interface members withdrawn with it were **never in a release**: + `ConnectorProviderContext.connectionTimeoutMs`, + `RestConnectorOptions.connectionTimeoutMs` and + `OpenApiConnectorConfig.connectionTimeoutMs` all entered with `b929e0a662`, + after the `@objectstack/*@17.4.0` tag, and leave in this same release. A factory + or caller built against a released version never saw them; only code written + against an unreleased `main` in between can read them, and it stops. + + ⚠️ Runtime behaviour is **unchanged for every shipped provider**, because none + ever applied the value: a connector that authored `connectionTimeoutMs: 1000` + made exactly the same calls, with exactly the same deadlines, as one that did + not. What does change is observable and intended: the def served by + `GET /connectors` no longer echoes a connect deadline nobody keeps. + + ### ⭐ This is NOT the zero-mention retirement shape + + Measured with `git grep -n connectionTimeoutMs SHA -- . ':!packages/spec'` at + `e07843b5a6`, the tree this retirement landed on: **thirteen** non-test source + occurrences over seven files in five + packages — **six reads** (`openapi-connector.ts:242`, `openapi-provider.ts:193`, + `rest-connector.ts:134`, `rest-provider.ts:64`, `plugin.ts:307`, + `plugin.ts:1589`), **four type declarations**, and **three** surviving hardcoded + `30000` writes. Reading the retirement as "nothing referenced it" loses the + finding. Measured across all six reads, every one is a **pass-through**: the + value's only termini were the def `GET /connectors` echoes and the fingerprint + that decides whether to re-materialize. `connectorFetchOptions()` — the one + mapping from authored policy onto the platform's outbound `fetch` — was handed + `{ retryConfig, requestTimeoutMs }` only. Carrying a number is not honouring it, + and ADR-0049 forbids the parsed-unmarked-unenforced state whether the inert + value travels or sits still. + + Nor was the `实现` arm available. A connector's outbound call is a WHATWG + `fetch`, whose only cancellation surface is ONE `AbortSignal` covering the whole + operation; nothing in that interface observes the connection phase. Bounding + "time until the response arrives" with this key would kill a slow-but-connected + upstream the author meant to allow with a large `requestTimeoutMs` — breaking + the very promise the key makes. (undici's `connectTimeout` needs a custom + dispatcher: Node-only, and a new subsystem underneath every connector, which the + ruling that made the siblings live forbids.) + + ### The retirement kit + + - The **authorable key** is a `retiredKey()` tombstone on `ConnectorSchema`, + registered as `integration/Connector:connectionTimeoutMs` and + `integration/DeclarativeConnectorEntry:connectionTimeoutMs` in + `RETIRED_KEYS_BY_MAJOR[18]`. The schema is not `.strict()`, so a bare deletion + would strip an authored key in silence (ADR-0104): the tombstone is audible in + both channels — `tsc` (input type `never`) and the parse, which raises the + prescription itself. `DeclarativeConnectorEntrySchema` carries it too — both + published carriers wrap the same private `ConnectorBaseSchema` — so + `stack.connectors[]` and the `/meta/connector` door refuse it too: every value + but the retired default `30000`, which the residue stage below strips first. + - **A D2 conversion, `connector-connection-timeout-ms-removed`** — one strip per + `connectors[]` entry, a pure lossless delete. ⭐ The ruling left whether one was + owed to be **measured** ("a D2 conversion only if a stored connector row can + carry the key"). It can, and both legs were measured before the tombstone + landed: `getMetadataTypeSchema('connector')` — what `PUT /meta/connector/:name` + validates against — parsed a body carrying the key and its output **retained** + the authored value, so the number reached `sys_metadata`; and + `applyConversionsToStoredItem('connector', …)` is live for this type. Rows + written on 17.x therefore replay clean. + - **A D3 semantic entry, + `connector-provider-context-connection-timeout-ms-retired`**, for the withdrawn + `ConnectorProviderContext` member (never in a release, above). A provider + factory is code: there is no authored source and no `sys_metadata` row for a + conversion to rewrite, so the removal reaches a factory author who read it — + possible only against an unreleased `main` — as a `tsc` error and as that + entry. + - **No def leaves.** The key was a bare `z.number()`, never a `ConfigSchema` + shape, so `RETIRED_DEFS_BY_MAJOR[18]` gains nothing — and `api-surface/` and + `json-schema.manifest/` are byte-identical, which is the correct reading for a + key-only tombstone rather than a missed regeneration. + - `authorable-surface/integration.json` gains two `[RETIRED]` rows; + `authorable-defaults/integration.json` loses the two `= 30000` rows. + - The liveness row **stays** `dead` with a `REMOVED` note, because `retiredKey()` + keeps the key in the walked shape. Its previous note claimed "every occurrence + outside `packages/spec` is a WRITE". That reading was **correct at the SHA the + card cited and dated** (`0870fb5418` — exactly five non-spec source hits, all + five `connectionTimeoutMs: 30000,`) and was superseded by `b929e0a662`, the PR + the card itself flagged as pending. It is **stale, not false**, and the row now + carries both readings with their trees rather than one undated claim. + - **An `acceptRetiredDefaultResidue` stage** (#12840), `{ connectionTimeoutMs: 30000 }` + on both carriers. The key was `.optional().default(30000)`, so a 17.x parse + materialized it into **every** connector — measured on both sides of the + retirement: the released + `@objectstack/spec@17.4.0` emits `connectionTimeoutMs: 30000` for an entry that + authored only `name`/`label`/`type`, and the tombstone **without the stage** + refuses that exact object at `connectionTimeoutMs`. With the stage, as it + ships, that object is **accepted and the key stripped** before the tombstone + reads it — on `ConnectorSchema`, `DeclarativeConnectorEntrySchema`, the + `/meta/connector` schema and `stack.connectors[]` alike. + The D2 does **not** discharge the obligation, and the precedent shows it: + `ObjectPermission:allowPurge` carries a D2 **and** the residue stage, for its + own reason (a released toolchain materialized its default into every built + artifact's entries). The reason *here* is a different one — this schema has a + second door: `AutomationEngine.registerConnector` parses `ConnectorSchema` for + a def a plugin or provider factory builds **in code**, where no conversion + ever runs, and in 17.4.0 all four shipped connector packages put that `30000` + straight into the def literal. So the emitted `30000` is accepted-and-stripped, + while every other value (`15000`, `1000`, the string `"30000"`) keeps the + tombstone's refusal — at `connectionTimeoutMs`, or at + `connectors.0.connectionTimeoutMs` inside a stack — and nothing is un-retired: + `z.input` stays `never` and the `[RETIRED]` row stays. + - **No deprecation window** (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」), + and no staged retirement. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no download, dependent or source + telemetry was consulted for. The pinned sibling checkout **was** measured: zero + occurrences of the name at objectui `87af769e`, against a lit control on the same + command and scope, so no sibling fix or pin bump rides with this. + + `Clause-②: yes (narrowing)` — a published authorable key is removed on two + carriers, so the accept set a consumer writes against narrows. Nothing is + widened and nothing is renamed. Contract-review tier. + + +- 40b315b: feat(spec)!: retire the connector resilience family — `health` (health probe + circuit breaker), `status` and the nested `webhooks`, sixteen keys nothing read (#20273) + + **BREAKING** — `connector.health` (the `healthCheck` probe, eight keys, and the + `circuitBreaker`, six keys), `connector.status` and the connector-nested + `webhooks` are removed from `ConnectorSchema` and `DeclarativeConnectorEntrySchema` + — so from `defineConnector`, `stack.connectors[]`, the `PUT /api/v1/meta/connector/:name` + door and `AutomationEngine.registerConnector`. ADR-0049 enforce-or-remove, one + batch for the family, by the maintainer's criterion: does the mainstream platform + offer this capability? Author-configured health probes and circuit breakers are + not connector metadata in the mainstream (breakers live in API-gateway + infrastructure), and an authored status and a nested webhook list duplicate what + is already delivered here by other keys. + + Measured before removal, each against a lit control: zero reads of any of the + sixteen keys outside `packages/spec`. No loop ever polled a connector endpoint, + counted consecutive failures or tripped a breaker, and none of the four + `fallbackStrategy` behaviours existed. Nothing read an authored `status`: the + runtime's dispatchability answer is the COMPUTED `state` (`ready` / `degraded`) + on `GET /api/v1/automation/connectors`, which no authored value sets. A webhook + nested in a connector was never registered as a `webhook` item, so it was never + materialized into `sys_webhook` and never delivered. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `connector.health` (`healthCheck.*`, `circuitBreaker.*`, including `monitoringWindowMs` and the pre-rename `monitoringWindow`) | delete the block. Put health probes and circuit breaking in the connector provider or an upstream gateway. | + | `connector.status` | delete the key. `enabled: false` on a declarative entry is what withdraws a materialized instance or marks a catalog-only descriptor; whether a registered connector can be dispatched is the computed `state`. | + | `connector.webhooks` | delete the array. A webhook that is actually delivered is declared in the stack's top-level `webhooks:` collection — moving one there STARTS deliveries this connector never made, so decide per webhook. `events` and `signatureAlgorithm` have no counterpart there. | + | `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` (schemas, types, `…Parsed` types) | no replacement — nothing parsed or constructed them. | + + **The one-line fix: delete `health:`, `status:` and `webhooks:` from every connector.** + `os migrate meta --from 17` lists the mechanical edits for existing sources. + + ⚠️ Runtime behaviour is deliberately **unchanged**: none of the sixteen keys ever + changed what a connector did. What changes is the answer an author gets — each + key is refused at parse with a prescription, and in `tsc` (its input type is + `never`), instead of being saved with no effect. + + ### The retirement kit + + - **Tombstones.** `health`, `status` and `webhooks` are `retiredKey()` tombstones + on the private `ConnectorBaseSchema` both published carriers wrap (the schema + is not `.strict()`, so a bare deletion would be a silent strip, ADR-0104). + `RETIRED_KEYS_BY_MAJOR[18]`: `integration/Connector:{health,status,webhooks}` + and `integration/DeclarativeConnectorEntry:{health,status,webhooks}`. + - **Retired-default residue.** `status` was `.default('inactive')`, so every 17.x + parse emitted `status: 'inactive'` into every connector; that exact value joins + `connectionTimeoutMs: 30000` in the residue stage (accepted and stripped, so a + def a 17.x toolchain built still registers). Every other value is refused. + - **Seven defs leave whole** (`RETIRED_DEFS_BY_MAJOR[18]`): the four + `integration/` schemas and three enums listed above. + - **D2 conversion `connector-resilience-keys-removed`** (step 18, retired from + the load path): strips the three keys from `connectors[]` and from stored + `sys_metadata` connector rows (the rehydration seam replays it), one notice per + key, as a lossless delete. Nested webhooks are stripped, never moved. + - **The chain.** In the same step, `connector-health-and-trigger-durations-unit-in-key` + renamed `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs`. That + breaker half is absorbed by this removal: the renamed key is itself removed, so + an author holding either spelling ends with no `health` block. The + conversion's `triggers[].interval` → `intervalSeconds` rename is unaffected. + - **D3 entry `connector-resilience-keys-retired`** carries the family's + judgement: which probe, breaker or nested webhook the author actually relied + on, and where it goes now. + - **Writers deleted.** The four shipped connector packages wrote + `status: 'active'` and the automation service's degraded husk wrote + `status: 'error'`; nothing read either back, and both writes are gone. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no telemetry was consulted for. + + Clause-②: no (narrowing) + + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index 5eb1c7b7ae9..a35beec8174 100644 --- a/packages/connectors/connector-slack/package.json +++ b/packages/connectors/connector-slack/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-slack", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Slack Web API connector for ObjectStack — registers `chat.postMessage` / `chat.update` / `call` actions on the automation engine's connector registry (ADR-0018 §Addendum, ADR-0022).", "main": "dist/index.js", diff --git a/packages/console/CHANGELOG.md b/packages/console/CHANGELOG.md index 073d4fc2afd..12feb8659e6 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,580 @@ # @objectstack/console +## 17.5.0 + +### Minor Changes + +- 48c91e9: Console (objectui) refreshed to `62597c588072`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 27 releasing of 30 changesets added across 37 non-merge commits; omitted: 3 release-nothing changesets, 7 commits carrying no changeset (they ship no package code). + + - **minor** — fix(plugin-grid): `object-grid`'s `pagination` and `selection` now read their own presence by the SAME rule — presence enables, an explicit off wins (objectui `62597c588`) + - **minor** — A render-time filter refusal now renders a named "this view's filter is malformed" state instead of throwing out of render (objectui#9050). (objectui `804831c2a`) + - **minor** — `record:related_list` `add.picker.filter` is declared as the contract's rule array on the authoring face, not as `unknown` (objectui#9964). (objectui `9a9780072`) + - **minor** — feat(core): `object-tree` joins the curated public tier — the spec declared the block, the roster withheld it (objectui `c131d9e69`) + - **minor** — A signed-in user's language has one source of truth: `sys_user.locale`. (objectui `63f4f928f`) + - **minor** — A record form can no longer be saved while an upload is still in flight (objectui#10166). (objectui `e686f4d9a`) + - **minor** — Nav `visible`: an ancestor's predicate now reaches the whole subtree, and a group no longer outlives its children (objectui#10119) (objectui `73a3c89af`) + - **minor** — A field-backed action param reaches its record picker, and a param whose backing field cannot be read is refused instead of rendered as an empty text box (objectui#10129). (objectui `6cc910b6d`) + - **patch** — fix(react): the refused-`data` dev warning can be reset between tests (objectui `befd40ccd`) + - **patch** — fix(components): a half-typed `between` range in the filter builder shows itself as incomplete instead of being dropped with no signal (objectui#10061) (objectui `777fca22f`) + - **patch** — fix(plugin-detail): a related list resolves `user` columns to names, like every other list (objectui `0d379f571`) + - **patch** — fix(core): a date-only value renders the calendar day it names, in every viewer timezone (objectui `516583b54`) + - **patch** — fix(plugin-detail): the record-grained write verdict is memoised PER PRINCIPAL (objectui#10107). (objectui `b06c3de2a`) + - **patch** — fix(console): a `file` field on a FormView renders the shared upload control, not a text box (objectui `7725c10a0`) + - **patch** — Stop the Studio permission matrix' Delegated Admin Scope editor authoring an `adminScope` the framework spec refuses, and make the section's collapsed badge report a scope it is c… (objectui `2d7fff3b8`) + - **patch** — fix(console): mint a `sys_file` id for files picked in the global action dialogs (objectui `98178b206`) + - **patch** — fix(plugin-detail): `requiredPermissions` on `record:details`, `record:highlights` and `record:related_list` is an ADR-0066 capability set, read fail-closed — it used to pass for… (objectui `c2dabf032`) + - **patch** — A form no longer submits — nor offers — a field the CALLER may read but not edit (objectui#10120). (objectui `80c54122e`) + - **patch** — fix(components): `page:header` resolves a lookup title candidate instead of handing its expanded object to JSX (objectui#10117) (objectui `4c6f549ef`) + - **patch** — `ObjectCalendar`'s user-visible copy now reaches the locale packs. The component was already i18n-aware — it imports and calls both translation hooks — and a set of English senten… (objectui `afb228418`) + - **patch** — `useSettledSchema` no longer republishes an EQUAL definition as a new object, so swapping the adapter on a record-bound view costs ONE record query instead of two. (objectui `162621b6a`) + - **patch** — A form no longer emits the columns the server owns, and a master-detail batch sends only the cells the user changed (objectui#10108). (objectui `e026e15f9`) + - **patch** — fix(plugin-calendar): the month gridcell's accessible name follows the resolved locale (objectui `708f2711b`) + - **patch** — The AI build bar, the Studio workbench and the chat transcript's draft cards report the runtime authoring gate's per-draft advisories (objectui#10039) (objectui `e16f504d9`) + - **patch** — fix(plugin-detail): `record:quick_actions.requiredPermissions` is an ADR-0066 capability set, read fail-closed — it used to pass for every reader of the object (objectui `28b065800`) + - **patch** — fix(plugin-map): the top-level-`style` warning now prescribes a spelling the runtime reads (objectui `2252653d0`) + - **patch** — `record:history` refuses a row cap the contract rejects instead of repairing it (objectui#10005). (objectui `cd2cb4183`) + + **In this console build, declared nowhere** — objectui merged 7 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ docs(agents): 一次派发跑出来的 PR 停在 draft —— 给自行合并条款加第二条例外 (#10213) (objectui `87a0ed8cf`) + - _(no changeset)_ docs(agents): state the attribution-footer append as conditional on absence (#10212) (objectui `2ec310198`) + - _(no changeset)_ docs(skills): the console guide names the resolver `/` actually has (objectui#10140) (#10215) (objectui `8d7915ed2`) + - _(no changeset)_ ci(pm): adopt the board-snapshot archiver — route B, and the five files this repo's own gates demanded (objectui#9387) (#10203) (objectui `0cf2d6644`) + - _(no changeset)_ ci: compile objectui against @objectstack/spec@main as the shape gate (objectui#9860) (#9877) (objectui `6cae9a0b3`) + - _(no changeset)_ perf(console): move the `types` zod validators off the eager budgeted line (#10082) (objectui `64d250c8a`) + - _(no changeset)_ ci(bundle-analysis): retire the exhausted-headroom leg, keep every ceiling (#10151) (objectui `cbda340f0`) + + objectui range: `87af769e9a3e...62597c588072` +- fbc12be: Console (objectui) refreshed to `87af769e9a3e`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 584 releasing of 891 changesets added across 1156 non-merge commits; omitted: 307 release-nothing changesets, 279 commits carrying no changeset (they ship no package code). + + - **minor** — **BREAKING** — a record id is a **string** wherever metadata names one, and on the last `DataSource` door (objectui#9511). An authored `recordId: 42` / `resourceId: 42` no longer… (objectui `87af769e9`) + - **minor** — Store a typed percent by shifting the decimal, not by dividing a float by 100 (objectui#9810, maintainer ruling batch #161 item 3, letter B). (objectui `1560d4682`) + - **minor** — `object-grid`'s `operations` block is now the CEILING over `rowActions`, instead of one half of a union with it. (objectui `c269ed9e0`) + - **minor** — **Breaking — the published `RootRedirect` is removed; `/` now has exactly one resolver.** (objectui `4274861f4`) + - **minor** — Report display sites follow the display locale instead of the literal tag `en-US` (objectui#10020). (objectui `f7af6372d`) + - **minor** — `ObjectGallerySchema.filter` is typed as the destination its own docblock names — `QueryParams['$filter']` — on both faces, the TS interface in `objectql.ts` and the zod mirror in… (objectui `7cbefa540`) + - **minor** — Render the environment admin's read-rate report from the usage endpoint's `readRate` reading (objectui#9954; maintainer ruling on cloud#2333, batch #164 item 3). (objectui `c698a814a`) + - **minor** — `DetailViewField` declares `dueLike?: boolean` — the published TypeScript twin now accepts the key its own validator already judged and its own renderer already honours (objectui#… (objectui `8b446f5d0`) + - **minor** — feat(console): a "Language" item on the profile page, writing the signed-in user's own `sys_user.locale` (objectui#7501). (objectui `c2f0f4832`) + - **minor** — `RecipientPickerField` gains a picker mode for the `field` sharing recipient (objectui#7613; maintainer ruling objectstack#14103, executor objectstack#15072). (objectui `23b99585f`) + - **minor** — The console's error-recovery exits follow the declared landing (objectui#7373). (objectui `3fa3b3eb6`) + - **minor** — Studio's "publish whole app" reports the runtime authoring gate's per-draft advisories (objectui#6965; server half objectstack#9343). (objectui `ce986aafc`) + - **minor** — Client-evaluated `ConditionBuilder` mounts declare the scope roots their own host binds (objectui#9856) — the declared cost of objectui#9645, now paid. (objectui `030a675b0`) + - **minor** — **BREAKING** — Build the chat runtime's discriminated tool parts at the PRODUCER, and delete the last `as any` on the `useChat` call (objectui#8426; director seat, decision batch #86, 2026-09-08… (objectui `4dbab84d4`) + - **minor** — Drop a saved view's row cap that the contract refuses, at the LOWERING layer every repaired read point sits under — and say so on the one path that has no renderer to say it (obje… (objectui `5eabe8646`) + - **minor** — **BREAKING** — Refuse `operators` on `object-grid` by name, and name the correct spelling (objectui#9739, maintainer ruling 2026-09-18, letter C). (objectui `6ee259a8f`) + - **minor** — Declare the row-click modifier payload on `ObjectDataTableSchema.onRowClick` (objectui#9799), so the object-arm face stops denying a second argument its node already receives. (objectui `3b6d53bc3`) + - **minor** — **BREAKING (authoring surface): `body` is no longer a child-list key. Author `children`.** (objectui `2acd8e109`) + - **minor** — Execute the declarative row-level `operation: 'update'` action (objectui#7551, the objectui half of objectstack#14092; consumes `@objectstack/spec` 17.3.0's `ActionSchema.operatio… (objectui `feac43909`) + - **minor** — `FlowRunner`: a run that ended with `outcome: 'refused'` renders as a Close-only notice (objectui#7707 — lane 3 of the maintainer ruling on objectstack#14945, decision batch #42;… (objectui `98a6bddf9`) + - **minor** — **`resolveRecordSourceConfig`'s `data` parameter now follows the arm it is already told, instead of contradicting it.** (objectui `ab856ed30`) + - **minor** — `object-form`'s two seed keys now merge PER MEMBER. `initialData` is registered as the "alternate spelling of `initialValues` … read FIRST", and every presentation arm implemented… (objectui `63bf47da6`) + - **minor** — The drill `filter[...]` URL dialect can spell IS NOT NULL (objectui#9508). (objectui `6c06f0b50`) + - **minor** — `SchemaRenderer` now applies the objectui#9571 authored-`data` strip to the legacy `props` alias bag as well, so all three authoring spellings of that key lose the React prop seat… (objectui `7649f4364`) + - **minor** — fix(app-shell): `ConditionBuilder`'s subject dropdown stops offering roots the host does not bind (objectui `aced50d2e`) + - **minor** — `record:*` blocks honour the `aria` bag the protocol declares on them, and `RecordComponentAriaProps.ariaLabel` states the contract's inline locale vocabulary instead of narrowing… (objectui `272a53066`) + - **minor** — `object-form.customFields` now MERGES over the metadata-generated field set, as its registered description always promised (objectui#9778, maintainer ruling 2026-09-18, director s… (objectui `5a311a38d`) + - **minor** — **BREAKING** — `useNavigationOverlay` stops reading the retired `navigation.view` key, and stops substituting an authored name for the navigation-MODE token (objectui `0c789a402`) + - **minor** — Stop `FilterConditionField` writing a `between` row into stored criteria until BOTH bounds are filled in (objectui#9914). (objectui `4d7d322aa`) + - **minor** — **BREAKING** — Converge the bare `dashboard` key on `plugin-dashboard`, and retire `view:dashboard` with a by-name tombstone (objectui#9533). (objectui `e356c39ee`) + - **minor** — The percent EDIT WIDGET reads `scale` for its fraction width, not `precision` (objectui#9568). (objectui `9aa2a573b`) + - **minor** — `objectui generate page` scaffolds its child list as `children` (objectui#9847). (objectui `d2f723fd1`) + - **minor** — The VS Code extension no longer ignores a child list spelled `children`, and everything the platform scaffolds now emits that spelling (objectui#7181). (objectui `4b5bb9525`) + - **minor** — Declare `scale` on `PercentFieldMetadata` (objectui#9784). (objectui `e7084269f`) + - **minor** — Give the `file` grid cell a per-file view/download affordance (objectui#9485). (objectui `f0f204677`) + - **minor** — Forward the row-click modifier payload through the three hops that were dropping it (objectui#9462), so Cmd/Ctrl/middle-click on a row reaches a host handler. (objectui `f0f3cd5e0`) + - **minor** — A scatter's two numeric axes now honour the spec `ChartAxis` every other chart family already honours, instead of dropping it. (objectui `1b969aec6`) + - **minor** — **BREAKING** — The schema-driven condition editor now lints in the scope its host declares (objectui#8167, director ruling of 2026-09-17, batch #150 item 5 letter B). (objectui `95121fb99`) + - **minor** — **BREAKING** — `useSpecGesture`'s `onGesture` fallback payload reports the DECLARED spec gesture at every arm, not the recognizer's own name (objectui#9691). (objectui `0ee6e316e`) + - **minor** — Take the percent cell's magnitude from the value, never from the column's name (objectui#9452). (objectui `e05553c46`) + - **minor** — **BREAKING** — `useSpecGesture` fires a swipe on MEMBERSHIP of the declared direction set (objectui#7974, maintainer ruling of decision batch #70). (objectui `1cfdff814`) + - **minor** — Derive the bulk executor's data-source face from `DataSource` and stop erasing the check at the hand-off (objectui#9722). (objectui `20b5e361e`) + - **minor** — Honour `columns[].collapsed` on the registered kanban board (objectui#9628). (objectui `dea17b469`) + - **minor** — **BREAKING if your code hands a numeric primary key to one of these four** — a record id at the `DataSource` boundary is a `string`, as `@objectstack/spec` declares every record d… (objectui `bbba09840`) + - **minor** — Declare `cardTitle` — the canonical card-title spelling — on `ObjectKanbanSchema` (objectui#9606, director seat decision batch #150 item 3 letter 1, maintainer approved 2026-09-17… (objectui `78a9c6744`) + - **minor** — **BREAKING** — Stop `collapsible` from honouring an authored `open`, and retire the declaration on both published faces (objectui#8236, ADR-0049 enforce-or-remove). (objectui `ee70287e4`) + - **minor** — **BREAKING** — Refuse `onNavigate` and `onAddComment` by name on the `detail-view` JSON authoring face (objectui#9447). (objectui `ac716fff4`) + - **minor** — Export `ObjectTreeSchema` from the `@object-ui/types` root barrel (objectui#9550) (objectui `bbe57fdd5`) + - **minor** — **`buttonVariant` becomes authorable on `toast` and `sonner`.** Both registrations now declare it in their registry `inputs`, as `type: 'enum'` over exactly the six values the TS… (objectui `72f55c9ec`) + - **minor** — Build history rows now state their item count in each language's own grammar (objectui#9266). (objectui `15b33aeb4`) + - **minor** — **BREAKING** — `ChatbotSchema` no longer accepts `body`, on either published face (objectui#8572). The chat API's body params are authored as `requestBody`, which is what the rend… (objectui `c42554e94`) + - **minor** — **BREAKING** — the calendar date aliases `dateField` and `endField` are retired at both faces (objectui#8355). They are now **declared refusals**: an authored value is rejected **… (objectui `474797d62`) + - **minor** — **BREAKING** — Declare the one handler key the `'tree-view'` renderer reads (objectui#7804, the `TreeViewSchema` slice). (objectui `604476d97`) + - **minor** — ⚠️ **Behaviour change in the metadata designer: a bare field reference typed into a hook's "Run only when (optional CEL)" is now an ERROR in the editor.** It was accepted. Read th… (objectui `16603b9c9`) + - **minor** — One home for the `datetime` display convention in the readonly field widgets, one face per register (objectui#8209, maintainer ruling batch #142 item 2). (objectui `ac0e39a84`) + - **minor** — `record:details` honours `hideEmpty` on a section again — an all-empty section hides itself, `hideEmpty: false` keeps its heading and skeleton (objectui `542718f45`) + - **minor** — **BREAKING** — `ObjectKanbanSchema` no longer accepts `allowCollapse`, on either published face (objectui#8801). (objectui `d234fa91e`) + - **minor** — **`ObjectView` honours a spec-shaped named list view** (objectui#8254, the renderer half objectui#7928's option A requires — decision batch #70, 2026-09-07 — before `ObjectViewSch… (objectui `5226263ef`) + - **minor** — **BREAKING** — `AIInsightsSchema` and the `ai-insights` node type are RETIRED from the published type face (objectui#8800, ADR-0049 enforce-or-remove). (objectui `1bd1be7e2`) + - **minor** — **BREAKING (shipped as `minor` — see below):** `list` and `timeline` now refuse both content channels by name. Neither renderer reads `body` or `children`, so both keys become `?:… (objectui `53374dc07`) + - **minor** — Two frozen cell-renderer censuses now read the registry instead of a literal (objectui#8734) (objectui `3ecc369bf`) + - **minor** — **BREAKING** (declared `minor` — this repo pins its major to `@objectstack`, so a breaking change ships as a minor with this banner; AGENTS.md §版本号策略): `WidgetInput.label`, `Widge… (objectui `335abea3e`) + - **minor** — `SchemaRenderer` no longer spreads an authored `data` key as a React prop for blocks whose published `data` row is the `ViewData` OBJECT arm (objectui#9571, ruling objectui#8348 Q… (objectui `f0f4d6c8e`) + - **minor** — **BREAKING** — fix(components): Tailwind no longer compiles this package's prose into the published stylesheet (objectui `f7fcc2cdb`) + - **minor** — **BREAKING** — All three percent surfaces read `scale` for their fraction width, not `precision` (objectui#9295). (objectui `4a94c38b0`) + - **minor** — **BREAKING** — Declare the five handler keys the `'list-view'` renderer reads (objectui#7804, the `ListViewSchema` slice). (objectui `f1cd29032`) + - **minor** — Say why a capability-gated action is missing, in the action designer (objectui#7234, maintainer ruling 2026-09-08, option B). (objectui `45889f8e9`) + - **minor** — `record:chatter` / `record:discussion` now read `feed.filterMode` and `feed.enableMentions` (objectui#8968). (objectui `88561fdc4`) + - **minor** — `EventHandlersSchema` is removed from `@object-ui/types` (objectui#6910). (objectui `40f34b4ba`) + - **minor** — `ObjectTreeProps.schema` is the published `object-tree` node instead of `any`, and `getTreeConfig`'s parameter with it (objectui#8655). (objectui `009f92d7a`) + - **minor** — The four plain `objectql.ts` node faces declare the nine handler keys their registered renderers read (objectui#7804, the `objectql.ts` slice): `ObjectFormSchema.onCancel` / `.onE… (objectui `8d50bc2bf`) + - **minor** — **BREAKING (shipped as `minor` — see below):** six component schemas now refuse both content channels by name. `text`, `image`, `icon`, `tabs`, `accordion` and `calendar` (and `ui… (objectui `b7479abc7`) + - **minor** — `UIActionSchema` declares the four keys the two action renderers were reading through `as any` — `disabled`, `recordIdField`, `resultDialog`, `undoable` (objectui#8648, the object… (objectui `f95b1409f`) + - **minor** — `DataTableSchema` declares the seven handler keys its registered renderer reads: `onAddRecord`, `onBatchSave`, `onCellChange`, `onColumnResize`, `onRowActionDef`, `onRowClick` and… (objectui `75fca9669`) + - **minor** — **`NamedListView` declares the 17 members the protocol declares on the same surface, and each one now has a read point** (objectui#8980, director-seat class-one adjudication of 20… (objectui `0e2ddd418`) + - **minor** — `InputShorthandSchema` and `UiCalendarSchema` are now named exports of `@object-ui/types` itself, not only of `@object-ui/types/form` and `@object-ui/types/zod` (objectui#9406). (objectui `bbc9dc34e`) + - **minor** — The ingestion choke point says out loud when it CANNOT fold a retired spelling (objectui#8938) (objectui `84defabb2`) + - **minor** — fix(plugin-calendar): type `ObjectCalendar` at the published `object-calendar` schema, and declare the `calendar` container (objectui `51e144eda`) + - **minor** — A record id is a `string` everywhere in the published types, as `@objectstack/spec` has always declared it. Three published declarations that admitted `number` no longer do. (objectui `72d65875c`) + - **minor** — `AppAction.items` 上的 `shortcut` 由「静默剥掉」改为「具名拒收」 (objectui `c9f9baedf`) + - **minor** — The drill "escape hatch" can spell an empty bucket: the `filter[...]` URL dialect grows an is-null operator on both sides plus a chip for it (objectui#9159). (objectui `136ff4bb3`) + - **minor** — **BREAKING** — Retire 23 measured-dead locale keys from all ten packs — two whole families and eleven individual leaves (objectui#8754; director seat summon #22, 2026-09-12, maintainer verbatim… (objectui `ef5200107`) + - **minor** — Give a read-only `file` field a per-file view/download affordance (objectui#9161). (objectui `6d5db7b17`) + - **minor** — `record:related_list` accepts `relationshipValueField`, and three record renderers stop erasing their own props annotation (objectui#8649). (objectui `541ce4e02`) + - **minor** — `list-view`: retire the legacy `title` alias from the export-filename read, and pin the `rowActionDefs` exemption at both of `ListView`'s read sites (objectui#8653, the objectui#8… (objectui `3a9ab021c`) + - **minor** — The wrong-layer root advisory asks the platform for its verdict instead of keeping a second copy of it (objectui#9318). (objectui `e3cb47624`) + - **minor** — The row/card click props on the view components now declare the modifier payload they have always been invoked with (objectui#9357). (objectui `502eb5880`) + - **minor** — **BREAKING** — Declare the two handler keys the `'detail'` renderer reads (objectui#7804, the `plugin-detail` slice; director seat, decision batch #69, 2026-09-07). (objectui `7ca6ddd4b`) + - **minor** — **BREAKING (scored `minor` per this repo's version-alignment convention)** — `KanbanRenderer` takes `onCardMove` as an explicit React prop, and the `object-kanban` document face t… (objectui `55f39ee90`) + - **minor** — `UseNavigationOverlayOptions.onRowClick` now declares the modifier payload it has always been called with (objectui#9357). (objectui `0ce32d514`) + - **minor** — Give the flow `end` node's inspector a typed control for `config.message`, the key a refused outcome requires (objectui#9336). (objectui `d27dcf2c9`) + - **minor** — A record page shows a discussion panel if and only if it composes one (objectui#7298). (objectui `7aaa89160`) + - **minor** — Take lucide's runtime `icons` record off the console's eager path (objectui#9251, maintainer ruling of 2026-09-13, decision batch #132 item 4). (objectui `67485872e`) + - **minor** — One record-overlay shell: all five list-type renderers honour all four overlay `navigation.mode` values (objectui#9299, director seat decision batch #128 item 1, 2026-09-13). (objectui `7098eed36`) + - **minor** — **BREAKING** — `RecordDetailsComponentProps` no longer declares `layout` (objectui#9040 item 1). (objectui `63fb72c42`) + - **minor** — An unbound map now REFUSES; coordinates are never guessed (objectui#8169, maintainer ruling 2026-09-07, decision batch #67, option B). (objectui `cb725e78f`) + - **minor** — **BREAKING** — The console's "app not available" screen now says what it measured, and the by-name app probe stopped folding four answers into one (objectui#9262). (objectui `2bf34f70c`) + - **minor** — Name two of objectui#8499's arms on the `@object-ui/types/zod` barrel, and record the other two as absent by decision (objectui#9067, director seat, decision batch #121 item 5, ma… (objectui `279e48e8c`) + - **minor** — An `onCardClick` supplied to an `object-kanban` board runs **once** per card click instead of twice, and the published declaration of the key grows the second parameter the surviv… (objectui `a272a4ffe`) + - **minor** — **BREAKING** — Retire the "Tremor/simple format" adapter in `ChartRenderer` — the `index`, `category` and `value` reads (objectui#8650, triage ruling `5619609278` on AGENTS.md #0.1: route to the… (objectui `bb383e83d`) + - …and 484 more releasing changesets in this range (list capped at 100; see the objectui range below). + + ⚠️ 98 of these carry a breaking change: 98 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 279 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ ci: one `Test` aggregator becomes the required test context, shards 4 -> 8, dist pins get their own job (#9584) (objectui `eb1c9f9d2`) + - _(no changeset)_ docs(skills,AGENTS): teach `action:button` + `actionType`, retire the `events` bag (#9592) (objectui `7550728a6`) + - _(no changeset)_ docs(census): state the no-changeset-for-tooling ruling in the census header (objectui#9795) (#10077) (objectui `3631937fc`) + - _(no changeset)_ refactor(scripts): the item-carrier disposition is RULED and says it is not a dialect (#10087) (objectui `205b97353`) + - _(no changeset)_ docs(guide): remove the lazy-loading promise nothing keeps from the schema-rendering guide (objectui#9989) (#10086) (objectui `66d870feb`) + - _(no changeset)_ docs(detail-view): teach `dueLike` where a detail-view field is authored (#10075) (objectui `af57dc729`) + - _(no changeset)_ gate(doc-types): walk packages/NAME/README.md, with its ruled DOC_TYPE_EXEMPTIONS entries (#9996) (objectui `6f76bb7ad`) + - _(no changeset)_ docs(skills): move the three published guides off the retired `dataSource` expression root (#9378) (objectui `8ec28d73d`) + - _(no changeset)_ docs(skills): page-builder.md names the channel that publishes expression roots (objectui#9672) (#9997) (objectui `078f2e4f7`) + - _(no changeset)_ docs(skills): gate the usePermissions example on can(), a boolean, and mark its fence (objectui#9671) (#9994) (objectui `3d72fb65f`) + - _(no changeset)_ docs(guide): user-state-persistence taught the rejected user_app_state shape (objectui#5950) (#10011) (objectui `ec1de927d`) + - _(no changeset)_ docs(changeset): four pending bodies cite the retire-vs-remove discriminator instead of restating it (#9970) (objectui `706e09f27`) + - _(no changeset)_ docs(scripts): the $-dialect census keeps its by-name self carve-out, with the reasons pinned (objectui#9891) (#9915) (objectui `2bc9829ea`) + - _(no changeset)_ fix(scripts): type-check-coverage stale-entry messages carry no card-ending keyword (#9898) (objectui `fb7dfedbb`) + - _(no changeset)_ chore(claude): allow-list the two landing REST calls in settings.json (objectui#9862) (#9900) (objectui `99bcde511`) + - _(no changeset)_ feat(scripts): read one changeset against itself, and date the contradiction (#9850) (objectui `5365b4c34`) + - _(no changeset)_ fix(scripts): the polarity census reads the optional marker as a decoration, not as part of the key (objectui#9794) (#9831) (objectui `3ae740c4a`) + - _(no changeset)_ docs(changeset): date the `MEMBER_PIN_EXEMPTION_CEILING` reading in the pending 8171 body (#9823) (objectui `26ac50369`) + - _(no changeset)_ docs(changeset): date the rotted "keeps its own copy" clause in the 5993 note (#9822) (objectui `aed4b4f71`) + - _(no changeset)_ docs(plugin-detail): state the Reference Rail opt-in and the option its count is read from (#9811) (objectui `78a0582ba`) + - _(no changeset)_ fix(scripts): the polarity census reads a key as a NAME, not as any lowercase token (#9793) (objectui `6a0d1a435`) + - _(no changeset)_ docs(changeset): correct the `SpinnerSchema` member attribution in the 5632 pending body (#9763) (objectui `d18322415`) + - _(no changeset)_ fix(scripts): forward-parity stale-entry messages carry no card-ending keyword (#9755) (objectui `2414e3751`) + - _(no changeset)_ test(ci-cd-doc): read the Playwright reporter through the shared comment mask (#9748) (objectui `5e8a31aa1`) + - _(no changeset)_ feat(gate): read BORN-FALSE claims — an address this change's own diff moves (#9744) (objectui `cbb2e45ac`) + - _(no changeset)_ fix(scripts): key the indirect registration bypass by collection, not by file (#9724) (objectui `64deb1603`) + - _(no changeset)_ fix(scripts): refuse a `-t` name filter that cannot match the title it spells (objectui#9660) (#9730) (objectui `10cc93b79`) + - _(no changeset)_ docs(changeset): date the two rotted present-tense declaration claims in the kanban pending entries (#9723) (objectui `2dbb49975`) + - _(no changeset)_ fix(scripts): derive the indirect registrations' namespace from the call, not from the hand-kept table (#9716) (objectui `4cf57b6da`) + - _(no changeset)_ docs(changeset): stop the pending 8499 entry publishing a stale registry size (#9715) (objectui `e3ff936ca`) + - _(no changeset)_ docs(changeset): retire two present-tense "in-flight PR" claims before they publish (objectui#9706) (#9714) (objectui `3172b85fa`) + - _(no changeset)_ fix(ci): the lockfile-dedupe gate reports on pull requests instead of blocking (#9707) (objectui `50e5cafe3`) + - _(no changeset)_ test(ci): widen the live-reading lock to what required jobs RUN, not just the workflows (#9696) (objectui `dd871fc08`) + - _(no changeset)_ docs(changeset): correct the AIInsights paragraph in the 8178 entry (objectui#9625) (#9694) (objectui `a961ad174`) + - _(no changeset)_ test(ci): take the live registry reading out of a REQUIRED context (objectui#9562) (#9690) (objectui `29a8a9526`) + - _(no changeset)_ docs(plugin-calendar): compile the Direct Component Usage block and gate it (#9678) (objectui `bb2d33570`) + - _(no changeset)_ docs(skills): auth-permissions stops teaching `dataSource` as the `data` expression root (objectui#9379) (#9669) (objectui `61b755346`) + - _(no changeset)_ feat(scripts): census every page key a renderer reads against PageSchema (objectui#9438) (#9670) (objectui `53f2b189e`) + - _(no changeset)_ fix(scripts): the vite resolve oracle stops writing its scratch root into the swept repo root (objectui#9468) (#9658) (objectui `e896c3899`) + - _(no changeset)_ docs(tooling): the handler-key gate names the owner its ledger consults, in all three places (objectui#9456) (#9657) (objectui `0fb382eca`) + - _(no changeset)_ fix(scripts): the spec-symbol ratchet detects its own dead anchor (objectui#9537) (#9646) (objectui `0b7be13ad`) + - _(no changeset)_ chore(labeler): delete the inert `designer` rule and the exemption it needed (objectui#7771) (#9644) (objectui `8ad231846`) + - _(no changeset)_ feat(scripts): gate a test source naming a changeset the tree carries (objectui#9583) (#9635) (objectui `dda8f3815`) + - _(no changeset)_ fix(e2e): root the live storage-state write and both storageState configs on their own file (#9636) (objectui `8d1242b58`) + - _(no changeset)_ fix(census): the continuation-scope docblocks stop crediting a guard that cannot fire there (#9632) (objectui `15f01223d`) + - _(no changeset)_ fix(scripts): lint-coverage's stale-entry message stops spelling a closing keyword before its anchor (objectui#9538) (#9595) (objectui `ff29450a9`) + - _(no changeset)_ fix(scripts): make the body-dialect census report the key population it counted over (#9599) (objectui `a5b660f21`) + - _(no changeset)_ docs(plugins): author the `listViews` filter operator in its canonical spelling (objectui#7993) (#9612) (objectui `fdbfe2302`) + - _(no changeset)_ docs(components): document `wrapperClass` and its new refusal on the five pages that omit it (#9614) (objectui `e0a87dbbd`) + - _(no changeset)_ fix(scripts): check:spec-symbols reads EVERY occurrence of a claim phrase, not the first (#9608) (objectui `253c31418`) + - _(no changeset)_ docs(agents): point AGENTS.md at the invocation guard pin test instead of counting its refusals (objectui#9505) (#9587) (objectui `163630bc9`) + - _(no changeset)_ feat(ci): deliver the changeset claim re-read onto the pull request (objectui#9140) (#9581) (objectui `f508000b5`) + - _(no changeset)_ test(scripts): stop pinning a pending changeset filename in the two gate suites (#9582) (objectui `29f4c0582`) + - _(no changeset)_ docs(agents): record the merge_group leg's SECOND refusal predicate (the contract-review carrier) (#9466) (objectui `4b9a0a8f0`) + - _(no changeset)_ fix(scripts): fail on a new bare-name registry collision (objectui#9264) (#9531) (objectui `2904c5c40`) + - _(no changeset)_ build(tsconfig): raise test-program `lib` to ES2022 across 31 packages (#9512) (objectui `4e96becf5`) + - _(no changeset)_ fix(scripts): label an `any` index signature `index signature`, not `return type` (#9510) (objectui `2e1d0f032`) + - _(no changeset)_ fix(devx): refuse an appended path filter that a baked positional already swallows (objectui#7814) (#9504) (objectui `8fa7d69af`) + - _(no changeset)_ docs(gate): name key-refusal as a class check-spec-range-floors deliberately does not judge (objectui#9036) (#9481) (objectui `02d424ab3`) + - _(no changeset)_ fix(tests): register @testing-library/jest-dom in three test programs' types (#9480) (objectui `360300fea`) + - _(no changeset)_ ci(coverage): give the instrumented lane its own per-test budget so the coverage gate can run (#9474) (objectui `511e4024a`) + - _(no changeset)_ test(docs): pin command parity for every ci-cd-pipeline.md section by default (#9467) (objectui `db6aa19a9`) + - _(no changeset)_ docs: finish objectui#9297 — schema-rendering.md stops teaching silence, and the expression sandbox states its real allowlist (#9461) (objectui `75fc9df6e`) + - _(no changeset)_ fix(devx): resolve spec export conditions in the map's key order, and say which arm won (#9455) (objectui `72932dfcd`) + - _(no changeset)_ docs: publish each page's own values through the scope channel on the three remaining teaching surfaces (#9376) (objectui `035d3fac3`) + - _(no changeset)_ docs(fields): blank line before `## Field Schema` on four field pages (#9435) (objectui `63d9ca6f4`) + - _(no changeset)_ refactor(tsconfig): rename tsconfig.base.json to what it is (objectui#9330) (#9426) (objectui `6c7319753`) + - _(no changeset)_ docs(api): stop teaching quickAdd and allowCollapse on the object-kanban table (#9353) (objectui `dab9f96ec`) + - _(no changeset)_ docs(tooling): reserve --rewrite-governed-file by its condition, not by actor (#9383) (objectui `07da32e28`) + - _(no changeset)_ fix(skills): guard the DataSource read in the marked data-integration example (#9352) (objectui `28be0786d`) + - _(no changeset)_ hooks: the three remaining guards name the environment their hatch variable must be set in, never a command prefix (#9300) (objectui `a5921a0f8`) + - _(no changeset)_ fix(ci): put the `scripts/__tests__` markdown population on the shard trigger (#9141) (objectui `4c0dc090a`) + - _(no changeset)_ docs(skills): teach object-nav target exclusivity, not a precedence the spec refuses (#9227) (objectui `bedd7344f`) + - _(no changeset)_ docs(agents): require a runtime reading for inertness claims, and a control for any population-size reading (#9226) (objectui `aebc3a31f`) + - _(no changeset)_ fix(ci): read a locale-catalogue chunk whose content hash contains a hyphen (#9228) (objectui `2102f6125`) + - _(no changeset)_ feat(ci): refuse a merge group whose queued pull request still carries `needs:contract-review` (#9212) (objectui `a94e4d073`) + - _(no changeset)_ docs(scripts): record what a re-baseline absorbs, and that BASELINE.commit cannot be checked from `main` (objectui#7848) (#9208) (objectui `75d34d604`) + - _(no changeset)_ devx(scripts): register check-bash32-floor.mjs in the upstream port pin at its own ref (#9207) (objectui `0f7f8e61c`) + - _(no changeset)_ fix(gate): the expression-carriage blind-spot leg reads the JS object-literal dialect (#9193) (objectui `7696daac0`) + - _(no changeset)_ test(scripts): census why the push-lane coverage gate was red, by cause (#9180) (objectui `87f174c00`) + - _(no changeset)_ fix(gate): make an unrecognised half status LOUD in the eager-closure fold (#9156) (objectui `af674b99a`) + - _(no changeset)_ test(scripts): derive the zero-test workspace members and pin the exclusion so it can expire (objectui#9106) (#9147) (objectui `049f09504`) + - _(no changeset)_ fix(prompts): rule each key-teaching section, and widen check:prompt-keys to read them (#9143) (objectui `44a9b4bd2`) + - _(no changeset)_ docs(changeset): correct six pending changesets whose claims a later merge falsified (#9139) (objectui `36fc71f1c`) + - _(no changeset)_ feat(scripts): derive what each pack-object importer reads off the pack, and how deep (objectui#9046) (#9128) (objectui `c736084bf`) + - _(no changeset)_ fix(docs): stop pricing the eager-closure ruling with a page count nothing derives (#9118) (objectui `567f37019`) + - _(no changeset)_ test(devx): give layout, test-support and console-starter a package-level test entry (#9105) (objectui `58a4fada7`) + - _(no changeset)_ fix(prompts): teach only view keys a real renderer answers, and gate it (#9099) (objectui `d2f0c108c`) + - _(no changeset)_ ci(test): run the shards when a markdown document a test READS changes (#9097) (objectui `a92eef266`) + - _(no changeset)_ fix(scripts): check-side-effects-array walks every published entry point, not just the source barrel (#9084) (objectui `7f3a7ea69`) + - _(no changeset)_ test(ci-docs): pin the Workflow Inventory table, which the inventory test could not see (#9062) (objectui `4ffc333df`) + - _(no changeset)_ docs(changeset): correct two now-false sentences in pending types changesets (#9064) (objectui `a7a818383`) + - _(no changeset)_ fix(scripts): derive the dead-keys pack-object importer population, pin its readings (#9047) (objectui `13372e19d`) + - _(no changeset)_ docs(changeset): drop the stale cardinal from the objectui#8315 changeset (#9023) (objectui `a650bb356`) + - _(no changeset)_ fix(ci): trigger Build Docs on what the site build actually consumes (#9015) (objectui `1e433418b`) + - _(no changeset)_ fix(ci): make pre-install-import-graph.yml point at its population instead of counting it (#8995) (objectui `e8b7b0785`) + - _(no changeset)_ docs(changeset): correct two present-tense claims a later PR falsified (#8994) (objectui `35c6a3453`) + - _(no changeset)_ fix(lint): drop git-ignored build output from ESLint's own walk (#8986) (objectui `403d9efde`) + - _(no changeset)_ docs(setup): point setup.sh's third "Next steps" read at a doc that exists (#8982) (objectui `6112e0dad`) + - _(no changeset)_ test(scripts): census the `$`-dialect lowercase aliases before objectui#8568 is ruled (#8977) (objectui `ca67d42f0`) + - …and 179 more commits with no changeset — this list is capped at 100, the range has 279 in total. Run `node scripts/objectui-range.mjs --from 53ded82bf7a4 --to 87af769e9a3e --all` for the complete list. + + + + objectui range: `53ded82bf7a4...87af769e9a3e` +- 3cf6449: Console (objectui) refreshed to `dd3f7e1be356`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 325 releasing of 348 changesets added across 328 non-merge commits; omitted: 23 release-nothing changesets, 25 commits carrying no changeset (they ship no package code). + + - **minor** — Show the environment admin a storage-capacity banner from the tenant runtime's own storage verdict (objectui#10439, the objectui half of cloud#2135). (objectui `4357a278b`) + - **minor** — **BREAKING** — The metadata-admin `SchemaForm` no longer crashes on a form section that references a field group, and the form-section authoring type accepts that shape (objectui#8725). (objectui `8522396c0`) + - **minor** — fix(auth, console): a registration started from an invitation link comes back to the invitation after email verification (objectui `7ea8118f7`) + - **minor** — **BREAKING** — A grouped grid over a data source that declares no `queryGroupHeaders` now **refuses grouping** instead of grouping a page of rows (objectui#10881, maintainer ruling F). Grouping… (objectui `244d516df`) + - **minor** — **BREAKING (shipped as `minor` — see below):** twelve node types now refuse both content channels by name — `object-grid`, `object-form`, `object-kanban`, `object-map`, `object-tr… (objectui `775e0795b`) + - **minor** — `safeValidateSchema` — and so `objectui validate` — accepts twenty of the ADR-0080 public blocks, the ones whose props `@objectstack/spec` declares as a `ComponentPropsMap` row le… (objectui `6a7f24e92`) + - **minor** — **BREAKING** — fix(types,plugin-designer)!: the Studio app wizard saves a document the platform accepts, and an edit keeps the stored `accentColor` (objectui#10867) (objectui `73433769a`) + - **minor** — **BREAKING (authoring)** — the three `@object-ui/plugin-ai` node declarations stop offering members that no runtime honours, and type the handler slots their components call (obje… (objectui `c30c8dd4c`) + - **minor** — **BREAKING (authoring surface and render behaviour, shipped as `minor` per this repo's version policy): an item-level `body` on a `list` item or a `tabs` item is refused at the do… (objectui `412818870`) + - **minor** — **BREAKING** — Grid grouping is now **server-side** (objectui#7189, maintainer ruling A): the set of groups and every number in a group header — the count and any per-group aggregation — come fr… (objectui `e2b38267d`) + - **minor** — `safeValidateSchema` — and so `objectui validate` — accepts the three `@object-ui/plugin-ai` node types, `ai-form-assist`, `ai-recommendations` and `nl-query` (objectui#10859, bat… (objectui `c6678b1bd`) + - **minor** — **BREAKING** — **`ObjectViewSchema.listViews` is the protocol's named-view record, by reference** (objectui#7928: maintainer ruling A, then the director ruling on `options`). (objectui `b93e245f9`) + - **minor** — `ObjectChartSchema` (and its TS twin) accepts the `object-chart` node that the react tier's `` block produces: the spec's `{ name }` series arm, with the chart family… (objectui `b956e693d`) + - **minor** — **BREAKING** — `NamedListView.densityMode` is retired on the TypeScript authoring face (objectui#7924, director-seat ruling **A′**). It is now a `?: never` tombstone: a TypeScript… (objectui `66e8b2ab3`) + - **minor** — **BREAKING** — The Studio's app wizard saves an app the platform accepts, and the app favicon has one spelling, `branding.favicon` (objectui#10842). (objectui `25cb364b2`) + - **minor** — **BREAKING** — fix(types)!: a `filter-builder` `value` is a filter group only, and `defaultValue` is retired (objectui#10825) (objectui `4aebea00a`) + - **minor** — **BREAKING** — Retire `events`, `orientation` and `position` on the timeline node (objectui#6170, ADR-0049 stage 2 — maintainer ruling 2026-08-25: these three go the enforce-or-remove route; wit… (objectui `195052fff`) + - **minor** — An app's `branding.logo` now shows in the console, and it is the only logo spelling (objectui#10827). (objectui `f97677471`) + - **minor** — Retire the published alias `PluginComponentInput` — use `ComponentInput` (objectui#5674). (objectui `fda49e557`) + - **minor** — **BREAKING** — feat(types)!: a timeline item is a declared feed item or gantt row, judged against the shape `variant` selects (objectui `3a5817f07`) + - **minor** — `MaskedCellRenderer`, the mask a `password` / `secret` cell draws, is now exported, so a table that cannot type a column yet draws it WITHHELD instead of as text (objectui#10657,… (objectui `12809a595`) + - **minor** — `object-grid` no longer draws an untyped column as text before its object schema has loaded: the column is WITHHELD until it has, and stays withheld when the read fails; a host ca… (objectui `12809a595`) + - **minor** — feat(components)!: `FilterBuilderCondition` and `FilterGroup` derive from `@object-ui/types` — `operator` narrows to `FilterBuilderOperator`, the group `id` becomes optional (obje… (objectui `fb91ac9b0`) + - **minor** — feat(types)!: a `filter-builder` group is flat — a nested sub-group, `allowGroups` and `maxDepth` are retired and refused by name (objectui#9306) (objectui `fb91ac9b0`) + - **minor** — **BREAKING** — fix(fields): an avatar pick uploads through the `UploadProvider` and submits its `sys_file` id, or is refused by name — never stored as a `data:` URL (objectui `ff94a12d2`) + - **minor** — **BREAKING** — feat(plugin-chatbot)!: `useObjectChat` drops the never-honoured `maxToolRoundtrips` option (objectui#5605) (objectui `95bad1236`) + - **minor** — **BREAKING** — feat(types)!: `maxToolRoundtrips` is retired behind a tombstone on all three chat nodes (objectui#5605) (objectui `95bad1236`) + - **minor** — `@object-ui/core` exports `withoutDeniedFields(record, policy, objectName, extraKeep?)`, the field-read rule for one record: the record as the viewer may read it on `objectName` (… (objectui `9a5f99880`) + - **minor** — **BREAKING** — feat(components): `div` is deprecated on the html tier too — a `kind:'html'` page that authors `
` is refused at compile time, and the error names `box` (objectui `39b8d5102`) + - **minor** — **BREAKING** — **A named view's stray `kanban.groupBy` on an `object-view` document is now refused by name, with the message the `list-view` route already gives.** (objectui `a14fb23b3`) + - **minor** — feat(components): `Calendar` takes a `localeTag`, a BCP-47 tag it reads in place of the display locale (objectui#10747) (objectui `13220af93`) + - **minor** — fix(components): an action container's members evaluate `properties.params` the way a top-level `action:button` does (objectui `6cf599985`) + - **minor** — **BREAKING** — fix(fields): a file or image upload that surfaces no `sys_file` id is refused by name, never submitted as an inline blob (objectui `c907a9c87`) + - **minor** — feat(core,components): the html tier registers and declares `code` — `inline` on a `kind:'html'` page renders its text instead of the `field:code` editor (objectui `29b45f6a0`) + - **minor** — feat(types)!: `FilterUISchema.filters[].operator` is retired on the `filter-ui` node and refused by name (objectui `17cc3a377`) + - **minor** — ⚠️ **BREAKING (scored `minor` per this repository's version-alignment convention) — `@object-ui/types` no longer exports the eight "Phase 3.5" validation types (objectui#10719).** (objectui `97b6c2199`) + - **minor** — feat(app-shell): a package-provided permission set offers "Clone to customize" as its primary action, and names it first (objectui#5987) (objectui `35eb2f0ee`) + - **minor** — feat(types,runner)!: the app node's `actions` array, `AppAction` and `AppActionSchema` are retired; app-level actions are `navigation` items of `type: 'action'` (objectui `25c7d584e`) + - **minor** — feat(core,sdui-parser): the published manifest declares the html tier's registered intrinsic elements, marked `tier: 'html'` — `div` stays out (objectui `baac95a26`) + - **minor** — `DashboardComponentSchema.header` and `.globalFilters` now state `@objectstack/spec`'s `DashboardSchema` member on both faces, the TypeScript interface and the Zod validator (obje… (objectui `1422a920e`) + - **minor** — **BREAKING** — three more record ids that the objectui#9511 ruling's enumeration left out are strings now (objectui#10078). `CommentSearchResult.recordId`, `RecordSubscription.rec… (objectui `7b395d8c5`) + - **minor** — feat(types)!: `ObjectChartSchema.xAxisField` / `yAxisFields` / `aggregation` are retired on the `object-chart` node, each refused by name with the spec spelling as its remedy (objectui `932739704`) + - **minor** — **BREAKING** — `object-data-table` refuses `drillDown.filter`, `.maxRows`, `.report` and `target: 'navigate'`, and `object-pivot` refuses `drillDown.mode` (objectui#10685) (objectui `5ad3b8886`) + - **minor** — **BREAKING (scored `minor` per this repo's version-alignment convention)** — `ColumnWidthConfig` and its Zod mirror `ColumnWidthConfigSchema` are deleted (objectui#10582, ADR-0049… (objectui `c3a26ccda`) + - **minor** — Every data node that sends its own authored `filter` into a query now resolves the spec's context tokens first (objectui#10666). With `filter: [['owner', '=', '{current_user_id}']… (objectui `f9c06ef6a`) + - **minor** — Once the grid has its object schema, a masked grid field's raw value is withheld from copy, tooltip, inline edit, the client export and the mobile card, and a masked field is refu… (objectui `a66e58ea9`) + - **minor** — ⚠️ **BREAKING — `@object-ui/core` no longer exports `ValidationEngine`, `defaultValidationEngine`, `validate` or `validateFields` (objectui#7659).** (objectui `aef97e504`) + - **minor** — `@object-ui/i18n` now publishes `TranslateFn` — i18next's `t` narrowed to `(key: string, options?: Record) => string` — as the one authority for that name (object… (objectui `a695f505f`) + - **minor** — **`@object-ui/types/zod` now accepts a `combobox` without `options` and a `command` without `groups`** (objectui#6033, rulings C7 / C8) (objectui `1e946c96a`) + - **minor** — feat(plugin-chatbot): `ChatbotEnhanced` takes an optional `surfaceContextTitle`, the tooltip of the surface-context chip (objectui `69a6fc1ce`) + - **minor** — **BREAKING** — BREAKING (`@object-ui/components`): `RefreshIndicator`'s `ariaLabel` prop is now required and has no default. It used to default to the English literal "Refreshing", so the progre… (objectui `9fbbb17a2`) + - **minor** — `ObjectChartSchema` (and its TS twin) declares `xAxis` and `yAxis` as `@objectstack/spec`'s axis config: `xAxis` is ONE `ChartAxisSchema` object and `yAxis` a list of them. The Zo… (objectui `fb13e8583`) + - **minor** — **Breaking:** `AppSidebar` is removed from `@object-ui/app-shell` (objectui#5817). The package entry, `dist/index.d.ts` included, no longer exports it. The bump is `minor` only be… (objectui `8e5fba7a0`) + - **minor** — fix(fields)!: the record picker's display column labels a record the way the lookup dropdown does, and `RecordPickerDialog`'s `titleFormat` prop is replaced by `objectSchema` (objectui `8c0e5508b`) + - **minor** — **BREAKING — removes a published export from two packages.** Retire the `PermissionGuardConfig` type (objectui#8024, ADR-0049 enforce-or-remove). The name is deleted from `@object… (objectui `8cd8eb562`) + - **minor** — feat(fields): `isMaskedFieldType()` and `MASKED_FIELD_TYPES` answer "is this field type's cell drawn as a mask?" (objectui `ab7751321`) + - **minor** — **BREAKING** — Framework navigation can now reach the console pages that the retired System Hub card wall used to be the only link to (objectui#10520). (objectui `50e41f738`) + - **minor** — **BREAKING** — `ObjectChartBlock`, the registry shell exported beside `ObjectChart`, declares its props instead of taking `(props: any)` (objectui#8885). (objectui `47ba7903d`) + - **minor** — **BREAKING** — fix(types): a `filter-builder` condition's `value` is judged against its `operator` by the protocol's own rule (objectui `d22b37bd8`) + - **minor** — `ChartRendererProps.schema.series`: the `dataKey` arm now declares `type?: string`, the per-series family override the `name` arm already declares, with the same member type (obje… (objectui `aa6be300b`) + - **minor** — An authored `detail-section` node now takes `hideEmpty`, and it reaches the section. (objectui `9cbe4dbca`) + - **minor** — **BREAKING (scored `minor` per this repo's version-alignment convention)** — `useColumnWidths` is removed from `@object-ui/plugin-kanban`, together with its `UseColumnWidthsOption… (objectui `1434bb434`) + - **minor** — **BREAKING (node type key):** `@object-ui/plugin-map` no longer registers the bare `map` node type key, and its namespaced twin `view:map` goes with it. A node authored `"type": "… (objectui `64563a915`) + - **minor** — **BREAKING** — The `FilterBuilder` dropdown speaks the protocol's operator ids (objectui#9306). (objectui `641fb55b0`) + - **minor** — **BREAKING** — Retire `object-grid`'s `defaultSort` and `object-view`'s `table.defaultSort` (objectui#5861 — ADR-0049 enforce-or-remove, the "C half" of the 2026-08-22 ruling on objectui#4869). (objectui `0cba1b73f`) + - **minor** — `ObjectGantt` refreshes in place when its query really changes, instead of tearing the chart down to the loading placeholder (objectui#7237). (objectui `db3896c4c`) + - **minor** — Retire the widget `dataProvider` key. It was declared and written, but nothing read it (objectui#7353, ADR-0049 remove arm). (objectui `2ceb43a7d`) + - **minor** — feat(app-shell): the Object Field inspector authors `valueDomain` on text fields (objectui#7597) (objectui `51d18f161`) + - **minor** — feat(app-shell): an assignment value can be written as a CEL expression in the flow designer (objectui `82bdd3ac9`) + - **minor** — `ChartSchema` (and its TS twin) declares `xAxis` and `yAxis` as `@objectstack/spec`'s axis config object — `ChartAxisSchema`, referenced by the Zod mirror and typed by the spec's… (objectui `a137d0cc8`) + - **minor** — An authored `detail-section` node now takes `icon`, and draws it. (objectui `80a0ecda9`) + - **minor** — fix(react,app-shell): `usePageAssignment` picks a record page by `type` alone — `pageType` is a key `PageSchema` refuses (objectui `39395455a`) + - **minor** — A `chart` list view bound to a semantic `dataset` takes its scope from the dataset: a view filter on it is now refused at authoring, and its toolbar no longer offers filter contro… (objectui `ae98f1d44`) + - **minor** — **BREAKING: `useClientNotifications` is removed from `@object-ui/react`** (objectui `71a4a5335`) + - **minor** — fix(plugin-form): `customFields` members render inside explicit `sections` on the drawer, modal, tabbed, wizard and split arms (objectui `d5cb2619f`) + - **minor** — **BREAKING:** `@object-ui/core` no longer exports `DataScopeManager` or the row-level filter vocabulary that came with it (objectui#7750). This narrows the package's published sur… (objectui `93fea2ef9`) + - **minor** — feat(types): `FilterOperatorSchema` is the protocol's operator set, and normalises aliases on parse (objectui `1779e8dee`) + - **minor** — fix(fields,components): a single select or radio emptied by a cascade clear is now saved — as `null` (objectui `274e14af4`) + - **minor** — A currency field in `dynamic` mode shows the tenant's currency, not its `currencyConfig.defaultCurrency` (objectui#10422). (objectui `ea9d17fb6`) + - **minor** — **BREAKING** — feat(components)!: an action node's static values ride `properties.params`; `params` is only the input list (objectui `808f33986`) + - **minor** — An incremental edit is no longer read as a whole-app build. An `apply_edit` result says `kind: 'edit'` on its envelope, and its `drafted[]` may list the `app` artifact the edit re… (objectui `e6203d756`) + - **minor** — The form routes `/f/:slug` and `/forms/:name` render every field with the widget the shared field resolver names for it — the same widget the record form renders (objectui#10179,… (objectui `212c45175`) + - **minor** — fix(dashboard,charts): two dashboard surfaces the spec types as translatable now resolve (objectui `061f5e829`) + - **minor** — **BREAKING:** `@object-ui/plugin-detail` no longer exports seven components that nothing registered and nothing mounted (objectui#7192, objectui#7175). This narrows the package's… (objectui `0348bc9f1`) + - **minor** — fix(plugin-list): a map list view requests the fields its markers are drawn from (objectui `974760adb`) + - **minor** — The permission matrix no longer locks a tenant's own permission set as if a code package shipped it (objectui#4526). (objectui `d32824aba`) + - **minor** — **BREAKING** — The non-grid row ceiling's mechanism moves to `@object-ui/core`, its probe row lives in one query helper, and its footnote takes the result and nothing else (objectui#7508, mainta… (objectui `1237ae45a`) + - **minor** — **BREAKING** — fix(types): the spec-derived `ListViewSchema` and `PageNodeSchema` refuse what the spec's publish door refuses — they now run the spec's own object-level checks (objectui#7715) (objectui `309c75ed4`) + - **minor** — The grid's row Delete and bulk Delete now delete the record when `ObjectView` renders the grid itself — the registered `object-view` renderer, with no host list view (objectui#103… (objectui `ff14e29b5`) + - **minor** — New export `recordDelete` — the one record-delete core that list hosts bind to, so a Delete behaves the same wherever it is offered (objectui#10383). (objectui `ff14e29b5`) + - **minor** — fix(core): Undo of an `undoable` update no longer writes `null` over a field the row did not carry (objectui `8acc51b90`) + - **minor** — Retire the undeclared timeline `metaFields` reads in both packages (objectui#10222). (objectui `57a2bc281`) + - **minor** — The line-item grid (`GridField` / `LineItemsField`) no longer gives a currency cell a default of two decimal places or a default `¥` symbol. A currency column without a `scale` no… (objectui `2fc2a2439`) + - **minor** — The Studio flow Runs panel now groups and labels parallel-branch steps by the branch they ran in, and names the loop row as well when the parallel node sits inside a loop (objectu… (objectui `378a4f6ca`) + - **minor** — fix(plugin-dashboard): a metric tile counting rows over a currency field shows a plain number, not money (objectui `6dc82a381`) + - **minor** — Translations that `I18nProvider` loads after mount now reach the readers already on screen (objectui#10382). (objectui `5e67837e8`) + - **minor** — One declaration of which chart families ignore `compareTo` (objectui#7495). (objectui `721d1e008`) + - **minor** — **BREAKING** — fix(types): retire the eight `header-bar` keys the renderer never read (objectui#10387) (objectui `9b281519f`) + - **minor** — A `dataSource` binding's own `limit` that the contract refuses is now treated as **not authored**: the row cap falls through to the named saved view's usable cap, and only when th… (objectui `97abedc98`) + - **minor** — The four declared action renderers (`action:button`, `action:icon`, `action:group`, `action:menu`) now pass an action's `objectName` to the action runner, and `action:button`, `ac… (objectui `778138e20`) + - …and 225 more releasing changesets in this range (list capped at 100; see the objectui range below). + + ⚠️ 41 of these carry a breaking change: 41 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 25 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ fix(console): an injected @objectstack/client joins the vendor-objectstack chunk group (objectui#10920) (#10921) (objectui `dd3f7e1be`) + - _(no changeset)_ docs(changeset): date-note six pending entries that PRs #10793, #10802 and #10821 made false (objectui#10877) (#10891) (objectui `733fd5ac6`) + - _(no changeset)_ docs(skills): the plugin guide's tombstone comment lists the serializer key list with `of` (objectui#10337) (#10780) (objectui `4f38f3928`) + - _(no changeset)_ docs(changeset): date-note two pending entries whose option-description and rows refusals spec 17.3.0 lifted (objectui#10801) (#10828) (objectui `610819c40`) + - _(no changeset)_ ci(dependabot-gate): wait for Spec Main Shape Gate now that it is enrolled (objectui#9969) (#10796) (objectui `4785523b0`) + - _(no changeset)_ docs: a lookup option’s `description` is declared by the spec; filter-ui lists `multi-select` (objectui#10769, objectui#10779) (#10794) (objectui `a599517cd`) + - _(no changeset)_ test(scripts): cite the bare-ValidationRule negatives by file, not by line; mark the two retired names synthetic (objectui#10781) (#10791) (objectui `dac022c4e`) + - _(no changeset)_ docs: cite the landing commits where ten objectui issue links answer 404 (objectui#10755) (#10766) (objectui `b5b6f3753`) + - _(no changeset)_ docs(skills): the grid 2xl paragraph names objectui's own BreakpointColumnMap and reads all six keys (#10708) (objectui `f308a655b`) + - _(no changeset)_ docs(skills): the CLI table says what `objectui check` does and points to `objectui validate` for the verdict (objectui#10526) (#10574) (objectui `a2f361b8c`) + - _(no changeset)_ docs(changesets): say what `objectui check` does with a bare document in three pending changesets, and name `objectui validate` for the verdict (objectui#10606) (#10642) (objectui `56b7fb4d5`) + - _(no changeset)_ docs(schema-reference): ObjectGridSchema.sort row reads SortConfig[] only (objectui#10581) (#10621) (objectui `5add18a67`) + - _(no changeset)_ docs(components): annotate refused runtime-slot handler rows (objectui#8235) (#10515) (objectui `0d5d66aee`) + - _(no changeset)_ docs(schema-reference): the kanban mirror sentence says what @object-ui/types declares today (#10532) (objectui `6881e9e47`) + - _(no changeset)_ docs(cli): say what `objectui check` does; point to `objectui validate` for the verdict (#10523) (objectui `d6884d862`) + - _(no changeset)_ docs(fields): grid column examples use only declared column keys (#10505) (objectui `bc4efba73`) + - _(no changeset)_ fix(console): return packages/core to the framework chunk by grouping rule, and pin chunk membership (#9488) (objectui `8cc0e2984`) + - _(no changeset)_ chore(tooling): retire the objectstack tooling port — PM scripts run from objectstack with PM_SWEEP_REPO; hooks become plain copies (objectui#10208) (#10458) (objectui `b274b6103`) + - _(no changeset)_ docs(agents): add issue and pull-request bodies to the cite-by-content commandment (objectui#10048) (#10440) (objectui `606b5c802`) + - _(no changeset)_ docs(agents): a `-t` name filter is a regex, so a pasted title is a false green (objectui#9731) (#10412) (objectui `9d6eb5a80`) + - _(no changeset)_ fix(schema-catalog): author the five stacked-label flex roots as direction "col" (#10414) (objectui `f475557c4`) + - _(no changeset)_ docs(skills): `isContainer` means layout containment, not "accepts children" (objectui#9910 Q2, governed) (#10268) (objectui `2c9d4d370`) + - _(no changeset)_ docs(changeset): the 8415 body states the row-identity property, not a stale read-site count (#10196) (#10385) (objectui `00cdaff1f`) + - _(no changeset)_ fix(console-starter): namespace a spec translation payload the way the console does (#10381) (objectui `170fcb6df`) + - _(no changeset)_ docs(plugin-charts): state the host-page font precondition for axis tick density (#10374) (objectui `923e1b8ab`) + + + + objectui range: `f8a9d0fb0596...dd3f7e1be356` +- 0bf85ea: Console (objectui) refreshed to `f8a9d0fb0596`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 86 releasing of 91 changesets added across 92 non-merge commits; omitted: 5 release-nothing changesets, 3 commits carrying no changeset (they ship no package code). + + - **minor** — `aggregate()` reads the analytics answer in ONE spelling: `rows` on the `AnalyticsResult` that `client.analytics.query` resolves to (objectui#7028). The `{ success, data: { rows }… (objectui `512bc9049`) + - **minor** — **BREAKING** — Retire `carousel` from `AIRecommendationsSchema.layout` and from the `ai-recommendations` designer enum (objectui#10330, ADR-0049 enforce-or-remove). (objectui `f98eddf63`) + - **minor** — **BREAKING** — feat(types)!: `DetailViewFieldSchema.options` is the spec's authoring `SelectOptionSchema` (objectui#10296) (objectui `6096f20b3`) + - **minor** — Honour `filter` and the platform row ceiling on a tree's inline (`provider: 'value'`) data (objectui#9136) — the fourth surface of the objectui#8769 repair, after objectui#9061 po… (objectui `9c08dc6b2`) + - **minor** — feat(core): export `declaredNameField`, the one spelling of the ADR-0079 declared name pointer (objectui#9436) (objectui `ba0b61a60`) + - **minor** — fix(plugin-detail): `DetailView`'s header and the `record:details` H1 dedupe rank the declared `nameField` above `titleFormat`, the ADR-0079 order (objectui `ba0b61a60`) + - **minor** — fix(components): the record page H1 ranks the declared `nameField` above `titleFormat`, the ADR-0079 order (objectui `ba0b61a60`) + - **minor** — The grid summary footer and the dashboard metric tile take a currency amount's decimal places from the currency, never from `scale`, and the field designer no longer offers `Scale… (objectui `0651e7ab4`) + - **minor** — fix(react): a data object in a node's `properties` / `props` bag reaches the renderer whole, even when it carries a `source` field (objectui `2b5f509bf`) + - **minor** — `CalendarSchema.defaultValue` / `.value` cross the JSON/TS boundary once (objectui#10293, objectui#7759 ruling D1-(iii)). (objectui `8c10f4f71`) + - **minor** — fix(core): a dashboard `dateRange` that omits `defaultRange` now takes the spec's declared default preset (objectui#10339). (objectui `86982ace0`) + - **minor** — **BREAKING** — feat(types): `TooltipSchema.content` is text only, on both faces (objectui#10295) (objectui `90dac98fa`) + - **minor** — fix(auth,app-shell,console): a browser that changes hands no longer keeps the previous account's UI language (objectui `b57107d46`) + - **minor** — **BREAKING** — `UIEventHandler` and `EventableSchema` are RETIRED from `@object-ui/types`, and `APISchema` loses its `EventableSchema` arm (objectui#6497, ADR-0049 enforce-or-remo… (objectui `cb55718a9`) + - **minor** — **BREAKING** — `record:related_list`'s top-level `filter` is declared as the protocol's rule array on the authoring face, not as `any` (objectui#10199). (objectui `e3ea4f97b`) + - **minor** — On `tree` and `chart` list views, the toolbar's Filter control and the `UserFilters` chips now narrow the view, and on a `gantt` list view the toolbar's Search box now narrows the… (objectui `af243c1fd`) + - **minor** — Refuse the four remaining function-valued mirror keys by name (objectui#7759 group E, the objectui#6124 shape). (objectui `d05fe17f6`) + - **minor** — `ObjectView` opens a Cmd/Ctrl/middle-clicked row in a new browser tab (objectui#9806). (objectui `687353f4e`) + - **minor** — `CurrencyField` takes its fraction digits from the currency, never from the field-level `precision` (objectui#10276). (objectui `31938f01d`) + - **minor** — `object-form`: one rule for section divider rows on the default, modal and drawer layouts, and there a section's own settings apply whether or not it has a heading (objectui#9849… (objectui `8813335bd`) + - **minor** — `CommandItem` and `CommandGroup` are now named exports of `@object-ui/types` itself, not only of `@object-ui/types/form` (objectui#9526). They are the element types of `CommandSch… (objectui `3be720ef8`) + - **minor** — **BREAKING** — BREAKING (`@object-ui/types`, `@object-ui/plugin-chatbot`): the authoring `ChatToolInvocation.state` union sheds the AI SDK's three runtime-only approval states — `approval-reques… (objectui `b46c58f34`) + - **minor** — **BREAKING** — `RecordRelatedListRenderer`'s props type refuses a misspelled key again (objectui#9963). (objectui `905913c0e`) + - **minor** — Fix: a `dependsOn` field is no longer permanently gated when it is edited inline on a record's detail page. (objectui `a33803796`) + - **minor** — fix(core): the shared date path refuses a calendar day that does not exist, with the marker it already renders for an unparsable value (objectui `ad694ac3d`) + - **minor** — fix(plugin-kanban): a kanban lane matches records by its `id` only, never by its `title` (objectui `7a564e004`) + - **minor** — feat(plugin-detail): row caps on `record:activity`, `record:history`, `record:chatter` and `record:discussion` admit only a positive integer number, and a refused one warns (objectui `879ecac78`) + - **minor** — feat(types): `slider` and `tooltip` single-or-list keys follow their read sites (objectui#10280, objectui#7759 group B) (objectui `f3f4e4c9a`) + - **minor** — **BREAKING** — `NamedListView` (one entry of `ObjectViewSchema.listViews`) retires sixteen members on its TypeScript authoring face (objectui#7924). Each is now a `?: never` tombs… (objectui `aa083cd69`) + - **minor** — feat(types): `AppComponentSchema`, `DashboardComponentSchema` and `PageNodeSchema` take the spec by reference, like their zod mirrors (objectui `1bbaa163a`) + - **minor** — **BREAKING: the unimplemented async export-job path is removed from `@object-ui/types` and `@object-ui/components`** (objectui `8b1f06619`) + - **minor** — fix(sdui-parser,components,layout,types): containment is the declared `children` slot, not `isContainer` (objectui#9910) (objectui `5ea623eab`) + - **minor** — feat(react): an action's `params` values are templates, evaluated where `properties` are (objectui `95bf1287a`) + - **minor** — An action param that declares the spec's `carryOver` is shown read-only and submitted verbatim (objectui#6246) (objectui `06b82b8c3`) + - **minor** — A date-only value now renders the calendar day it names, west of UTC, at four more places (objectui#10183). (objectui `4ab4f1ba2`) + - **minor** — `ObjectTreeSchema.filter` is declared on both faces, in the shape objectui#9309 settled for `ObjectGallerySchema.filter`: `QueryParams['$filter']` by indexed access on the TS inte… (objectui `d16d0e977`) + - **minor** — `object-grid`'s `rowActions` now NARROWS the row kebab's generic Edit / Delete inside the `operations` ceiling — the second half of the ruling whose first half ("`operations` is t… (objectui `185079bdf`) + - **minor** — Dates and numbers across the console and the plugins format in the session's display locale instead of the machine's (objectui#9909). (objectui `a78cd378c`) + - **patch** — fix(fields): a lookup's candidate queries expand the reference columns they display (objectui `65f1e8dc6`) + - **patch** — fix(plugin-list): a list view whose every authored column is denied by field-level security still sends a `$select` (objectui `fb7f38bdf`) + - **patch** — fix(plugin-detail): feed diagnostics name the block that carries the bad value (objectui `462bafb9e`) + - **patch** — docs(types): the `WidgetInput` divergence docblock lists the serializer's key list with `of` (objectui#10337) (objectui `a5b08c9ce`) + - **patch** — fix(console): `FormPage`'s required `*` no longer lands in the control's accessible name (objectui `069ce12b4`) + - **patch** — fix(charts): a cartesian chart that declares no series at all now says so instead of drawing an empty frame (objectui#4695) (objectui `f82f85756`) + - **patch** — fix(plugin-list): gallery cards now pass a field's declared `scale` to the shared cell renderer, so a number or percent field declaring `scale` renders padded (`25.00%`) exactly a… (objectui `a883ec21a`) + - **patch** — Gantt: a row that is not an object is now refused instead of drawn as an empty, unlabelled row (objectui#7364). `items: [0]`, `items: ['x']`, `items: [true]` and `items: [[]]` use… (objectui `0b6b295a1`) + - **patch** — fix(plugin-form,plugin-list,plugin-view,react): read `SchemaRendererContext` as declared, not through a cast to `any` (objectui#7209) (objectui `3ed3eec08`) + - **patch** — fix(i18n): a translation bundle with no field label is recognised as a spec payload (objectui#10235) (objectui `dc666f70d`) + - **patch** — An `action:icon` now runs an action it receives with `autoTrigger` set, the same way `action:button` and `action:menu` do (objectui#10274). (objectui `cff4b7754`) + - **patch** — Two display-locale faces now follow the session's display locale (objectui#10232). (objectui `bb5d4eea7`) + - **patch** — fix(app-shell): the metadata form's machine-name chip is judged on the field's untranslated source label, so it shows alike in every locale (objectui#8231) (objectui `78b572f60`) + - **patch** — fix(fields): a failed image upload is reported in `ImageField`, not swallowed (objectui `1f8ef0a89`) + - **patch** — fix(plugin-charts): the legend swatch carries its series colour as a custom property (objectui `a9f34df28`) + - **patch** — Saving a view's config no longer turns the view read-only (objectui#10210). (objectui `baf98cde0`) + - **patch** — fix(plugin-view): a read-only view's menus no longer open empty or on a leading separator (objectui `b07de29d1`) + - **patch** — The two declared display-locale contracts now each name the caller they govern, and each points at the other (objectui#10098). This is documentation only: no module's behaviour mo… (objectui `8cedb0dba`) + - **patch** — **The stray-`groupBy` kanban refusal no longer tells an author their view "never came through the validated path".** (objectui `89bb77a11`) + - **patch** — The package dialog judges a version with the installed `@objectstack/spec`'s own `ManifestSchema` version field instead of a hand-copied regex, so it accepts exactly what the spec… (objectui `c84221daa`) + - **patch** — fix(app-shell): the Studio dataset-filter inspector stores a `between` range as the spec's `$between`, with both bounds required (objectui#10062) (objectui `856bf0f74`) + - **patch** — A flow launched from an action that ends with `outcome: 'refused'` without ever pausing at a screen now shows its refusal instead of reporting success (objectui#9973). (objectui `7616d8935`) + - **patch** — fix(plugin-dashboard): a field's `format` is read as a date pattern only on a `date` / `datetime` field (objectui `e2bd3e400`) + - **patch** — fix(plugin-designer): the Navigation Designer has an entry for the spec's `doc` navigation item type (objectui `1dbb9933c`) + - **patch** — A master-detail form's child grid no longer offers a cell the CALLER may read but not edit (objectui#10163). (objectui `6099dd870`) + - **patch** — **Behaviour change:** an action whose own declared `visible` gate hides it is no longer run by `autoTrigger`, and the refusal is reported instead of swallowed (objectui#4191). (objectui `978507b9a`) + - **patch** — fix(plugin-detail): a related list's row fetch drops a column the principal cannot read once the permission answer has loaded (objectui `5f44cc6f4`) + - **patch** — The published `ViewNavigationConfig` docblock no longer teaches the retired `navigation.view` key (objectui#9938). Its example of `mode` being optional on the authoring side used… (objectui `32bf2d6f6`) + - **patch** — feat(types): `SliderFieldMetadata` declares `step` (objectui `5f00ff491`) + - **patch** — `object-grid`'s `rowActions` now carries a describe, and the `ObjectGridSchema.operations` / `rowActions` docblocks state how the two keys combine: `operations` is the CEILING ove… (objectui `087981282`) + - **patch** — fix(plugin-form): `DrawerForm` no longer paints an editable form before the record it edits has loaded (objectui `88a4ef616`) + - **patch** — fix(plugin-kanban): `object-kanban` honours the binding's `dataSource.sort` (objectui `c9e073ac8`) + - **patch** — fix(plugin-detail,plugin-grid): the record-grained write verdict is forgotten when its record changes, and the row kebab's memo is per principal (objectui#10184). (objectui `fa5fbd9dc`) + - **patch** — fix(plugin-form): `customFields` merges on the drawer and modal arms too (objectui `142fdfd87`) + - **patch** — On a `gantt` list view, the toolbar's Filter control and the `UserFilters` chips now narrow the chart (objectui#10037). (objectui `0427036f5`) + - **patch** — fix(app-shell): the object page no longer re-issues the identical list query on re-renders that change nothing (objectui `5b6d177b2`) + - **patch** — `ui:menubar` now draws an item's authored `icon`, and walks submenus to any depth (objectui#6326). (objectui `d7de5348a`) + - **patch** — The `dateField` alias refusal that objectui#8355 adds to a calendar binding quotes only the first clause of the calendar refusal screen, "Calendar configuration required": the cla… (objectui `6f96fca95`) + - **patch** — `useRecordSearch` no longer keys its search effect on the identity of the caller-supplied `getDisplayName` option (objectui#10044). (objectui `0aacecc08`) + - **patch** — fix(fields): a declared `scale` above 100 no longer crashes the number cell or a grid's computed column (#10071) (objectui `0361d6bd4`) + - **patch** — `record:activity`'s landmark is named after the heading it shows, not "Discussion" (objectui#9998). (objectui `6358a2d59`) + - **patch** — `object-form`: a drawer section that declares `collapsed: true` can be opened again. The drawer now resolves `collapsed` / `collapsible` the way the default layout does (objectui#… (objectui `58d65c50d`) + - **patch** — fix(app-shell): the screen-flow runner draws the app's translated flow copy (objectui#5920) (objectui `5d895c13c`) + - **patch** — A `record:line_items` grid no longer offers a cell the CALLER may read but not edit (objectui#10163). (objectui `b809375ac`) + - **patch** — fix(mobile): `usePullToRefresh` arms on a host that mounts after the first render, and one pull has one owner (objectui#10105) (objectui `6ce001a35`) + - **patch** — A wizard no longer writes a record without a file whose upload was still running when the user pressed Next (objectui#10180). (objectui `a04b06db5`) + - **patch** — `ElementDataSourceGate` now reports a saved view's refused row cap on the renderer path (objectui#10015). (objectui `7b10befe6`) + - **patch** — Full-page search results now read correctly in Russian and Arabic at every count (objectui#10024). (objectui `a507334d2`) + + ⚠️ 9 of these carry a breaking change: 9 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 3 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ docs(changeset): the 9242 changeset says the stray-groupBy refusal covers the list-view route only (#10364) (objectui `f8a9d0fb0`) + - _(no changeset)_ docs(changeset): the manifest serializer forwards seven keys per input, not six (#10318) (objectui `c7ab34836`) + - _(no changeset)_ fix(ci): spec-main shape gate re-points the injected spec's declared dependencies (#10238) (objectui `f4f1f4552`) + + + + objectui range: `62597c588072...f8a9d0fb0596` + +### Patch Changes + +- 28ce612: The prebuilt Console dist now ships `dist/sdui.manifest.json`: the ADR-0080 public-tier + component manifest of the objectui registry at the pinned commit. + + It is the same file the framework repository tracks at its root and gates on every pull + request. `scripts/build-console.sh` copies it in, and one producer writes it: + `scripts/gen-sdui-manifest-node.mjs`, which reads objectui's built tree at the pin. No + earlier published `@objectstack/console` carried this file. The RC cut used to write a + browser-dumped copy into `dist/`, but the release build replaced `dist/` before packing, so + none reached a tarball (17.0.0, 17.3.0 and 17.4.0 each list 0 matches). That browser dump is + retired. It was byte-identical to the tracked file over the same built tree. + + For now the file is only present in the tarball. This package's `exports` map exposes + `./package.json` and nothing else, so resolving `@objectstack/console/dist/sdui.manifest.json` + through `exports` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Anything that resolves through + `exports` cannot read the file yet. That includes the CLI's JSX-page manifest fallback, which + catches the error and keeps parse-level validation, as before. + ## 17.4.0 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index 8e4edce2a24..c64eb6b0681 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "17.4.0", + "version": "17.5.0", "description": "Prebuilt Console SPA pinned to this framework release, installed as a dependency of @objectstack/cli. Source of truth: @object-ui/console (https://github.com/objectstack-ai/objectui).", "license": "Apache-2.0", "homepage": "https://github.com/objectstack-ai/objectstack/tree/main/packages/console", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 807bb1869d9..2ad0aa89d1e 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,2003 @@ # @objectstack/core +## 17.5.0 + +### Minor Changes + +- 74eaab8: feat(spec,core)!: the startup contract describes what the kernel produces — the orchestrator vocabulary is retired and `PluginStartupResult` is declared once (#16059) + + + + **BREAKING** — a published exported surface is removed, landing in the launch window as + `minor` (the lockstep convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition above). + + `@objectstack/spec` declared a plugin startup ORCHESTRATOR that was never built, and its + one shape that *is* real had drifted away from the kernel that produces it. The maintainer + ruling on this card keeps a startup-result contract, and makes it describe what the kernel + actually returns. + + ## What is removed + + `IStartupOrchestrator` (`orchestrateStartup` / `rollback` / `checkHealth` / + `startWithTimeout`) and the three schemas it tied together. Nothing in any repository + implemented the interface and nothing parsed the schemas; `healthCheck` and `HealthStatus` + named a per-plugin startup health probe the runtime has never had. + + | removed | from | what to write instead | + |:--|:--|:--| + | `IStartupOrchestrator` | `@objectstack/spec/contracts` | nothing — plugin startup is the kernel's own boot loop | + | `StartupOptionsSchema` / `StartupOptions` / `StartupOptionsParsed` | `@objectstack/spec/kernel`, `/contracts` | `startupTimeout` on the plugin; `rollbackOnFailure` on the kernel config | + | `StartupOptions.healthCheck` | (with the schema) | **no replacement** — no startup probe system exists | + | `HealthStatusSchema` / `HealthStatus` | `@objectstack/spec/kernel`, `/contracts` | **no replacement** — see above | + | `StartupOrchestrationResultSchema` / `StartupOrchestrationResult` | `@objectstack/spec/kernel` | `ObjectKernel.getPluginStartupDurations()` | + + `StartupOptions.parallel` and `StartupOptions.context` have no replacement either: the + kernel starts plugins sequentially and passes its own `PluginContext`. + + ## What survives, re-declared + + `PluginStartupResultSchema` / `PluginStartupResult` stay on both entries, rewritten to the + shape `@objectstack/core` has always returned from `ObjectKernel.startPluginWithTimeout()`. + `@objectstack/core` now **imports** that type instead of declaring a twin, so the two + cannot drift again. + + | member | before (spec) | after (spec and core, one declaration) | + |:--|:--|:--| + | `plugin: { name, version? }` | required | **removed** — write `pluginName: string` | + | `pluginName` | absent | `string`, required | + | `success` | `boolean`, required | unchanged | + | `durationMs` | `number`, **required** | `number`, **optional** (absent when the plugin declares no `start()`) | + | `startTime` | absent (it was core's own deprecated alias) | **removed** — read `durationMs`, which always carried the same value | + | `error` | serializable projection | unchanged (a thrown `Error` satisfies it) | + | `timedOut` | absent | `boolean`, optional — set when the failure was the timeout | + | `health: HealthStatus` | optional | **removed** — no probe ever filled it | + + **The one-line fix:** rename `plugin: { name }` to `pluginName`, delete `health`, and read + `durationMs` wherever you read `startTime`. All three old spellings are `retiredKey()` + tombstones on the surviving schema, so each is a `tsc` error at the construction site and a + parse error carrying the prescription. + + `startTime` is the one member whose removal a reader can OBSERVE: `@objectstack/core` + populated it beside `durationMs` with the identical elapsed value, under its own ADR-0087 + L1 deprecation, and `ObjectKernel.startPluginWithTimeout()` stops setting it here. Mirroring + it on the contract was the alternative and the tree refuses it — `check:duration-unit-keys` + (ruling B on #14478) fails an elapsed number whose key name carries no unit, and neither of + that rule's two schema-declared exemptions fits: it is not an `EpochMs` instant and it + mirrors no external standard. Renaming it to `startTimeMs` would mint a spelling nothing has + ever produced, for a member already documented as slated for removal. + + For `@objectstack/core` consumers the members are unchanged; the one narrowing is that + `PluginStartupResult.error` is now typed as the serializable projection + (`name` / `message` / `stack?` / `code?`) rather than `Error`. The kernel still puts the + thrown instance there, so `result.error instanceof Error` still narrows — only code that + reads an `Error`-only member such as `cause` off it without that guard needs the guard. + + ## The retirement kit + + Route 3 of the `spec-property-retirement` playbook: no authored document carried any of + the three defs, so there is no seam for a D2 conversion and no author to hand a tombstone + to. `RETIRED_DEFS_BY_MAJOR[18]` (`kernel/StartupOptions`, `kernel/HealthStatus`, + `kernel/StartupOrchestrationResult`) plus the D3 semantic entry + `startup-orchestrator-retired` **are** the declaration, and the three + `json-schema.manifest/kernel.json` keys plus their 16 `authorable-surface/kernel.json` + lines are deleted deliberately in this same change. The two keys of the SURVIVING result + schema (`plugin`, `health`) take the tombstone route instead, registered in + `RETIRED_KEYS_BY_MAJOR[18]`, because that def keeps emitting and its type is imported by + `@objectstack/core`. + + Runtime behaviour is deliberately unchanged: nothing ever read the retired surfaces, and + the kernel boot loop is untouched. +- 271d6bb: Record the acting agent on the audit row — ADR-0090 D10 rule 4 dual attribution + + A `sys_audit_log` row written by an MCP OAuth client acting for a human used to + be byte-identical to a row that human wrote in the Console. The envelope carried + the delegation (`principalKind: 'agent'` + `onBehalfOf`), the row did not, and + nothing in between copied it: `assembleExecutionContext` consumed the OAuth + `azp` as a boolean and dropped the value, so the acting client did not exist + downstream of the door at all. + + The delegation now travels the whole way and lands on the row: + + - `ExecutionContext.performedBy` (`{ clientId }`) — decided at the `/mcp` OAuth + door, on the same branch that already decides `principalKind: 'agent'` and + `onBehalfOf`; a member of the closed entry field set like every other. + - `HookContext.provenance.performedByClientId` — the hook-layer carrier, beside + `flowRunId` and `attributedUserId`. Provenance, not `session`: no + caller-gating hook may read the client as the caller. + - `sys_audit_log.metadata` gains `{ performed_by, on_behalf_of }` on a delegated + write, and nothing at all on a personal one — the two shapes are told apart by + absence rather than by guesswork. + + Additive, and attribution only. `user_id` stays the human, so owner-stamping, + `current_user.*` RLS and the `sys_user` join are untouched (ADR-0073 D3 — + attribution is not ownership). `actor` is untouched too: ADR-0118 D1/D5 keeps + that column two-valued — a user id, or `null` for the system — and answers + "which non-user acted" with an added attribution field rather than a second + actor vocabulary. No existing row changes meaning, and no historical row is + rewritten. + + Rule 4's third element, the run id, is NOT delivered here and is not declared + either: nothing on the request path mints one today (`ExecutionContext.traceId` + is declared but resolved by no transport entry point), and declaring a carrier + nothing populates is the defect this change exists to close. +- e7ff9c2: `dateRange`'s array arm has ONE arity everywhere: a two-element window, or the ADR-0112 refusal (#17596) + + The shared conformance kit + (`analyticsDateRangeConformanceFindings`) had exactly one array case — a + two-element window — so the ARITY of the array arm was governed nowhere and + every analytics face was free to invent a meaning for `dateRange: + ['2026-01-01']`. Four faces in one package had invented three (#17124), and a + fifth — `driver-memory`'s cube face — had invented a fourth. + + **The kit** now exports `ANALYTICS_DATE_RANGE_NOT_A_WINDOW` and holds every + registered face to the rule the `service-analytics` faces already carry: an + array that is not two non-empty string bounds is refused with + `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400. No new rule was invented for it, and + the existing two-element window case is untouched — it is this case's control, + so "refuse every array" cannot pass. + + **`driver-memory`** now answers that refusal instead of dropping the window. + MEASURED end to end over four rows spanning 2020…2099: `['2026-01-01']`, `[]` + and `['2026-01-01', '2026-01-31', '2026-02-01']` each emitted a pipeline + byte-identical to one with **no `dateRange` at all** — every row selected, the + "plot all of history" failure #3650 was filed about — and `[null, null]` + compared instants against the string `'null'` and selected none. + + **Levels.** `@objectstack/core` is `minor`: it gains a new exported symbol on + its index (`ANALYTICS_DATE_RANGE_NOT_A_WINDOW`), and a purely additive widening + of a published package's public surface takes at least `minor` whatever the + commit type says. `@objectstack/driver-memory` is `patch`: its public surface is + byte-unchanged — no new export, no new accepted key or value. Its behaviour does + change, from selecting every row to refusing with `400 + ANALYTICS_DATE_RANGE_UNRECOGNIZED`, and that is a `patch` because the old + behaviour was a defect and never a contract: the spec's own refusal wording + already said an explicit window is the two-element array, and the #16322 + migration table already told authors to write a single day as two bounds. A + release that stops answering a shape the contract never admitted is a fix, not a + feature — and the shapes it now refuses had no correct answer to lose. + + **If you wrote a one-element array**, write both bounds: `['2026-01-01']` + becomes `['2026-01-01', '2026-01-01']`, which selects exactly that day on every + face and did so before this change too. The refusal names the shape that + arrived, the two-element contract and that spelling. +- 75237a9: fix(spec)!: `timeDimensions[].dateRange`'s array arm is exactly two string bounds, and each refusal ORIGIN gets a true sentence (#17598; ruling A, decision batch #117 item 3) + + + + **BREAKING** accept-set narrowing at `timeDimensions[].dateRange` — shipped as + `minor` under this repo's launch-window convention for breaking changes + (`scripts/check-changeset-no-major.mjs`), above the `patch` floor the `fix` + commit type sets, and the same grade the one comparable precedent took: the + STRING-arm closing on this same schema is #16041, and it shipped + `"@objectstack/spec": minor` (`packages/spec/CHANGELOG.md` 17.4.0, under Minor + Changes). ⚠️ Its driver half #16322 declares `"@objectstack/spec": patch`, but + that entry is — in that changeset's own words — "a `PROVENANCE_WAIVERS` row + only", not an accept-set narrowing, so it is not a grade this one is measured + against. The maintainer + ruling calls it a "major changeset"; under the launch window that phrase maps to + the protocol MAJOR the migration registers against (18), not to the changeset's + bump level, which `scripts/check-changeset-no-major.mjs` reserves. The semantic + prescription is registered under protocol major 18 as + `analytics-date-range-array-two-bounds-required`. + + ### What changed + + `AnalyticsDateRangeSchema`'s array arm was `z.array(z.string())` with **no length + constraint**, so `['2026-01-01']`, `[]` and `['a', 'b', 'c']` were schema-valid. + It is now `z.tuple([z.string(), z.string()])` — a tuple rather than a length + refinement, so the arity is stated to the author's compiler before any parse runs. + Preset names, two-bound windows and an absent `dateRange` parse byte-identically + to before. + + `analyticsDateRangeRefusalMessage(input)` becomes + `analyticsDateRangeRefusalMessage(input, origin)`, where `origin` is `'schema'` or + `'runtime'` and is **required** — there is deliberately no default. + + ### Migration: FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `dateRange: ['2026-01-20']` | `dateRange: ['2026-01-20', '2026-01-20']` — a single day is that day as both bounds, the shape the shipped #16322 table already prescribes | + | `dateRange: []` | no conversion. An empty array names no window: write the two bounds the widget was meant to show, or omit `dateRange` (it is optional, and absent means the query is not time-bounded) | + | `dateRange: ['a', 'b', 'c']` | no conversion. Decide which two bounds you meant and write them | + | `analyticsDateRangeRefusalMessage(value)` | `analyticsDateRangeRefusalMessage(value, 'schema')` at a parse door, `…(value, 'runtime')` past one | + + `os migrate meta --from 17` emits the first three as a structured TODO rather than + rewriting them: rewriting a one-element array to the same day twice at load would + be the platform deciding, silently, that the author meant one day rather than a + window whose end they forgot, and for the other two shapes there is nothing to + decide from. + + ### Why it is not a new class of breakage + + Since PR #17593 all four analytics faces (`ObjectQLStrategy`, `NativeSQLStrategy`, + the draft-preview evaluator, `DatasetExecutor.runCompare`) already refused anything + that is not exactly two bounds with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`, so + every stored range this narrowing refuses was **already failing at query time**. + The contract door was looser than every reader behind it; this moves the refusal + to authoring time and states it accurately. Blast radius is the WIDGET, not the + page: a stored dashboard carrying a now-refused range loses that widget with the + refusal shown and still loads. + + ### The wording half + + The shared sentence ended `"Refused at the schema"` and described every refused + array as `"received an array with a non-string bound"`. For a one-element window + refused by a face **both clauses were false** — every bound present is a string, + and it was refused past the schema, not at it — which is why + `@objectstack/service-analytics` had to overwrite the message rather than reuse it, + leaving one condition with two wordings. The origin is now a parameter and the + `received …` clause names the arity and the bad bound separately, so the sentence + is true for each origin both before and after the arm narrows. + + The same rule reaches the WIRE. Narrowing the arm to a tuple gave the union a + second voice: its arm answers `Too small: expected array to have >=2 items` for + the very arity the prescription just prescribed, and the ADR-0114 union + expansion emitted both as `fields[]` entries on `POST /analytics/query` and + `POST /analytics/dataset/query`. `fieldsFromZodIssues` (`@objectstack/types`), + the one mapper both doors report through, now drops the branch issues that land + at the union's OWN path for this refusal — recognised structurally through + `isAnalyticsDateRangeRefusalIssue`, never by message prose. A refusal that names + a DEEPER position keeps it: `dateRange: ['2026-01-01', 3]` still reports + `timeDimensions.0.dateRange.1`, because WHICH bound is not a string is a + location the prescription does not carry. Every other union expands exactly as + before. Client-visible effect: one `fields[]` entry for an arity refusal instead + of two, with the prescriptive one kept. +- 920f887: `DbQueueAdapter` backs off while `sys_job_queue` is idle instead of polling flat at 1 s, and the loop that does it is now published from `@objectstack/core` as `DispatchLoop` (#17612). + + A registered-but-idle queue issued **3600 candidate reads an hour, per queue**, whatever was in the table — on a remote driver, 3600 HTTP round trips an hour of pure idle cost. Measured over one simulated idle hour on the engine boundary the adapter really talks to: **3601 reads before, 124 after**, with the flat-poll number re-measured on the same harness as a control so the new one is a reading about the backoff rather than about a loop that stopped ticking. + + - **One mechanism, not a third copy.** The idle-backoff loop was written for `NotificationDispatcher` (#17610), shared with `HttpDispatcher` (#17623), and lived unexported inside `@objectstack/service-messaging`. `DbQueueAdapter` was the third polling worker needing it. It moves to `@objectstack/core` — the package all three already depend on — because it is a timing primitive owned by neither the messaging domain nor the queue domain, and having `service-queue` depend on `service-messaging` to reach it would invert the dependency direction. **New export from `@objectstack/core`: `DispatchLoop`, `DispatchLoopOptions`, `DEFAULT_MAX_IDLE_INTERVAL_MS`.** + - **Nothing published moved.** `@objectstack/service-messaging` exports only its `index`, which never carried the loop; its two dispatchers now import it from `@objectstack/core` and its own surface is byte-unchanged. + - **New option `DbQueueAdapterOptions.maxIdleIntervalMs`** (default 30 s). Each tick that claims nothing doubles the delay to the next from `pollIntervalMs` up to this ceiling; anything claimed, and every wake, snaps it straight back. **Setting it at or below `pollIntervalMs` restores the flat poll exactly.** + - ⚠️ **What the backoff costs, and what it does not.** Work published through this adapter now wakes the loop, so a due `publish()` and `replay()` are picked up at the base interval as before — the ceiling is never on their latency path. What it does cost is up to `maxIdleIntervalMs` of extra latency on work this process was never told about: a row another node wrote, a deferred row coming due, a crashed worker's lease expiring. A deferred `publish()` deliberately does **not** wake the loop, since that tick would claim nothing and would throw the backoff away. +- 98bd798: feat(spec)!: the three `kernel/plugin-lifecycle-advanced.zod.ts` duration keys carry their unit in the key name (#17780, ruling A on #15939) + + + + **BREAKING** — the health-check period, the health-check deadline and the hot-reload debounce + now carry `Ms` in the key name. + + | | before | after | + |:--|:--|:--| + | `PluginHealthCheck` | `interval: 30000` | `intervalMs: 30000` | + | `PluginHealthCheck` | `timeout: 5000` | `timeoutMs: 5000` | + | `HotReloadConfig` | `debounceDelay: 1000` | `debounceDelayMs: 1000` | + | values, defaults, min bounds | ms; 30000 / 5000 / 1000; min 1000 / 100 / 0 | **unchanged** | + + ## Migration + + ```diff + const health = PluginHealthCheckSchema.parse({ + - interval: 30000, + - timeout: 5000, + + intervalMs: 30000, + + timeoutMs: 5000, + }); + + hotReload.registerPlugin('my-plugin', { + - debounceDelay: 1000, + + debounceDelayMs: 1000, + }); + ``` + + Rename the keys. Every value is the same number of milliseconds it always was, and the + 30000 / 5000 / 1000 defaults are unchanged; nothing else on either def moves. + + ## Why + + Each key named milliseconds in a source JSDoc — "Health check interval in milliseconds", + "Timeout for health check in milliseconds", "Debounce delay before reloading (milliseconds)" — + and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is. + Measured by the `check:duration-unit-keys` census on this tree, all three read + `[name: -] [prose: -]`: no unit in the name and none in the published prose either. + `interval` was the sharpest of the three — its describe carried one unit-shaped token, the + parenthetical "(default: 30s)", naming SECONDS for a value the schema bounds and defaults in + MILLISECONDS. Executes director-seat ruling A on #15939 (2026-09-11, maintainer 「同意」, + decision batch #115), the per-file remediation of the #14478 rule. + + The suffix is the family's own spelling, counted on this tree: 100 key-position `*Ms` + declarations across `packages/spec`, `timeoutMs` 29 of them and `intervalMs` 3. + `debounceDelay` takes the plain suffix rather than a shortened form because it is the only + debounce-shaped key spelling in the repo (no `debounceMs` variant anywhere) while the + Delay-plus-`Ms` pairing is already attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, + `delayMs`) — so unlike the `Ttl`-versus-`TTL` question the sibling round settled, there was no + competing family spelling to choose between. + + ## The kit + + - a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value + reaching the parse raises the rename prescription instead of being silently stripped — + neither `PluginHealthCheckSchema` nor `HotReloadConfigSchema` is `.strict()`, and here the + stripped value would land on a `setInterval` period, a race deadline and a `setTimeout` delay + - the ADR-0087 D3 semantic entry `kernel-health-check-and-hot-reload-durations-unit-in-key` and + three `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: neither def is an authorable + surface — both are library parameters a host passes to `PluginHealthMonitor` / + `HotReloadManager` in TypeScript — so the chain has no seam that runs on them, the same + reading `plugin-auto-restart-never-reinitialised` and `hot-reload-watch-placeholder-retired` + recorded for keys on these two defs + - `@objectstack/core` moves with the rename: `PluginHealthMonitor` and `HotReloadManager` read + the suffixed keys, and each class's registration-time refusal table gains a row so a host + still passing an old spelling is answered with an ADR-0112 `VALIDATION_ERROR` / 400 naming + the rename, rather than getting `undefined` where a duration belongs + - pin tests on both schemas and both classes: the refusal carries the rename prescription, the + suffixed keys parse at the magnitude the retired ones carried with the same defaults, and the + describes publish the unit. The two minimum-bound pins were rewritten rather than left: spelled + through the bare keys they would have stayed green off the tombstone's refusal instead of the + bound, so they now assert the `too_small` issue code on the suffixed keys + - `HotReloadConfig.shutdownTimeout` is deliberately NOT renamed with them — its JSDoc reads + "Graceful shutdown timeout" and names no unit anywhere, so it is the unit-nowhere shape the + #14478 gate leaves outside its verdict, not part of this row set +- 5ba2ec3: feat(spec,core,objectql,driver-sql,driver-turso): a transport can declare it has no transactions, and every transaction gate reads the declaration instead of method presence (#18063) + + Maintainer ruling, decision batch #148 item 3, letter B, 「同意」 2026-09-17, verbatim and untranslated: + + > `packages/spec`: the driver contract gains a way for a transport to **declare 「no transactions」** (the dev picks the smallest spelling the existing capability/contract surface already has — a capability bit is preferred over a new key), and the engine's transaction gating reads the declaration instead of method presence. + + **`DriverCapabilities` gains one live bit, `transactionsUnsupported`.** A transport sets it to say that a handle it issued would be a FALSE SUCCESS rather than a missing feature: the caller gets a handle, the writes execute and are already durable, `rollback()` resolves and undoes nothing. Absence means `false`, exactly like `batchSchemaSync`, so a driver that declares nothing keeps the behaviour it has today. + + **⛔ This is not `DriverCapabilities.transactions` un-retired, and the difference is not cosmetic.** That key was tombstoned in 17.0.0 under ADR-0049 enforce-or-remove and STAYS tombstoned — writing it is still a compile error and still a parse refusal carrying its prescription. It claimed "I support transactions" and nothing read it; this one declares "my transport cannot honour one" and the engine dispatches on it. Reviving the name would have inverted the record's own `absence = false` convention into a tri-state, turned a documented refusal into silent acceptance of a value whose meaning had changed underneath it, and made the tombstone's published text ("no code in any repository ever read it") false. A new key costs one bit; the name costs all of that. + + **Adding a bit to a record enforce-or-remove has pruned SATISFIES that ADR rather than reversing it.** The audit removed thirty-one bits for one stated reason — no code anywhere read them — and kept the three where method presence provably cannot carry the signal. This change is the creation of the missing reader: `driverSupportsTransactions()` (exported from `@objectstack/spec`) is the one definition of the gate, and all FOUR places that used to spell `typeof driver.beginTransaction === 'function'` ask it — `ObjectQL.transaction()`, `ScopedContext.transaction`, the `ScopedContext` begin/commit/rollback trio, and `@objectstack/core`'s `engineCanRollBack`. The bit arrives WITH its reader, in the same change, which is the honest order the ADR asks for. + + **Why method presence could not carry it.** `TursoDriver extends SqlDriver`, whose `beginTransaction()` opens a real knex transaction, so the inherited method reported the libSQL REMOTE transport as transactional. It is not — `RemoteTransport`'s data methods take no `options` argument at all, so a handle cannot reach the statement that would have to join it. A subclass cannot opt out of a door it did not open. This is the mirror of `batchSchemaSync`, which exists because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot. + + **What changes for a caller.** On a datasource whose driver declares the bit, `engine.transaction()` now takes the DECLARED non-transactional path (ADR-0119 D1) instead of opening a transaction it cannot honour: the degrade warns once per datasource — naming the declaration, not a missing method — and `{ require: true }` throws `TransactionUnsupportedError` before the callback writes anything. `ScopedContext.transaction` and the discrete begin/commit/rollback trio read the same predicate; the trio's `begin` returns `null`. Both are the answers a driver with no `beginTransaction` already received. + + **`driver-turso`.** The remote face declares `transactionsUnsupported: true`; local and embedded-replica inherit `false` from the base and are untouched. `TursoDriver.beginTransaction()` publishes the inherited declaration instead of `Promise` — the annotation the earlier `any` was masking an LSP violation to avoid, dissolved rather than widened: the remote arm returns `never` (it refuses), so the only arm that still returns is the base's. `SqlDriver.beginTransaction()` keeps its narrow `Promise`; nothing in the base was widened. + + **`@objectstack/core`.** `engineCanRollBack()` — the ADR-0119 D4 gate that `@objectstack/metadata-protocol` uses for `batchData` / `updateManyData` / `deleteManyData` under `options.atomic`, and that `runMigrationJournal()` uses to decide whether to start at all — reads the same predicate. It has to: it does not open the transaction itself, it vouches that `engine.transaction()` will, and on a driver that declares the bit the engine now takes its non-transactional path. A gate still reading method presence would vouch for a runtime that is about to run the callback with no transaction, so the atomic batch would answer `rollback` over writes that stayed on disk and the journal would write `chunk_done` rows its own contract says mean "committed". What a caller sees on such a datasource instead: `batchData({ atomic: true })` refuses with `501 NOT_IMPLEMENTED` — retry without `atomic`, or probe `capabilities.transactionalBatch` on `/discovery` first — and `runMigrationJournal()` refuses with `MigrationJournalRefusal('NOT_IMPLEMENTED')` before writing a single journal row. Both are the answers a driver with no `beginTransaction` already received. + + **`RemoteTransport` loses `beginTransaction()`, `commit()` and `rollback()`.** They are a published surface, and this is **minor** rather than major on the ruling's own stated ground: that transport never honoured a transaction, so no working behaviour is withdrawn. They had already become unreachable from every caller in the repository when the driver started refusing them; they are now gone, and the declaration keeps them gone by design rather than by audit. +- 74832b6: **Breaking (shipped as `minor` under the launch-window convention).** Under a **walled** tenancy posture (`group` / `isolated`), a legacy unscoped `admin_full_access` grant row no longer confers `PLATFORM_ADMIN`; platform standing there is derived from `OS_PLATFORM_OWNER_EMAIL` and from nothing else. The migration pointer that announced this since 17.3.0 is retired with it: `reportLegacyPlatformAdminGrant` and `resetLegacyPlatformAdminGrantReport` are **removed from `@objectstack/core`'s published entry** (#18336, #11663 leg L5). + + ⚠️ **The `single` posture is untouched, deliberately.** Its zero-config first-user promotion still mints that row and that row still confers `PLATFORM_ADMIN` — a development environment started for a moment cannot be asked to declare an administrator first. Choice 4A (#11974) rules that promotion correct, and the maintainer's 2026-09-08 ruling on #16682 is verbatim: 「retiring the walled write must not retire the `single` one」. The `single` half's disposition is #11979's. ADR-0131 D5, as amended 2026-09-17 (#18413), is the governing record. + + **What a walled deployment must do.** Declare each administrator's **verified** address in `OS_PLATFORM_OWNER_EMAIL` (comma-separated for several) before upgrading. A walled rig that upgrades with the variable undeclared and an unscoped grant row still in place has **zero** platform administrators; the bootstrap now says so **at error**, naming the variable, the row and its holder — L4 used to skip that line for exactly this rig, on the ground that the deprecation pointer carried the remedy instead, and both halves of that arrangement have now expired. + + - **17.3.0 opened the window, this closes it.** L4 (17.3.0) stopped the walled bootstrap from ever *writing* the row and started the once-per-process pointer; L5 stops the walled derivation from *reading* it. The window was time-boxed and loud by design (#11663 P5). + - **The retirement takes the ANCHOR, not the ROW.** Nothing here writes, deletes or re-owns any grant row — a walled holder keeps the `admin_full_access` permission set they hold, and loses only platform-admin *standing*: the rung and the built-in `platform_admin` position. That row's ownership is ADR-0131 C3's, on the v18 line. + - **No new query.** The posture gate reads the environment, never the engine, so the recorded query multiset is identical under both of its answers — measured, not asserted. Under a wall the guard's grade-1 scan is skipped outright, so that path issues one read fewer. + - **`@objectstack/plugin-auth` moves with it, at TWO readers.** `ensureDefaultOrganization`'s step-2 legacy fallback is keyed on the same expression: under a wall it no longer answers「which user is the platform admin?」from the oldest unscoped grant, so the account it would have bound as the Default Organization's `owner` — and handed the org's seeded rows to — is no longer selected. ⛔ That reader does not merely count the population, it **confers** on it, which is why it is keyed here rather than sequenced. Its bootstrap-trigger predicate retires the matching `sys_user_permission_set`-insert arm under a wall with it (cost only; the `sys_user` arms are untouched, and on a walled rig the declared owner's verifying update is the only write that ever grows the population). And: + - **`@objectstack/plugin-auth`'s break-glass guard moves with it.** `last-admin-guard.ts` enumerates the administrator population from the SAME anchor, and its contract is to answer the same question the derivation answers. Its grade-1 (grant-anchored) enumeration is now keyed on the identical expression, so under a wall the guard no longer counts a holder the derivation does not recognise. Consequence on a walled rig: a write that would end the last **config**-anchored administrator's standing is now REFUSED where it was permitted, and a write that removes the now-inert grant row is no longer refused as though it removed the last administrator. Under `single` the guard is unchanged. Its two zero-population refusals also gained a walled clause, because「restore the `admin_full_access` row」stopped being a remedy that ends the emptiness there. + - **`@objectstack/organizations`' walled bootstrap moves with it.** That package wraps `ensureDefaultOrganization` and is the runtime that actually performs the default-organization bootstrap on a walled deployment (plugin-auth's own wiring skips it there). With the helper's legacy fallback keyed off, a walled rig carrying a legacy grant row **no longer** has a Default Organization created for that holder, and that holder is no longer bound as its `owner`; the bootstrap waits for a declared administrator to verify instead. ⚠️ Named because the behaviour an operator gets **from this package** moves — its own source does not change, and the pin re-authored inside it is not the reason. + - **Why `@objectstack/runtime` and `@objectstack/plugin-hono-server` are named.** Neither package's own source changes. Both carry `export * from '@objectstack/core'` (`runtime/src/index.ts`, `plugin-hono-server/src/adapter.ts`) and their built `.d.ts` carry that statement, so the two removed names leave their published surfaces too. All publishable packages sit in one Changesets `fixed` group, so naming them moves no version — it is named so the tombstone reaches the CHANGELOG an upgrading consumer of THOSE packages greps. Precedent is mixed (a core-only declaration exists); this follows the `ApiRegistry` precedent, which named every package the removal reached. + + +- fc91239: feat(spec)!: the canon for "the version of a package or plugin" is SemVer 2.0.0 — nine carriers, one grammar + + Clause-②: yes (narrowing) + + + + **BREAKING** — four published accept sets converge on one, and the fringe each + of them carried outside SemVer 2.0.0 is refused. The widening half needs no + action from anyone; the narrowing half is listed per carrier below, with its + FROM → TO. + + One concept was judged by four different grammars across ten carriers in two + repositories, and the strictest refused `2.0.0-beta.1` — the exact string a + sibling declaration documented as an example of itself. The disagreement was + observable between doors on the same resource, not merely between schema files: + `os plugin build` refused a prerelease the publish door accepted, the Studio + form refused it twice over, the `PATCH` door answered `400`, and the install + door parsed nothing at all. An earlier change collapsed the eight regex literals + onto three exported constants, which removed the drift but not the disagreement. + + `@objectstack/spec/kernel` now exports ONE grammar — + `SEMVER_2_0_0_VERSION_PATTERN`, semver.org's own published expression — and + every carrier references it. + + ## What every author gains, with no edit + + Prerelease and build suffixes are accepted on the five carriers that demanded a + bare three-segment core, so `2.0.0-beta.1`, `17.0.0-rc.5`, `1.0.0+20230101` and + `1.0.0-rc.1+exp.sha.5114f85` now pass a key that refused all of them. Identifiers + are case-preserving everywhere, as the standard requires. This repository cuts + prereleases of its own packages while the key describing a package could not + express one; that ends here. + + ``` + FROM ManifestSchema.parse({ id: 'com.acme.crm', version: '2.0.0-beta.1', … }) + -> throws // and `os plugin build` exits 1 + + TO ManifestSchema.parse({ id: 'com.acme.crm', version: '2.0.0-beta.1', … }) + -> parses + ``` + + ## What stops being accepted, per carrier + + Eight strings, all of them forms SemVer 2.0.0 forbids and none of them a valid + prerelease. What they have in common is that no precedence order exists for any + of them — `dependency-resolver.ts` can place none in an order — so a package + versioned this way could be published and never compared against its own + successor. + + ``` + FROM version: '01.1.1' TO version: '1.1.1' // §2 no leading zero in + FROM version: '1.01.1' TO version: '1.1.1' // a numeric identifier + FROM version: '1.1.01' TO version: '1.1.1' + FROM version: '1.0.0-0123' TO version: '1.0.0-123' // §9 no leading zero in a + // numeric prerelease id + FROM version: '1.0.0-alpha..1' TO version: '1.0.0-alpha.1' // §9 no empty + FROM version: '1.0.0-alpha..' TO version: '1.0.0-alpha' // identifier + FROM version: '1.0.0-.' TO version: '1.0.0' + FROM version: '1.0.0+.' TO version: '1.0.0' // §10 no empty build id + ``` + + ⛔ Each repair above is one defensible reading and not the only one, which is + why they ship as ADR-0087 D3 semantic TODOs rather than as mechanical D2 + conversions: a version is how a release is addressed, so rewriting one + re-points whatever already resolved the old string. Run + `objectstack migrate meta --from ` for the per-site list. + + Per carrier: + + - `ManifestSchema.version` and its three sibling declarations + (`MetadataPluginManifestSchema`, `PluginRegistryEntrySchema`, + `PluginMetadataSchema`), plus the `PATCH /api/v1/packages/:id` door: gain the + whole prerelease and build space; lose a leading zero in the numeric core. + - `PluginSchema.version` and the plugin boot path in `@objectstack/core`: lose + those eight and **nothing else**. ⭐ Every valid prerelease and build form the + loader accepts today it still accepts, which is what keeps the widen-never- + narrow ruling on that path honoured rather than reversed; both halves of that + bound are pinned in `plugin.test.ts` and `plugin-loader.test.ts`. + - `PackageVersionSchema.version`: gains case-preserving identifiers + (`1.0.0-Beta.1`, `1.0.0+Build.5`), which the boot path has always accepted and + this key alone refused; loses the same eight. + - `PackageManifestSchema.version`: was a bare `z.string()` constraining nothing, + so it is the one carrier where the grammar is entirely new. `latest`, + `v1.0.0`, `1.0`, the empty string and `2.0.0-beta.1extra!` were accepted and + frozen into a published manifest snapshot; each is refused now. A dist-tag + becomes the version it pointed at, a `v`-prefix drops, a two-segment string + gains its patch. + + ## The prose moved with the grammar + + Every `.describe()` names SemVer 2.0.0 and the nine generated reference-doc rows + follow; the `PATCH` door's refusal says so; `manifest.test.ts`'s + 「should enforce semantic versioning」 case stops listing `1.0.0-beta` among the + invalid versions. `PluginLoader.isSemverShapedVersion` becomes `isSemverVersion` + — a predicate named for a standard it does not implement gets misused by the + next caller whatever its docblock says, and the name is true now. + + Three exported constants are retired, each replaced by the one canon: + + ``` + FROM import { MAJOR_MINOR_PATCH_VERSION_PATTERN } from '@objectstack/spec/kernel' + FROM import { SEMVER_SHAPED_VERSION_PATTERN } from '@objectstack/spec/kernel' + FROM import { SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN } from '@objectstack/spec/kernel' + TO import { SEMVER_2_0_0_VERSION_PATTERN } from '@objectstack/spec/kernel' + ``` + + ⛔ They are not interchangeable with what they replaced — each named an accept + set that no longer exists, which is why they are retired rather than aliased. A + consumer that referenced one to REPRODUCE a verdict gets the canon's verdict + now; one that referenced it to match a foreign grammar owns that grammar itself. + + The accept set is pinned witness by witness in `version-grammar.test.ts`: move a + cell there and you have moved a published accept set on nine carriers at once, + in one visible edit. +- 0318faf: feat: the server answers `current_user.can(object, verb)` in an option's `visibleWhen` (#18783) + + A `select` / `multiselect` / `radio` / `checkboxes` option can gate itself on the acting subject's grants: + + ```ts + stage: Field.select({ + label: 'Stage', + options: [ + { value: 'open', label: 'Open' }, + { value: 'escalated', label: 'Escalated', visibleWhen: "current_user.can('crm_account', 'edit')" }, + ], + }), + ``` + + `@objectstack/formula` answers `can` from `EvalContext.permissions` and refuses loudly when none is passed — and until now nothing on the write path passed one. Every authenticated write that picked such an option took the evaluator's fail-open branch: the value was admitted, one `warn` said the predicate "failed to evaluate", and the gate was never enforced for anyone. + + **What changes.** The write path now evaluates the predicate with the subject's effective object permissions — on `insert` (single and batch), by-id `update`, bulk `update`, and the `validate()` preview. A subject whose map withholds the verb is refused with `VALIDATION_FAILED` and a field error `invalid_option` on that field; a subject who holds it is admitted. Options whose `visibleWhen` never calls `can` are unaffected. + + **Where the map comes from — one producer.** + + - `@objectstack/plugin-security` implements `ISecurityService.getEffectiveObjectPermissions` (declared optional in `@objectstack/spec`) and registers the same method on the engine. + - `@objectstack/objectql` gains `registerEffectiveObjectPermissionsResolver(fn)`. The engine asks it at most ONCE per write (an N-row bulk update is one resolution), only when a picked option's predicate calls `can`, never for a write with no acting user, and never keeps the answer past the write. The answer goes through formula's `toEvalPermissions`, so a map that is not the published shape is refused rather than answered from. + - `@objectstack/core` exports `buildEffectiveObjectPermissions`: the most-permissive merge plus the super-user seed, wildcard fold, managed-write clamp and `apiOperations` annotation. `/auth/me/permissions` builds its `objects` slot with it and the new security method returns it, so the console and the server's own `can()` read the same map. The four folds (`foldWildcardSuperUser`, `clampManagedObjectWrites`, `seedSuperUserRestrictedObjects`, `annotateEffectiveApiOperations`) and the `ManagedSchemaLike` / `ApiExposureSchemaLike` types moved from `@objectstack/plugin-hono-server` to `@objectstack/core`; `@objectstack/plugin-hono-server` re-exports them under the same names, so no import changes. The `/auth/me/permissions` response is byte-identical for the same resolved sets (measured on five fixtures against the previous build). + + **Failure stance.** + + - If the security service cannot resolve the map, a write that needs it is refused with the resolution's own error — fail closed. It is never read as "no grants". + - With no security plugin, or an engine older than the seam, there is no permission data. The gate stays unevaluable and the value is admitted with the same `warn` as before, which names the missing input. The security plugin logs one `warn` at start when the engine lacks the seam. + + **Plain-wildcard coverage, closed in this release.** `can()` reads only the per-object entries of the map. Before #20083, `/auth/me/permissions` listed an object for a `'*'` wildcard grant only when that grant carried a super-user bit, so a subject whose access to an object came only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — got `false` from `current_user.can()` for that object, although the data plane admits the write, and was refused on a `can`-gated option. That gap is closed in this same release by #20083 (`.changeset/20083-effective-map-plain-wildcard.md`): `buildEffectiveObjectPermissions` now puts each set's plain `'*'` on the registered public objects that set does not name, so that population's map — and any client that answers `can()` from the same `/auth/me/permissions` map — carries an entry for each object the wildcard covers, with the wildcard's grants, narrowed on a guarded managed object by the same managed-write clamp as every other entry. The map also differed from `PermissionEvaluator.checkObjectPermission` for subjects holding a super-user wildcard: an entry the super-user set itself names narrower read as granted, which is closed in this same release (`.changeset/20136-super-user-fold-per-set.md`). Its missing `transfer` is closed in this same release (`.changeset/20134-super-user-entries-every-bit.md`). + + **No spec key, route or config key is added or removed.** +- 4ec3987: **BREAKING for runtime-authored `translation` items** — the registered `translation` metadata type no longer declares `settings`: platform settings copy is platform-only at BOTH application doors (#19620) + + Clause-②: no + + `TranslationItemSchema` — one `translation` metadata item, authored with + `defineTranslation`, in Studio, or through the metadata API — now takes the same + ten groups as a per-app bundle entry (`TranslationData`). `settings`, and its + singular `setting`, are refused by name with the platform-only prescription, + exactly as the per-app bundle has refused them since #15178. The file door and + the item door are two authoring surfaces for one app metadata type, so they + accept one shape. + + ### Migration — FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `defineTranslation({ locale: 'zh-CN', settings: { mail: { title: '邮件投递' } } })` | delete the `settings` group — there is no application-side replacement key | + | a `translation` item saved through the metadata API or Studio carrying `settings` | delete the `settings` group; the save answers `422 INVALID_METADATA` until you do | + | `const t: TranslationItem = { locale: 'en', settings: … }` | move the copy to the PLATFORM bundle (`PlatformTranslationData`), or delete it | + + **The one-line fix: delete the `settings` group from the item.** Settings copy is + not application-authorable — `settings` is keyed by `SettingsManifest.namespace` + and only platform code declares a manifest. `settingsCommon` is **not** affected: + the Settings UI shell strings (the source badges, under + `settingsCommon.sourceLabels`) stay on both application faces. + Run `os migrate meta --from 17` to list the mechanical edits for existing + sources; apply them by hand. + + ### Rows already stored are converted, not refused + + A `translation` row saved before this change keeps loading. The runtime + translation sync (`@objectstack/core`'s `authored-translation-sync`) reads + `sys_metadata` itself and used to merge the RAW stored payload; it now replays + the ADR-0087 conversion chain over each row before merging it, the same policy + as every other stored-metadata read seam. `translation-per-app-settings-removed` + has learned the item shape, so a stored row's `settings` is dropped there, the + rest of the item (`objects`, `apps`, …) still loads, and the server logs one + warning per row naming the row, the group and the conversion. Run + `os migrate meta --stored --apply` to persist the canonical rows. + + ### What changes on screen, which is not nothing + + On the item door the group was STRONGER than on the bundle door. A published + item is loaded into the runtime-authored layer, which both i18n adapters read + **over** the shipped bundles — so an item's `settings` overrode the platform's + own Settings copy for its locale, rather than only filling gaps. After + upgrading, re-read the Settings screens in each locale such an item covered: + where it overrode a platform string, **the platform's string renders again**; + where it filled a gap the platform bundle leaves, the **manifest's own literal + renders, which is English**. If a platform string is wrong or missing for your + locale, correct it in the platform bundle (`@objectstack/service-settings`'s + `settingsBuiltinTranslations`). + + No deprecation window: the item door refuses the key by name from this major. + + ### Unchanged + + The platform face — `PlatformTranslationDataSchema`, `settingsBuiltinTranslations`, + and `GET /api/v1/i18n/translations/:locale`, whose served document is the merged + tree — still declares `settings`. The liveness ledger's `translation.settings` + row is deleted because the key left the ITEM's shape; the platform capability it + evidenced is untouched. + + Ruling batch #210 item 2 letter B (2026-09-22) — maintainer 「210 同意」. + + +- a9fb83e: fix(core,runtime,plugin-dev,plugin-security): a release artifact whose `packages` is `null` is refused as malformed, never read as absent (#19926) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on a value the schema already refuses, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the level). + + `ObjectStackDefinitionSchema.packages` is `z.array(ArtifactPackageSchema).optional()`, and `.optional()` admits `undefined`, not `null`. The schema refused `packages: null` (`invalid_type`), and `composeStacks` refused it with two or more inputs (`STACK_SCHEMA_INVALID`, `status: 422`). The runtime readers below read it as absent instead: a single-package artifact whose own top level is the one package body. Those readers now follow the declaration. An absent `packages` is `undefined` and nothing else; `null` is one of the present, non-array values the rule beside `AssembledPackageBodySchema` calls malformed, like `{}`, `0` or `'x'`, and it is refused with the same envelope: `INVALID_ARTIFACT_PACKAGES`, `status: 422`. No error code is added. + + - **`@objectstack/core`**: `resolveArtifactPackageOrder` refuses `packages: null` where it returned `[artifact]`. The refusal message names the value `null`, not `object`. The resolver's callers that hand it the whole artifact raise the refusal: the kernel `manifest` service's `register()` (`ObjectQLPlugin`) and `@objectstack/verify`'s collection reader for a collection the stack's top level does not carry. + - **`@objectstack/runtime`**: `resolveArtifactCollections` drops `null` from its absent branch, so `AppPlugin`, `createStandaloneStack`, `loadArtifactBundle`'s runtime-module merge and `resolveProjectDatabaseUrl` answer a `packages: null` artifact exactly as they already answer `packages: {}`. `carriedPackageIds`, and `resolveArtifactGrantBinding` for an artifact whose `grantedPermissions` is a record, read the package list through the core resolver and raise its refusal too. + - **`@objectstack/plugin-dev`**: the i18n detector's private absent guard moves in lockstep with the resolver's absent branch, so `devI18nPluginOptions` reaches the resolver and raises its refusal when the `i18n` config (on the stack or its `manifest`), a non-empty `manifest.translations` and a non-empty top-level `translations` do not answer first. `DevPlugin` keeps its posture: it reports the metadata defect on its `error` line and boots on the in-memory i18n fallback. + - **`@objectstack/plugin-security`**: `appSecurityPluginOptions` has no guard of its own and raises the resolver's refusal for `packages: null`. + - **What does not change**: the schema; an absent `packages` (no key, or an explicit `undefined`), which still returns the caller's own object by identity; a well-formed `packages[]`; and `composeStacks` with a single input, which still returns that input by identity. + + No in-repo producer writes `packages: null`, and `os build` and `os validate` refuse it at the schema before any reader runs. For a single-package artifact, leave the `packages` key out. +- e7f69db: A record-scoped filter token, `{record_id}`: the id of the record a `type: 'record'` page is showing. It resolves where a record is in context, and is refused by name everywhere else (#20003). + + On a record page, `record:related_list` was the only component that could scope itself to the record in view. Every other data-bearing component takes a `FilterCondition`, and the only dynamic values a filter could hold named the signed-in viewer. So "open tasks" on a person's record page counted the whole organisation's tasks, under that person's name. `{ assignee: '{record_id}' }` now says "this record's". + + **Where it is accepted, and where it is refused:** + + - **Accepted:** a filter on a component of a `type: 'record'` page (`regions[].components[]`, `slots`, and any filter key inside them). `os lint` / `os validate` pass it there. A page with no `type` is a record page by `PageSchema`'s default. + - **Refused by `os lint` / `os validate`** (rule `filter-token-unknown`, `error`), with the reason "no record in context on this surface" rather than the unknown-token message: list views (top-level `views` and an object's list views and field filters), dashboard widgets and dashboard filters, reports, datasets, app navigation filters, and every page whose `type` is not `'record'` (`home`, `app`, `utility`, `list`, including a list page's `interfaceConfig.filterBy`). + - **Refused on every server path.** `resolveFilterTokens()` in `@objectstack/core` throws `UnresolvedFilterTokenError` (`FILTER_TOKEN_UNRESOLVED` / 400, `token: 'record_id'`) on the ObjectQL read path (`find`, `findOne`, `count`, `aggregate`), the write path (`update` / `delete`, by id or `multi`), the analytics query door and the dataset executor. That is the same envelope a session token gets when the request has no value for it. It happens whatever the request carries, because no server path knows which record a page is showing. The token never becomes `null` (a count "about nobody"), is never dropped (a count "about everybody"), and never reaches the driver. + + **What is in `@objectstack/spec/data`:** + + - `RECORD_CONTEXT_TOKENS` (`['record_id']`), `RecordContextToken` and `isRecordContextToken()`: a sibling of `CONTEXT_TOKENS`, not a member. `CONTEXT_TOKENS` resolves against the caller's session, and `{record_id}` resolves against the surface. So `isContextToken('record_id')`, `ContextTokenSchema` and `ContextTokenPlaceholderSchema` are unchanged and still reject it, and a client resolver that fills `CONTEXT_TOKENS` from the session does not pick it up. + - `classifyFilterToken('{record_id}')` returns the new kind `{ kind: 'record-context', token: 'record_id' }` instead of `unknown`. A consumer that switches exhaustively on `kind` gets a compile error until it handles the new kind. + - `isKnownFilterToken('record_id')` stays `false`. That predicate answers "can the server resolve it?", and its one consumer, the flow engine's filter hand-off, is a server position. A flow addresses its own record as `{record.id}`. + - Near misses are still refused, now with `{record_id}` suggested: `{recordId}` (the URL / flow-template placeholder), `{record.id}`, `{record-id}`, `{current_record_id}`. `CONTEXT_TOKEN_SUGGESTIONS`' value type widens to `ContextToken | RecordContextToken`. + + **Presentation scope, not access.** Like `{current_user_id}`, `{record_id}` narrows what a component shows. It decides nothing about which rows the caller may read; that is still RLS. + + **What you do:** on a record page, filter a component on the record in view with `{ : '{record_id}' }`. If `os validate` refuses it with "no record in context on this surface", the filter is on a surface with no record: move it onto a component of a `type: 'record'` page, or filter on a concrete id. Until the renderer you run resolves `{record_id}`, a record-page query that carries it is refused by the server with `FILTER_TOKEN_UNRESOLVED` rather than answered with a wrong number. +- fe677ae: fix(core): the effective object-permission map covers what a plain `'*'` grant covers, so `current_user.can()` agrees with the server for a wall-less org admin (#20083) + + `buildEffectiveObjectPermissions` builds the `objects` slot of `GET /auth/me/permissions` (`@objectstack/plugin-hono-server`) and the map `ISecurityService.getEffectiveObjectPermissions` returns (`@objectstack/plugin-security`), which the engine hands to `current_user.can(object, verb)` on the write path. It merged each permission set's EXPLICIT entries and kept `'*'` as a key of its own. The server's check does not stop there: `PermissionEvaluator.checkObjectPermission` resolves each set to its explicit entry for the object when it has one, and otherwise to its `'*'` — for a public object always, for a private one only when the wildcard carries a super-user bit. So an object reached only through a plain wildcard (no `viewAllRecords` / `modifyAllRecords`) had no entry in the map, and `can()` — which reads an absent entry as "no grant" — answered `false` where the server allows. + + The population it hit: `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins. With it and `member_default`, `current_user.can('crm_account', 'edit')` was `false` while the data plane accepted the edit, so a `can()`-gated option was refused on the write path, and a client that answers `can()` from `/auth/me/permissions` got the same `false`. `viewer_readonly` read the same way (`read` on every object it covers). + + **What changes.** A new step in `buildEffectiveObjectPermissions`, after the super-user seed and before the wildcard fold, applies each set's plain `'*'` to the registered objects that set does not name: + + - only registered objects, and only public ones (`access.default` other than `'private'`); + - a set that names the object keeps its explicit entry as its whole answer, as on the server; + - another set's plain wildcard widens an entry that is already present, bit by bit; + - only `true` grant bits are copied; a wildcard's `false` or unset bit adds nothing; + - an object the step would add, but whose entry grants no verb on its own, is left out. + + The step reads `name` and `access.default` off the `allSchemas` entries, so the element type of `allSchemas` on `buildEffectiveObjectPermissions`' schema source gains an optional `access?: unknown` member (the package exports no new name for it). That is a type widening only: every call that compiled before still compiles, and a schema literal carrying `access` now does too. A direct caller passes the registered schemas themselves there, as both in-repo callers do; an entry without `access` reads as public, exactly as the server reads it. + + **What a reader of `/auth/me/permissions` sees.** For a subject holding a plain wildcard, `objects` gains an entry for every registered public object the wildcard covers that had none, annotated with `apiOperations` by the same rule as every other entry. An entry that was already there may gain `true` bits. Nothing is removed. For a subject holding no plain wildcard — `admin_full_access`, a walled `organization_admin`, `member_default` alone — the response is byte-identical to before. The response shape, its keys and the route are unchanged. + + This closes the known gap that the `current_user.can()` write-path entry in this release describes: `organization_admin_no_bypass` now reads `true` from `can()` where the data plane allows. +- 437bb0d: fix(core): an effective-map entry reached through a super-user `'*'` carries every bit the server grants, so `current_user.can(object, 'transfer')` agrees with `checkObjectPermission` for a platform admin (#20134) + + `buildEffectiveObjectPermissions` builds the `objects` slot of `GET /auth/me/permissions` (`@objectstack/plugin-hono-server`) and the map `ISecurityService.getEffectiveObjectPermissions` returns (`@objectstack/plugin-security`), which the engine hands to `current_user.can(object, verb)` on the write path. For a subject holding a super-user wildcard — a `'*'` carrying `viewAllRecords` or `modifyAllRecords`, as `admin_full_access` and `organization_admin` do — its entries diverged from `PermissionEvaluator.checkObjectPermission` in three places, each in the refuse direction: + + - **`transfer`.** The fold put only read, create, edit and delete on an entry. `modifyAllRecords` also grants `transfer` on the server, so `can(object, 'transfer')` answered `false` for `admin_full_access` on every object, and for the walled `organization_admin` on every object its own set does not name. + - **A super-read wildcard's own bits.** A `'*'` carrying `viewAllRecords` beside plain bits (`allowEdit`, `allowTransfer`, …) put only the read on an entry, so `can(object, 'edit')` answered `false` where the server edits. + - **A super-user wildcard carrying `allowExport`.** The super-user seed skipped every unrestricted object whose export stays allowed, because it needs no `apiOperations`. That left no entry at all, and `can()` reads an absent entry as "no grant", so every verb answered `false` on those objects. + + **What changes.** The seed now places an entry for every registered object the merged map does not already carry. A new step after the fold then applies each set's super-user `'*'` to every entry that set does not name, and sets every bit the spec's `objectPermissionGrants` says that wildcard grants: `transfer` through `modifyAllRecords`, the wildcard's own plain bits, and its `allowExport`. This is the per-set reading `checkObjectPermission` applies. A set that names an object keeps its explicit entry as its whole answer for that object, and a private object is covered, as on the server. Only `true` bits are set. The step runs before the managed-write clamp, which still narrows create, edit and delete on a guarded managed object. No exported name or type changes. + + **What a reader of `/auth/me/permissions` sees.** For a subject holding a super-user wildcard, entries gain `true` bits (`allowTransfer`, and the wildcard's own plain and export bits). Where that subject's wildcards also grant `allowExport`, the map gains an entry for each registered unrestricted object that had none. That entry carries no `apiOperations` — unless the object declares `enable.apiEnabled: false`, which is annotated `[]` since #20135 — so for every other such object the operation channel says what it said before and a client's default-allow path is unchanged. Nothing is removed and no `true` bit turns `false`. The response is byte-identical for a subject holding no super-user wildcard: `member_default` alone, and `viewer_readonly` or `organization_admin_no_bypass` beside it. The response shape, its keys and the route are unchanged. On the write path, a `can(object, 'transfer')`-gated option or default is now admitted for these subjects wherever the server grants `transfer`. + + **Still broader than the server, unchanged here.** The fold still folds the merged super-user bits into an entry the super-user set itself names narrower. It also still pulls `allowCreate` on `modifyAllRecords` alone, which the server does not grant. Both over-grants are left exactly as they were by this change, and both are closed in this same release (`.changeset/20136-super-user-fold-per-set.md`). +- e5cf27d: fix(objectql,core): a per-aggregation `filter` and `having` on `engine.aggregate` read a temporal comparand by the column's storage rule, the rule `where` already applies — one function, `temporalStorageForm`, now exported by `@objectstack/core` and shared by both drivers (#20176) + + A per-aggregation `filter` (`aggregations[i].filter`) and `having` are evaluated by the engine itself, over the rows (or aggregated rows) a driver returns. Both compared a temporal comparand exactly as written, while the same condition as a `where` is put into the column's storage form by the driver first. So they counted differently. Measured through `engine.aggregate` and through `POST /data/:object/query`, on `driver-memory` and `driver-sql`, over six rows: + + | in `aggregations[i].filter` (or `having`) | before | now, and the `where` twin | + |:--|:--|:--| + | an ISO instant on a `date` field, `{ placed_on: { $gte: '2026-02-01T00:00:00.000Z' } }` | 1 | 3 | + | the same instant under `$eq` | 0 | 2 | + | a bare day as the upper bound of a `datetime`, `{ opened_at: { $lte: '2026-02-01' } }`, or as a `$between` max | 2 | 3 | + | an epoch-millisecond bound on a `datetime` | 0 | 3 | + | a `Date` carrying a time of day on a `date` field, `$gte` / `$lt` / `$eq` (in-process only) | 1 / 5 / 0 | 3 / 3 / 2 | + | a `Date` on a `time` field (in-process only) | 0 | 3 | + | `having` on `max` of a `date` field with an ISO-instant bound | kept one group | keeps the two groups whose day is on or after it | + + The same holds for `$ne`, `$in` / `$nin` members, `$between` endpoints, implicit equality, an offset instant (`'…T18:00:00+08:00'`), an epoch-millisecond string, a zone-naive `'2026-02-01T10:00'`, and a short wall clock (`'11:00'`) or an ISO instant on a `time` field. On a `having` column, the class comes from the query, as the `addDays` rule already reads it: `min` / `max` take the class of the field they read, a `groupBy` projection takes its field's, and a `day` date bucket is a `date`. + + What the rule does, now in one place: + + - A comparand, and the row's value, are put into the column's storage form: canonical UTC ISO text for `datetime`, `YYYY-MM-DD` for `date`, and `HH:MM:SS` (`.fff` only when non-zero) for `time`. + - A bare `YYYY-MM-DD` used as the upper bound of a `datetime` (`$lte`, a `$between` max) means that whole day, as it does in a `where` (ADR-0053 D-D). On a `date` or `time` column it is not widened. + - A value the rule cannot read is compared as written, and so is every non-temporal column, presence tests (`$exists`, `$null`), the text operators and a `{ $field }` reference. + - An object whose declared fields the engine cannot see keeps the previous comparison. + + `@objectstack/core` exports the rule as `temporalStorageForm(value, kind)`, `kind` being `'datetime' | 'date' | 'time'`. `driver-sql` (`canonicalUtcDatetime`, `toDateOnly`, `canonicalTimeOfDay`) and `driver-memory` (`coerceTemporalValue`) each carried a copy of it; both now call it. The copies agreed on every shape measured when they were lifted, so the lift itself changes no `where`, write or read answer of either driver (#20203, in the same release, then reads an epoch-millisecond number on a `date` field as its UTC calendar day). MySQL still binds a `datetime` in its own literal spelling. + + `@objectstack/objectql`'s `applyInMemoryAggregation(rows, ast, timezone?, fields?)` takes the object's declared field map as an optional fourth argument, and a per-aggregation `filter` reads a temporal comparand by the rule only when it is given. Called without it, the function answers as before. + + Not changed, measured identical before and after on both drivers: every `where` answer, every refusal a per-aggregation `filter` or `having` gives, and every per-aggregation `filter` and `having` cell whose column is not temporal. +- 89f87f2: fix(core,objectql)!: a number or `Date` compared against a `date` field spells its year with four digits, and one whose UTC year falls outside 0..9999 is refused `INVALID_FILTER` / 400, as its ISO string already was (#20240) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what a filter on a `date` field accepts. A number or `Date` whose UTC calendar day falls in a year below 0 or above 9999 used to answer 200 with the wrong rows, or a 500 on PostgreSQL; it now answers `INVALID_FILTER` / 400. It ships as `minor` under the launch-window convention for accept-set narrowings. + + `temporalStorageForm(value, 'date')` in `@objectstack/core` spelled the year of a `Date` or an epoch-millisecond number unpadded: `999-06-15`, `10000-01-01`, `-1-01-01`. The ISO string and the bare day of the same instant spelled `0999-06-15`, and as text an unpadded year sorts as no day does. Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query` (the two doors agree), on a `date` field holding six 2026 days and 0999-06-15, `$gt` / `$lt` / `$eq`: + + | position | comparand | before: memory · SQLite · PostgreSQL | now, on all three | + |:--|:--|:--|:--| + | `where` | a number (or, in-process, a `Date`) for 0999-06-15 | 0/7/0 · 0/7/0 · 6/0/1 | 6/0/1 | + | per-aggregation `filter` | the same | 0/7/0 on all three | 6/0/1 | + | `having` on `max(date)` | the same | no group / every group / no group | the three 2026 groups / none / the 0999 group | + | `where` | a number (or `Date`) for 10000-01-01 | 6/1/0 · 6/1/0 · 0/7/0 | `INVALID_FILTER` / 400 | + | `where` | a number (or `Date`) for -1-01-01 | 7/0/0 · 7/0/0 · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | + | per-aggregation `filter` | either of those two | 6/1/0 and 7/0/0 on all three | `INVALID_FILTER` / 400 | + | `where`, per-aggregation `filter` | the ISO string of either | `INVALID_FILTER` / 400 | unchanged | + + What changes: + + - The rule pads a year from 0 to 999 to four digits, for a `Date` and a number alike, so a number, its `Date` and its ISO string spell one day. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call it. + - `isUninterpretableTemporalComparand('date', value)` is now also true for a finite number or a valid `Date` whose UTC year is below 0 or above 9999, a finite number past the `Date` range (±8.64e15) included. The engine's temporal-comparand door refuses such a comparand on `where` for every verb (`find`, `findOne`, `count`, `aggregate`, `update`, `delete`), in both the object and the array spelling, and in a per-aggregation `filter`, before any driver read. `IObjectQLEngine.judgeFilter` runs the same door. + - The write path: `create()` / `update()` on `driver-memory` or SQLite, given a year-0..999 number or `Date` for a `date` field, now stores `0999-06-15` where it stored `999-06-15`; `engine.insert` of such a `Date` does the same. PostgreSQL and MySQL already stored a three-digit year's day, but not a shorter one: under its default `DateStyle` (`ISO, MDY`) PostgreSQL stored the unpadded `9-03-04` as 2004-09-03 and refused `99-03-04` (`22008`), and MySQL 8.0 stored `99-03-04` as 1999-03-04. All three dialects now store the day. A year outside 0..9999 keeps the spelling it had on the write and read paths; no ordered form is invented for it. + + **Who is affected.** A caller that compares a `date` field with an epoch-millisecond number or a `Date` in a year below 0 or above 9999. No writer that stores or queries such a day has been measured; the reach is the public query door. + + **Fix.** Compare against a `YYYY-MM-DD` day, or a number or `Date` whose UTC calendar day falls in a four-digit year. + + **Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included (#20264, in the same release, then narrows the range to 0001..9999 on `date` and `datetime` alike: year 0 is refused too, and so is a `datetime` number, `Date` or string outside it, and the padding covers 0001..0999); every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 read back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch and #20280, in the same release, corrects by reading a MySQL `DATE` as its text. `having` reaches the same door in the same release (#20263), so a number or `Date` outside 0..9999 is refused there too. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. +- 3062e50: fix(core,objectql)!: a `date` or `datetime` value names a year from 0001 to 9999, or it is refused: `INVALID_FILTER` / 400 as a comparand on `where`, a per-aggregation `filter` and `having`, and `VALIDATION_FAILED` / 400 as a written value (#20264) + + Clause-②: yes (narrowing) + + + + **BREAKING**: this narrows what a `date` or `datetime` field accepts, as a filter comparand and as a written value. A value whose year falls outside 0001..9999 used to answer 200 with the wrong rows, 201 with a non-day stored, or a 500 on PostgreSQL; it now answers 400. It ships as `minor` under the launch-window convention for accept-set narrowings. + + FROM a `date` or `datetime` value in year 0, before it, or after 9999 (`"+010000-01-01T00:00:00.000Z"`, `"-000001-…"`, `"0000-06-15"`, or the epoch-millisecond number or `Date` of such an instant) → TO `INVALID_FILTER` / 400 as a comparand and `VALIDATION_FAILED` / 400 (`invalid_date`) as a written value. The fix is one line: write a year from 0001 to 9999. + + Measured through `engine.find` / `engine.aggregate` / `engine.insert` and `POST /data/:object/query` / `POST /data/:object` (the two doors agree), over seven 2026 rows, `$gt` / `$lt` / `$eq`: + + | position | value | before: memory · SQLite · PostgreSQL 16 | now, on all three | + |:--|:--|:--|:--| + | `where` on a `datetime` | year 10000 or −1: a number, `Date` or ISO string | 7/0/0 · 7/0/0 · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | + | per-aggregation `filter`, `having` on `min` of a `datetime` | the same, `$gt` | 7 rows, every group, on all three | `INVALID_FILTER` / 400 | + | `where` on a `datetime` | year 0, every spelling | 7/0/0 · 7/0/0 · 500 | `INVALID_FILTER` / 400 | + | `where` on a `date` | year 0: a number, `Date`, ISO string or bare `0000-06-15` | 7/0/0 · 7/0/0 · 500 | `INVALID_FILTER` / 400 | + | create a `date` | `"+010000-01-01T00:00:00.000Z"` | 201, read back verbatim (not a day) · the same · 500 | `VALIDATION_FAILED` / 400 | + | create a `date` or a `datetime` | year 0, year −1, year 10000 | 201 · 201 · 500 | `VALIDATION_FAILED` / 400 | + + MySQL 8.0 answered the year-10000 and year-−1 cells with a 500 and the year-0 cells like SQLite. A `datetime` in year 10000 spells `+010000-…`, which sorts below every four-digit year as text (its `where` answer was 7/0/0 for `$gt` / `$lt` / `$eq`, where the right answer is 0/7/0); PostgreSQL's `DATE` and `timestamptz` have no year 0 (`22008`). Year 0 was answered right on memory and SQLite and a 500 on PostgreSQL; it is refused everywhere now, one answer on every driver. Each refused query or write now reaches no driver. + + What changes: + + - `@objectstack/core` exports `isOutsideTemporalYearRange(value, kind)`, the one range both doors ask. The year is the one the kind's storage rule reads: a `datetime`'s UTC year, a `date` string's leading `YYYY-MM-DD` year (otherwise the UTC year of the instant it names), never a `time`'s. + - `isUninterpretableTemporalComparand` is true for a `date` or `datetime` number, `Date` or readable string whose year falls outside 0001..9999; before, it judged only a `date` number or `Date`, against 0..9999. The engine's temporal-comparand door refuses such a comparand on `where` (every verb, both spellings), in a per-aggregation `filter` and on `having`, before any read, in words that name the year range. `IObjectQLEngine.judgeFilter` and `service-analytics`' raw-SQL decline read the same predicate. + - The record validator's `date` / `datetime` arm refuses a value outside the range on insert, update, a multi-row update and `engine.validate`, with the field's `invalid_date` code and its existing message. + - `temporalStorageForm(value, 'date')` pads a `Date`'s or a number's year to four digits for 0001..0999 only; year 0 keeps its unpadded spelling (`0-06-15`) like every other year outside the range. Only a direct driver write, which bypasses both doors, reaches that arm with year 0. + + **Who is affected.** A caller that filters on or writes a `date` or `datetime` in year 0, before it, or after 9999. No writer that stores or queries such a year has been measured; the reach is the public query and write doors. + + **Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every year from 0001 to 9999 (the edges 0001-01-01 and 9999-12-31T23:59:59.999Z included) and every 2026 control; every `time` cell; every string the rules could not read before, refused in its existing words, except a `date`-column string whose instant names a year outside 0001..9999 (`+010000-01-01T00:00:00.000Z`, `-000001-…`, an out-of-range epoch-millisecond string), refused with the same code and status on `where`, the per-aggregation `filter` and `having` but now in the year-class words; `NaN`, ±Infinity and an Invalid Date, which name no year; the `datetime` storage rule's own spelling of any instant on the write and read paths. On MySQL 8.0, a `datetime` in years 0001..0099 is still stored right and read back a century late through mysql2's instant parser (`0009-03-04T10:00Z` as `2004-09-03T10:00Z`), which ADR-0053 D-F2 keeps and this change does not touch; from year 0100 up it reads back as written. `driver-mongodb` keeps its own copy of the storage rule and is not changed; both doors sit in the engine, in front of it. +- 5a6267f: `os test` reports the suite and scenario names an author writes, and selects scenarios with `--tags` (#20289) + + Clause-②: no + + A Quality Protocol suite's `name`, each scenario's `name` and `description`, and scenario `tags` were parsed at load and then read by nothing: the report headed each suite with its file's basename, printed every scenario by its `id`, and `os test --tags critical` failed with `Nonexistent flag: --tags`. + + - **Names in the report.** The suite heading is now the suite's `name` followed by its file — `📄 Running suite: Accounts smoke (accounts.test.json)` — and each scenario line is its `name` with the `id` in brackets — `✅ Scenario: An account can be created [acct-create] (12ms)` (the id alone when the two are equal). A failed scenario's `description` is printed under its line, before the error. A suite whose file fails to load is still headed by the file alone, since no name was parsed. + - **`--tags TAG[,TAG...]`** runs only the scenarios carrying AT LEAST ONE of the listed tags (any-of, exact, case-sensitive) — the comma-list reading of Odoo's `--test-tags` and the everyday use of Playwright's `--grep @a|@b`. With the flag, an untagged scenario is left out. Left-out scenarios are **deselected**: not run, counted on the summary (`--tags smoke selected 1 of 4 scenarios; 3 deselected (not run, not counted as passed).`), never counted as passed. A requested tag that no loaded scenario carries is named on the summary. An empty entry (`--tags smoke,`) is refused before anything runs. Without the flag nothing changes: every scenario runs. + - **Exit status.** A selection that matches no scenario takes the posture an empty pattern already has: exit `0` with `No scenario matched --tags …`, and exit `1` under `--fail-on-empty`, whose description now covers both cases. The `Found N test suites.` line and the `SUCCESS: All N scenarios passed.` / `FAILED: …` summary lines keep their spelling. + - **`@objectstack/core`:** `QA.TestResult` gains `scenarioName` and `description` on every result, and `suiteName` on every result `runSuite` produces (absent only from a lone `runScenario` call, which has no suite). + - **`@objectstack/spec`:** the liveness ledger (`liveness/qa.json`) moves the four keys above to `live`, citing their readers. `TestScenario.requires`, the family's fifth key, is checked in this same release and has its own note: an unmet `params` or `services` entry skips the scenario with its reason, and `requires.plugins` is retired into `requires.services`. +- 0bbe400: feat(spec,core,cli)!: a scenario's `requires` is checked before it runs — unmet `params` or `services` SKIP it with a reason; `requires.plugins` is retired into `requires.services` (#20289) + + Clause-②: yes (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, + never by the level). + + A Quality Protocol scenario's `requires` block declared preconditions — + `params` (environment variables) and `plugins` (plugins that must be loaded) — + that nothing checked: measured on a stub target, a scenario naming a missing + plugin and an unset variable reported PASSED exactly like its no-requirements + control. ADR-0049 enforce-or-remove, verdict ENFORCE (the mainstream has + declared preconditions: JUnit `@EnabledIfEnvironmentVariable`, pytest `skipif`), + ruled B for the shape: each key is judged against something `os test` can + actually observe. + + - **`requires.params`** — each variable must be set to a non-empty value in the + environment of the process running `os test` (not the target server's, which a + suite cannot see). An empty value counts as unset: an unconfigured CI secret + arrives as an empty string. + - **`requires.services`** (new) — each entry is a discovery service key + (`CoreServiceName`: `auth`, `automation`, `analytics`, `ai`, `storage`, …; a + misspelling is refused when the suite loads) that the target must declare + `enabled` with status `available` in its discovery document (ADR-0076 D12). + It is read from the discovery request the HTTP adapter already makes once per + run; a suite that requires no service issues no extra request. + - **SKIPPED.** A scenario with an unmet entry runs no step — `setup` included — + and `os test` prints it with its reason, naming every unmet entry and, for a + service, the services the target does declare available: + `Skipped: requires.services 'ai' is not available on the target (enabled: false, status: unavailable). The target declares available: auth, data, metadata.` + It is counted on its own — `SUCCESS: 3 scenarios passed. 1 skipped (not run, not counted as passed).` — + and never as passed. Skips alone exit `0`; a run in which EVERY selected + scenario was skipped prints `No scenario ran: …` instead of `SUCCESS`, exits + `0`, and exits `1` under `--fail-on-empty`. With nothing skipped, the summary + lines keep their spelling. + - **`@objectstack/core`:** `QA.TestResult` gains `status` (`'passed' | 'failed' | 'skipped'`) + and, on a skipped result, `skipped` (`reason`, `unmet[]`, `availableServices`); + `passed` stays and is `false` on a skip. `TestRunner` takes an optional + `{ env }` (default: this process's environment), and `TestExecutionAdapter` + gains an optional `readTargetServices()` — `HttpTestAdapter` answers it from + its one discovery probe. An adapter without it skips a service requirement + rather than running it. + + ``` + FROM { "id": "ai-summary", "requires": { "plugins": ["@objectstack/service-ai"] }, "steps": [...] } + -> ran anyway; the missing plugin surfaced as whatever failure it caused, or passed + TO -> os test refuses the suite at load: + ✗ scenarios.0.requires.plugins: `scenarios[].requires.plugins` was removed in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever checked it: … + Delete the key and name the service the scenario needs in `requires.services`, … + Plugin → service: @objectstack/service-analytics → analytics, @objectstack/plugin-auth → auth, … + + FROM { "requires": { "services": ["ai"] }, … } (new key) + TO -> against a target whose discovery does not declare `ai` enabled and available: + ⏭️ Scenario: Summarise an account [ai-summary] (skipped) + Skipped: requires.services 'ai' is not available on the target (…). The target declares available: … + ``` + + **Fix.** `requires.plugins: [""]` → `requires.services: [""]`, + using the mapping the refusal prints (derived from `CORE_SERVICE_PROVIDER`, the + provider table discovery itself reports): `@objectstack/plugin-auth` → `auth`, + `@objectstack/service-analytics` → `analytics`, `@objectstack/service-automation` + → `automation`, `@objectstack/service-storage` → `storage`, and so on; the `ai` + service is provided by ObjectStack Cloud/Enterprise. A plugin that fills no + discovery service slot has no service to require — gate that scenario with a + `params` variable or select it with `--tags`. `tsc` refuses `plugins` at a typed + authoring site (its input type is `never`). A `TestResult` consumer that counted + `!passed` as a failure should read `status` — a skipped result is `passed: false` + and is not a failure. + + **What does not change.** A scenario without `requires` runs exactly as before, + and a suite that requires no service issues no discovery request it did not + already issue. + + ### The retirement kit + + - **Schema.** `TestScenarioSchema.requires` is a non-strict `z.object()`, so + `plugins` is a `retiredKey()` tombstone carrying its prescription (a bare + deletion would have stripped it in silence); `services` is new, closed over + `CoreServiceName`. + - **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `qa/TestScenario:requires.plugins`. + No D2 conversion: a QA suite is a loose JSON file `os test` loads, never a + stack collection member or a stored row. The family's D3 entry, + `qa-scenario-requires-plugins-retired`, carries the prescription to + `os migrate meta` and the upgrade guide. + - **Ledger and docs.** `liveness/qa.json` moves `qa.scenarios.requires` from + `dead` to `live`, citing the runner's judgement and the adapter as producer; + `state-counts.md` moves `qa` to 9 live / 0 dead. The `os test` section of the + CLI reference documents the check, the skip line and the exit posture, and the + generated `qa/testing` reference page is regenerated. + + +- f6ceddc: A grants resolution with no active organization now applies only the global grants. `resolveUserAuthzGrants` applies a grant scoped to an organization only while that organization is the active tenant, and one rule decides it for all three kinds of grant row it reads: position assignments (`sys_user_position`), permission-set grants (`sys_user_permission_set`) and the organization's own position rows whose bound permission sets it collects (`sys_position`). + + **BREAKING** for a principal acting with no active organization. Before, "no organization" read as "every organization": each organization-scoped grant the user held anywhere applied, with no organization boundary left on it. That is the resolution a session falls back to when it names an organization its owner no longer belongs to, so a member removed from an organization kept the capabilities that organization had granted until someone revoked each grant by hand. Such a principal now holds its global grants and nothing scoped to an organization. + + - **Unchanged:** a principal with an active organization resolves exactly as before, and a global grant (no organization) applies everywhere as before. Platform-admin standing is unchanged: it was only ever derived from the unscoped `admin_full_access` grant or the declared administrator list, never from an organization-scoped grant. + - **If a principal relied on it:** act in the organization. Select it as the active organization, or mint the API key from a session that has it active, or grant the permission set globally (no organization) when it is meant to apply everywhere. + - **No "every organization" mode.** No option asks the resolver for every organization's grants, and nothing falls back to that reading. + - **`@objectstack/plugin-security`:** `buildContextForUser(ql, userId, nowMs?, tenantId?)` takes the organization to resolve the user in. The access explainer (`explainAccessForCaller`) resolves the explained user in the caller's organization, and the delegator behind an on-behalf-of principal is resolved in the live principal's organization, so the delegated intersection counts the delegator's grants where the request actually runs. + + Clause-②: yes (narrowing) + + +- 0da638c: fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) + + + + **BREAKING** for an in-process caller that reaches an analytics face PAST the + schema door with a string the closed vocabulary does not contain: it used to be + answered, and is now refused. Shipped as `minor` under the repo's launch-window + convention. The driver half of #16041, whose spec change closed + `AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen + dashboard preset names; every value affected here was already refused at + `POST /analytics/query` and `/analytics/sql` when that landed. + + ## What was wrong + + #16041 closed the contract; the faces behind it never aligned, so the defect it + abolished simply moved onto the newly-blessed vocabulary. Measured on the built + `driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, + 2099): + + | input | before | after | + |:--|--:|--:| + | `today` | 1/5 | 1/5 | + | the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | + | `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | + + `driver-memory` recognised exactly `today`: every snake_case preset missed its + `startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose + two bounds were the preset's own NAME, which matched every `Date`-typed row + under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered + the same names — and unrecognised strings, and `today` — to the point window + `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is + whatever the dialect decides a vocabulary word compares as. So a dashboard + asking for one month got all of history on one backend and a nonsense + comparison on the other, at HTTP 200 on both. + + ## What it does now + + - **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / + `resolveAnalyticsDateRangeString` resolve every declared preset to + `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens + handed to the existing macro resolver, so `dateRange: 'this_month'` and a + `{month_start}` filter token cannot answer differently, and the anchoring on + `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) + come from that resolver rather than from each face. + - **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 + envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own + `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door + answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call + it, so "memory and SQL refuse identically" is one function rather than an + agreement. + - **The upper bound keeps #16179's separation.** A window a face RESOLVED is + compared exclusively (`$lt` / `<`) for the ten calendar presets and + inclusively for the three rolling `last_N_days`, whose bound is NOW; an + explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. + - The fifteen `driver-memory` date-range pins #16041 retired are reinstated in + preset form (DST cells re-measured under calendar semantics, not re-spelled), + and one cross-face conformance fixture holds all FOUR faces to the same + windows and the same refusal. + - **The draft-preview evaluator is the fourth face**, and it is in that fixture + for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — + the Live Canvas preview over a pending seed draft) carried the identical + `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, + silently, while the published chart beside it answered a real window — across + a publish boundary the preview exists to make continuous, since publish + materialises the same seed. + + ## FROM → TO + + Unchanged from #16041's — the spelling that is refused here is the spelling that + was already refused at the door. + + | you wrote | write instead | + |:--|:--| + | `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | + | `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | + | `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | + | `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + + The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's + code stays registered under `@objectstack/runtime` (the door that names the wire + vocabulary), and the waiver records that the shared constructor spelling it + lives one package over. +- f03f6c7: fix(driver-memory)!: an analytics time dimension buckets by its declared `granularity`, and refuses a sub-day one instead of ignoring it (#16178) + + + + **BREAKING** in three senses, all on `driver-memory`'s analytics face, landing in + the launch window as `minor` under the lockstep convention this cluster's + siblings already use: + + - an accepted request now answers **differently**: a time dimension carrying a + `granularity` folds its rows into calendar buckets instead of returning one + group per distinct timestamp. Every affected answer was wrong before; + - a **trend query answers rows where it used to answer one total**: a + `granularity` on a member `dimensions` does not also list is now a group + column of its own, so `{measures, timeDimensions: [{dimension, granularity}]}` + — the canonical trend shape — comes back one row per bucket, carrying the + member and a `fields` entry for it, instead of a single ungrouped total with + no such column; + - an accepted request is now **refused**: `granularity: 'second' | 'minute' | + 'hour'` answers `NOT_IMPLEMENTED` / 501 instead of being silently dropped. + + ## What was wrong + + `AnalyticsQuery.timeDimensions[].granularity` is declared by the spec and a cube + dimension enumerates the granularities it offers (`granularities: ['day']`). + `memory-analytics.ts` read neither. The `$group` stage keyed on the raw field + path, so a time dimension bucketed **one group per distinct timestamp** — one bar + per row in a "new accounts by month" chart, which is the symptom #3588 + catalogued and repaired for `service-analytics`. + + Measured through the public entry against the built package, two rows on one UTC + calendar day (`2026-09-06T01:00:00Z` and `2026-09-06T23:00:00Z`) under + `granularity: 'day'`: + + | | before | after | + |:--|--:|--:| + | `granularity: 'day'` | **2 groups**, keyed on the raw instants | 1 group, `2026-09-06` | + | no granularity (control) | 2 groups | 2 groups, unchanged | + | `granularity: 'hour'` | **2 groups**, silently | `NOT_IMPLEMENTED` / 501 | + | same, but with no `dimensions` | **`{count: 2}`** — one total, no time column, and no `fields` entry naming it | `{'events.createdAt': '2026-09-06', count: 2}`, `fields` naming both | + | `granularity: 'fortnight'` past the schema door | — | `INVALID_QUERY` / 400 | + + The emitted pipeline was byte-identical across all three, which is the whole + finding: the request was accepted, no warning was emitted, and the key was inert. + + ## What it does now + + - **One forward labeller, in `@objectstack/core`.** `bucketDateKey(value, + granularity, timezone)` sits beside the inverse `bucketKeyToCalendarRange` and + the `calendarPartsInTzOrUtc` primitive it builds on, and it is now the only + statement of the rule. `BUCKET_GRANULARITIES` and `isBucketGranularity` name + the five granularities that HAVE a canonical key, so a face that must refuse + the other three quotes the accepted set instead of hand-listing it. + - **`@objectstack/objectql`'s `bucketDateValue` is a delegate**, export name and + signature unchanged, answers unchanged — pinned across granularity, timezone + and input form rather than asserted. A driver that pushes the bucket down into + SQL and this in-memory path must label one instant identically or a drill-down + breaks at the seam, and that is now one function rather than an agreement + between two. + - **A granular time dimension is a group column, listed or not.** `dimensions` + no longer decides alone what `$group` keys on: every `timeDimensions` entry + carrying a `granularity` is grouped, projected and named in `fields`, deduped + against `dimensions` on the resolved member so two spellings of one member + stay one column. This is the rule the SQL/ObjectQL face already records + (`projectedDimensions`, #4033/#5688) — one set feeding grouping, row mapping + and field metadata, because rows carrying a bucket under a `fields` list that + never mentions it is a trend chart with no x-axis. ⛔ An entry carrying only a + `dateRange` is a predicate and is still **not** projected. + - **`driver-memory` folds by granularity before its `$group`.** The pipeline is + cut at that stage: the `$match` half still runs in the driver, the bucket keys + are written onto the selected rows, and the grouping half runs over those. The + key travels under a synthetic field rather than overwriting the row's own, so a + member that is both a group key and a measure's aggregand still ranks instants + in `max()` while grouping on the label. + - **The output vocabulary is the published one** — `2026`, `2026-Q3`, `2026-09`, + `2026-09-06`, `2026-W36`. The week label is `YYYY-Www`, never the Monday's + `YYYY-MM-DD`: `DriverCapabilitiesSchema.queryDateGranularity` calls this an + output contract, and a second spelling is what breaks a drill-down across a + backend seam. + - **Bucketing honours `AnalyticsQuery.timezone`** — the same reference zone + #16042 threaded through the `dateRange` window resolver, so the window that + selects the rows and the bucket that folds them agree on where a calendar day + starts. The same two rows answer one group in UTC, two in `America/New_York` + and two in `Asia/Tokyo`. An absent zone buckets in UTC, the resolver's default. + + ⚠️ That agreement is about the PRESET arm of `dateRange`, which the resolver + reads in the reference zone. An explicit `[start, end]` array is the caller's + own **instant** window and keeps its published reading (#16179), while the + bucket beside it is always a **calendar** label (ADR-0053) — so an array + window and a bucket can still disagree about where a day starts. That + combination is legitimate and is not refused; it is stated here rather than + left to be discovered. + - **`second` / `minute` / `hour` are refused at compile**, in the ADR-0112 + envelope this driver's other capability gaps speak (`NOT_IMPLEMENTED` / 501, + the class `refusePerAggregationFilter` uses for the same reason: the query is + spelled correctly, the spec declares the value, and it is this backend that + compiles nothing for it). The canonical key vocabulary defines no label for a + sub-day bucket, so there is no string another backend's pushed-down SQL would + agree with. Passing it through unbucketed is this card's own defect wearing a + new name. + - **An undeclared granularity is a 400, not a 501.** A 501 says "this backend + cannot", which is only honest about a value the contract declares. + `TimeUpdateInterval` is checked first, so a spelling it never declared — + reachable past the schema door, where `POST /analytics/dataset/query` types + `selection.timeDimensions` without Zod-parsing them — answers `INVALID_QUERY` + / 400 rather than a 501 asserting the spec declared it. The same separation + the `dateRange` half of this face already draws (#16322 / #16041). + + ## If a caller is refused + + A stored widget or a request asking for a sub-day granularity was never bucketed + by this backend — it received one group per distinct timestamp under an ordinary + 200. Nothing that worked stops working. Ask for `day` or coarser and the answer + is a real bucket; keep the raw timestamps deliberately by dropping the key, which + is the behaviour that key used to produce by accident. +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- 07150b3: `PluginSchema.version` now accepts the whole of the SemVer 2.0.0 grammar, and `version` becomes the ninth declared key `kernel.use()` enforces. + + Two declarations in this repository disagreed about what a plugin `version` is, and the disagreement became load-bearing the moment the boot path started running the schema: + + | Declaration | Grammar | Accepted `1.0.0-alpha.1` / `1.0.0+20230101` | + |---|---|---| + | `PluginSchema.version` (`@objectstack/spec`, `kernel/plugin.zod.ts`), described `"Semantic Version"` | `/^\d+\.\d+\.\d+$/` | **no** | + | `PluginLoader.isValidSemanticVersion` (`@objectstack/core`), the check the boot path has always run | `/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/` | **yes** | + + SemVer 2.0.0 defines prerelease and build metadata as **parts of** a semantic version, so the key's own `describe()` — `"Semantic Version"`, no qualifier — claimed the wide grammar while its regex implemented a subset of it. The spec key was the one that was wrong, and it is the one that moved. + + **The spec adopts the loader's grammar character for character**, deliberately, rather than a third spelling: that is the check the boot path has always run, so the two declarations now converge exactly and nothing that loaded before is refused now. + + **`@objectstack/spec` — a WIDENING of a published contract.** `Plugin.json`'s `pattern` in the shipped `json-schema/` tree changes from `^\d+\.\d+\.\d+$` to `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`. This is a strict superset — same three-segment core, two **optional** suffix groups — so every string that validated before still validates. A tool that mirrors this schema to validate plugin manifests should widen with it; one that does not will merely keep refusing prerelease versions the platform accepts. + + **`@objectstack/core` — `version` joins the enforced set, which NARROWS `LiteKernel`.** **BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A plugin object `LiteKernel` accepted before can be refused now.** `assertPluginContract` filtered `version` issues out while the two spellings disagreed; that stopgap is gone. The full enforced set is now **NINE** keys, each refused with the offending key named in the message: + + - **`id`** — a non-string, or the empty string. + - **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`. + - **`staticPath`** — a non-string. + - **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`. + - **`default`** — a non-boolean. + - **`version`** — a non-string, or a string outside the SemVer grammar above. **New in this release.** + - **`description`** — a non-string. + - **`author`** — a non-string. + - **`homepage`** — a non-string, or a string that is not a URL. + + **`null` is refused on every one of the nine**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is still refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope. + + ⚠️ **This supersedes the eight-key enumeration published in `@objectstack/core@17.4.0`.** Both of that release's entries — the `kernel.use()` and the `LiteKernel.use()` enforcement notes — say the enforced set is eight keys and that `version` is excluded, and both point at reconciling the two `version` spellings as separate spec work. This is that work. Those entries stay as written, because they describe what 17.4.0 did; **nine is the current set**, and `version` is no longer excluded from anything. + + **What actually changes behaviour, stated narrowly.** On **`ObjectKernel`** nothing moves: `PluginLoader.validatePluginStructure` already judged `version` with this exact grammar and still runs first, so a malformed `version` is still refused as `Invalid semantic version`, never as `PLUGIN_CONTRACT_VIOLATION`. On **`LiteKernel`** a plugin object with a malformed `version` — `version: 'v1.0.0'`, say — was **registered** before and is **refused** now, with `PLUGIN_CONTRACT_VIOLATION` at `'version'`. `LiteKernel` has never run the loader's structural checks, so `version` was the one declared key it did not judge at all: such a plugin was green in vitest and refused by `ObjectKernel` at production boot. That is exactly the split the `LiteKernel` convergence closed for the other eight keys, closed now for the ninth. + + **What is unchanged.** `1.0.0-alpha.1`, `1.0.0+20230101` and `0.0.0-fixture` load on **both** kernels, as they did before — measured, not assumed, and pinned per kernel. A version-less plugin still loads; `version` is `.optional()`. Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the object that was passed in). A class-based plugin keeps its identity, prototype and prototype methods. + + ⚠️ **The accepted grammar is wider than SemVer 2.0.0 itself**, and this release neither introduced nor widened that fringe: leading zeroes in the numeric core (`01.1.1`) were accepted by **both** spellings before this change and are accepted by both after it, and the loader's prerelease/build classes admit degenerate identifiers SemVer forbids (`1.0.0-alpha..1`, `1.0.0-0123`, `1.0.0+.`). Tightening to the official SemVer regex would have **narrowed** this key rather than widening it, so it is deliberately not done here. + + **Migration.** Nothing to rename, and nothing to do if your plugin's `version` is a real semantic version. If you register plugins on `LiteKernel` with a `version` string that is not one — a leading `v`, a two-segment `1.0` — spell it `MAJOR.MINOR.PATCH` with optional `-prerelease` and `+build`, or drop the key. The refusal names the plugin and the key. + + + +### Patch Changes + +- 0f95f43: docs(identity): re-point the cloud-identity `ADR-0024` citations at the records that decide them (#14361) + + From this repository's point of view `ADR-0024` names two unrelated decisions. + `docs/adr/0024-mcp-connectors.md` is *MCP Servers as Connectors* — an open, + vendor-neutral tool protocol, with a Decision section numbered §1–§5 and no + D-lettered clauses at all. The identity surface's citations mean something else + entirely: the identity-and-access decision taken in `objectstack-ai/cloud` as + its own ADR-0024, whose open mechanism half has been mirrored into this repo + since 2026-09-07 as + [ADR-0135](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0135-identity-and-access-architecture.md). + A reader following one of those citations landed on a real page about the wrong + subject, which is worse than a dangling id: a plausible-looking record invites + belief rather than a second question. + + 79 citation lines were read one at a time and re-pointed. 73 mean a clause + ADR-0135 restates and now name it with its letter — D4 (source-of-truth marking, + managed vs env-native), D5.2 (the break-glass last-administrator invariant), D6 + (SSO per production environment, including the opt-in DNS domain-verification + clause this tree spelled `ADR-0024 ②`) and D9 (environment users and + organization membership). 6 mean a clause ADR-0135 deliberately leaves in the + cloud record and now carry the anchors gate's cross-repo qualifier + `cloud ADR-0024`: `V1` (the SSO default-role provisioning, the roadmap and + commercial framing) and `§7` (the `ai_seat` synthesis, which ADR-0135 does not + restate). + + What actually reaches a consumer of these packages: + + - `@objectstack/plugin-auth` — the **operator-facing break-glass refusal + detail** now reads `break-glass invariant, ADR-0135 D5.2 — an environment must + always keep at least one administrator who can sign in`. The condition that + raises it, its status, its error code and the rest of its wording are + unchanged; only the ADR number moves. ⚠️ A deployment that greps that message + for the literal `ADR-0024` should grep for `ADR-0135`. The guard's + registration log line moves the same way. + - `@objectstack/platform-objects` — `sys_sso_provider`'s `domain_verified` field + help text, its `protection.reason`, and the matching leaf in all four shipped + locale bundles (`en`, `es-ES`, `ja-JP`, `zh-CN`). + - `@objectstack/spec` — the doc comment above `AuthConfigSchema`'s + `ssoDomainVerification`, published both in `dist/` and as + `src/system/auth-config.zod.ts`. + - `@objectstack/core`, `@objectstack/cli` — doc comments only, published in + `dist/`; no runtime string and no behaviour. + + No behaviour moves. No schema accepts or refuses anything it did not accept or + refuse before, no security or permission semantics are touched, and no ADR + record is written or edited. Bare `ADR-0024` still resolves exactly as it did: + the 15 citations that mean the local MCP-connectors record are byte-identical to + `main`, and `check:adr-anchors` reports the same resolving-citation totals before + and after. Historical archives are deliberately untouched — 36 CHANGELOG lines + across seven packages, and the 22 lines under `docs/adr/`, which is a governed + surface this change does not enter. +- baf9745: Three source comments now state the registered position for the `door: 'none'` boot-refusal codes instead of the pre-#16404 one + + `SERVICE_NOT_REGISTERED`, `PLUGIN_CONTRACT_VIOLATION` and — as the worked + example the `driver-sql` comment cites — `MONGODB_MULTI_TENANT_UNSUPPORTED` are + all registered in `ERROR_CODE_LEDGER`. #16649 registered fourteen `door: 'none'` + codes under the #16404 door-or-no-door ruling, and re-registered the MongoDB one + that #8035 had removed. Three TSDoc comments still asserted the position that + preceded that ruling — that these codes are deliberately not wire vocabulary, + and that registering one is "not something to start doing at a door" — and each + was false the moment #16649 landed. They also pointed at + `dispatcher-error-vocabulary.ts`'s `boot-refusal` verdict, which the same PR + ratcheted from fourteen rows to zero, so the pointer dangled. + + These docblocks ship inside each package's `dist/*.d.ts`, which is why this is a + published change rather than an internal one: the sentence is what an agent or + an IDE reader sees at the point it decides whether the code needs registering. + + ⛔ No behaviour changes. Every reachability sentence is kept verbatim — none of + these codes reaches an HTTP door on this tree — no code is added, removed or + re-registered, and no gate moves. With every comment character removed by + `scripts/js-comment-mask.mjs`, all three files' executable token streams are + byte-identical to the commit this branched from. +- aaacf1d: Say what the install-time granted permission set actually does: it is REGISTERED at load and refuses nothing. + + Four shipped sentences claimed the structured `manifest.permissions` / `granted_permissions` set was enforced. Measured on `9bd4344e4`: `SecurePluginContext` — the only reader of `PluginPermissionEnforcer`'s service and hook gates — has zero production construction sites, and `enforceFileRead` / `enforceFileWrite` / `enforceNetworkRequest` are called by nothing at all, `SecurePluginContext` included. So #13457's binding registers a consented set that nothing queries, and the `fs` and `network` classes have no enforcement surface even in principle. + + Corrected, each to the same truthful split ("registered at load · queried by nothing · refuses no operation"): the `registerGrantedPermissions` docblock, the `PluginPermissions` schema docblock, the `manifest.loading` tombstone prescription, and the ADR-0087 D3 entry that ships that prescription into `docs/protocol-upgrade-guide.md`. The hand-written plugin development guide gains the same note beside its permission table. + + `plugin-runtime-tier-truthful-text.test.ts`'s coordination pin — which held the permissions half verbatim so it would go red the day that half was corrected — has been discharged and replaced by pins on the truthful text, in both carriers, each with the negative assertion that keeps the retracted sentence from returning beside it. + + New in `@objectstack/core`: `granted-permissions-not-enforced.pin.test.ts` pins the MEASUREMENT as well as the words, so the claim cannot rot in either direction. It fails the day a production `SecurePluginContext` construction site appears — i.e. the day the ADR-0025 materialize seam lands — and names every text that then becomes false. + + No behaviour changes: no accept/reject, no registration, no gate is added or removed. +- 6548118: Sweep the retracted "enforces exactly the consented surface" phrasing repo-wide, not just in the file it shipped on. + + The #17147 pin read one file, and a post-merge sweep found what that missed: `artifact-granted-permissions.test.ts` carried the retracted sentence as a CASE TITLE — "a CONSENTED entry enforces exactly the consented surface" — beside a sibling titled "registered, and denies". Neither case asserts a refusal; both read a permission bag and check what it answers. But a case title is read as evidence (ADR-0033), and those two said the platform confines plugins while nothing on the tree queries the registry at all. + + Both titles now name what they assert, the file carries a verb-discipline note (`answers` / `registered` / `bound`; ⛔ never `enforces` / `denies` / `gates` / `refuses` / `blocks` until the seam exists), and the pin's negative assertion is a repo-wide `git grep` excluding only its own specimen — with an anti-vacuity limb so a broken scan cannot read as a clean one. + + No behaviour, no assertion semantics, and no accept/reject changes. +- d3a2331: fix(spec,core): every ADR-0049 tombstone names the npm release that actually carries its removal, and a gate keeps it that way (#18048) + + Clause-②: no + + Thirty-six sites across fifteen files dated a removal to the next npm major of + `@objectstack/spec` — a bare **18** attached to the package name. + There is no npm 18, and under ADR-0087's level ruling (Amended 2026-09-13) there + will not be one as the carrier for a retirement: *"A tombstone names the npm + release it ships in, ⛔ never the protocol major […] a retirement shipping + `minor` lands in `17.x.y`"*. An author who met one of these was sent to a + version that does not exist. A sibling repository had already hung a cleanup + schedule on "the PR that pushes the spec package to its next major" — an event + that will never come. + + **The number was determined per site from `packages/spec/CHANGELOG.md`, not + pasted.** The sites split cleanly in two, and the two halves take different + spellings because different things are known about them: + + - **Already shipped ⇒ the release that carries it.** The three + `PluginHealthCheck` restart keys (`b72db01`), `HotReloadConfig.stateStrategy` + / `distributedConfig` (`4635f3e`), `HotReloadConfig.watchPatterns` + (`ee3595c`) and the form-view `options[].default` narrowing (`c459da6`) all + landed in **`17.3.0`**, which is published. These say `17.3.0` — the version + an upgrading reader greps in the CHANGELOG. + - **Not shipped yet ⇒ the bare published major `17`.** The seven cron-typed + positions, the `scheduled` cache-warmup strategy and the three + `PluginStartupResult` members are still unreleased changesets, so the carrier + minor is unknown at authoring time and any digit would be a guess — the same + guess that produced this defect. ADR-0087 guarantees the major: a pre-GA + retirement ships `minor`, so the carrier is some `17.x.y`. Bare `17` asserts + exactly what is known, cannot go stale as minors accumulate, and is the house + form already on 588 other sites. + + **Why `Clause-②: no`.** Every affected string is a docblock, a doc page, or a + `retiredKey()` / `guidance` MESSAGE. The key is refused before and after, so the + accept/reject result does not move for any input. The control that decides it: + the phrase has 0 hits across `packages/*/api-surface` and + `packages/*/export-origins`, so no published declaration baseline carries these + sentences and none moves. + + **No protocol-major reference is altered.** `toMajor: 18`, `step18` and the + `PROTOCOL_VERSION` ladder are correct and untouched — ADR-0087: *"the two move + independently"*. + + **Three sites are deliberately left saying 18**, because they quote the wrong + number in order to forbid it: the ADR-0087 ruling itself, and the two + `docs/v17-docs-sweep.md` rows that carry this class's detection fingerprint. + Four more say `99` on purpose — a synthetic "next major" fixture that must name + a version that does not exist. + + **A gate lands with the prose**, because this is the class's second appearance: + ten sites of it were corrected by hand in July with no gate, and the card closed + `completed`. `pnpm check:future-spec-major` derives the class from + `packages/spec/package.json` at runtime — a major above the published one, never + a hardcoded 18 — joins string-concatenation, JSDoc and plain-wrap line breaks + before matching, and reads the backticked package name, because each of those is + an independent way for a matcher to read zero and print green. +- fe0ae5c: analytics `dateRange`: one condition, one refusal wording + + An array `dateRange` that is not a two-bound window is refused by the + `service-analytics` faces with the platform's ONE shared sentence + (`analyticsDateRangeRefusalMessage`, origin `runtime`) instead of a + package-private second wording. The envelope is unchanged — + `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 — so nothing that classifies on + `code`/`status` is affected; only the `message` text changes, and it now agrees + byte-for-byte with the sentence the schema door answers with for the same value. + + The second wording existed because the shared sentence used to judge a bare + string against the preset vocabulary and to end with "Refused at the schema", + neither of which is true of an array refused past the schema door. Both grounds + were removed when `analyticsDateRangeRefusalMessage` gained its required + `origin` parameter and began describing a non-string by what is wrong with it. + + ⚠️ **The message no longer echoes the value you sent.** For an ARRAY + `dateRange` the shared sentence DESCRIBES the shape instead: what used to read + `dateRange ["a","b","c"] is a 3-element array` now reads `received a 3-element + array, not the two bounds [start, end]`. That applies to EVERY array shape this + face refuses, not to unusual ones only — `[null, null]` now reads `received an + array with a non-string bound`, and `['', '']` is where the description carries + least, `received a two-element array`. A bare STRING `dateRange` is still quoted + back to you. So a log line that used to carry the offending array no longer + does: if you need the value at that site, read it from the request you already + have, ⛔ not from the message. + + ⛔ If you match on the old text (`[service-analytics] dateRange …`), match on + `error.code === 'ANALYTICS_DATE_RANGE_UNRECOGNIZED'` instead — the message was + never the contract, the envelope is. +- 2cac363: feat(spec): one declaration per version grammar — eight regex carriers of "the version of a package or plugin" now reference three exported constants + + Clause-②: yes (widening) + + **No accept set moves, and that is the whole point of this change.** Eight sites + spelled a version regex out as a literal of their own. Five of those spellings + were byte-identical to each other, two more were byte-identical to each other, + and the eighth stood alone — three accept sets written eight times, growing on + their own: three of the eight were published schema declarations with no parse + caller at all, added by authors who copied a neighbour's literal. Each site now + references the constant carrying the pattern it already enforced, byte for byte. + A ninth in-repo carrier of the same concept spelled no regex at all: + `PackageManifestSchema.version` is a bare `z.string()`, and it stays one here. + + `@objectstack/spec/kernel` gains three exported patterns: + + - `MAJOR_MINOR_PATCH_VERSION_PATTERN` — three numeric segments and nothing + else. Referenced by `ManifestSchema.version`, + `MetadataPluginManifestSchema.version`, `PluginRegistryEntrySchema.version`, + `PluginMetadataSchema.version`, and the `PATCH /api/v1/packages/:id` door in + `@objectstack/runtime`. + - `SEMVER_SHAPED_VERSION_PATTERN` — `major.minor.patch` with an optional + `-prerelease` and an optional `+build` suffix, identifiers in either ASCII + case. Referenced by `PluginSchema.version` and by + `PluginLoader.isSemverShapedVersion` in `@objectstack/core`. Those two + converged on one spelling under the widen-never-narrow ruling and were held + equal by hand until now; they reference one declaration and can no longer + drift apart. + - `SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN` — the same with the suffix + identifiers restricted to lowercase ASCII. Referenced by + `PackageVersionSchema.version`. + + ⛔ **The three are not interchangeable** — they are three different accept sets, + and referencing the wrong one moves a published accept set. None of the three is + a SemVer 2.0.0 conformance check and none is named as one: two accept forms + SemVer forbids (leading zeroes in the numeric core, empty and leading-zero + identifiers), one refuses forms it requires. For ordering or precedence, + `dependency-resolver.ts` in `@objectstack/core` is still the module to extend. + + **Nothing an author can write changes.** Every regex is byte-identical to the + literal it replaces — verified per carrier by sha256 over the extracted literal + — and every existing suite passes unedited. Those two together are the + neutrality proof, and they are the whole of it. `PackageManifestSchema.version` + keeps its bare `z.string()`; it is deliberately untouched here. No `.describe()` + text, refusal message or JSON Schema `pattern` moves. Regenerating the spec's + artifacts moved `api-surface/kernel.json` and `export-origins/kernel.json` and + nothing else, each gaining the three constant names — ⛔ read that as a check + that nothing unexpected regenerated, never as evidence about the accept set: the + artifacts that stayed byte-unchanged do not record a `.regex()` pattern in the + first place. A new pin, + `src/kernel/version-grammar.test.ts`, records each grammar's verdict on twelve + witness strings so the next deliberate move to any of them is one visible edit + to one matrix. +- 95fb417: **The declared `zod` floor moves from `^4.4.3` to `^4.6.1`**, because on zod below 4.6.1 the three standard error formatters — `z.treeifyError()`, `error.format()` and `error.flatten()` — cannot render a refusal these packages actually emit (#19581). + + Clause-②: no + + **What breaks below the new floor.** All three formatters walked an issue's `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming a member of `Object.prototype` was answered by the prototype and no node was ever created. Two different failures follow: + + | path shape | what happened on `^4.4.3` | + |:---|:---| + | terminal element (`['assignments','__proto__']`, `['x','toString']`) | the inherited member is adopted as the node, then `node._errors.push(...)` runs on it — `TypeError: Cannot read properties of undefined (reading 'push')` | + | non-terminal element (`['__proto__', …]`) | the walk continues **into** `Object.prototype` and writes the next segment onto it — the message is silently dropped from the returned tree and the process gains a global prototype key | + + **Why it reached this platform's consumers.** `@objectstack/spec` refuses a `__proto__` key on its open-key authoring surfaces, and that refusal's issue path is `['assignments','__proto__']` — precisely the terminal shape. Anything that formatted one of these refusals for display crashed on it, and the crash was in the formatter, not in the guard. The guards themselves are unchanged and still necessary: 4.6.1 still drops a `__proto__` key from `z.record()` and `.catchall()` output, which is what they exist to refuse. + + **What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. + + `@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. + + ## The second half the floor move needs: an unknown key refuses TERMINALLY again + + From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no + longer aborts the shape that raised it. Two things follow, and both were + measured on this package with the same bodies on 4.4.3 and 4.6.1: + + 1. **A closed shape's own refinements now run after the refusal**, adding a + second complaint that contradicts the first. + 2. **A union containing that shape loses its envelope.** zod's + `handleUnionResults` returns a single non-aborted member's issues + *unwrapped* instead of raising `invalid_union`, so the union's message + becomes whichever branch zod judged closest. + + At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong + branch's prescription. Writing `type: 'page'` on a ViewItem answered: + + ``` + Unrecognized key(s) on this view container: `viewKind`, `config`. + • `viewKind` belongs to a single VIEW, not to the container. Wrap it: … + ``` + + — naming neither `page` nor its removal. It now answers, as it did before: + + ``` + config.type: 'page' was removed from the list-view `type` enum in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … + ``` + + **What an upgrading consumer must do.** Nothing. No key or value changed + status: everything this package accepted before it accepts now, and everything + it refused it still refuses. What changed is which of several competing + complaints an author reads, and that a refusal behind a union is again + reported as `invalid_union` with its branches, which is what `z.treeifyError()` + and this package's own `formatZodError` expand. + + ⚠️ A closed shape declared with a bare `z.object(…).strict()` or + `z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT + get this and will still collapse its union. Build closed authoring shapes with + `strictObject`, or re-declare an existing one through `closedObject`. +- 49144fc: fix(core): the `apiOperations` of `/auth/me/permissions` offers only what the REST door serves — nothing on an object with `enable.apiEnabled: false`, and `export` only where the export door admits it (#20135) + + `buildEffectiveObjectPermissions` builds the `objects` slot of `GET /auth/me/permissions` (`@objectstack/plugin-hono-server`) and the map `ISecurityService.getEffectiveObjectPermissions` returns (`@objectstack/plugin-security`). Its last pass, `annotateEffectiveApiOperations`, attaches each entry's `apiOperations`: the operation set a client renders, where an absent annotation means default-allow. That set disagreed with the REST door in two places, both in the direction of offering an operation the door refuses: + + - **`enable.apiEnabled: false`.** The door answers `404 OBJECT_API_DISABLED` for every verb on such an object, whatever `apiMethods` says. The annotation ignored the switch. It carried the object's whole closure, or, for a subject whose export stays allowed on an otherwise unrestricted object, no annotation at all, which a client reads as default-allow. + - **The export slot.** It fell back to the merged `'*'` export bit whenever an entry carried no `allowExport` of its own. The merged bit cannot say which set's wildcard reaches which object, so `export` was offered on a private object that only a plain `'*': { allowExport: true }` reached (a plain wildcard never covers a private object), and on an object that the exporting set itself names without the grant. The export door answers both `403 EXPORT_NOT_PERMITTED`. + + Clause-②: no + + **What changes.** The annotation now asks the door's own two questions, entry by entry: + + - the object half is the spec's `canServeApiOperation`, the boolean face of `apiExposureDenialReason`, which `enforceApiAccess` in `@objectstack/rest` turns into its 404 and 405. An object with `enable.apiEnabled: false` is annotated `apiOperations: []`; + - the export half is the entry's own grant as the export door reads it: read and `allowExport`, through the spec's `objectPermissionGrants`. The coverage passes that run first have already put each set's `'*'` on exactly the entries that set reaches, per posture. + + The entry of an API-disabled object stays in the map with its grants: `apiEnabled` closes the API, not the data, and `current_user.can()` reads those grants on the server. Which entries carry an annotation keeps its rule: an unrestricted object whose every operation is still served gets none. + + **What a reader of `/auth/me/permissions` sees.** An object declaring `enable.apiEnabled: false` now reads `apiOperations: []` in every entry. That includes an unrestricted object whose export stays allowed, which used to carry no annotation at all; this release's notes for #18931, #18990 and #20134 name that exception. `export` leaves the annotation of a private object reached only through a plain wildcard export grant, and of an object named without the grant by the set whose wildcard carries it. A private, unrestricted object that lost its `export` this way is now annotated with its closure minus `export`. Nothing else moves: no entry is added or removed, no `allow*` bit changes, and no annotation gains an operation. The response shape, its keys and the route are unchanged. The REST door is unchanged, so no request changes its answer. + + **For a caller of the exported helper.** `annotateEffectiveApiOperations` keeps its signature. It no longer reads the map's `'*'` entry: it reads each entry's own grants, which `buildEffectiveObjectPermissions` puts there. A map composed some other way should be built with `buildEffectiveObjectPermissions`. +- e2c4e12: fix(core): the effective object-permission map grants no cell the server refuses for a super-user subject — a super-user set's own narrower entry is that set's answer, and `modifyAllRecords` alone grants no create (#20136) + + `buildEffectiveObjectPermissions` builds the `objects` slot of `GET /auth/me/permissions` (`@objectstack/plugin-hono-server`) and the map `ISecurityService.getEffectiveObjectPermissions` returns (`@objectstack/plugin-security`), which the engine hands to `current_user.can(object, verb)` on the write path. For a subject holding a super-user wildcard — a `'*'` carrying `viewAllRecords` or `modifyAllRecords` — the map granted cells that `PermissionEvaluator.checkObjectPermission` refuses. Before building each set's own contribution, it ran a fold over the MERGED map that put the merged bypass bits on every entry: + + - **Into an entry the super-user set names itself.** The server answers each set with its explicit entry for the object when it has one, so the set's wildcard never reaches that object. The walled `organization_admin` names `sys_position`, `sys_permission_set`, `sys_position_permission_set`, `sys_user_permission_set` and `sys_user_position` read-only, and the identity tables write-denied; the map granted create, edit and delete on the first five anyway, and edit on `sys_organization`. With `member_default` that was 38 cells, and 44 without it (edit on `sys_user` and `sys_api_key` as well). An explicit `{}` entry read as readable and writable. An explicit entry granting `allowExport` without read read as readable and exportable, so `apiOperations` offered `export` where the export door answers `403 EXPORT_NOT_PERMITTED`. + - **`allowCreate` on `modifyAllRecords` alone.** The spec's `objectPermissionGrants` gives the write bypass no create cell, and the server grants none. A `'*': { modifyAllRecords: true }` without `allowCreate` read `create` and `import` as granted on every object the managed-write clamp does not cover. + + On the write path this failed OPEN: an option gated on `current_user.can('sys_position', 'edit')` was admitted for the walled `organization_admin`, whom the server refuses that edit. + + Clause-②: no + + **What changes.** The merged fold is no longer a step of `buildEffectiveObjectPermissions`. The super-user fold is the per-set one alone: each set's super-user `'*'` puts on every entry that set does not name exactly the bits the spec's `objectPermissionGrants` says that wildcard grants. A set that names an object keeps its explicit entry as its whole answer for that object, and another set's super-user wildcard still widens that entry bit by bit, as `checkObjectPermission` combines sets. The seed, the plain-wildcard coverage, the managed-write clamp and the `apiOperations` annotation are unchanged. `checkObjectPermission` and every route are unchanged, so no request changes its answer on the server. + + **What a reader of `/auth/me/permissions` sees.** For a subject whose super-user set names an object narrower than its wildcard, or whose only create grant was `modifyAllRecords`, the entry reads what the server enforces: `allowCreate`, `allowEdit`, `allowDelete` or `allowRead` turn from `true` to `false` on those cells, and an entry whose export no longer holds gains an `apiOperations` list without `export`. No entry is added or removed and no bit turns from `false` to `true`. The response is byte-identical for every subject whose super-user sets name no object narrower and grant `allowCreate` wherever they carry `modifyAllRecords` — `admin_full_access` alone or beside `member_default` — and for every subject holding no super-user wildcard. The response shape, its keys and the route are unchanged. On the write path, a `can()`-gated option for those cells is now refused and a `can()` default reads `false`, as the server refuses the write they describe. + + **For a caller of the exported helper.** `foldWildcardSuperUser` keeps its name, signature and body, and `@objectstack/plugin-hono-server` still re-exports it. It is no longer what the map is built from: over a merged map it cannot tell a set's own entry from another set's. Build the map with `buildEffectiveObjectPermissions`. +- 615c468: fix(core): an epoch-millisecond number compared against a `date` field is read as the UTC calendar day of its instant, by every driver and at every position that compares it (#20203) + + Clause-②: no — no key, export or operator moves, and no comparand that was accepted is now refused: a number was already an accepted comparand on every `date` position, and its answer moves to the storage rule's reading. + + `temporalStorageForm(value, 'date')` in `@objectstack/core` returned a finite number unchanged, so each face compared it by its own type rules and they disagreed. Over six rows, with `1769940000000` (2026-02-01T10:00:00.000Z) against a `date` field: + + | face | `$gt` | `$lt` | `$eq` | `$in` (with a Jan 10 member) | `$between` (from Jan 2) | + |:--|:--|:--|:--|:--|:--| + | `where` on `driver-memory` | 0 | 0 | 0 | 0 | 0 | + | `where` on `driver-sql`, SQLite | 6 | 0 | 0 | 0 | 0 | + | `where` on `driver-sql`, PostgreSQL | `DATABASE_ERROR`, a 500 at REST | the same | the same | the same | the same | + | a per-aggregation `filter` on `engine.aggregate` | 0 | 0 | 0 | 0 | 6 | + | **now, on every face above** | **1** | **3** | **2** | **3** | **5** | + + The same holds through `engine.find` and `POST /data/:object/query`, and for `$gte`, `$lte`, `$ne`, `$nin` and implicit equality. PostgreSQL's server refused the bound number itself (`22008`, date/time field value out of range), on an empty table too. `having` over `max` of a `date` field kept no group for `$gt`, `$eq` or `$in`; it now keeps the groups whose day compares. + + A finite number is now read as the `datetime` rule already reads it, as epoch milliseconds. It takes the UTC calendar day of that instant: the day `new Date(value)` names, through the same conversion a `Date` takes. So a number and its `Date` always answer alike. A time of day is dropped, never rounded, a negative number is a day before 1970, and a fraction truncates toward zero as the `Date` constructor does. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call this rule, so they now agree. + + The rule is shared by the drivers' write and read paths too: + + - `create()` / `update()` on either driver, given a number for a `date` field, stores its UTC day. Before, `driver-memory` and SQLite stored the number, and PostgreSQL refused the statement. The engine and REST write doors refuse a number on a `date` field before a driver sees it (`VALIDATION_FAILED`), as before. + - A number already stored in a SQLite `date` column is read back as its UTC day by `find()`, a `groupBy` key and `distinct()`. Only a direct driver write could have put one there. + + Not changed, measured identical before and after: `NaN`, ±Infinity, a number outside the `Date` range (past ±8.64e15), a bigint, an epoch-millisecond string, every `Date` and every string on a `date` field (#20240, in the same release, then pads a `Date`'s or a number's year 0..999 to four digits and refuses one whose year falls outside 0..9999, a number past the `Date` range included; #20264, in the same release, narrows that to 0001..9999, so year 0 is refused rather than padded), and every `datetime` and `time` reading. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. +- 4c42fd1: fix(core): an absent or empty path is no longer exempt from the ADR-0069 auth gate (#7898) + + `isAuthGateAllowlisted` answered `true` for a falsy path — it treated "no path" + as allow-listed. That is a fail-OPEN default on an authorization seam: any + caller that reached the ADR-0069 gate with an absent or empty `path` was exempt + on **every** route, and a transport author who simply forgot to populate `path` + disabled the gate with no diagnostic of any kind. + + ``` + FROM isAuthGateAllowlisted(undefined) -> true // exempt, on every route + isAuthGateAllowlisted('') -> true + + TO isAuthGateAllowlisted(undefined) -> false // exemption must be earned + isAuthGateAllowlisted('') -> false + ``` + + Exemption is now something a path has to EARN by naming an allow-listed route, + so the failure mode of omission is a `403` rather than a bypass. The predicate + is split in two so it carries exactly one meaning: a private + `matchesAllowlistedRoute` answers the route question for a real, non-empty path + — its body is unchanged, the #16839 anchoring rules included — and the exported + predicate answers "is this request exempt", which a request with no path is not. + + **No current caller's behaviour moves.** The caller census was re-run: the same + four production call sites, and no fifth. Two of them (`RestServer.enforceAuth`, + `shouldDenyAnonymous`) already guard for a non-empty path and so only ever reach + the predicate with a real string; a corpus differential against the pre-flip + predicate over more than 10,000 paths moves exactly one input — the empty string + — and nothing else, in either direction. + + **The one exemption that remains for a genuinely pathless caller is explicit**, + and lives at the one seam that really routes by body: `shouldDenyAnonymous` + declares `path` optional and decides the no-path case itself (it denies), ahead + of this predicate. That guard is deliberately kept rather than collapsed into + the now-agreeing default — a seam's contract should not be re-derived from what + a predicate happens to do with a falsy argument. + + **Known follow-up, tracked as #17625.** The dispatcher's bare-root + `` `${prefix}/` `` arrives as `cleanPath === ''` (the trailing slash is + stripped), which was exempt via the fail-open default and is not exempt now, so + a *gated* session — one carrying an `authGate`, i.e. an expired password or a + required MFA enrollment — reaching the bare root gets a `403` instead of the + discovery payload. Every named remediation route (`/auth/*`, `/health`, + `/ready`, `/discovery`, `/me/apps`, `/me/localization`) is unaffected, so + remediation itself stays reachable. Normalising that empty `cleanPath` is step 2 + of the same ruling and is **not** a tolerance re-added here. +- bc2ec80: Build freshness: these three packages now write the repo's build-input content + stamp as the last step of their own build, and are checked for freshness (not + merely existence) by `check:dev-prereqs`. + + What changes for a consumer: each tarball now carries two extra inert metadata + files inside `dist/` — `.build-input-hash` and `.build-input-hash-dts`, the same + pair `@objectstack/spec` has always shipped. Nothing is imported, executed or + resolved from them, no export moves and no runtime behaviour changes. + + Why: a sibling checkout that links these packages by `link:` compiles against + their `dist/`, so a dist built from an older tree surfaces as a type error + naming an import nobody touched, with the symbol present in `src/` the whole + time. A HEAD-versus-pin comparison is silent through that; a content stamp + written by the build itself is not. +- cf79182: `isAuthGateAllowlisted` matches allow-listed routes at a mount boundary, so an object named `auth` or a record whose id is `health` no longer bypasses the ADR-0069 authentication-policy gate. + + The predicate that decides which paths are exempt from the password-expiry / enforced-MFA gate matched with two UNANCHORED tests: `path.includes('/auth/')` matched at any position, and an `endsWith` test over `['/health', '/ready', '/discovery', '/me/apps', '/me/localization']` matched at any depth. A path segment whose VALUE merely spelled one of those tokens therefore carried the exemption — and object names and record ids are tenant-controlled. Both transport seams hand the predicate a data-plane path directly (`HttpDispatcher.enforceAuthGate` passes `cleanPath`, `RestServer.enforceAuth` passes `req.path`), so these were reachable requests. Measured on the built package before the repair: `/data/auth/123`, `/meta/auth/objects`, `/data/x/health` and `/data/xyz/me/apps` were all exempt, while `/auth/me` (exempt) and `/data/contacts/1` (gated) held as controls. + + - **What replaced them.** The path is read as segments and each test is anchored to a mount base — `/api/v1`, `/api`, or the empty base the dispatcher sees (the hono adapter hands `dispatch()` the app prefix already stripped) — plus at most one environment scope immediately after that base (`/environments/`, or ADR-0006's superseded `/projects/`), because the dispatcher evaluates the gate before its scoped-URL strip. `/auth/…` at that position stays exempt; the five bootstrap reads are EXACT routes there instead of suffixes. The scope is only recognised immediately after a base, which is why `/data/environments/x/health` is not a scoped `/health`. + - **This only ever removes exemptions.** Measured, not asserted: over a generated corpus of 111,152 paths, the number that are newly exempt is **0** and 25,979 stopped being exempt. The check is kept as a test, with the pre-anchoring predicate transcribed beside it, so a later widening cannot arrive quietly. + - **Every genuinely-exempt shape still is**, pinned in both directions: `/auth/sign-out`, `/health`, `/ready`, `/discovery` (dispatcher shapes); `/api/auth/sign-in`, `/api/v1/auth/change-password`, `/api/v1/auth/me/permissions`, `/api/v1/health`, `/api/v1/me/apps`, `/api/v1/me/localization`; and the scoped `/api/v1/environments//auth/sign-out`. + + **If you serve the API from a non-default mount,** an allow-listed route reached as `${basePath}/${version}/…` with `basePath`/`version` moved off `/api` and `v1` is no longer named by the allow-list. That price cannot be avoided: `/rest/v2/health` and `/data/xyz/health` are the same shape, so a rule that accepts an arbitrary base is the defect itself. It costs nothing at either live seam — the dispatcher's path arrives base-stripped, and REST registers its control-plane routes without `enforceAuth` at all — but if you gate a custom mount through this predicate, mount the remediation routes under one of the named bases. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index c5f35c78666..b44a1dcefd8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Microkernel Core for ObjectStack", "type": "module", diff --git a/packages/create-objectstack/CHANGELOG.md b/packages/create-objectstack/CHANGELOG.md index ea590320fe7..bd6d6bee072 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,172 @@ # create-objectstack +## 17.5.0 + +### Minor Changes + +- 097d268: feat(spec)!: `manifest.id` enforces the reverse-domain rule its registry face already had (#17534) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the repo's convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition): + `ManifestSchema.id` was `z.string()` and accepted any string. It now enforces + reverse-domain notation — the same rule `PackageSchema.manifestId` has always + carried, now declared once and referenced from both sites so the two cannot + drift again. + + Two declarations named one identity and disagreed. The registry enforced the + shape; the key an author actually writes did not. So a package scaffolded, + validated, built and booted with an id the publish path would refuse, and the + author met the rule for the first time at the most expensive possible moment. + + FROM → TO, for metadata that used to parse and now fails: + + ```ts + // FROM — accepted by defineStack, refused at publish + defineStack({ manifest: { id: 'my_app', /* … */ } }); + defineStack({ manifest: { id: 'com.acme.my_app', /* … */ } }); + + // TO — dot-separated lowercase segments; hyphens inside a segment, never underscores + defineStack({ manifest: { id: 'com.example.my-app', /* … */ } }); + defineStack({ manifest: { id: 'com.acme.my-app', /* … */ } }); + ``` + + The refusal carries the repair rather than restating the rule: it names the key, + echoes the value, shows both documented examples, and — having first checked the + candidate against the pattern itself — suggests `com.example.blank` for a bare + word and `com.dogfood.flow-fixture` for a value whose only fault is an + underscore. A suggestion it cannot verify it does not make. + + ⚠️ **Changing an id is a republish, not an edit.** An id is an identity: the + registry addresses a package by `manifest_id`, an installed row is keyed on it + and a dependent declares it. Before renaming, confirm nothing still addresses + the old value. That is why this ships as an ADR-0087 **semantic** entry + (`manifest-id-reverse-domain-required`) with a structured TODO and no automatic + rewrite — `objectstack migrate meta` will not rename an id for you. + + `manifest.namespace` is unchanged and still admits underscores, so the two are + derived from a project name under different rules and neither is the other. Both + scaffolders were producing ids the new rule refuses and both now derive a + conforming one: the bundled `create-objectstack` template ships + `com.example.blank` and interpolates `com.example.` in kebab form, + and `os init` derives its id from the project name instead of interpolating the + snake_case namespace (`os init my-app` produced `com.example.my_app`). + + ## ⚠️ One consent path reverses direction: fail-OPEN → fail-CLOSED + + Narrowing `manifest.id` also narrows the **accept set of the artifact load + path**, and on one route that is a **fail-OPEN → fail-CLOSED reversal on a + consent/permission path**. Stating it explicitly because a reversal in that + direction is owed a named direction and a named population, however small the + population turns out to be. + + **What changed.** `AssembledPackageBodySchema` extends `ManifestSchema`, so the + artifact package entry schema now carries this rule too. An assembled package + whose `manifest.id` is `''` used to parse: `artifactPackageId` is + `manifest.id || manifest.name`, so such a package was carried under its `name`, + while an install-time `grantedPermissions` record keyed by `''` matched no + carried package and was registered nowhere. The package loaded **with no + consent record at all** — reported as unbound, warned about, and otherwise + allowed to run. That is the fail-OPEN half. Such an entry is now refused + outright (`INVALID_ARTIFACT_PACKAGE_ENTRY`, 422) and the artifact does not + materialize at all — fail-CLOSED. + + **Who is affected: artifacts carrying `manifest.id: ''`, and they were already + half-broken in both directions.** + + - They could never be **published**: the registry face + (`PackageSchema.manifestId`) has carried this exact pattern all along — the + same regex literal, now the shared `MANIFEST_ID_PATTERN` — so the publish path + has always refused them. + - Their granted-permissions **consent already did not apply**: a record keyed by + `''` bound to nothing, silently, on every load. + + ⇒ For that population this converts a silent, already-ineffective consent + binding into an explicit refusal that names `manifest.id`. Nobody who could + publish an artifact loses the ability to load it; what they lose is a shape that + only ever half-worked. + + ⛔ This is the **artifact package door** refusing a malformed id, **not** the + permission enforcer acquiring teeth. The install-time granted permission set is + still registered and not enforced (#17147) — nothing on the tree queries that + registry, and the repo-wide pin asserting so is unchanged and still green. + +### Patch Changes + +- fce7cd4: The scaffolded `pnpm-workspace.yaml` records the retired `@better-auth/scim>better-call` peer rule instead of advertising it as live + + `objectstack init` wrote a paragraph into every project it scaffolds explaining + an `@better-auth/scim>better-call` suppression that is not in the map it + annotates — the entry retired with objectstack#3653, and `init.test.ts` pins its + absence. All three of its claims were false on today's tree as well: + `@better-auth/scim` is not "held at a release candidate deliberately" (it is + pinned at exact stable `1.7.3`), and stable `@better-auth/scim@1.7.3` declares + `peerDependencies["better-call"]` as the exact string `1.4.0` — the single copy + `better-auth@1.7.3` itself depends on — so the `1.3.7` skew the paragraph + described does not exist. + + It now records the retirement, in the shape `create-objectstack`'s bundled + `blank` template already used, and dates the measurement the way the + neighbouring `better-sqlite3` paragraph in the same block does. Both scaffold + paths previously named `1.7.1` as the current pin; both now name the measured + `1.7.3`, so the two paths tell a user the same thing. + + Comments only — no declaration moves. The rendered `allowedVersions` map is + byte-identical before and after, so no resolution, lockfile or suppression + changes. +- c577e66: fix(create-objectstack): the blank starter wires every directory `os generate` writes into + + `npm create objectstack` scaffolded an `objectstack.config.ts` that imported `./src/objects` alone. `os g view`, `action`, `flow`, `dashboard`, `app` and `skill` each wrote a file and a barrel `index.ts` that nothing imported, and `os validate` then exited 0 printing `Logic: 0 Flows`: the generated metadata was never loaded. + + **What a new blank project now ships** is the wiring `os init` writes: + + - `objectstack.config.ts` imports every directory `os generate` writes into (`src/objects`, `src/views`, `src/actions`, `src/flows`, `src/dashboards`, `src/apps`, `src/skills`) and hands each barrel's exports to `defineStack` under its key (`objects`, `views`, …). A file `os g` writes there is part of the stack with no edit to the config. The keys read the barrels through a small `exportsOf` helper declared in the config, because `Object.values` on an empty barrel does not type-check against `defineStack`'s collection types. + - An `index.ts` containing only `export {};` in each of those directories except `src/objects`, which keeps the sample object. + - `requires: ['automation', 'triggers']`. `automation` was already there for the three connector plugins. `triggers` fires a flow that starts on a record change, the kind `os g flow` writes, and without it the config stops loading as soon as it holds one. A project with no flow boots as before. + + **Projects scaffolded by an earlier release** keep their config. `os g` says when a file it wrote is not wired, and prints the lines to add. +- f6b7c53: fix(cli): re-measure the `better-auth` > `better-sqlite3` peer record, correct what it credits, and pin the declaration it justifies (#16813) + + A tree containing `@objectstack/cli` reports an unmet peer on every fresh + resolve — `better-auth` peers `better-sqlite3@^12.0.0`, the CLI declares + `^13.0.3` — and the reading that decides what to do about it lived only inside + the scaffold generator's prose. No range moves here and no resolution moves: + what changes is the recorded reason, which had two measured errors in it, plus + a gate that now holds the declaration to that reason. + + **The declaration is correct and stays at `^13`.** Three readings, taken rather + than inherited: + + - The peer is `optional`, and it governs exactly one configuration — a raw + better-sqlite3 `Database` passed to better-auth's `database` option. + `AuthManager.createDatabaseConfig()` returns an ObjectQL adapter factory, or + `undefined` for better-auth's in-memory adapter. Never a `Database`. + - better-auth cannot be incompatible with better-sqlite3 13, because it never + touches it: of the 464 files in the published `better-auth@1.7.2` tarball, + exactly one names better-sqlite3 — `package.json`, the peer declaration + itself — and no code file references it (positive control: `kysely` names 9). + It accepts a `Database` the caller constructs; its own sqlite test path uses + node's built-in `node:sqlite`. + - Pinning back to `^12` is not a neutral alternative. Measured on a bare + project depending on `@objectstack/cli@17.3.0`, it clears the report only by + resolving a **second** native better-sqlite3 (12.11.1 beside 13.0.3) that + nothing loads. The scaffold's existing `allowedVersions` entry clears the + same report with the lockfile byte-identical. + + **Two corrections to the record.** It credited `@objectstack/driver-sql` for + the 13.x copy; on the chain that actually reports + (`cli` → `runtime` → `plugin-auth` → `better-auth`) the binding copy is the + CLI's own `optionalDependencies` entry, which pnpm names in the warning itself. + And it was measured on better-auth 1.7.1 while the family has been pinned at + 1.7.2 since — re-measured, with the empirical reading replaced by a structural + one. + + The scaffold's rendered `pnpm-workspace.yaml` comment changes wording in both + producers (`objectstack init` and the `create-objectstack` blank template); the + declarations, the widening entry and the resolution are untouched. + ## 17.4.0 ### Minor Changes diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 49e071ff2a9..0d0b699e8c3 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -1,6 +1,6 @@ { "name": "create-objectstack", - "version": "17.4.0", + "version": "17.5.0", "description": "Create a new ObjectStack project — npx create-objectstack", "bin": { "create-objectstack": "./bin/create-objectstack.js" diff --git a/packages/drivers/driver-memory/CHANGELOG.md b/packages/drivers/driver-memory/CHANGELOG.md index 6c40a06b5e8..f9a3252ba60 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,1044 @@ # @objectstack/driver-memory +## 17.5.0 + +### Minor Changes + +- 8a44ce7: fix(spec, drivers)!: a `$like` / `$ilike` pattern holding U+0000 is refused by every driver that answers `$like`, instead of being cut at the NUL on SQLite + + Clause-②: yes (narrowing) + + On the SQLite faces `$like` / `$ilike` compile to `GLOB`, and SQLite reads a pattern only up to its first U+0000. A pattern holding U+0000 was cut there, so the filter answered a different question, and nothing raised. Measured through `find` over 13 stored values (12 non-NULL), against `@objectstack/formula` on the same rows: all 20 U+0000 cases of the probe (10 patterns, bare and under `$not`) differed on `SqlDriver` over better-sqlite3, on `SqliteWasmDriver`, on `TursoDriver`'s local mode, and on its remote mode over a stub and over a real `@libsql/client` engine, with identical answers on all five. For example: + + - `$like: '%'` + U+0000 returned all 12 non-NULL rows, where `formula` returns the two ending in U+0000; + - `$like: 'a'` + U+0000 + `'b'` also returned `'a'`; + - `$ilike: 'AB'` + U+0000 also returned `'AB'` and `'ab'`. + + `driver-memory` answered all 20 as `formula` does. SQLite has no NUL-safe pattern primitive to compile to instead: `LIKE` cuts the same way, `replace()` cannot target U+0000, and `instr()` has no wildcards. So the one contract is a refusal, the way a pattern ending in a lone unpaired backslash is refused. + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A filter that answered before is now refused**: a `$like` or `$ilike` pattern holding U+0000 anywhere (at the start, in the middle, at the end, alone, or after a backslash) gets `INVALID_FILTER` / 400, on every door that already refused the lone trailing backslash: + + - `@objectstack/driver-sql`: on the filter walk, before a dialect is chosen, so SQLite, Postgres and MySQL all refuse it. `@objectstack/driver-sqlite-wasm` and `TursoDriver`'s local mode inherit it; `@objectstack/driver-sqlite-wasm`'s own code does not change. + - `@objectstack/driver-turso`: the remote transport's `$like` / `$ilike` arm, before anything is sent to the engine. + - `@objectstack/driver-memory`: the shape gate of the query path and of the reference matcher `match()`, and the QueryAST `comparison` spelling (`like` / `ilike`). + - `@objectstack/spec` exports the shared test, `hasNulInLikePattern`, beside `hasDanglingLikeEscape`, and the `$like` operator's description now names the refusal. + + On `driver-sql` and the Turso remote transport the refusal goes through the read-scope provenance seam, like every other filter-compile refusal there. On `driver-sql` (and so `driver-sqlite-wasm` and Turso's local mode), a caller whose predicate is marked `'author'` reads the operator, the field, the filter path and the pattern, with U+0000 written as `\u0000`. Any other caller gets only the class statement, and the rest goes to the server log. The remote transport withholds the same way, and through `TursoDriver` in remote mode no mark reaches it, so every caller gets the class statement there. On `driver-memory` every caller reads the full text, as for its dangling-escape refusal. + + A pattern that ends in a lone unpaired backslash AND holds U+0000 keeps the dangling-escape refusal it had before. + + **What stays accepted**, pinned per face: every `$like` / `$ilike` pattern without U+0000 answers exactly as before. + + **Not changed here:** + + - A pattern without U+0000 matched against a STORED value that holds U+0000 is not refused: it is well formed, and on the SQLite faces it reads the whole stored value, by its own entry in this release. + - `@objectstack/formula` still evaluates such a pattern. It refuses nothing, and answers `false` for a dangling escape rather than refusing it, so it is not one of these doors. + - `driver-mongodb`, objectql `having` and `service-analytics` refused every `$like` / `$ilike` before this change, and still do. + + **What an affected author does.** Remove the U+0000 from the pattern. No escape makes it portable: a backslash before it still leaves a U+0000 in the pattern. + + Blast radius, measured on this tree: no example or template writes a `$like` or `$ilike`, and the published `objectstack-query` skill and the hand-written docs that show one show no pattern holding U+0000. Whether any out-of-repo caller sends one is NOT measured and is not claimed to be zero. + + +- 93cfc3f: `$like` / `$ilike`: `_` matches exactly one Unicode code point on every face, so an emoji or any other character outside the Basic Multilingual Plane is one `_`, as SQL `LIKE` and SQLite `GLOB` count it (#20143). + + The SQLite faces (`driver-sql` on better-sqlite3, `driver-sqlite-wasm`, `driver-turso` local and remote) already answered by code points. The JavaScript faces did not: they compiled the spec's `likePatternToRegexSource` with no regular-expression flags, so `_` read one UTF-16 code unit, which is half of an emoji. The same REST filter returned a different row set depending on which driver backed the object. Measured at `e7f69dbb` over values holding `😀` (U+1F600) and `𝒜` (U+1D49C), 48 answer cells on the JS faces differed from the SQLite faces; after this change, none do. + + - **`@objectstack/spec`**: a new export, `likePatternToRegExp(pattern, foldAscii?)`, compiles the translation with the `u` flag, the one compilation in which `_` is one code point. `matchesLikePattern` evaluates it. `likePatternToRegexSource` is unchanged and still exported; its source means one code point per `_` only under `u`. The `$like` description now says that a character is one Unicode code point. + - **`@objectstack/formula`**: `matchesFilterCondition` answers `$like` / `$ilike` by code points, through the spec's `matchesLikePattern`. Its own CEL `size()` already counted code points. + - **`@objectstack/driver-memory`**: all three `$like` doors (the `$like` filter and the AST `like` / `ilike` node through mingo, and the reference matcher) answer by code points. + + The answer set moves in both directions on those three faces, only for values holding a character outside the BMP: + + | pattern | a stored `😀` | `a😀b` | `a😀😀b` | + |---|---|---|---| + | `_` | now matches | — | — | + | `__` | no longer matches | — | — | + | `a_b` | — | now matches | — | + | `a__b` | — | no longer matches | now matches | + + `$ilike` moves the same way. No pattern is newly refused and no refusal is lifted. Values made only of characters inside the BMP answer exactly as before. + + Clause-②: yes (narrowing) + + +- fb38607: feat(drivers,formula,objectql): the engine's filter faces answer the staged `$empty` operator (#20444) + + Clause-②: yes (widening) + + `$empty: true | false` is declared by `@objectstack/spec` (`FieldOperatorsSchema`) with a per-type meaning: a text-like field is empty when it is null or `''`, a multi-value field (multiselect, checkboxes, tags, or a select / radio / lookup / user / file / image with `multiple: true`) when it is null or `[]`, and every other type only when it is null. `$empty: false` is the exact complement. Until now every face in this list refused it (`INVALID_FILTER` / 400), except `matchesFilterCondition`, which answered `false` for every record. **A driver or evaluator called directly now answers it:** + + - **By the field's declared type**, through the spec's one expansion (`expandEmptyOperator`): `driver-sql`'s filter compiler (and so `driver-sqlite-wasm` and `driver-turso`'s local transport, which inherit it), `driver-turso`'s remote transport, `driver-memory`'s query path (`find` / `count` / `update` / `delete`) and `driver-mongodb`'s `translateFilter` (its `find`, its aggregate `$match`). The declaration is the one each driver already receives — `initObjects` / `registerObjectMetadata` / `registerExternalObject` on the SQL family, `syncSchema` on the others. On SQL a multi-value field's empty list is tested as stored JSON per dialect (SQLite `json_array_length` behind a `json_valid` guard, PostgreSQL a `jsonb` comparison, MySQL `JSON_LENGTH`), never as an equality comparand. + - **By value** — null, a missing value, `''` and `[]` are empty (`isEmptyFilterValue`) — on the faces that read no field declaration: `@objectstack/formula`'s `matchesFilterCondition` (the RLS write-side `check`), `driver-memory`'s reference matcher, and `@objectstack/objectql`'s `having` and per-aggregation `filter`. In `having`, a `count` or `sum` holding `0` is not empty. + + **Refused, never guessed** (`INVALID_FILTER` / 400): `$empty` on a field whose declaration the driver does not hold (a table built outside its registration, a builtin column such as `id`, a field with no `type`, or `translateFilter` / `RemoteTransport` used standalone without a declaration), a multi-value field on a SQL dialect the driver does not model, and a flag that is not a boolean. `driver-memory`'s analytics (cube) face refuses `$empty` as an operator it cannot compile, as it does `$null`. + + New optional API: `translateFilter(where, temporalKind?, valueShape?)` in `@objectstack/driver-mongodb` takes a declared-value-shape resolver (type `ValueShapeResolver`), and `buildAggregationPipeline` a `valueShape` option; `RemoteTransport.setDeclaredValueShapeResolver` in `@objectstack/driver-turso`, which `TursoDriver` wires. `@objectstack/spec`'s shared `FILTER_LOGIC_CASES` table gains seven `$empty` cases: a backend that runs it answers `$empty` or goes red, and its harness must declare the fixture's columns. + + `$empty` stays staged: it is not in `FILTER_OPERATORS`, so the engine's front door still refuses it until the flip card adds it, and the view operators `is_empty` / `is_not_empty` still lower to `$null`. +- 0da638c: fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) + + + + **BREAKING** for an in-process caller that reaches an analytics face PAST the + schema door with a string the closed vocabulary does not contain: it used to be + answered, and is now refused. Shipped as `minor` under the repo's launch-window + convention. The driver half of #16041, whose spec change closed + `AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen + dashboard preset names; every value affected here was already refused at + `POST /analytics/query` and `/analytics/sql` when that landed. + + ## What was wrong + + #16041 closed the contract; the faces behind it never aligned, so the defect it + abolished simply moved onto the newly-blessed vocabulary. Measured on the built + `driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, + 2099): + + | input | before | after | + |:--|--:|--:| + | `today` | 1/5 | 1/5 | + | the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | + | `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | + + `driver-memory` recognised exactly `today`: every snake_case preset missed its + `startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose + two bounds were the preset's own NAME, which matched every `Date`-typed row + under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered + the same names — and unrecognised strings, and `today` — to the point window + `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is + whatever the dialect decides a vocabulary word compares as. So a dashboard + asking for one month got all of history on one backend and a nonsense + comparison on the other, at HTTP 200 on both. + + ## What it does now + + - **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / + `resolveAnalyticsDateRangeString` resolve every declared preset to + `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens + handed to the existing macro resolver, so `dateRange: 'this_month'` and a + `{month_start}` filter token cannot answer differently, and the anchoring on + `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) + come from that resolver rather than from each face. + - **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 + envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own + `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door + answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call + it, so "memory and SQL refuse identically" is one function rather than an + agreement. + - **The upper bound keeps #16179's separation.** A window a face RESOLVED is + compared exclusively (`$lt` / `<`) for the ten calendar presets and + inclusively for the three rolling `last_N_days`, whose bound is NOW; an + explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. + - The fifteen `driver-memory` date-range pins #16041 retired are reinstated in + preset form (DST cells re-measured under calendar semantics, not re-spelled), + and one cross-face conformance fixture holds all FOUR faces to the same + windows and the same refusal. + - **The draft-preview evaluator is the fourth face**, and it is in that fixture + for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — + the Live Canvas preview over a pending seed draft) carried the identical + `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, + silently, while the published chart beside it answered a real window — across + a publish boundary the preview exists to make continuous, since publish + materialises the same seed. + + ## FROM → TO + + Unchanged from #16041's — the spelling that is refused here is the spelling that + was already refused at the door. + + | you wrote | write instead | + |:--|:--| + | `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | + | `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | + | `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | + | `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + + The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's + code stays registered under `@objectstack/runtime` (the door that names the wire + vocabulary), and the waiver records that the shared constructor spelling it + lives one package over. +- f03f6c7: fix(driver-memory)!: an analytics time dimension buckets by its declared `granularity`, and refuses a sub-day one instead of ignoring it (#16178) + + + + **BREAKING** in three senses, all on `driver-memory`'s analytics face, landing in + the launch window as `minor` under the lockstep convention this cluster's + siblings already use: + + - an accepted request now answers **differently**: a time dimension carrying a + `granularity` folds its rows into calendar buckets instead of returning one + group per distinct timestamp. Every affected answer was wrong before; + - a **trend query answers rows where it used to answer one total**: a + `granularity` on a member `dimensions` does not also list is now a group + column of its own, so `{measures, timeDimensions: [{dimension, granularity}]}` + — the canonical trend shape — comes back one row per bucket, carrying the + member and a `fields` entry for it, instead of a single ungrouped total with + no such column; + - an accepted request is now **refused**: `granularity: 'second' | 'minute' | + 'hour'` answers `NOT_IMPLEMENTED` / 501 instead of being silently dropped. + + ## What was wrong + + `AnalyticsQuery.timeDimensions[].granularity` is declared by the spec and a cube + dimension enumerates the granularities it offers (`granularities: ['day']`). + `memory-analytics.ts` read neither. The `$group` stage keyed on the raw field + path, so a time dimension bucketed **one group per distinct timestamp** — one bar + per row in a "new accounts by month" chart, which is the symptom #3588 + catalogued and repaired for `service-analytics`. + + Measured through the public entry against the built package, two rows on one UTC + calendar day (`2026-09-06T01:00:00Z` and `2026-09-06T23:00:00Z`) under + `granularity: 'day'`: + + | | before | after | + |:--|--:|--:| + | `granularity: 'day'` | **2 groups**, keyed on the raw instants | 1 group, `2026-09-06` | + | no granularity (control) | 2 groups | 2 groups, unchanged | + | `granularity: 'hour'` | **2 groups**, silently | `NOT_IMPLEMENTED` / 501 | + | same, but with no `dimensions` | **`{count: 2}`** — one total, no time column, and no `fields` entry naming it | `{'events.createdAt': '2026-09-06', count: 2}`, `fields` naming both | + | `granularity: 'fortnight'` past the schema door | — | `INVALID_QUERY` / 400 | + + The emitted pipeline was byte-identical across all three, which is the whole + finding: the request was accepted, no warning was emitted, and the key was inert. + + ## What it does now + + - **One forward labeller, in `@objectstack/core`.** `bucketDateKey(value, + granularity, timezone)` sits beside the inverse `bucketKeyToCalendarRange` and + the `calendarPartsInTzOrUtc` primitive it builds on, and it is now the only + statement of the rule. `BUCKET_GRANULARITIES` and `isBucketGranularity` name + the five granularities that HAVE a canonical key, so a face that must refuse + the other three quotes the accepted set instead of hand-listing it. + - **`@objectstack/objectql`'s `bucketDateValue` is a delegate**, export name and + signature unchanged, answers unchanged — pinned across granularity, timezone + and input form rather than asserted. A driver that pushes the bucket down into + SQL and this in-memory path must label one instant identically or a drill-down + breaks at the seam, and that is now one function rather than an agreement + between two. + - **A granular time dimension is a group column, listed or not.** `dimensions` + no longer decides alone what `$group` keys on: every `timeDimensions` entry + carrying a `granularity` is grouped, projected and named in `fields`, deduped + against `dimensions` on the resolved member so two spellings of one member + stay one column. This is the rule the SQL/ObjectQL face already records + (`projectedDimensions`, #4033/#5688) — one set feeding grouping, row mapping + and field metadata, because rows carrying a bucket under a `fields` list that + never mentions it is a trend chart with no x-axis. ⛔ An entry carrying only a + `dateRange` is a predicate and is still **not** projected. + - **`driver-memory` folds by granularity before its `$group`.** The pipeline is + cut at that stage: the `$match` half still runs in the driver, the bucket keys + are written onto the selected rows, and the grouping half runs over those. The + key travels under a synthetic field rather than overwriting the row's own, so a + member that is both a group key and a measure's aggregand still ranks instants + in `max()` while grouping on the label. + - **The output vocabulary is the published one** — `2026`, `2026-Q3`, `2026-09`, + `2026-09-06`, `2026-W36`. The week label is `YYYY-Www`, never the Monday's + `YYYY-MM-DD`: `DriverCapabilitiesSchema.queryDateGranularity` calls this an + output contract, and a second spelling is what breaks a drill-down across a + backend seam. + - **Bucketing honours `AnalyticsQuery.timezone`** — the same reference zone + #16042 threaded through the `dateRange` window resolver, so the window that + selects the rows and the bucket that folds them agree on where a calendar day + starts. The same two rows answer one group in UTC, two in `America/New_York` + and two in `Asia/Tokyo`. An absent zone buckets in UTC, the resolver's default. + + ⚠️ That agreement is about the PRESET arm of `dateRange`, which the resolver + reads in the reference zone. An explicit `[start, end]` array is the caller's + own **instant** window and keeps its published reading (#16179), while the + bucket beside it is always a **calendar** label (ADR-0053) — so an array + window and a bucket can still disagree about where a day starts. That + combination is legitimate and is not refused; it is stated here rather than + left to be discovered. + - **`second` / `minute` / `hour` are refused at compile**, in the ADR-0112 + envelope this driver's other capability gaps speak (`NOT_IMPLEMENTED` / 501, + the class `refusePerAggregationFilter` uses for the same reason: the query is + spelled correctly, the spec declares the value, and it is this backend that + compiles nothing for it). The canonical key vocabulary defines no label for a + sub-day bucket, so there is no string another backend's pushed-down SQL would + agree with. Passing it through unbucketed is this card's own defect wearing a + new name. + - **An undeclared granularity is a 400, not a 501.** A 501 says "this backend + cannot", which is only honest about a value the contract declares. + `TimeUpdateInterval` is checked first, so a spelling it never declared — + reachable past the schema door, where `POST /analytics/dataset/query` types + `selection.timeDimensions` without Zod-parsing them — answers `INVALID_QUERY` + / 400 rather than a 501 asserting the spec declared it. The same separation + the `dateRange` half of this face already draws (#16322 / #16041). + + ## If a caller is refused + + A stored widget or a request asking for a sub-day granularity was never bucketed + by this backend — it received one group per distinct timestamp under an ordinary + 200. Nothing that worked stops working. Ask for `day` or coarser and the answer + is a real bucket; keep the raw timestamps deliberately by dropping the key, which + is the behaviour that key used to produce by accident. +- 555a89c: fix(driver-memory): refuse a call the engine tenant-scoped, instead of silently answering with every organization's rows (#16589) + + **BREAKING** for a `driver-memory` deployment that holds more than one organization's rows: an operation the engine tenant-scoped now refuses loudly instead of answering. Shipped as `minor` under the launch-window convention, the same grading the driver's `update()`/`upsert()` type-surface narrowing used. + + Two predicates decided "is this object tenant-scoped", and they disagreed on the default case. The engine scopes an object **unless** it opts out (`buildDriverOptions`: `execCtx?.tenantId !== undefined && !isTenancyDisabled(objectSchema) && !isFederated`), while this driver's boot guard refused only an explicit opt-**in** (`declaresTenantScope`: `tenancy.enabled === true`). An object that **omits the `tenancy` block entirely** — the common case — therefore fell between them: the engine scoped it, the guard never saw it, the deployment posture really was `single` so the posture check passed, and the driver then discarded the scope and returned every organization's rows. A SQL driver refuses the same read. + + This driver still implements **no row-level tenant isolation**, and deliberately does not gain any: it declines to answer rather than answering correctly. `assertCallNotTenantScoped` is a third seam beside the two boot seams, and it judges the scope the engine actually handed over (`DriverOptions.tenantId` / `tenantIds`) rather than re-deriving the engine's predicate from object metadata — a driver that re-derived it would drift from the engine the first time that reasoning changed, and drift here is silent exposure. It runs first in every driver door that accepts a `DriverOptions`, so a refusal leaves the store exactly as it found it. + + **⚠️ Every isolation measurement previously taken on the memory driver is void and must be re-taken.** A suite asserting "tenant A cannot see tenant B's rows" passed here trivially — not because isolation worked, but because both tenants' rows came back to every caller and the assertion was written against a single tenant's fixture. An app that proved out its isolation model on this driver measured nothing. + + What is unaffected, and why: an object declaring `tenancy: { enabled: false }` is never scoped by the engine (ADR-0066), so the driver never sees a scope for it and serves it unchanged; a caller with no organization context is never scoped either, which is the ordinary dev, example-app and single-organization path. Only a call that actually arrives carrying a tenant scope is refused. A deployment that needs organization-scoped reads in development uses `@objectstack/driver-sql`, whose `:memory:` connection is the closest in-process replacement; a deployment whose data genuinely is platform-global can say so with the ADR-0066 posture, which stops the engine scoping it at all. + + The refusal reuses the existing `MemoryMultiTenantUnsupportedError` and its `MEMORY_MULTI_TENANT_UNSUPPORTED` code rather than introducing a second error family: the cause is identical, so a host that already recognises the boot refusal recognises this one with no new code and no second code to learn. + + Also corrects `declaresTenantScope`'s docstring, which closed on a false sentence — "every object in a single-tenant deployment omits the block". A `single` posture constrains the **wall**, not the number of organizations: a `single`-posture run was measured holding 13 `sys_organization` rows, with each row carrying whichever `organization_id` it was written with. The sentence is recorded as superseded rather than deleted, because it is what justified the predicate being an opt-in test. + + +- b90aff8: fix(driver-memory): a scalar comparand against a stored ARRAY is read as membership on both filter faces, so a filter written to narrow stops returning rows it never selected (#16838) + + `memory-matcher.ts`'s equality arm ended in `value == condition`. Loose `==` converts a stored ARRAY to a primitive — `['a','b']` becomes the string `"a,b"` — so this package's reference matcher and its live query path (`InMemoryDriver.find`, through mingo) answered the same filter two different ways, in both directions at once: + + | filter | stored value | reference matcher, before | live query path | + |---|---|---|---| + | `{ tags: 'a' }` | `['a','b']` | no row | the row | + | `{ tags: 'a,b' }` | `['a','b']` | the row | no row | + | `{ tags: 'a' }` | `['a']` | the row | the row | + + The second row is the sharper one: a **false positive**, a filter written to narrow returning a row it should not, which on a read scope is a permission concern rather than a degraded filter. The first is fail-open in the other direction and just as silent — `if (!rows.length)` cannot tell "genuinely none" from "the predicate asked the wrong question". + + **What changes.** A stored array is now read as its elements, and each is asked the question the arm asks of a scalar: the answer for a row storing an array is the OR of the answers for the rows storing its elements. That is MongoDB's array semantics and therefore mingo's, so the reference face converges on the path this package's users actually run rather than on a third reading nobody wrote. One level only — a nested array is not descended into, matching mingo. `$eq` and `$ne` take the same equality as the implicit spelling, so `$ne` stays the exact complement. + + **What does not change.** An array in the **comparand** position is still refused (`INVALID_FILTER` / 400) by the shape gate every face of this package runs; this is the VALUE side, which that door does not judge. The live query path is untouched — it already answered membership — so a caller who only ever used `find()` sees no difference. Callers who compared results against the reference matcher, or who ran it directly as a driver double, will see a stored array select on membership instead of on its joined string. +- 0f38ab0: fix(driver-memory,driver-sql): an explicit `tenancy.enabled: false` opt-out is sticky, so a partial `syncSchema` re-registration no longer flips a platform-global object's UNIQUE partition (#16729) + + ## What was wrong + + `InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever + schema THAT call happened to carry. A second registration without a `tenancy` + block — the `{ name, fields }` shape — fell through to the implicit + `organization_id` heuristic, so a `unique` field moved from **one row per + install** (`scopeField: null`, which is what `tenancy.enabled: false` declares) + to **one row per organization**. A duplicate the declaration refuses then + landed. Measured at the driver door on `origin/main` `d61139f1ba`: + + | sequence | second `key: 'K'`, different organization | + |:--|:--| + | register with `tenancy.enabled: false` | `REFUSED` — `UNIQUE_VIOLATION` / 409 | + | …then re-register with `{ name, fields }` | **`LANDED`** | + + `SqlDriver` running the same sequence refuses in **both** cases: it has kept a + sticky `tenantOptOutByTable` since #3249. `driver-memory` had mirrored the inner + `computeTenantField` and not the wrapper that consults the record, so "mirrors + `computeTenantField` arm for arm" stayed literally true while the pair diverged. + + It is silent in both directions — nothing logs the flip, and the refusal names + the field, never the partition. That is the declared-vs-enforced shape Prime + Directive #10 forbids, reached by a state change rather than by a missing check. + + ## What it does now + + - **`@objectstack/driver-memory`** gains `computeAndRecordTenantField`, the + sticky resolver, and the `TenantOptOutRecord` type for the per-instance record + a driver owns. `InMemoryDriver` holds one and resolves through it, handing + BOTH declaration surfaces — field-level `unique` and declared `indexes[]` — + the same resolved column. `uniqueConstraintsFromFields` and + `uniqueConstraintsFromDeclaredIndexes` accept that column as an optional + second argument; called with one argument they answer exactly as before. + `tenantFieldOf` is unchanged and still a pure function of its argument. + - **`@objectstack/driver-sql`**: the shard leaf resolved its tenant column with + the BARE `computeTenantField`, so a `rotateShards` sweep carrying no `tenancy` + block gave a shard an organization key part the base table's index does not + have — one object, two partitions, decided by which physical table a row + landed in. It now resolves through the record, keyed by the base table. + - **`@objectstack/objectql`**: `LifecycleObjectLike` declares `tenancy`. The + Archiver hands that object straight to `cold.syncSchema`, and the published + type refused the key while the driver below read it — so an author writing a + fresh literal was pushed into producing exactly the partial re-registration + above. Same correction #16711 made where the shard leaf narrowed the key off + the object it was handed. + + The record is deliberately narrow. Only the explicit OPT-OUT is sticky: a + declared `tenancy.tenantField` is not recorded, matching `SqlDriver`. An object + that never declared the opt-out never enters the record, so a genuinely + org-scoped object keeps its `organization_id` partition across a partial + re-registration — an implementation answering `null` more often would not be + stickier, it would be tenant isolation switched off. A carried `tenancy` block + stays authoritative in both directions and CLEARS a recorded opt-out. + + `@objectstack/driver-memory` is `minor` for the two new public-entry exports. + The behaviour repairs themselves are `patch`: each restores an implementation to + the `tenancy.enabled: false` contract (`isTenancyDisabled`, ADR-0066) it was + already declaring, rather than replacing one legal published answer with + another. The `objectql` entry is a published type WIDENING — a key the interface + refused is now accepted, and nothing that compiled before stops compiling. +- 9c44eed: fix(spec)!: `TimeUpdateInterval` retires its three sub-day intervals and derives its members from `DateGranularity` (#17296) + + + + ## ADR-0087 disposition + + `second`, `minute` and `hour` leave a published closed enum that reaches TWO authored sites: an analytics request body's `timeDimensions[].granularity`, and an analytics cube dimension's `granularities[]`, which is stored metadata (`defineCube()` / `defineStack({ analyticsCubes })`). The stored half is rewritten by the D2 conversion `cube-sub-day-granularities-removed`, which strips the retired members from `analyticsCubes[].dimensions..granularities` and drops the key entirely when nothing coarser remains (an empty list would read as "offers none", the absent key as "offers all"). The semantic entry `time-update-interval-sub-day-retired` carries the half no transform can decide: a dimension that offered ONLY sub-day intervals needs an author to say what it actually serves. `day`, `week`, `month`, `quarter` and `year` are untouched and parse byte-identically. + + **BREAKING** for anyone authoring or sending `granularity: 'second'`, + `'minute'` or `'hour'`, and for anyone importing the `TimeUpdateInterval` + TYPE. Landing in the + launch window as `minor` under the lockstep convention this cluster's siblings + already use. + + ## What was wrong + + `TimeUpdateInterval` declared **eight** intervals. The rest of the contract + never carried three of them, and this is the measurement rather than the + argument: + + | layer | declares | + |:---|:---| + | `TimeUpdateInterval` (`data/analytics.zod.ts`) | **8** — the five below plus `second`, `minute`, `hour` | + | `DateGranularity` (`data/query.zod.ts`) — what a `groupBy` entry and every driver bucket expression are typed by | 5 | + | `@objectstack/core`'s `BUCKET_GRANULARITIES` — the canonical bucket-KEY output contract a drill-down crosses | 5 | + | `driver-mongodb`'s `MONGODB_DATE_GRANULARITIES` | 5 | + + `DriverCapabilitiesSchema.supports.queryDateGranularity` — the one mechanism a + backend has for saying which granularities it buckets natively — is a + `z.record(DateGranularity, boolean)`. Measured: `{ day, week, month, quarter, + year }` parses; the same record plus `hour` raises `unrecognized_keys: ["hour"]`. + **No driver could advertise sub-day bucketing even if it had one.** That is what + makes this a retirement rather than a capability gap: a declared value one + backend cannot serve is a gap and the contract has a place to say so, but a + declared value *no* backend can even claim has no counterpart anywhere in the + contract that carries it. + + Driven against the built packages, two rows fourteen hours apart on one UTC + calendar day, before this change: + + | face | `granularity: 'hour'` | `granularity: 'day'` (control) | + |:---|:---|:---| + | `driver-memory` analytics | `NOT_IMPLEMENTED` / 501 | 1 group, `2026-09-06` | + | `driver-mongodb` bucket builder | `NOT_IMPLEMENTED` / 501 | `$dateToString` `%Y-%m-%d` | + | engine in-memory aggregation — the fallback every SQL/ObjectQL analytics query carrying a granularity lands on, since `NativeSQLStrategy` declines on a granularity | **200, 2 groups keyed on the RAW instant** | 1 group, `2026-09-06` | + + Two honest refusals and one silently wrong answer. No third behaviour, and no + backend that bucketed it. + + ## What changed + + - `TimeUpdateInterval` is now `z.enum(DateGranularity.options, …)` — the members + come from the single source instead of a second literal list that disagreed + with it by three members for as long as both existed. + - A refusal message splits two populations that are not the same mistake: a + **retired** sub-day name gets the retirement and the `os migrate meta --from + 17` line; anything else gets the vocabulary. `driver-memory`'s own analytics + door carries the same split. + - `driver-memory`'s `NOT_IMPLEMENTED` / 501 answer for these three is **not + silenced** — the declaration it announced is gone, so the class moves to the + 400 the retirement makes correct. The 501 arm stays, and a pin measures that + its population is now empty (`TimeUpdateInterval.options` equals + `BUCKET_GRANULARITIES`), so the day one of the two is widened alone it lights + up again instead of a freshly declared value being called undeclared. + + ## What this does NOT decide + + Sub-day analytics bucketing as a **capability**. Offering it means widening + `DateGranularity`, the `queryDateGranularity` record, the canonical bucket-key + vocabulary and every driver's bucket expression together — new capability, + decided as such, rather than a name that parses in one enum and resolves + nowhere. + +### Patch Changes + +- e7ff9c2: `dateRange`'s array arm has ONE arity everywhere: a two-element window, or the ADR-0112 refusal (#17596) + + The shared conformance kit + (`analyticsDateRangeConformanceFindings`) had exactly one array case — a + two-element window — so the ARITY of the array arm was governed nowhere and + every analytics face was free to invent a meaning for `dateRange: + ['2026-01-01']`. Four faces in one package had invented three (#17124), and a + fifth — `driver-memory`'s cube face — had invented a fourth. + + **The kit** now exports `ANALYTICS_DATE_RANGE_NOT_A_WINDOW` and holds every + registered face to the rule the `service-analytics` faces already carry: an + array that is not two non-empty string bounds is refused with + `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400. No new rule was invented for it, and + the existing two-element window case is untouched — it is this case's control, + so "refuse every array" cannot pass. + + **`driver-memory`** now answers that refusal instead of dropping the window. + MEASURED end to end over four rows spanning 2020…2099: `['2026-01-01']`, `[]` + and `['2026-01-01', '2026-01-31', '2026-02-01']` each emitted a pipeline + byte-identical to one with **no `dateRange` at all** — every row selected, the + "plot all of history" failure #3650 was filed about — and `[null, null]` + compared instants against the string `'null'` and selected none. + + **Levels.** `@objectstack/core` is `minor`: it gains a new exported symbol on + its index (`ANALYTICS_DATE_RANGE_NOT_A_WINDOW`), and a purely additive widening + of a published package's public surface takes at least `minor` whatever the + commit type says. `@objectstack/driver-memory` is `patch`: its public surface is + byte-unchanged — no new export, no new accepted key or value. Its behaviour does + change, from selecting every row to refusing with `400 + ANALYTICS_DATE_RANGE_UNRECOGNIZED`, and that is a `patch` because the old + behaviour was a defect and never a contract: the spec's own refusal wording + already said an explicit window is the two-element array, and the #16322 + migration table already told authors to write a single day as two bounds. A + release that stops answering a shape the contract never admitted is a fix, not a + feature — and the shapes it now refuses had no correct answer to lose. + + **If you wrote a one-element array**, write both bounds: `['2026-01-01']` + becomes `['2026-01-01', '2026-01-01']`, which selects exactly that day on every + face and did so before this change too. The refusal names the shape that + arrived, the two-element contract and that spelling. +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- e5cf27d: fix(objectql,core): a per-aggregation `filter` and `having` on `engine.aggregate` read a temporal comparand by the column's storage rule, the rule `where` already applies — one function, `temporalStorageForm`, now exported by `@objectstack/core` and shared by both drivers (#20176) + + A per-aggregation `filter` (`aggregations[i].filter`) and `having` are evaluated by the engine itself, over the rows (or aggregated rows) a driver returns. Both compared a temporal comparand exactly as written, while the same condition as a `where` is put into the column's storage form by the driver first. So they counted differently. Measured through `engine.aggregate` and through `POST /data/:object/query`, on `driver-memory` and `driver-sql`, over six rows: + + | in `aggregations[i].filter` (or `having`) | before | now, and the `where` twin | + |:--|:--|:--| + | an ISO instant on a `date` field, `{ placed_on: { $gte: '2026-02-01T00:00:00.000Z' } }` | 1 | 3 | + | the same instant under `$eq` | 0 | 2 | + | a bare day as the upper bound of a `datetime`, `{ opened_at: { $lte: '2026-02-01' } }`, or as a `$between` max | 2 | 3 | + | an epoch-millisecond bound on a `datetime` | 0 | 3 | + | a `Date` carrying a time of day on a `date` field, `$gte` / `$lt` / `$eq` (in-process only) | 1 / 5 / 0 | 3 / 3 / 2 | + | a `Date` on a `time` field (in-process only) | 0 | 3 | + | `having` on `max` of a `date` field with an ISO-instant bound | kept one group | keeps the two groups whose day is on or after it | + + The same holds for `$ne`, `$in` / `$nin` members, `$between` endpoints, implicit equality, an offset instant (`'…T18:00:00+08:00'`), an epoch-millisecond string, a zone-naive `'2026-02-01T10:00'`, and a short wall clock (`'11:00'`) or an ISO instant on a `time` field. On a `having` column, the class comes from the query, as the `addDays` rule already reads it: `min` / `max` take the class of the field they read, a `groupBy` projection takes its field's, and a `day` date bucket is a `date`. + + What the rule does, now in one place: + + - A comparand, and the row's value, are put into the column's storage form: canonical UTC ISO text for `datetime`, `YYYY-MM-DD` for `date`, and `HH:MM:SS` (`.fff` only when non-zero) for `time`. + - A bare `YYYY-MM-DD` used as the upper bound of a `datetime` (`$lte`, a `$between` max) means that whole day, as it does in a `where` (ADR-0053 D-D). On a `date` or `time` column it is not widened. + - A value the rule cannot read is compared as written, and so is every non-temporal column, presence tests (`$exists`, `$null`), the text operators and a `{ $field }` reference. + - An object whose declared fields the engine cannot see keeps the previous comparison. + + `@objectstack/core` exports the rule as `temporalStorageForm(value, kind)`, `kind` being `'datetime' | 'date' | 'time'`. `driver-sql` (`canonicalUtcDatetime`, `toDateOnly`, `canonicalTimeOfDay`) and `driver-memory` (`coerceTemporalValue`) each carried a copy of it; both now call it. The copies agreed on every shape measured when they were lifted, so the lift itself changes no `where`, write or read answer of either driver (#20203, in the same release, then reads an epoch-millisecond number on a `date` field as its UTC calendar day). MySQL still binds a `datetime` in its own literal spelling. + + `@objectstack/objectql`'s `applyInMemoryAggregation(rows, ast, timezone?, fields?)` takes the object's declared field map as an optional fourth argument, and a per-aggregation `filter` reads a temporal comparand by the rule only when it is given. Called without it, the function answers as before. + + Not changed, measured identical before and after on both drivers: every `where` answer, every refusal a per-aggregation `filter` or `having` gives, and every per-aggregation `filter` and `having` cell whose column is not temporal. +- 615c468: fix(core): an epoch-millisecond number compared against a `date` field is read as the UTC calendar day of its instant, by every driver and at every position that compares it (#20203) + + Clause-②: no — no key, export or operator moves, and no comparand that was accepted is now refused: a number was already an accepted comparand on every `date` position, and its answer moves to the storage rule's reading. + + `temporalStorageForm(value, 'date')` in `@objectstack/core` returned a finite number unchanged, so each face compared it by its own type rules and they disagreed. Over six rows, with `1769940000000` (2026-02-01T10:00:00.000Z) against a `date` field: + + | face | `$gt` | `$lt` | `$eq` | `$in` (with a Jan 10 member) | `$between` (from Jan 2) | + |:--|:--|:--|:--|:--|:--| + | `where` on `driver-memory` | 0 | 0 | 0 | 0 | 0 | + | `where` on `driver-sql`, SQLite | 6 | 0 | 0 | 0 | 0 | + | `where` on `driver-sql`, PostgreSQL | `DATABASE_ERROR`, a 500 at REST | the same | the same | the same | the same | + | a per-aggregation `filter` on `engine.aggregate` | 0 | 0 | 0 | 0 | 6 | + | **now, on every face above** | **1** | **3** | **2** | **3** | **5** | + + The same holds through `engine.find` and `POST /data/:object/query`, and for `$gte`, `$lte`, `$ne`, `$nin` and implicit equality. PostgreSQL's server refused the bound number itself (`22008`, date/time field value out of range), on an empty table too. `having` over `max` of a `date` field kept no group for `$gt`, `$eq` or `$in`; it now keeps the groups whose day compares. + + A finite number is now read as the `datetime` rule already reads it, as epoch milliseconds. It takes the UTC calendar day of that instant: the day `new Date(value)` names, through the same conversion a `Date` takes. So a number and its `Date` always answer alike. A time of day is dropped, never rounded, a negative number is a day before 1970, and a fraction truncates toward zero as the `Date` constructor does. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call this rule, so they now agree. + + The rule is shared by the drivers' write and read paths too: + + - `create()` / `update()` on either driver, given a number for a `date` field, stores its UTC day. Before, `driver-memory` and SQLite stored the number, and PostgreSQL refused the statement. The engine and REST write doors refuse a number on a `date` field before a driver sees it (`VALIDATION_FAILED`), as before. + - A number already stored in a SQLite `date` column is read back as its UTC day by `find()`, a `groupBy` key and `distinct()`. Only a direct driver write could have put one there. + + Not changed, measured identical before and after: `NaN`, ±Infinity, a number outside the `Date` range (past ±8.64e15), a bigint, an epoch-millisecond string, every `Date` and every string on a `date` field (#20240, in the same release, then pads a `Date`'s or a number's year 0..999 to four digits and refuses one whose year falls outside 0..9999, a number past the `Date` range included; #20264, in the same release, narrows that to 0001..9999, so year 0 is refused rather than padded), and every `datetime` and `time` reading. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. +- 89f87f2: fix(core,objectql)!: a number or `Date` compared against a `date` field spells its year with four digits, and one whose UTC year falls outside 0..9999 is refused `INVALID_FILTER` / 400, as its ISO string already was (#20240) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what a filter on a `date` field accepts. A number or `Date` whose UTC calendar day falls in a year below 0 or above 9999 used to answer 200 with the wrong rows, or a 500 on PostgreSQL; it now answers `INVALID_FILTER` / 400. It ships as `minor` under the launch-window convention for accept-set narrowings. + + `temporalStorageForm(value, 'date')` in `@objectstack/core` spelled the year of a `Date` or an epoch-millisecond number unpadded: `999-06-15`, `10000-01-01`, `-1-01-01`. The ISO string and the bare day of the same instant spelled `0999-06-15`, and as text an unpadded year sorts as no day does. Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query` (the two doors agree), on a `date` field holding six 2026 days and 0999-06-15, `$gt` / `$lt` / `$eq`: + + | position | comparand | before: memory · SQLite · PostgreSQL | now, on all three | + |:--|:--|:--|:--| + | `where` | a number (or, in-process, a `Date`) for 0999-06-15 | 0/7/0 · 0/7/0 · 6/0/1 | 6/0/1 | + | per-aggregation `filter` | the same | 0/7/0 on all three | 6/0/1 | + | `having` on `max(date)` | the same | no group / every group / no group | the three 2026 groups / none / the 0999 group | + | `where` | a number (or `Date`) for 10000-01-01 | 6/1/0 · 6/1/0 · 0/7/0 | `INVALID_FILTER` / 400 | + | `where` | a number (or `Date`) for -1-01-01 | 7/0/0 · 7/0/0 · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | + | per-aggregation `filter` | either of those two | 6/1/0 and 7/0/0 on all three | `INVALID_FILTER` / 400 | + | `where`, per-aggregation `filter` | the ISO string of either | `INVALID_FILTER` / 400 | unchanged | + + What changes: + + - The rule pads a year from 0 to 999 to four digits, for a `Date` and a number alike, so a number, its `Date` and its ISO string spell one day. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call it. + - `isUninterpretableTemporalComparand('date', value)` is now also true for a finite number or a valid `Date` whose UTC year is below 0 or above 9999, a finite number past the `Date` range (±8.64e15) included. The engine's temporal-comparand door refuses such a comparand on `where` for every verb (`find`, `findOne`, `count`, `aggregate`, `update`, `delete`), in both the object and the array spelling, and in a per-aggregation `filter`, before any driver read. `IObjectQLEngine.judgeFilter` runs the same door. + - The write path: `create()` / `update()` on `driver-memory` or SQLite, given a year-0..999 number or `Date` for a `date` field, now stores `0999-06-15` where it stored `999-06-15`; `engine.insert` of such a `Date` does the same. PostgreSQL and MySQL already stored a three-digit year's day, but not a shorter one: under its default `DateStyle` (`ISO, MDY`) PostgreSQL stored the unpadded `9-03-04` as 2004-09-03 and refused `99-03-04` (`22008`), and MySQL 8.0 stored `99-03-04` as 1999-03-04. All three dialects now store the day. A year outside 0..9999 keeps the spelling it had on the write and read paths; no ordered form is invented for it. + + **Who is affected.** A caller that compares a `date` field with an epoch-millisecond number or a `Date` in a year below 0 or above 9999. No writer that stores or queries such a day has been measured; the reach is the public query door. + + **Fix.** Compare against a `YYYY-MM-DD` day, or a number or `Date` whose UTC calendar day falls in a four-digit year. + + **Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included (#20264, in the same release, then narrows the range to 0001..9999 on `date` and `datetime` alike: year 0 is refused too, and so is a `datetime` number, `Date` or string outside it, and the padding covers 0001..0999); every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 read back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch and #20280, in the same release, corrects by reading a MySQL `DATE` as its text. `having` reaches the same door in the same release (#20263), so a number or `Date` outside 0..9999 is refused there too. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/drivers/driver-memory/package.json b/packages/drivers/driver-memory/package.json index 0ce5cec066c..2bb55234d0e 100644 --- a/packages/drivers/driver-memory/package.json +++ b/packages/drivers/driver-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-memory", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "In-Memory Driver for ObjectStack (Reference Implementation)", "main": "dist/index.js", diff --git a/packages/drivers/driver-mongodb/CHANGELOG.md b/packages/drivers/driver-mongodb/CHANGELOG.md index 329093f028f..e7c37cccad4 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,551 @@ # @objectstack/driver-mongodb +## 17.5.0 + +### Minor Changes + +- 9347c1f: A row-level or sharing-rule predicate comparing a field against a list with `!=` / `==` is refused at the CEL lowering instead of lowering to a filter that widens on driver-mongodb, and driver-mongodb refuses `$ne` with an array comparand (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/driver-mongodb`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-list-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS reads on MongoDB and RLS write checks.** A policy written `record.status != ['closed', 'archived']` (or `!(record.status == [...])`, or `!=` against a `current_user` membership set) lowered to `{ status: { $ne: [...] } }` (or `$not` around a bare-array equality). The RLS `using` clause is composed into the query after the engine's comparand-shape check, and driver-mongodb passed the shape to the server, where it selects every scalar row: the read returned the rows the policy was written to hide. A `check` written `!=` against a membership set admitted every write. + + - `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose comparand is a list (`unsupported`): a list literal, or a `current_user` variable that resolves to an array. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports the literal; a resolved array is refused per request. + - `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403). A CEL-authored `check` gets this 403; the `INVALID_FILTER` / 400 of `matchesFilterCondition` remains for a filter passed to it directly. + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. + - `@objectstack/lint`: the list-literal form is reported (`rls-predicate-unenforceable`, `sharing-rule-unlowerable-condition`). The RLS reference pass probes each kernel-resolved `current_user` key with its runtime type. + - `@objectstack/driver-mongodb`: `translateFilter` refuses `$ne` with an array comparand at any depth, with `INVALID_FILTER` / 400, as driver-sql and driver-memory already do. + - `@objectstack/spec`: the migration registry carries the entry. + + **What to change.** "One of these values" is `record.status in ['open', 'pending']`; "none of these values" is `!(record.status in ['closed', 'archived'])`. In a raw filter, use `$in` / `$nin`. `in`, scalar `==` / `!=`, `null` and field-to-field comparisons are unchanged. + + +- 98f722a: fix(driver-mongodb)!: `translateFilter` refuses a `{ $field }` cross-field reference instead of sending it to MongoDB as a literal value (#19949) + + Clause-②: no (narrowing) + + **BREAKING**: an accept-set narrowing, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. **A filter that answered before is now refused**: any filter carrying a `{ $field }` reference gets `INVALID_FILTER` / 400 from `translateFilter`, and so from every driver door that reads a `where` (`find`, `findOne`, `count`, `updateMany`, `deleteMany`, `aggregate`, `explain`). + + **Security fix for RLS reads on MongoDB.** `compileCelToFilter` lowers a field-to-field comparison such as `record.s != record.t` to `{ s: { $ne: { $field: 't' } } }`. This driver has no lowering for a column-to-column comparison, and `translateFilter` sent the reference to the server as a literal sub-document. The RLS `using` clause is composed into the read after the engine's comparand checks, so nothing stopped it on the way. Measured over the rows `{ s: 'a', t: 'a' }` and `{ s: 'a', t: 'b' }`, with mingo 7.2.4 as the proxy for MongoDB's query semantics: + + - `$ne` against the reference selected both rows, including the one where `s` equals `t`, and `$eq` selected none; + - `$nin: [ref]`, `$notContains: ref`, `$ne` against a reference carrying `addDays`, and `$eq` under `$not` also selected both rows; + - through `ObjectQL`, `SecurityPlugin` with a `rowLevelSecurity` policy `using: 's != t'`, and `MongoDBDriver` over a mingo-backed collection, `find` returned both rows, `count` returned `2`, and `findOne` returned the `s == t` row. The policy's read restriction was lost. + + A live `mongod` was not measured, because this fleet cannot fetch the binary. + + **What changes.** The driver's filter walk refuses a `{ $field }` reference in every comparand position, at any depth under `$and` / `$or` / `$not`: + + - the whole comparand of any operator: the orderings, `$eq` / `$ne`, the string operators, `$null`, `$exists`; + - a member of a list: `$in` / `$nin` members, either `$between` endpoint, an array given to `$ne` or `$eq`; + - the implicit-equality position: the bare `{ field: { $field: … } }` form, or a list holding a reference; + - a reference carrying `addDays`, and a malformed reference whose `$field` is not a string. + + The refusal uses the same envelope as the driver's other filter refusals. Its message names the unsupported feature (field-to-field comparison) and withholds the fields, the operator and the position, because the filter may be an access policy the caller did not write. Through the engine, an RLS read carrying such a policy is now refused with that 400, and the server is never asked. Before, it returned the unfiltered rows. A policy written `s == t`, which used to return no rows, is refused the same way. + + **What does not change.** + + - Every literal comparand translates to the same document as before, including literal `$in` / `$nin` / `$between` lists and `$not` around a literal. + - `$ne` with an array of literal values keeps its own refusal. An array with no reference in the equality slot still passes through `translateFilter`: the shared comparand-shape face owns that slot. + - Field-to-field comparison is not implemented on this driver. It is refused, not lowered to a MongoDB `$expr`. `driver-sql` and the in-memory evaluator are not touched. + + **What an affected author does.** On a MongoDB datasource, a row-level policy or filter can compare a field only against a literal value or a `current_user` value, not against another field of the same record. A policy that needs a field-to-field comparison cannot be enforced by this driver. Before this change it returned every row. + + +- fb38607: feat(drivers,formula,objectql): the engine's filter faces answer the staged `$empty` operator (#20444) + + Clause-②: yes (widening) + + `$empty: true | false` is declared by `@objectstack/spec` (`FieldOperatorsSchema`) with a per-type meaning: a text-like field is empty when it is null or `''`, a multi-value field (multiselect, checkboxes, tags, or a select / radio / lookup / user / file / image with `multiple: true`) when it is null or `[]`, and every other type only when it is null. `$empty: false` is the exact complement. Until now every face in this list refused it (`INVALID_FILTER` / 400), except `matchesFilterCondition`, which answered `false` for every record. **A driver or evaluator called directly now answers it:** + + - **By the field's declared type**, through the spec's one expansion (`expandEmptyOperator`): `driver-sql`'s filter compiler (and so `driver-sqlite-wasm` and `driver-turso`'s local transport, which inherit it), `driver-turso`'s remote transport, `driver-memory`'s query path (`find` / `count` / `update` / `delete`) and `driver-mongodb`'s `translateFilter` (its `find`, its aggregate `$match`). The declaration is the one each driver already receives — `initObjects` / `registerObjectMetadata` / `registerExternalObject` on the SQL family, `syncSchema` on the others. On SQL a multi-value field's empty list is tested as stored JSON per dialect (SQLite `json_array_length` behind a `json_valid` guard, PostgreSQL a `jsonb` comparison, MySQL `JSON_LENGTH`), never as an equality comparand. + - **By value** — null, a missing value, `''` and `[]` are empty (`isEmptyFilterValue`) — on the faces that read no field declaration: `@objectstack/formula`'s `matchesFilterCondition` (the RLS write-side `check`), `driver-memory`'s reference matcher, and `@objectstack/objectql`'s `having` and per-aggregation `filter`. In `having`, a `count` or `sum` holding `0` is not empty. + + **Refused, never guessed** (`INVALID_FILTER` / 400): `$empty` on a field whose declaration the driver does not hold (a table built outside its registration, a builtin column such as `id`, a field with no `type`, or `translateFilter` / `RemoteTransport` used standalone without a declaration), a multi-value field on a SQL dialect the driver does not model, and a flag that is not a boolean. `driver-memory`'s analytics (cube) face refuses `$empty` as an operator it cannot compile, as it does `$null`. + + New optional API: `translateFilter(where, temporalKind?, valueShape?)` in `@objectstack/driver-mongodb` takes a declared-value-shape resolver (type `ValueShapeResolver`), and `buildAggregationPipeline` a `valueShape` option; `RemoteTransport.setDeclaredValueShapeResolver` in `@objectstack/driver-turso`, which `TursoDriver` wires. `@objectstack/spec`'s shared `FILTER_LOGIC_CASES` table gains seven `$empty` cases: a backend that runs it answers `$empty` or goes red, and its harness must declare the fixture's columns. + + `$empty` stays staged: it is not in `FILTER_OPERATORS`, so the engine's front door still refuses it until the flip card adds it, and the view operators `is_empty` / `is_not_empty` still lower to `$null`. + +### Patch Changes + +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/drivers/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json index 5fb6a0b0d48..26c955fdde2 100644 --- a/packages/drivers/driver-mongodb/package.json +++ b/packages/drivers/driver-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-mongodb", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "MongoDB Driver for ObjectStack - Native document database driver via official mongodb client", "main": "dist/index.js", diff --git a/packages/drivers/driver-sql/CHANGELOG.md b/packages/drivers/driver-sql/CHANGELOG.md index da91d9f31c4..f07d032e08e 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,1811 @@ # @objectstack/driver-sql +## 17.5.0 + +### Minor Changes + +- fe71032: feat(driver-sql,objectql,cli)!: the ADR-0104 file-family column step, and the kernel→driver supply that arms it (#15989) + + + + **BREAKING** on the published storage behaviour of `@objectstack/driver-sql`. A deployment that runs `os migrate files-to-references --apply` now has its media columns **retyped and their values rewritten** into the bare-`sys_file`-id encoding, and its driver writes bare ids from the next boot. This completes the maintainer ruling on #15041 (「15041 应该改为实际 id 保存。选A,其他同意」) whose encoding half shipped in the previous release. + + Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + ## The column step + + `os migrate files-to-references --apply` gains a further step, run **only after** the backfill and its self-check report zero blocking rows — and it moves nothing at all until three gates pass: + + 1. the migration's own gate (zero blocking rows); + 2. **every** abort pre-check, across **every** planned column, before a single statement runs; + 3. no refusals — a column the driver could not plan stops the columns it could. + + **PostgreSQL** and **SQLite** only. ⛔ MySQL is refused by name and belongs to #17788, where its statement ORDER is settled against a real instance rather than transcribed. + + Per column, the shape is read off the column's **physical type**, not off the dialect: a `json` column is retyped (`ALTER … TYPE varchar(2048) USING (col #>> '{}')`), while a column that is already `varchar` — the population `os generate migration --format sql` creates and a JSON-arm driver fills with quoted ids — has its values unquoted in place. SQLite has only the second shape, since it has no json type. + + ### ⛔ The abort clause is NOT the one the ADR sketched + + The #15041 addendum prescribed the retype with nothing in front of it while *requiring* the step to abort "on the first cell that is not a JSON string". Those two sentences contradict each other, and which was wrong was settled by running it. Measured on live PostgreSQL 16.13, `USING (col #>> '{}')` is **accepted** over a row holding an inline metadata blob, because `#>> '{}'` extracts *any* json type as text: the bytes survive, but the column is no longer `json`, so an object becomes a plain string in a column whose declared contents are ids — silently, in a migration that reports success. The director ruling (decision batch #120 item 1) replaced the clause with the pre-check that implements the requirement: `json_typeof(col) IS DISTINCT FROM 'string'` on PostgreSQL, and `json_valid(col) AND json_type(col) <> 'text'` on SQLite, where excluding invalid JSON is what keeps a re-run idempotent over cells a previous run already moved. + + Both the destructive form and the guarded one are executed side by side, on one fixture, in this release's own test suite — so the difference stays a measurement rather than a comment. + + ## The kernel→driver supply seam + + `SqlDriverConfig.fileColumnsMoved` shipped last release and no host outside the driver supplied it. It is supplied now: `ObjectQL.registerDriver` hands every driver that has the seam a closure over the new `ObjectQL.haveFileColumnsMoved()`, which reads `sys_migration.columns_moved_at` — and requires the `adr-0104-file-references` flag to be verified **as well**, since the stamp alone would attest a column move with nothing attesting the values inside it. + + ⭐ **Every way of not knowing still answers "not moved".** The option omitted, a resolver that throws or rejects or answers a non-`true` value, a resolver that never runs because the host never calls `initObjects`, a driver with no such seam, no `sys_migration` object, no row, an unreadable table, a null or empty stamp — all the JSON arm. That is the encoding every deployment in the world is on, and a driver that guessed the other way would write bare ids into a JSON column. + + ⛔ **A host that names `fileColumnsMoved` in its own config wins**, in either polarity. The engine only ever fills an empty slot, and never contradicts an explicit composition: overruling a declared `false` is precisely the bare-ids-into-a-JSON-column failure this mechanism exists to prevent. + + ## New published surface + + - `@objectstack/spec` — `hasMovedFileColumns(flag)`, the single arbiter of the conjunction above, beside `isDataMigrationFlagVerified` and `authorisesIrreversibleAction`. + - `@objectstack/objectql` — `ObjectQL.haveFileColumnsMoved()`, sharing one memoized read (and one `invalidateDataMigrationFlags()`) with `isFileReferencesMigrationVerified()`, so the two answers can never come out of one another's date. + - `@objectstack/platform-objects` — `recordFileColumnMove(engine, migrationId)`, which refuses to stamp a deployment with no verified flag row. `readDataMigrationFlag` now carries `columns_moved_at`; it previously dropped it, which made a moved deployment indistinguishable from an unmoved one to every caller. + - `@objectstack/driver-sql` — `SqlDriver.setFileColumnsMovedResolver()`, `SqlDriver.planMediaColumnMove()`, and the statement builders `mediaColumnMovePlan` / `mediaColumnMoveDialect` / `isJsonColumnType` with `MEDIA_COLUMN_MOVE_DIALECTS`, `MEDIA_COLUMN_MOVE_ROLLBACK_NOTES` and `MEDIA_ID_MOVE_WIDTH`. The statements live in the package that owns the dialects and measured them; a second copy in the CLI would be a second copy of the clause the ruling got wrong. + + ## What does NOT change + + A deployment that does not run `--apply` is byte-for-byte where it was: the column stays `json`, the write still JSON-encodes, and the read still accepts both encodings. A backfill re-run does not set the stamp and — deliberately — cannot clear it either: `recordDataMigrationRun` omits the key rather than writing a preserved value, so a ledger read that FAILS cannot demote a moved deployment back onto the JSON arm. A partial or failed column step records nothing at all, which leaves such a datastore on the arm that reads both encodings. + + `multiple: true` media is untouched on both arms: its value is a list of ids and a JSON column on every deployment. +- d285bf0: fix(spec)!: `multiple: true` is refused on every type outside the multi-capable set, and driver-sql derives JSON-column storage from the spec predicate (#17469) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition). + + Two definitions of "multi-valued" disagreed, and the user saw the disagreement as + a `400`. + + - `FieldSchema` accepted `multiple: true` on **any** type. + - `@objectstack/driver-sql`'s `isJsonField` read the flag raw — + `JSON_COLUMN_TYPES.has(type) || !!field.multiple` — and built a **JSON array + column** for it. + - `isMultiValueField` — the published spec predicate consumers shape queries from + — answered **"not multi-value"** for that same field, because `master_detail` / + `tree` / `text` are outside `MULTI_CAPABLE_TYPES`. + + So a related list composed `=` against a JSON array column, and the driver refused + the equality family there with a `400`. + + In business terms: `multiple` means "this cell holds several values at once", and + that has meaning only on multi-select, multi-record / multi-user and multi-file + fields — exactly what the spec already declares. A child record with several + masters, a tree node with several parents, or a text box holding several texts has + no meaning on any mainstream platform. The declaration was accepted silently, the + UI rendered a single value, the database built a JSON array column, and the + related list answered the user a 400. + + FROM → TO, for metadata that used to parse and now fails: + + ```ts + // FROM — parsed, stored a JSON array, rendered single, answered `=` with 400 + { type: 'text', label: 'Aliases', multiple: true } + { type: 'master_detail', label: 'Parents', reference: 'account', multiple: true } + { type: 'tree', label: 'Parents', reference: 'category', multiple: true } + + // TO — pick the type that actually holds several values… + { type: 'tags', label: 'Aliases' } // several free-form strings + { type: 'lookup', label: 'Parents', reference: 'account', multiple: true } // several related records + + // …or drop the key, if the cell really holds one value. + { type: 'text', label: 'Alias' } + { type: 'master_detail', label: 'Parent', reference: 'account' } + ``` + + The refusal names the field, its type and the alternative, on the `multiple` path. + `radio` keeps its own narrower 2026-08-22 message (#11437); the two never + double-fire. + + **`MULTI_CAPABLE_TYPES` and `isMultiValueField` are untouched**, deliberately: a + field that was already multi-valued by that predicate keeps its declaration, its + storage and its read path byte-identically. What moved is which declarations can + be newly authored, plus the storage decision for the shapes that are now refused. + + **Storage change (`@objectstack/driver-sql`)**: every site that asked + `field.multiple` the question "is this value multi-valued" now asks + `isMultiValueField` — **eighteen expressions across two files**, not one. The + file's own header already called `JSON_COLUMN_TYPES` membership "owned by + `@objectstack/spec`"; that sentence is now true for the `multiple` half too. + + - `sql-driver.ts` — the DDL writer (`createColumn`'s multi-value short-circuit), + the read-side deserializer (`isJsonField`, both limbs), the `varchar` width + mirror (`varcharColumnChars`), the cross-field comparison class + (`crossFieldComparisonClass`), the four scalar registries filled by BOTH + `registerObjectMetadata` and `registerExternalObject` (`mediaFields`, + `booleanFields`, `numericFields`, `numericValueFields`), and the two MySQL + temporal-widening candidate sets. + - `schema-drift.ts` — the differ's `fieldHasColumn`, its `declaresJsonColumn` + disjunct and its `declaresArray` test, which #15771 bound to the writer's + predicate and which a pin test holds equal to it. + + Only one of those was named in the ruling; aligning it and leaving seventeen + would have re-opened #11535 in reverse — the DDL writing a JSON column that the + read-side deserializer no longer recognises. A column whose field is multi-valued + by the spec predicate behaves exactly as before; the shapes that change are the + ones the schema now refuses at the entrance. + + ⛔ Three `field.multiple` reads are deliberately NOT aligned: the three that + interpolate `', multiple'` into an `uncompilableFieldReferenceError` message. + They echo what the author DECLARED back to them; they do not ask whether the + value is multi-valued (the verdict there comes from `crossFieldComparisonClass`, + which is aligned). + + ⚠️ **Two consequences worth reading before you upgrade.** + + 1. A **stored** field carrying `multiple: true` on a non-capable type has no + lossless conversion — its column was physically built as a JSON array. The + ADR-0087 semantic entry `field-multiple-non-capable-type-refused` emits the + structured TODO naming the object, field and type; migrating the data is the + author's judgment call, and the entry states how to prove it. + 2. `isMultiValueField` reads the **authorable** `FieldType` vocabulary. A driver + -internal column-type alias (`string` / `integer` / `int` / `float` — the + introspected-column spellings) is not a `FieldType`, so a hand-declared + external object that puts `multiple: true` on one of those no longer gets a + JSON column. Declare such a column as `object` or `array` (both are + `JSON_COLUMN_TYPES` members and unchanged), or as the authorable type it + really is. + 3. `multiple: true` on `boolean` / `toggle` / `number` / `currency` / `percent` / + `date` / `datetime` / `time` **ceases to be a supported shape end to end**, as + a consequence of the entrance refusal above. Such a column is no longer a JSON + column, so it is no longer excluded from the scalar read-coercion registries + and the declared-type text-operator gate (`isNonTextColumn`) applies to it: a + `$contains` against one answers the declared no-match rather than a JSON + membership test. Stored data in that shape is the ADR-0087 entry's subject. +- e04a0af: `$contains` on a multi-valued / JSON column is a MEMBERSHIP test, compiled per dialect so SQLite, MySQL and PostgreSQL answer the same rows. + + `$contains` is the membership spelling on a `multiple: true` field or a `JSON_COLUMN_TYPES` member — the one operator that kept working on a JSON column after the scalar-comparison family was refused there, and the spelling that refusal's own message prescribes. It was lowered like any other text operator, so each backend was asked about the SERIALIZATION rather than about the members, and the three answered three different things: SQLite matched a substring of the stored array text, MySQL coerced its `json` column for `LIKE` and matched the same substring, and PostgreSQL raised SQLSTATE 42883 (`operator does not exist: json ~~ text`) — a `DATABASE_ERROR` 500 for a filter the spec accepts. + + `driver-sql` now compiles a real membership construct per dialect: `jsonb` containment on PostgreSQL, `JSON_CONTAINS` on MySQL, a `json_each` scan on SQLite. `$notContains` moves with it as its exact complement. + + **Behaviour change on SQLite and MySQL, in the narrowing direction.** Where the substring reading matched ACROSS element boundaries it no longer does: `{ tags: { $contains: 'red' } }` stops answering a row whose only tag is `redwood`, and `{ nums: { $contains: '1' } }` stops answering a row holding `[10, 21]`. Those rows were wrong answers, not a contract — a filter that needs the old reading is asking for a substring search over a serialization and should be written against a scalar column. On PostgreSQL the same filters change from a 500 to the member rows. + + Unchanged: `$contains` on a scalar string column is still the case-sensitive substring test, and the rest of the text family (`$startsWith`, `$endsWith`, `$icontains`, `$like`, `$ilike`) keeps the lowering it had on every column. + + `packages/spec`'s `StringOperatorSchema` docblock — published source — now states the membership reading and records, per face, which runtimes answer it. +- be5c602: fix(driver-sql,driver-turso): eight more `IDataDriver` doors publish their declared return type, not a nested `any` (#17690) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (PR #15280 for `SqlDriver.update()` and the `TursoDriver.update()` override, PR #14434 before it on `@objectstack/driver-memory`, PR #17258 for the five `SqlDriver` doors of #15267, PR #17689 for `aggregate()`). No runtime behaviour changes. + + Eight doors published an annotation whose `any` sat **inside** a wider type, while `packages/spec/src/contracts/data-driver.ts` had already declared each one narrower. A consumer holding one of these classes got `any` back and the compiler stopped checking: + + | class | door | published | now | + |---|---|---|---| + | `SqlDriver` | `find` | `Promise` | `Promise[]>` | + | `SqlDriver` | `upsert` | `Promise>` | `Promise>` | + | `SqlDriver` | `bulkUpdate` | `Promise[]>` | `Promise[]>` | + | `SqlDriver` | `temporalFilterValue` | `any` | `unknown` | + | `TursoDriver` | `find` (override) | `Promise` | `Promise[]>` | + | `TursoDriver` | `upsert` (override) | `Promise>` | `Promise>` | + | `TursoDriver` | `bulkUpdate` (override) | `Promise[]>` | `Promise[]>` | + | `RemoteTransport` | `beginTransaction` | `Promise` | `Promise` | + + The `TursoDriver` rows are separate sites, not consequences: an override re-declares the door in that package's own `.d.ts`, so the `@objectstack/driver-sql` narrowing does not reach a consumer holding a `TursoDriver`. + + **What a consumer does.** A cell read off a row now arrives as `unknown` and is typed before use (`String(row.name)`, `Number(cell)`, or a `typeof` narrowing); `Array.prototype.find` over a result set answers `… | undefined` and the absent arm is separated rather than asserted past. Measured across the whole consumer closure of both packages at this change's tree — 115 `typecheck` tasks — the repo-wide cost is **11 sites**, all inside `@objectstack/driver-sql` (9) and `@objectstack/driver-sqlite-wasm` (2), and **zero** outside the driver packages. + + `TursoDriver.beginTransaction` is deliberately NOT narrowed here and stays `Promise`. It overrides `SqlDriver.beginTransaction(): Promise` — narrower than the contract, the honest direction, and the binding declaration for an override — so the contract's `Promise` does not compile there (TS2416). That `any` masks an LSP violation, not an un-narrowed door, and closing it is a separate decision. + + +- 5ba2ec3: feat(spec,core,objectql,driver-sql,driver-turso): a transport can declare it has no transactions, and every transaction gate reads the declaration instead of method presence (#18063) + + Maintainer ruling, decision batch #148 item 3, letter B, 「同意」 2026-09-17, verbatim and untranslated: + + > `packages/spec`: the driver contract gains a way for a transport to **declare 「no transactions」** (the dev picks the smallest spelling the existing capability/contract surface already has — a capability bit is preferred over a new key), and the engine's transaction gating reads the declaration instead of method presence. + + **`DriverCapabilities` gains one live bit, `transactionsUnsupported`.** A transport sets it to say that a handle it issued would be a FALSE SUCCESS rather than a missing feature: the caller gets a handle, the writes execute and are already durable, `rollback()` resolves and undoes nothing. Absence means `false`, exactly like `batchSchemaSync`, so a driver that declares nothing keeps the behaviour it has today. + + **⛔ This is not `DriverCapabilities.transactions` un-retired, and the difference is not cosmetic.** That key was tombstoned in 17.0.0 under ADR-0049 enforce-or-remove and STAYS tombstoned — writing it is still a compile error and still a parse refusal carrying its prescription. It claimed "I support transactions" and nothing read it; this one declares "my transport cannot honour one" and the engine dispatches on it. Reviving the name would have inverted the record's own `absence = false` convention into a tri-state, turned a documented refusal into silent acceptance of a value whose meaning had changed underneath it, and made the tombstone's published text ("no code in any repository ever read it") false. A new key costs one bit; the name costs all of that. + + **Adding a bit to a record enforce-or-remove has pruned SATISFIES that ADR rather than reversing it.** The audit removed thirty-one bits for one stated reason — no code anywhere read them — and kept the three where method presence provably cannot carry the signal. This change is the creation of the missing reader: `driverSupportsTransactions()` (exported from `@objectstack/spec`) is the one definition of the gate, and all FOUR places that used to spell `typeof driver.beginTransaction === 'function'` ask it — `ObjectQL.transaction()`, `ScopedContext.transaction`, the `ScopedContext` begin/commit/rollback trio, and `@objectstack/core`'s `engineCanRollBack`. The bit arrives WITH its reader, in the same change, which is the honest order the ADR asks for. + + **Why method presence could not carry it.** `TursoDriver extends SqlDriver`, whose `beginTransaction()` opens a real knex transaction, so the inherited method reported the libSQL REMOTE transport as transactional. It is not — `RemoteTransport`'s data methods take no `options` argument at all, so a handle cannot reach the statement that would have to join it. A subclass cannot opt out of a door it did not open. This is the mirror of `batchSchemaSync`, which exists because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot. + + **What changes for a caller.** On a datasource whose driver declares the bit, `engine.transaction()` now takes the DECLARED non-transactional path (ADR-0119 D1) instead of opening a transaction it cannot honour: the degrade warns once per datasource — naming the declaration, not a missing method — and `{ require: true }` throws `TransactionUnsupportedError` before the callback writes anything. `ScopedContext.transaction` and the discrete begin/commit/rollback trio read the same predicate; the trio's `begin` returns `null`. Both are the answers a driver with no `beginTransaction` already received. + + **`driver-turso`.** The remote face declares `transactionsUnsupported: true`; local and embedded-replica inherit `false` from the base and are untouched. `TursoDriver.beginTransaction()` publishes the inherited declaration instead of `Promise` — the annotation the earlier `any` was masking an LSP violation to avoid, dissolved rather than widened: the remote arm returns `never` (it refuses), so the only arm that still returns is the base's. `SqlDriver.beginTransaction()` keeps its narrow `Promise`; nothing in the base was widened. + + **`@objectstack/core`.** `engineCanRollBack()` — the ADR-0119 D4 gate that `@objectstack/metadata-protocol` uses for `batchData` / `updateManyData` / `deleteManyData` under `options.atomic`, and that `runMigrationJournal()` uses to decide whether to start at all — reads the same predicate. It has to: it does not open the transaction itself, it vouches that `engine.transaction()` will, and on a driver that declares the bit the engine now takes its non-transactional path. A gate still reading method presence would vouch for a runtime that is about to run the callback with no transaction, so the atomic batch would answer `rollback` over writes that stayed on disk and the journal would write `chunk_done` rows its own contract says mean "committed". What a caller sees on such a datasource instead: `batchData({ atomic: true })` refuses with `501 NOT_IMPLEMENTED` — retry without `atomic`, or probe `capabilities.transactionalBatch` on `/discovery` first — and `runMigrationJournal()` refuses with `MigrationJournalRefusal('NOT_IMPLEMENTED')` before writing a single journal row. Both are the answers a driver with no `beginTransaction` already received. + + **`RemoteTransport` loses `beginTransaction()`, `commit()` and `rollback()`.** They are a published surface, and this is **minor** rather than major on the ruling's own stated ground: that transport never honoured a transaction, so no working behaviour is withdrawn. They had already become unreachable from every caller in the repository when the driver started refusing them; they are now gone, and the declaration keeps them gone by design rather than by audit. +- 9bfbacb: fix(driver-sql): the local SQLite `Field.json` storage backfill no longer turns a deeply nested array or object into a string on the next schema sync (#19912) + + The backfill that converges legacy json cells on their JSON-encoded form (it came with the change that made the SQLite write path JSON-encode every json value; the issue number that change cites no longer resolves, and its live record is the `SqlDriver.backfillCanonicalJsonEncoding` doc block and `sql-driver-12380-json-roundtrip.test.ts`) ran one `UPDATE … set col = json_quote(col)` over every TEXT cell SQLite's `json_valid()` rejects. `json_valid()` answers 0 for JSON nested more than 1000 levels deep (SQLite's JSON depth limit in every build this repository bundles), while the driver reads such a cell with `JSON.parse` without trouble. So a deep array written correctly through the driver was quoted into a JSON string by the next `syncSchema` / `initObjects`, and read back as a string from then on — silently, with no error. + + SQL now only pre-selects the candidate cells, a page at a time. The driver's own codec decides each one: a cell `JSON.parse` reads is left exactly as stored; a cell it cannot read is a legacy plain string and is rewritten to `JSON.stringify` of that string, byte-for-byte what the old statement wrote for it wherever the engine reads the stored bytes back verbatim. A cell the engine does not read back verbatim is left as stored and keeps reading as it did, where the old statement rewrote it: text holding invalid UTF-8, measured on better-sqlite3 and sql.js. `SqlDriver` on better-sqlite3, `TursoDriver` in local mode (which runs on better-sqlite3 too) and `SqliteWasmDriver` (sql.js) were each measured to read such a cell back with U+FFFD in place of the invalid bytes, so the text the rewrite would be decided from is not the stored text, and the cell is left alone. A legacy text with a leading U+FEFF or an embedded NUL is read back verbatim on all three faces, and is rewritten like any other plain string. Each rewrite is a compare-and-set on the text it was decided from, so a value written concurrently is never overwritten, and a re-run over a converged table still writes nothing. This covers every local SQLite face that inherits the backfill: `SqlDriver` on every client it treats as SQLite (`better-sqlite3`, `sqlite3` and its alias `sqlite`), `SqliteWasmDriver`, and `TursoDriver` in local mode. + + The decision rule is exported from `@objectstack/driver-sql` as `recoverUnencodedJsonText(stored)`, and `@objectstack/driver-turso`'s remote codec-residue backfill now imports it instead of carrying its own copy, so the local and remote backfills apply one rule. That is a new public export on `@objectstack/driver-sql`'s root entry, and the reason this package takes `minor`: the function returns `null` for text `JSON.parse` accepts and `JSON.stringify(stored)` for text it rejects, and it is exported so that `SqlDriver.backfillCanonicalJsonEncoding` and the remote backfill's `recoverResidueCell` decide each cell by one shared rule rather than by two copies that could drift apart. The remote backfill's behaviour is unchanged. +- 8a44ce7: fix(spec, drivers)!: a `$like` / `$ilike` pattern holding U+0000 is refused by every driver that answers `$like`, instead of being cut at the NUL on SQLite + + Clause-②: yes (narrowing) + + On the SQLite faces `$like` / `$ilike` compile to `GLOB`, and SQLite reads a pattern only up to its first U+0000. A pattern holding U+0000 was cut there, so the filter answered a different question, and nothing raised. Measured through `find` over 13 stored values (12 non-NULL), against `@objectstack/formula` on the same rows: all 20 U+0000 cases of the probe (10 patterns, bare and under `$not`) differed on `SqlDriver` over better-sqlite3, on `SqliteWasmDriver`, on `TursoDriver`'s local mode, and on its remote mode over a stub and over a real `@libsql/client` engine, with identical answers on all five. For example: + + - `$like: '%'` + U+0000 returned all 12 non-NULL rows, where `formula` returns the two ending in U+0000; + - `$like: 'a'` + U+0000 + `'b'` also returned `'a'`; + - `$ilike: 'AB'` + U+0000 also returned `'AB'` and `'ab'`. + + `driver-memory` answered all 20 as `formula` does. SQLite has no NUL-safe pattern primitive to compile to instead: `LIKE` cuts the same way, `replace()` cannot target U+0000, and `instr()` has no wildcards. So the one contract is a refusal, the way a pattern ending in a lone unpaired backslash is refused. + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A filter that answered before is now refused**: a `$like` or `$ilike` pattern holding U+0000 anywhere (at the start, in the middle, at the end, alone, or after a backslash) gets `INVALID_FILTER` / 400, on every door that already refused the lone trailing backslash: + + - `@objectstack/driver-sql`: on the filter walk, before a dialect is chosen, so SQLite, Postgres and MySQL all refuse it. `@objectstack/driver-sqlite-wasm` and `TursoDriver`'s local mode inherit it; `@objectstack/driver-sqlite-wasm`'s own code does not change. + - `@objectstack/driver-turso`: the remote transport's `$like` / `$ilike` arm, before anything is sent to the engine. + - `@objectstack/driver-memory`: the shape gate of the query path and of the reference matcher `match()`, and the QueryAST `comparison` spelling (`like` / `ilike`). + - `@objectstack/spec` exports the shared test, `hasNulInLikePattern`, beside `hasDanglingLikeEscape`, and the `$like` operator's description now names the refusal. + + On `driver-sql` and the Turso remote transport the refusal goes through the read-scope provenance seam, like every other filter-compile refusal there. On `driver-sql` (and so `driver-sqlite-wasm` and Turso's local mode), a caller whose predicate is marked `'author'` reads the operator, the field, the filter path and the pattern, with U+0000 written as `\u0000`. Any other caller gets only the class statement, and the rest goes to the server log. The remote transport withholds the same way, and through `TursoDriver` in remote mode no mark reaches it, so every caller gets the class statement there. On `driver-memory` every caller reads the full text, as for its dangling-escape refusal. + + A pattern that ends in a lone unpaired backslash AND holds U+0000 keeps the dangling-escape refusal it had before. + + **What stays accepted**, pinned per face: every `$like` / `$ilike` pattern without U+0000 answers exactly as before. + + **Not changed here:** + + - A pattern without U+0000 matched against a STORED value that holds U+0000 is not refused: it is well formed, and on the SQLite faces it reads the whole stored value, by its own entry in this release. + - `@objectstack/formula` still evaluates such a pattern. It refuses nothing, and answers `false` for a dangling escape rather than refusing it, so it is not one of these doors. + - `driver-mongodb`, objectql `having` and `service-analytics` refused every `$like` / `$ilike` before this change, and still do. + + **What an affected author does.** Remove the U+0000 from the pattern. No escape makes it portable: a backslash before it still leaves a U+0000 in the pattern. + + Blast radius, measured on this tree: no example or template writes a `$like` or `$ilike`, and the published `objectstack-query` skill and the hand-written docs that show one show no pattern holding U+0000. Whether any out-of-repo caller sends one is NOT measured and is not claimed to be zero. + + +- c7ad16f: fix(driver-sql): a declared index that can never be built is logged at `error` and reported in drift + + **Clause-②: yes (widening)**: the exported `DriftOp` union gains one member, `unbuildable_index`. + No accept set changes. Nothing an author could write before is refused now. + + A declared index names a column that no declaration will ever create when: + + - the name is not a field of the object, for example a misspelling that the Studio save door + admits (`os validate` / `os build` already refuse it); or + - the name is a virtual `formula` field, which is computed on read and has no column. The same + applies to a field-level `unique` on a formula field. + + The SQL driver skips such an index at every sync. It used to say so at `warn`, and the drift + report dropped the index from the expected set, so `os migrate plan` showed nothing. For a + `unique` index, the declared constraint was not enforced and duplicate rows were accepted, + while everything looked normal. + + - **The sync logs the skip at `error`**, on the same durability channel as the duplicate-row + refusals in the same loop. One line per skipped index per sync names the object, the index, + each missing column with its reason (not a field of the object, or a formula field), and + whether the index is `UNIQUE`. The structured meta carries `index`, `missing` and `unique`. + - **Drift reports it** as a report-only entry: `kind: 'index_mismatch'`, `actual: '(absent)'`, + `category: 'needs_confirm'`, `severity: 'error'` for a unique index and `'warning'` otherwise. + Its op is the new member: + + ```ts + { type: 'unbuildable_index'; table: string; column?: string; indexName: string; + unique: boolean; missingColumns: string[] } + ``` + + `missingColumns` lists only the columns that will never materialize. A declared column that + is merely not added yet is pending additive work, not this finding. + + **What a consumer that reads `op.type` now sees.** A new value, `'unbuildable_index'`. It has + no reconciler arm, and none can exist, because there is no column to build over. The remedy is + a metadata edit. It is in `INDEX_DRIFT_OPS`, so `isIndexDriftOp` answers `true` and it never + triggers a SQLite table rebuild. `applyMigrationEntries` reports it `skipped` on every dialect. + `os migrate plan` lists it under "Needs confirmation", addressed by its index name. `os migrate + apply` counts it like any `needs_confirm` entry (so it asks for `--yes`), and then reports it + skipped. The artifact-pinned boot warns about it and still starts, because + only `destructive` entries refuse a boot. A `switch` over `op.type` that treats unknown values + as "not applied" needs no change. An exhaustive `switch` with a `never` check gets one more case + to handle. + + **The object form's help text follows.** The `indexes` → Fields help in the Studio object form + said the skip left "a warning in the server log". It now says an error, in English and in the + zh-CN, ja-JP and es-ES translations. Nothing else in the text changes. + + **The lint message follows too.** `object-field-ref-unknown`, on a misspelt `indexes[].fields` + name, said the SQL driver skips the index "with only a warning, and drift drops it too". It now + says the skip is logged at error and `os migrate plan` reports the index as unbuildable. The rule, + its severity and its prescription are unchanged. + + **Upgrade note:** on a database that already carries such an index, `os migrate plan` now + reports one entry per index, and so does the boot's drift warning. That entry clears only when + the metadata names stored fields or drops the index. +- fb38607: feat(drivers,formula,objectql): the engine's filter faces answer the staged `$empty` operator (#20444) + + Clause-②: yes (widening) + + `$empty: true | false` is declared by `@objectstack/spec` (`FieldOperatorsSchema`) with a per-type meaning: a text-like field is empty when it is null or `''`, a multi-value field (multiselect, checkboxes, tags, or a select / radio / lookup / user / file / image with `multiple: true`) when it is null or `[]`, and every other type only when it is null. `$empty: false` is the exact complement. Until now every face in this list refused it (`INVALID_FILTER` / 400), except `matchesFilterCondition`, which answered `false` for every record. **A driver or evaluator called directly now answers it:** + + - **By the field's declared type**, through the spec's one expansion (`expandEmptyOperator`): `driver-sql`'s filter compiler (and so `driver-sqlite-wasm` and `driver-turso`'s local transport, which inherit it), `driver-turso`'s remote transport, `driver-memory`'s query path (`find` / `count` / `update` / `delete`) and `driver-mongodb`'s `translateFilter` (its `find`, its aggregate `$match`). The declaration is the one each driver already receives — `initObjects` / `registerObjectMetadata` / `registerExternalObject` on the SQL family, `syncSchema` on the others. On SQL a multi-value field's empty list is tested as stored JSON per dialect (SQLite `json_array_length` behind a `json_valid` guard, PostgreSQL a `jsonb` comparison, MySQL `JSON_LENGTH`), never as an equality comparand. + - **By value** — null, a missing value, `''` and `[]` are empty (`isEmptyFilterValue`) — on the faces that read no field declaration: `@objectstack/formula`'s `matchesFilterCondition` (the RLS write-side `check`), `driver-memory`'s reference matcher, and `@objectstack/objectql`'s `having` and per-aggregation `filter`. In `having`, a `count` or `sum` holding `0` is not empty. + + **Refused, never guessed** (`INVALID_FILTER` / 400): `$empty` on a field whose declaration the driver does not hold (a table built outside its registration, a builtin column such as `id`, a field with no `type`, or `translateFilter` / `RemoteTransport` used standalone without a declaration), a multi-value field on a SQL dialect the driver does not model, and a flag that is not a boolean. `driver-memory`'s analytics (cube) face refuses `$empty` as an operator it cannot compile, as it does `$null`. + + New optional API: `translateFilter(where, temporalKind?, valueShape?)` in `@objectstack/driver-mongodb` takes a declared-value-shape resolver (type `ValueShapeResolver`), and `buildAggregationPipeline` a `valueShape` option; `RemoteTransport.setDeclaredValueShapeResolver` in `@objectstack/driver-turso`, which `TursoDriver` wires. `@objectstack/spec`'s shared `FILTER_LOGIC_CASES` table gains seven `$empty` cases: a backend that runs it answers `$empty` or goes red, and its harness must declare the fixture's columns. + + `$empty` stays staged: it is not in `FILTER_OPERATORS`, so the engine's front door still refuses it until the flip card adds it, and the view operators `is_empty` / `is_not_empty` still lower to `$null`. +- 88a9330: feat(driver-sql): `aggregate()` publishes its declared return type — the contract's own, not `any` (#17277) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 set for this class of change on `@objectstack/driver-memory`, and PRs #15280 and #15267 followed on this very class). `SqlDriver.aggregate()` carried an EXPLICIT `Promise` over a door `IDataDriver` had already declared narrower: `aggregate?(object, query, options?): Promise[]>`. An explicit `any` satisfies that structurally, so `tsc` said nothing while the emitted `.d.ts` told every consumer that an aggregate row is whatever they like. + + The door is now declared as the contract declares it. A caller that read a cell straight off an aggregate row through the `any` now types what it reads — an aggregate cell arrives as `unknown` — and a caller that indexed the result array, or took `.find()` on it, now narrows the absent arm first. No runtime behaviour changes. + + `aggregate()` is OPTIONAL on the contract (`aggregate?`) where the five doors #15267 moved are required. That governs whether the member EXISTS, not what it returns once it does: a consumer that has already guarded `typeof driver.aggregate === 'function'` — the engine's own dispatch — holds a function whose published return was `any` and is now the contract's record array. The narrowing reaches it either way. + + `@objectstack/driver-sqlite-wasm` does not override this door and re-declares no member of its own, so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides it and carries its own entry. + + +- 3cbcedb: feat(driver-sql): the five remaining `IDataDriver` doors publish their honest types — the contract's own, not `any` (#15267) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 set for the same class of change on `@objectstack/driver-memory`, and PR #15280 followed for `update()` on this very class). `SqlDriver` carried an EXPLICIT `Promise` on five doors that `IDataDriver` had already declared narrower: `findOne()` (`Record | null` — it has always answered `results[0] || null`), `create()` (`Record`), `bulkCreate()` (`Record[]`), `execute()` (`unknown`) and `explain()` (`unknown`). An explicit `any` satisfies all five structurally, so `tsc` said nothing while the emitted `.d.ts` told every consumer that `findOne()` never returns `null` and that `create()` returns whatever they like. #15280 un-masked `update()` and filed the census of what was left; this is that remainder. + + Each door is now declared as the contract declares it. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members off `create()` / `bulkCreate()`, or to dereference a raw `execute()` / `explain()` result, now types what it reads. No runtime behaviour changes. + + `@objectstack/driver-sqlite-wasm` overrides none of these five and re-declares no member of its own, so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides four of the five and carries its own entry. + + Out of scope and deliberately unmoved: `analyzeQuery()` (not an `IDataDriver` member) and `aggregate()` keep their annotations. + + +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- 77c801e: feat(driver-sql)!: the file family's physical column holds the bare `sys_file` id, per deployment (#15989) + + + + **BREAKING** on the published storage behaviour of `@objectstack/driver-sql`, under the maintainer ruling on #15041 (decision batch #49 item 1), verbatim: 「15041 应该改为实际 id 保存。选A,其他同意」. The physical column for the file family — `file` / `image` / `avatar` / `video` / `audio` — holds the **actual `sys_file` id**, a bare id string in a string column, rather than a JSON-quoted id in a JSON column. The SQL generator already emitted `VARCHAR(2048)` for the family and does not move; the driver is the side that moves. + + Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + **The switch is per DEPLOYMENT, and its default is today's encoding.** The ADR-0104 addendum forbids keying it on the `adr-0104-file-references` flag alone: every creation-attested store since 17.0 and every deployment that ran `os migrate files-to-references --apply` before a column step existed holds that flag *and* JSON-quoted ids in a JSON column. The evidence is `sys_migration.columns_moved_at`, which reaches this driver as the new published option `SqlDriverConfig.fileColumnsMoved` — a boolean or an async resolver, resolved once at `initObjects` and memoized. **Every way of not knowing answers "not moved"**: the option omitted, a resolver that throws, a resolver that never runs, a host that never calls `initObjects`. Absence is the JSON arm because every flag row that exists in the world today lacks the field, and a driver that guessed the other way would write bare ids into a JSON column. + + **What an UNMOVED deployment gets** — which is every deployment until something supplies that option — is today's driver, with exactly one answer changed: + + - the column is still `json` / `jsonb` / SQLite `TEXT`, the write still JSON-encodes, and `isJsonField` still answers `true` for the family; + - a media cell whose bytes are a JSON-quoted id **sitting in a character column** now reads back as the id instead of as the id with its quotes. That population is not hypothetical: a database built by `os generate migration --format sql` has a `VARCHAR(2048)` media column, and MEASURED on live PostgreSQL 16.13, the driver wrote `"file_01HXYZ"` into it and handed it back verbatim — every consumer that matches the raw stored form (file resolution, ownership claims) refused it. SQLite never had this defect: its read arm parses the cell and keeps the raw string when the parse fails, which is why the gap was a server-dialect one. + + **What a MOVED deployment gets**: the family leaves `JSON_COLUMN_TYPES`, so `isJsonField` / `formatInput` / `formatOutput` stop treating a single-value media field as JSON; `createColumn` builds `varchar(2048)` — the generator's own width, mirrored by `varcharColumnChars` so the drift detector reads the column the emitter actually builds; and the id on disk is the id. Throughout the window the read path accepts **both** encodings on every dialect, so a cell a column step has not converted still reads correctly. The decode is deliberately narrow — it engages only on a leading `"`, `{` or `[`, none of which can begin a `sys_file` id, a resolver URL or a `data:` URI — because an all-digit id would otherwise parse to a number. + + `multiple: true` media is unaffected on both arms: its value is a list of ids, it is a JSON column on every deployment, and `createColumn` decides `multiple` above its type switch. + + **The drift detector moves with the writer.** `JSON_COLUMN_FIELD_TYPES` no longer names the family, because the family is no longer a constant on either side; `diffManagedTable` takes a `fileColumnsMoved` input instead, and OMITTING it reproduces this module's previous verdicts exactly — an unthreaded caller keeps reporting a `varchar` media column as the corruption it still is on an unmoved deployment. Without this, a deployment that moved its columns would be told by its own tooling to convert them back to `json`, i.e. to undo the ruling. + + **Not shipped here, and named rather than implied:** the column step itself. `os migrate files-to-references --apply` does not yet retype or rewrite media columns, and nothing in this diff moves any deployment's storage. A deployment moves only when it runs that step and its host supplies the arm, and the two must be one act — MEASURED on SQLite: after the columns are converted, a driver still on the JSON arm reads the migrated column correctly but its next write re-quotes. +- 9cdffbe: One physical representation for the NUMERIC column family, read by every producer of DDL + + `packages/spec` now states, per field type, what column a numeric field gets, and all three + producers read it: `SqlDriver.createColumn`, `os generate migration --format sql` and + `os generate migration --format typescript`. Measured on live PostgreSQL 16.13, one object + through all three producers, before and after: + + ``` + BEFORE AFTER + driver sql gen ts gen all three + number real numeric(18,2) numeric(8,2) numeric(65,30) + currency real numeric(18,2) numeric(8,2) numeric(65,30) + percent real numeric(5,2) numeric(8,2) numeric(65,30) + slider real numeric(18,2) numeric(8,2) numeric(65,30) + summary real numeric(18,2) numeric(8,2) numeric(65,30) + progress real numeric(5,2) numeric(8,2) numeric(65,30) + rating real integer integer integer + ``` + + 7 of 7 columns diverged before, 0 of 7 after. Every arm of the old split lost data in its own + direction: `real` is IEEE-754 binary32, so a `currency` of `1234567.89` read back `1234567.9`; + `numeric(5,2)` and `numeric(18,2)` silently ROUND a legitimate `33.333` to `33.33` (round + half-up — executed, not inferred); `numeric(8,2)` refused `1234567.89` outright. `65,30` is + MySQL's documented `DECIMAL` maximum and therefore the portable one, and it is the only + candidate measured to lose nothing on a nine-value corpus. + + Both migration formats also take the physical `NOT NULL` from `storage.notNull` and never from + `required`, which is where `SqlDriver.createColumn` has taken it since ADR-0113: `required` is + the write-time contract the record validator enforces, and binding the DDL to it made every + post-deploy tightening a destructive migration. + + **BREAKING** — new columns only; no existing column is retyped, no migration is planned, and no + backfill runs. Four consequences to know before creating new tables: + + - `rating` is an INTEGER column, and the two server dialects dispose of a fractional star count + DIFFERENTLY — do not read one answer for both. PostgreSQL REFUSES `4.5` outright, where a + `real` column accepted it. MySQL does NOT refuse: it ROUNDS, and `4.5` becomes `5` with no + error, which is a silent alteration and the reason to declare a `slider` (in the exact-decimal + set) for anything that wants fractional values. SQLite refuses nothing either: it stores `4.5` + as a REAL in an INTEGER-affinity column, unchanged from today. + - An exact-decimal column is bounded where a float is not, in BOTH directions. It keeps 30 + fractional digits: a magnitude whose significant digits run past the 30th decimal place loses + the tail silently — `1.2345678901234567e-15` stores as `0.000000000000001234567890123457`, so + the loss begins around |x| < 1e-13 and is total below 1e-30 — and magnitudes at or above 1e35 + are REFUSED, where `real` kept about seven significant digits out to ~1e38. A refusal is loud; + the rounding it replaces was not. + - Reads are bounded by the wire contract, not by the column. `find()` hands back a JS number + (`z.number().finite()`), so a value that was never a JS double does not survive the round trip + exactly — `1234567890123456.123` reads back `1234567890123456`, and 2^53+1 reads back 2^53. + The fidelity this buys is an exact COLUMN read through a double: values written by this + platform round-trip exactly, and SQL-side writers, `summary` roll-ups computed in SQL and any + magnitude at or above 2^53 are bounded by the read seam. Widening that is a wire-contract + change and is not in this release. + - A generated migration no longer emits `NOT NULL` for a field marked only `required: true`. + Declare `storage: { notNull: true }` for a physical constraint — which is what the platform's + own table has always done since ADR-0113, and what `os migrate meta` deliberately does NOT + supply on your behalf (the conversion that stamped it was withdrawn by maintainer ruling on + 2026-09-08). A source author who wants the column they had must write that block themselves; + `required: true` keeps its own meaning, the write-time contract the record validator enforces. + + SQLite emits byte-identical DDL for the six exact-decimal members: knex compiles both + `table.decimal(name, p, s)` and `table.float(name)` to the same `float` column there. + + +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. + +### Patch Changes + +- baf9745: Three source comments now state the registered position for the `door: 'none'` boot-refusal codes instead of the pre-#16404 one + + `SERVICE_NOT_REGISTERED`, `PLUGIN_CONTRACT_VIOLATION` and — as the worked + example the `driver-sql` comment cites — `MONGODB_MULTI_TENANT_UNSUPPORTED` are + all registered in `ERROR_CODE_LEDGER`. #16649 registered fourteen `door: 'none'` + codes under the #16404 door-or-no-door ruling, and re-registered the MongoDB one + that #8035 had removed. Three TSDoc comments still asserted the position that + preceded that ruling — that these codes are deliberately not wire vocabulary, + and that registering one is "not something to start doing at a door" — and each + was false the moment #16649 landed. They also pointed at + `dispatcher-error-vocabulary.ts`'s `boot-refusal` verdict, which the same PR + ratcheted from fourteen rows to zero, so the pointer dangled. + + These docblocks ship inside each package's `dist/*.d.ts`, which is why this is a + published change rather than an internal one: the sentence is what an agent or + an IDE reader sees at the point it decides whether the code needs registering. + + ⛔ No behaviour changes. Every reachability sentence is kept verbatim — none of + these codes reaches an HTTP door on this tree — no code is added, removed or + re-registered, and no gate moves. With every comment character removed by + `scripts/js-comment-mask.mjs`, all three files' executable token streams are + byte-identical to the commit this branched from. +- 32be735: `storage.notNull` now binds a multi-value column, as ADR-0113 says it does + + `SqlDriver.createColumn` decides the JSON column shape before its per-type + switch, and it `return`ed there — above the ADR-0113 nullability line and above + the column DEFAULT. So `storage: { notNull: true }` on a multi-valued field was + silently inert on the platform's own table, while both `os generate migration` + formats emitted the constraint from the same declaration: + + ``` + { d_multi_notnull: { type: 'lookup', reference: 'sys_user', multiple: true, storage: { notNull: true } } } + + field driver sqlgen tsgen + d_multi_notnull null=YES null=NO null=NO ← before + d_multi_notnull null=NO null=NO null=NO ← after + ``` + + One declaration, two databases: an INSERT omitting the field was accepted by the + platform's own table and refused by every table built from a generated + migration. + + ADR-0113 P0 names this site verbatim — 「the physical constraint now keys off the + explicitly-authored `storage.notNull` at that same `#createColumn` site」 — and + carves out no field type. `storage.notNull`'s only declared exclusivity is + `requiredWhen`, at the parse seam, so `multiple: true` + `storage.notNull` is an + authorable declaration this site was dropping on the floor. The differ, the + ADR's other named consumer in this package, never had the gap: `fieldHasColumn` + answers the multi-value question first and the nullability comparison then runs, + so the platform reported DESTRUCTIVE `tighten_not_null` drift against tables it + had just created itself, with no rows in them. That self-inflicted report is + gone. + + ⚠️ Not the destructive ceremony ADR-0113 routes around. `createColumn` runs on + `CREATE TABLE` and on `ALTER TABLE ADD COLUMN`, so the column constrained here + is always EMPTY — the same reason the string family's #11431 note gives for + sizing a `varchar` at this site. Imposing `NOT NULL` over an EXISTING column's + possibly-null data stays `tighten_not_null`, destructive category, behind + `os migrate apply --allow-destructive`, untouched. + + ⛔ Not a widening, and nothing else acquired the constraint: `multiple: true` + alone still produces a nullable column, and `required: true` alone still does + too — it is the write-time contract the engine enforces, never the column + (ADR-0113). The column DEFAULT is still not emitted on this path either: the + multi-value shape has no scalar DDL form, and `os generate migration` skips it + for the same recorded reason, so the two producers already agreed there. +- 82cb69f: A `multiple: true` boolean column keeps its `$contains` membership filter + + A `multiple: true` field is stored as a JSON TEXT array, and on such a column + `$contains` is not a substring test — it is the MEMBERSHIP spelling, the one + operator #7398 left working there after refusing the equality family. The + declared-type gate added in #14079 fired on the boolean limb regardless of + storage shape, so a membership filter over a `multiple: true` `boolean` or + `toggle` column compiled to the always-false constant: + + ``` + { flags: { $contains: 'true' } } + - select * from `probe_tbl` where 1 = 0 (matched nothing) + + select * from `probe_tbl` where `flags` GLOB '*true*' (matches the rows whose array holds it) + ``` + + That is the fail-CLOSED direction: the query returns a `200` with no rows, + byte-identical to a filter that legitimately matched nothing, so an author sees + "no matching records" and doubts their data rather than the filter. Both + registry fills — `initObjects` and `registerExternalObject` — were affected, and + both are fixed, because the repair is at the predicate they share. + + The same shape on a `multiple: true` NUMBER was already correct (its registry is + filled `!field.multiple`), and #15683 spelled the equivalent carve-out for the + temporal limb at the predicate. This change spells it on the boolean limb, the + one that had neither. `booleanFields` itself is deliberately unchanged: it is a + read-coercion registry, and the three other seams that read it — the Postgres + aggregate cast, the presentation-kind door and `formatOutput`'s row pass — are + about "this column holds a boolean", which a multi-valued column still does. + + ⚠️ Not a widening of the gate: a SCALAR `boolean` / `toggle` column still + answers the declared no-match for every positive text operator and `$notContains` + its exact complement, unchanged. What moves is exactly the JSON-column cell. +- d46deba: A `multiple: true` boolean/toggle column reads back as its stored array, not as a single inverted `true` + + `formatOutput` runs its `jsonFields` pass first, which `JSON.parse`s the cell + into a real array, and then its `booleanFields` pass did + `data[field] = Boolean(data[field])`. Every non-empty array is truthy, so a + `multiple: true` `boolean`/`toggle` column presented a single `true` whatever + the array held — a stored `[false]` read back as **`true`**, the opposite of + what is stored, with no error anywhere. `readPresentationKind` hands the same + presenter to the `aggregate()` / `distinct()` doors, so the collapse was not + confined to the row-read door. + + **Fixed at the registry fill.** `&& !field.multiple` is the condition the three + neighbouring pushes in both registration blocks already carry (`mediaCols`, + `numericCols`, `numericValueCols`); `booleanCols.push(name)` was the single + omission, in **both** fills (`registerExternalObject` and + `registerManagedObjectMetadata`). A `multiple: true` boolean/toggle is a JSON + column here, and its array is written faithfully — only the read collapsed it. + + **What moves for a caller.** A `find()` / `aggregate()` / `distinct()` read of a + `multiple: true` `boolean` or `toggle` column now returns the stored array of JS + booleans (`[false]`, `[true, false]`) where it previously returned `true`. Code + that consumed the old scalar was reading a value that did not reflect storage — + including for an all-`false` array. Scalar `boolean`/`toggle` columns are + unchanged and keep their stored-`1`/`0` → JS `true`/`false` coercion; the + `multiple: true` number and `tags` classes were already correct and do not move. +- 7c2c5ae: `distinct()` answers a backend refusal with the ADR-0112 envelope instead of leaking the dialect's own error + + `SqlDriver.distinct` awaited its query builder bare — no `try`/`catch`, no + envelope — so any refusal the statement raised left the driver as the backend's + own object: a raw SQLSTATE in `code`, `status` **undefined**, and the compiled + statement as the message. `@objectstack/rest` builds a wire status from the + envelope, so an error carrying no `status` and a `code` that is a raw SQLSTATE + is on no list it reads: an ordinary caller shape — *list the distinct values of + this column* — surfaced as an UNHANDLED server fault rather than a declared + `DATABASE_ERROR` 500. + + Measured on live PostgreSQL 16.13: this driver stores every `multiple: true` + column as `json`, and PostgreSQL's `json` defines no equality operator, so + `SELECT DISTINCT` over one is refused — + `code=42883 status=undefined`, `msg=select distinct "toggles" from "…" - could + not identify an equality operator for type json`. Class-wide across every JSON + column (`toggle`, `boolean` and `number` with `multiple: true`, and `tags`), + with a scalar `boolean` column in the same table answering normally. + + The third read door now routes through the same terminal + `backendStatementFault` that `find()` and `count()` have used since + objectstack#8931 and `aggregate()` since objectstack#11455: one catalogued + code, one status, the dialect's own text written to the server log for an + operator and withheld from the caller, and the original error kept as a + non-enumerable `cause` so `isMissingTableError` still reads through it. + + ⛔ No new export, no new error code, no new envelope field, and the accepted + input set does not move: `status` and `code` are fields this envelope already + declares. ⛔ This does not make `distinct()` ANSWER over a `json` column — the + call fails either way; what changes is whether the failure is classified. + Whether such a column should support a distinct read belongs with + objectstack#17590. +- 9ccc417: `SqlDriver.distinct()` now answers **one unresolvable column the way the other three read doors do** — a `400` that names it — instead of a `DATABASE_ERROR` / `500` server fault. + + The same condition (a column name the table does not have) asked at four doors used to get three answers and one server fault. Measured on `origin/main` at `dbea1756d9`, embedded SQLite, and identical on live PostgreSQL 16.13: + + | door | before | after | + |:--|:--|:--| + | `count(t, { where: { nosuchcol: 1 } })` | `INVALID_FILTER` / 400 | unchanged | + | `find(t, { where: { nosuchcol: 1 } })` | `INVALID_FILTER` / 400 | unchanged | + | `aggregate(t, { groupBy: ['nosuchcol'] })` | `INVALID_FIELD` / 400 | unchanged | + | `distinct(t, 'title', { nosuchcol: 1 })` | **`DATABASE_ERROR` / 500** | **`INVALID_FILTER` / 400** | + | `distinct(t, 'nosuchcol')` | **`DATABASE_ERROR` / 500** | **`INVALID_FIELD` / 400** | + + A caller's own mistake — a field name that does not exist — was served as a server fault naming nothing they could act on, one door away from a `400` that names the column. A picklist-populating `distinct()` sits beside the `find()` and `count()` of the same list view. + + **Attribution comes from the caller's own request, never from the backend's prose.** The dialect names the column but not the clause, so the clause is read off the call this driver just compiled — the shape `aggregateBackendFault` established for `aggregate()`: + + 1. the name **equals the `field` argument** ⇒ `INVALID_FIELD` / 400 naming the listed column, with the `field` and `object` riders the ingress door's refusals carry; + 2. it does not ⇒ the statement's only remaining column sources are the WHERE compiled from `filters` and the tenant-scope predicate, both filters, so the existing `INVALID_FILTER` refusal applies verbatim — the same sentence `find()` and `count()` give; + 3. the dialect wording yields **no name** ⇒ no attribution is supportable and the terminal `DATABASE_ERROR` / 500 envelope stands unchanged. + + Arm 2 is the **complement** of arm 1 rather than a search of the `filters` AST, which keeps a nested filter (`{ $or: [{ nosuchcol: 1 }] }`) on the same `400` as a flat one. + + ⛔ **No input that was refused before is accepted now, and no exported symbol moves.** The call fails either way; what changes is the refusal's code, status and words. No error code is minted — `INVALID_FIELD` is a standard-catalog member (ADR-0112) and already this repo's answer for a named column an object does not have. No new dialect recognizer is added: both predicates are the ones `find()`, `count()` and `aggregate()` already share. + + A caller that branched on `DATABASE_ERROR` / `500` for a mistyped `distinct()` field or filter key now sees `INVALID_FIELD` / `INVALID_FILTER` `400`s instead; that is the point of the change, and it matches what the same mistake already returned from every other read door. +- beac798: A bare comparand nested under `$and` / `$or` / `$not` now gets the same compilation and the same refusal it gets at top level + + A filter with no operator anywhere compiles through one loop; any other filter (a + combinator, or a single sibling key carrying an operator) compiles through + `applyFilterCondition`. That second path handled a bare `{ field: value }` leaf in + two ways the first one did not: + + - **An array in the equality slot was not refused.** `{ tags: ['a'] }` at top level + answers `INVALID_FILTER` / 400, but under `$and` / `$or` / `$not`, or beside an + operator-carrying sibling (`{ tags: ['a'], name: { $ne: 'x' } }`), it was bound as + it stood. SQLite refused the bind, so the caller got a 500 `DATABASE_ERROR` for a + filter it can fix. Postgres bound the array as its array-literal text (`{"a"}`) + and silently returned the wrong rows. Every such leaf now gets the top-level + refusal: the same code, status, message and server-side diagnostic. + - **A `Date` comparand was dropped, and a binary one was refused or dropped.** That + path treated any non-array object as an operator map. A `Date` has no entries, so + `{ $and: [{ closed_at: someDate }] }` emitted no predicate for the leaf and + answered every row on SQLite and Postgres, while `{ closed_at: someDate }` + answered the matching rows. A non-empty binary comparand (`Buffer` / `Uint8Array`) + had its byte indices read as operator names, so it was refused with + `INVALID_FILTER` / 400 `Unsupported filter operator "0"`, although the same leaf + at top level compiles. An empty one was dropped like the `Date`. Only a plain + object is now read as an operator map (the same test the filter-validating walk + already uses), so all of these compile as the equality they are at top level. + The `$not` NULL guard now reads a comparand the same way, so a binary comparand + under `$not` returns the rows whose column is NULL, as every other negated + equality does; an empty one used to get no guard at all. + + Valid filters are unchanged. A scalar leaf in any of these positions compiles to + byte-identical SQL, and the new suite pins that SQL against the output captured + before the fix. Neither shape is stopped before the driver today: `parseFilterAST` + and the engine's object-form comparand walk both pass the array leaf through, and + the engine's walk also keeps a `Date` leaf a `Date`. Both refuse a binary + comparand in every position, so the binary half reaches direct driver callers only. +- 9d81af7: fix(driver-sql, driver-turso): on SQLite, a `$contains` / `$notContains` / `$icontains` / `$startsWith` / `$endsWith` comparand holding U+0000 is compared whole, against the whole stored value, instead of being cut at the U+0000 by `GLOB` (#19999) + + Clause-②: no + + On the SQLite faces these five operators compile to `GLOB`, and SQLite's `glob()` reads both the pattern and the stored value only up to their first U+0000. Nothing raised, and the filter answered a different question. Measured on `SqlDriver` over better-sqlite3 (SQLite 3.53.4), on `SqliteWasmDriver` over sql.js (3.49.1), and on `TursoDriver`'s remote transport over a local libSQL engine (3.45.1). All three answered alike. Over the values `'a'` + U+0000 + `'b'`, `'ab'` + U+0000, U+0000 + `'z'`, `'plain'` and `''`: + + - `$contains: U+0000` and `$endsWith: U+0000` returned all five rows; + - `$contains: U+0000 + 'b'` returned all five rows, where the JavaScript answer is `'a'` + U+0000 + `'b'` only; + - `$startsWith: U+0000` returned `''` and U+0000 + `'z'`, where the JavaScript answer is U+0000 + `'z'` only. + + What changes: a comparand holding U+0000 now compiles to a length-aware comparison instead. `$contains`, `$notContains`, `$icontains` and `$startsWith` use `instr()`, and `$endsWith` compares the value's trailing bytes over BLOB. Such a filter now returns the rows `driver-memory` and `@objectstack/formula` return for it. The comparand is bound as written, so `*`, `?` and `[` in it are literal, as they were before. `$icontains` still folds ASCII letters only, and `$notContains` still returns a row whose value is NULL. + + - `@objectstack/driver-sql`: the SQLite arm of `SqlDriver`'s text-operator compiler. `SqliteWasmDriver` and `TursoDriver`'s local mode inherit it. + - `@objectstack/driver-sqlite-wasm`: none of its own code changes. It inherits the fix, and its exact-text bind reaches every parameter the new comparison binds. + - `@objectstack/driver-turso`: the remote transport's own emitter, changed the same way. + + What does not change: a comparand without U+0000 compiles to the same `GLOB` with the same bound pattern as before. The Postgres and MySQL arms are untouched. `GLOB` still reads a stored value only up to its first U+0000, so for a comparand without U+0000, `$contains`, `$notContains`, `$icontains` and `$endsWith` over a stored value that holds one still compare only the part before it. +- 57c2b73: fix(driver-sql, driver-turso): four filter-refusal doors stop naming a read scope's field and comparand unless the refused predicate is marked as the caller's own (#20020) + + Clause-②: no + + A read scope is the RLS, sharing or tenant predicate that `plugin-security` (ordinary reads) and `service-analytics` (the ObjectQL analytics face) AND into the caller's `where`. Both merges mark the scope `'policy'` and the caller's own predicate `'author'` (`markFilterSubtreeProvenance`, `@objectstack/spec/data`). When `SqlDriver` refused a scope at one of the four doors below, the `INVALID_FILTER` / 400 message named the scope's field, and for three of them its comparand too. It did not check the mark. Measured on both faces, through `POST /api/v1/analytics/query` and through an ObjectQL `find` under a merge shaped like `plugin-security`'s: + + - a column the table does not have. This is reachable from a real CEL rule on a field that is declared but has no column yet; + - a retired operator (`$regex`, `$options`) or an operator outside the vocabulary; + - `$and` / `$or` whose operand is not a list; + - a `$null` / `$exists` whose comparand is not a boolean. + + Each of these doors now reads the mark on the node it refused, the same way the cross-field and target-field refusals already did: + + - **`'policy'`, unmarked or ambiguous:** same `INVALID_FILTER` / 400. The message says which kind of refusal fired, but names no field, operator, comparand or filter path. Those go to the server log. For the unresolvable column, the message is the unnamed wording the driver already used when it could not parse the dialect's message. + - **`'author'`:** the full message, the same text the door answered before. + + To find the node, the unresolvable-column door looks up the column name the database reported. It discloses only when every node that names that column is marked `'author'`. A `$and` / `$or` with a primitive operand is judged by the node that carries the key. + + `SqliteWasmDriver` (`@objectstack/driver-sqlite-wasm`) and `TursoDriver` in local mode extend `SqlDriver`, so they inherit this change from it: the same four doors answer the same way there. + + **What an unmarked caller loses:** its own diagnostic from these four doors. Measured cases where the caller's own predicate reaches the driver unmarked: + + - no security plugin in the stack; + - a system-context call; + - an anonymous call; + - a `where` that holds a `{placeholder}` token, which the engine rewrites before the merge. + + That caller gets the withheld wording with the same code and status. A member's plain `where` under `plugin-security` is marked `'author'` and keeps the full text. + + The same three door classes on the Turso REMOTE transport (`RemoteTransport`) now read the mark too: the retired or unknown operator (including a non-operator key in an operator map), the non-list combinator, and the non-boolean `$null` / `$exists`. The operands go to its diagnostic sink. `TursoDriver`'s remote mode rebuilds every filter node before the transport sees it, so no mark reaches the transport there, and these refusals keep the withheld wording for every caller in that mode. The unresolvable WHERE column has no refusal on the remote face (the transport answers `[]`) and is not changed here. + + Not changed: which filters are refused, and the code and status of every refusal. The engine's declared-type, temporal-comparand and filter-token doors are not changed here. +- f09d412: fix(driver-sql, driver-turso): on SQLite, `$like` / `$ilike` read the whole stored value, instead of stopping at its first U+0000 (#20024) + + Clause-②: no + + On the SQLite faces `$like` and `$ilike` compiled to `GLOB`, and SQLite's `glob()` reads the stored value only up to its first U+0000. So a pattern without U+0000 answered a different question over a value holding one, and nothing raised. Measured on `SqlDriver` over better-sqlite3 (SQLite 3.53.4), on `SqliteWasmDriver` over sql.js (3.49.1), on `TursoDriver`'s local mode, and on its remote transport over a local libSQL engine (3.45.1). All four answered alike: + + - `$like: 'a'` returned a value stored as `'a'` + U+0000 + `'b'`, and `$like: ''` returned U+0000 + `'z'`; + - `$like: '%b'`, `$like: 'a_b'` and `$ilike: 'A_B'` did not return `'a'` + U+0000 + `'b'`; + - `$like: '_'` did not return a value that is a lone U+0000. + + Over 108 patterns and 22 `$not` / `$or` / `$and` compositions against 59 stored values, 359 of the 3380 cells over values holding U+0000 differed from `@objectstack/formula` on each face. + + What changes: a stored value holding U+0000 now has each U+0000 replaced by one stand-in character before `GLOB` reads it. The stand-in is never a literal character of the pattern, never an ASCII letter, and never U+0000. A U+0000 in the value can only be matched by `%` or `_`, and so can the stand-in, so the answer is the one the whole value gives. Such a filter now returns the rows `driver-memory` and `@objectstack/formula` return for it, under `$not`, `$or` and `$and` as well: 0 of those 3380 cells differ on any of the four faces. `$ilike` still folds ASCII letters only. + + - `@objectstack/driver-sql`: the SQLite arm of `SqlDriver`'s `$like` / `$ilike` compiler. `SqliteWasmDriver` and `TursoDriver`'s local mode inherit it. + - `@objectstack/driver-sqlite-wasm`: none of its own code changes. It inherits the fix. + - `@objectstack/driver-turso`: the remote transport's own emitter, changed the same way. + + What does not change: + + - A stored value without U+0000 gets the same answer as before: 0 of 16640 such cells moved on any face. + - A pattern that is a literal prefix followed only by `%` (`'ab%'`, `'%'`) compiles to the same `GLOB` with the same bound pattern as before. Cutting the value at its first U+0000 cannot change that answer. + - No index is lost. Under `EXPLAIN QUERY PLAN` over an indexed TEXT column on all three engines, each `$like` pattern measured that starts with a literal (`'ab%'`, `'ab_'`, `'ab%cd'`, `'abc'`, `'a%b%'`) keeps its covering-index search, and each one that starts with a wildcard still scans. A case-exact pattern with a literal prefix now leads with a `GLOB` on that prefix followed by `*`, which every matching value satisfies and which is what keeps that search. + - `_` still matches one character, as `GLOB`'s `?` does. A character outside the Basic Multilingual Plane is one character to `_` on SQLite and two to `@objectstack/formula`, which counts UTF-16 units. That difference is older than this change, and this change does not alter it. + - A pattern holding U+0000 is still refused (`INVALID_FILTER` / 400). + - The Postgres and MySQL arms are untouched. + + Cost, measured over 10,000 rows on the three engines: against a value without U+0000 the new compile adds one `instr()` per row, and those queries took 1.1 to 2.4 times as long as `GLOB` alone, at most 4.5 ms. A value holding U+0000 pays the rewrite: 10,000 rows each holding one to three U+0000 took up to 35 ms, against at most 3 ms for `GLOB`. +- adbbc5d: fix(driver-sql, driver-turso): on SQLite, `$contains` / `$notContains` / `$icontains` / `$endsWith` read the whole stored value, instead of stopping at its first U+0000 (#20024) + + Clause-②: no + + On the SQLite faces these four operators compiled to `GLOB` for a comparand without U+0000, and SQLite's `glob()` reads the stored value only up to its first U+0000. Nothing raised, and the filter answered a different question. Measured on `SqlDriver` over better-sqlite3 (SQLite 3.53.4), on `SqliteWasmDriver` over sql.js (3.49.1), on `TursoDriver`'s local mode, and on its remote transport over a local libSQL engine (3.45.1). All four answered alike: + + - `$contains: 'b'` did not return a value stored as `'a'` + U+0000 + `'b'`; + - `$endsWith: 'a'` returned that value, and `$endsWith: 'b'` did not; + - `$notContains: 'b'` returned it; + - `$icontains: 'B'` did not return `'A'` + U+0000 + `'B'`. + + What changes: these four operators now compile to the length-aware comparisons a comparand holding U+0000 already used, for every comparand. `$contains`, `$notContains` and `$icontains` use `instr()`, and `$endsWith` compares the value's trailing bytes over BLOB. An empty `$endsWith` comparand uses `instr()` too, so it still matches every non-NULL value. Such a filter now returns the rows `driver-memory` and `@objectstack/formula` return for it, under `$not`, `$or` and `$and` as well. The comparand is bound as written, so `*`, `?` and `[` in it are literal, as they were before. `$icontains` still folds ASCII letters only, and `$notContains` still returns a row whose value is NULL. + + - `@objectstack/driver-sql`: the SQLite arm of `SqlDriver`'s text-operator compiler. `SqliteWasmDriver` and `TursoDriver`'s local mode inherit it. + - `@objectstack/driver-sqlite-wasm`: none of its own code changes. It inherits the fix. + - `@objectstack/driver-turso`: the remote transport's own emitter, changed the same way. + + What does not change: + + - `$startsWith` with a comparand without U+0000 compiles to the same `GLOB` with the same bound pattern as before. The stored value's cut cannot change its answer. + - No index is lost. The SQL `SqlDriver` compiles, run under `EXPLAIN QUERY PLAN` over an indexed TEXT column on all three engines, scanned the table for these four operators under `GLOB` and still does; `$startsWith` keeps its index search. + - The Postgres and MySQL arms are untouched. + - `$like` and `$ilike` are outside this entry. Two other entries in this release cover them: on SQLite they now read the whole stored value as well, and every driver that answers `$like` refuses a pattern holding U+0000 (`INVALID_FILTER` / 400). +- 8d76c2d: fix(driver-sql, driver-turso): every filter-compile refusal stops naming a read scope's field or literal unless the refused predicate is marked as the caller's own (#20039) + + Clause-②: no + + A read scope is the RLS, sharing or tenant predicate that `plugin-security` (ordinary reads) and `service-analytics` (the ObjectQL analytics face) AND into the caller's `where`. Both merges mark the scope `'policy'` and the caller's own predicate `'author'` (`markFilterSubtreeProvenance`, `@objectstack/spec/data`). Nine more `SqlDriver` filter-compile refusals did not check the mark, so when one of them refused a scope, its `INVALID_FILTER` / 400 message named the scope's field, and for most of them its literal too. Measured through an ObjectQL `find` under a merge shaped like `plugin-security`'s, with the scope in the `'policy'` arm: + + - an empty or non-string `$icontains` comparand; + - a non-string `$like` / `$ilike` comparand; + - a `$like` / `$ilike` pattern ending in a lone backslash; + - an object or array comparand on `$contains`, `$notContains`, `$startsWith`, `$endsWith` or `$icontains`; + - an `$in` / `$nin` / `$between` member that cannot be bound; + - an `undefined` comparand, in any position; + - an element of `$and` / `$or`, or the operand of `$not`, that is not a filter condition object; + - a `$`-prefixed key in a node position that is not `$and`, `$or` or `$not`; + - a `where` that reaches the driver as an array (the message printed the whole array). + + Each of them now reads the mark on the node it was raised from, as the other compile refusals already did: + + - **`'policy'`, unmarked or ambiguous:** same `INVALID_FILTER` / 400. The message says which kind of refusal fired, but names no field, operator variant, comparand, list position or filter path. Those go to the server log. + - **`'author'`:** the full message, the same text the refusal answered before. + + With these nine, every refusal on `SqlDriver`'s filter-compile path goes through the same seam. + + `SqliteWasmDriver` (`@objectstack/driver-sqlite-wasm`) and `TursoDriver` in local mode extend `SqlDriver`, so they inherit this change from it: the same refusals answer the same way there. + + **What an unmarked caller loses:** its own diagnostic from these refusals. That is every caller whose predicate reaches the driver unmarked, for example with no security plugin in the stack, in a system-context or anonymous call, or with a `where` that holds a `{placeholder}` token (the engine rewrites it before the merge). That caller gets the withheld wording with the same code and status, and the full text is in the server log. A member's plain `where` under `plugin-security` is marked `'author'` and keeps the full text. + + The Turso REMOTE transport (`RemoteTransport`) compiles filters itself. Its copies of these refusals now read the mark the same way: the `$icontains`, `$like` / `$ilike`, lone-backslash, `undefined`, non-node element or operand, undeclared-key and non-object `where` refusals. So do its two other compile refusals that still named the field: an operator map with no operator in it, and a `$between` that reached the transport without being lowered. The operands go to its diagnostic sink. For six of these classes, the withheld sentence is the local one behind the `[RemoteTransport]` prefix. An object text comparand and an unbindable list member already answered there through its comparand refusal, which withholds. `TursoDriver`'s remote mode rebuilds every filter node before the transport sees it, so no mark reaches the transport there, and these refusals keep the withheld wording for every caller in that mode. + + Not changed: which filters are refused, and the code and status of every refusal. +- e01d347: fix(driver-sql, driver-turso): `reclaimSpace()` returns the whole SQLite freelist, not one page per call (#20106) + + Clause-②: no + + `reclaimSpace()` is what the lifecycle service calls after every sweep that deleted rows (ADR-0057 §3.4). On SQLite it runs `PRAGMA incremental_vacuum`, and that statement frees one page per step. Two clients stepped it once: + + - **`SqlDriver` on better-sqlite3, and `TursoDriver` in local mode.** knex's better-sqlite3 client runs a statement that declares no result columns with `Statement.run()`, which steps it once. A database with 300 free pages had 299 after the call, read from a second connection, and the file barely shrank. `incremental_vacuum(N)` freed one page too. The method now drives that binding through its own `exec()`, which steps the statement until SQLite reports done: 300 → 0, and the file shrinks by those pages. + - **`TursoDriver` in remote mode.** The libSQL client's `execute()` stepped the statement once and left it unfinished. Over a libSQL `file:` client, the issuing connection read one page fewer, but a second connection read the freelist and the file size unchanged, and a row written after the call on the same connection never reached the file. The remote route now reads `PRAGMA freelist_count`, sends nothing more when it is `0`, and otherwise runs the vacuum through the client's `executeMultiple()`: 300 → 0 from a second connection, and the later write lands. A server that refuses either statement answers `DATABASE_ERROR` / 500, as before. What a hosted libSQL server does with either call is not measured. + + `SqliteWasmDriver` was already complete: its dialect steps every PRAGMA to the end (300 → 0 before and after this change). + + Nothing to migrate: `reclaimSpace()` keeps its signature, and a database whose `auto_vacuum` mode is not `INCREMENTAL` still reclaims nothing, as before. +- e5cf27d: fix(objectql,core): a per-aggregation `filter` and `having` on `engine.aggregate` read a temporal comparand by the column's storage rule, the rule `where` already applies — one function, `temporalStorageForm`, now exported by `@objectstack/core` and shared by both drivers (#20176) + + A per-aggregation `filter` (`aggregations[i].filter`) and `having` are evaluated by the engine itself, over the rows (or aggregated rows) a driver returns. Both compared a temporal comparand exactly as written, while the same condition as a `where` is put into the column's storage form by the driver first. So they counted differently. Measured through `engine.aggregate` and through `POST /data/:object/query`, on `driver-memory` and `driver-sql`, over six rows: + + | in `aggregations[i].filter` (or `having`) | before | now, and the `where` twin | + |:--|:--|:--| + | an ISO instant on a `date` field, `{ placed_on: { $gte: '2026-02-01T00:00:00.000Z' } }` | 1 | 3 | + | the same instant under `$eq` | 0 | 2 | + | a bare day as the upper bound of a `datetime`, `{ opened_at: { $lte: '2026-02-01' } }`, or as a `$between` max | 2 | 3 | + | an epoch-millisecond bound on a `datetime` | 0 | 3 | + | a `Date` carrying a time of day on a `date` field, `$gte` / `$lt` / `$eq` (in-process only) | 1 / 5 / 0 | 3 / 3 / 2 | + | a `Date` on a `time` field (in-process only) | 0 | 3 | + | `having` on `max` of a `date` field with an ISO-instant bound | kept one group | keeps the two groups whose day is on or after it | + + The same holds for `$ne`, `$in` / `$nin` members, `$between` endpoints, implicit equality, an offset instant (`'…T18:00:00+08:00'`), an epoch-millisecond string, a zone-naive `'2026-02-01T10:00'`, and a short wall clock (`'11:00'`) or an ISO instant on a `time` field. On a `having` column, the class comes from the query, as the `addDays` rule already reads it: `min` / `max` take the class of the field they read, a `groupBy` projection takes its field's, and a `day` date bucket is a `date`. + + What the rule does, now in one place: + + - A comparand, and the row's value, are put into the column's storage form: canonical UTC ISO text for `datetime`, `YYYY-MM-DD` for `date`, and `HH:MM:SS` (`.fff` only when non-zero) for `time`. + - A bare `YYYY-MM-DD` used as the upper bound of a `datetime` (`$lte`, a `$between` max) means that whole day, as it does in a `where` (ADR-0053 D-D). On a `date` or `time` column it is not widened. + - A value the rule cannot read is compared as written, and so is every non-temporal column, presence tests (`$exists`, `$null`), the text operators and a `{ $field }` reference. + - An object whose declared fields the engine cannot see keeps the previous comparison. + + `@objectstack/core` exports the rule as `temporalStorageForm(value, kind)`, `kind` being `'datetime' | 'date' | 'time'`. `driver-sql` (`canonicalUtcDatetime`, `toDateOnly`, `canonicalTimeOfDay`) and `driver-memory` (`coerceTemporalValue`) each carried a copy of it; both now call it. The copies agreed on every shape measured when they were lifted, so the lift itself changes no `where`, write or read answer of either driver (#20203, in the same release, then reads an epoch-millisecond number on a `date` field as its UTC calendar day). MySQL still binds a `datetime` in its own literal spelling. + + `@objectstack/objectql`'s `applyInMemoryAggregation(rows, ast, timezone?, fields?)` takes the object's declared field map as an optional fourth argument, and a per-aggregation `filter` reads a temporal comparand by the rule only when it is given. Called without it, the function answers as before. + + Not changed, measured identical before and after on both drivers: every `where` answer, every refusal a per-aggregation `filter` or `having` gives, and every per-aggregation `filter` and `having` cell whose column is not temporal. +- 615c468: fix(core): an epoch-millisecond number compared against a `date` field is read as the UTC calendar day of its instant, by every driver and at every position that compares it (#20203) + + Clause-②: no — no key, export or operator moves, and no comparand that was accepted is now refused: a number was already an accepted comparand on every `date` position, and its answer moves to the storage rule's reading. + + `temporalStorageForm(value, 'date')` in `@objectstack/core` returned a finite number unchanged, so each face compared it by its own type rules and they disagreed. Over six rows, with `1769940000000` (2026-02-01T10:00:00.000Z) against a `date` field: + + | face | `$gt` | `$lt` | `$eq` | `$in` (with a Jan 10 member) | `$between` (from Jan 2) | + |:--|:--|:--|:--|:--|:--| + | `where` on `driver-memory` | 0 | 0 | 0 | 0 | 0 | + | `where` on `driver-sql`, SQLite | 6 | 0 | 0 | 0 | 0 | + | `where` on `driver-sql`, PostgreSQL | `DATABASE_ERROR`, a 500 at REST | the same | the same | the same | the same | + | a per-aggregation `filter` on `engine.aggregate` | 0 | 0 | 0 | 0 | 6 | + | **now, on every face above** | **1** | **3** | **2** | **3** | **5** | + + The same holds through `engine.find` and `POST /data/:object/query`, and for `$gte`, `$lte`, `$ne`, `$nin` and implicit equality. PostgreSQL's server refused the bound number itself (`22008`, date/time field value out of range), on an empty table too. `having` over `max` of a `date` field kept no group for `$gt`, `$eq` or `$in`; it now keeps the groups whose day compares. + + A finite number is now read as the `datetime` rule already reads it, as epoch milliseconds. It takes the UTC calendar day of that instant: the day `new Date(value)` names, through the same conversion a `Date` takes. So a number and its `Date` always answer alike. A time of day is dropped, never rounded, a negative number is a day before 1970, and a fraction truncates toward zero as the `Date` constructor does. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call this rule, so they now agree. + + The rule is shared by the drivers' write and read paths too: + + - `create()` / `update()` on either driver, given a number for a `date` field, stores its UTC day. Before, `driver-memory` and SQLite stored the number, and PostgreSQL refused the statement. The engine and REST write doors refuse a number on a `date` field before a driver sees it (`VALIDATION_FAILED`), as before. + - A number already stored in a SQLite `date` column is read back as its UTC day by `find()`, a `groupBy` key and `distinct()`. Only a direct driver write could have put one there. + + Not changed, measured identical before and after: `NaN`, ±Infinity, a number outside the `Date` range (past ±8.64e15), a bigint, an epoch-millisecond string, every `Date` and every string on a `date` field (#20240, in the same release, then pads a `Date`'s or a number's year 0..999 to four digits and refuses one whose year falls outside 0..9999, a number past the `Date` range included; #20264, in the same release, narrows that to 0001..9999, so year 0 is refused rather than padded), and every `datetime` and `time` reading. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. +- 89f87f2: fix(core,objectql)!: a number or `Date` compared against a `date` field spells its year with four digits, and one whose UTC year falls outside 0..9999 is refused `INVALID_FILTER` / 400, as its ISO string already was (#20240) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what a filter on a `date` field accepts. A number or `Date` whose UTC calendar day falls in a year below 0 or above 9999 used to answer 200 with the wrong rows, or a 500 on PostgreSQL; it now answers `INVALID_FILTER` / 400. It ships as `minor` under the launch-window convention for accept-set narrowings. + + `temporalStorageForm(value, 'date')` in `@objectstack/core` spelled the year of a `Date` or an epoch-millisecond number unpadded: `999-06-15`, `10000-01-01`, `-1-01-01`. The ISO string and the bare day of the same instant spelled `0999-06-15`, and as text an unpadded year sorts as no day does. Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query` (the two doors agree), on a `date` field holding six 2026 days and 0999-06-15, `$gt` / `$lt` / `$eq`: + + | position | comparand | before: memory · SQLite · PostgreSQL | now, on all three | + |:--|:--|:--|:--| + | `where` | a number (or, in-process, a `Date`) for 0999-06-15 | 0/7/0 · 0/7/0 · 6/0/1 | 6/0/1 | + | per-aggregation `filter` | the same | 0/7/0 on all three | 6/0/1 | + | `having` on `max(date)` | the same | no group / every group / no group | the three 2026 groups / none / the 0999 group | + | `where` | a number (or `Date`) for 10000-01-01 | 6/1/0 · 6/1/0 · 0/7/0 | `INVALID_FILTER` / 400 | + | `where` | a number (or `Date`) for -1-01-01 | 7/0/0 · 7/0/0 · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | + | per-aggregation `filter` | either of those two | 6/1/0 and 7/0/0 on all three | `INVALID_FILTER` / 400 | + | `where`, per-aggregation `filter` | the ISO string of either | `INVALID_FILTER` / 400 | unchanged | + + What changes: + + - The rule pads a year from 0 to 999 to four digits, for a `Date` and a number alike, so a number, its `Date` and its ISO string spell one day. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call it. + - `isUninterpretableTemporalComparand('date', value)` is now also true for a finite number or a valid `Date` whose UTC year is below 0 or above 9999, a finite number past the `Date` range (±8.64e15) included. The engine's temporal-comparand door refuses such a comparand on `where` for every verb (`find`, `findOne`, `count`, `aggregate`, `update`, `delete`), in both the object and the array spelling, and in a per-aggregation `filter`, before any driver read. `IObjectQLEngine.judgeFilter` runs the same door. + - The write path: `create()` / `update()` on `driver-memory` or SQLite, given a year-0..999 number or `Date` for a `date` field, now stores `0999-06-15` where it stored `999-06-15`; `engine.insert` of such a `Date` does the same. PostgreSQL and MySQL already stored a three-digit year's day, but not a shorter one: under its default `DateStyle` (`ISO, MDY`) PostgreSQL stored the unpadded `9-03-04` as 2004-09-03 and refused `99-03-04` (`22008`), and MySQL 8.0 stored `99-03-04` as 1999-03-04. All three dialects now store the day. A year outside 0..9999 keeps the spelling it had on the write and read paths; no ordered form is invented for it. + + **Who is affected.** A caller that compares a `date` field with an epoch-millisecond number or a `Date` in a year below 0 or above 9999. No writer that stores or queries such a day has been measured; the reach is the public query door. + + **Fix.** Compare against a `YYYY-MM-DD` day, or a number or `Date` whose UTC calendar day falls in a four-digit year. + + **Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included (#20264, in the same release, then narrows the range to 0001..9999 on `date` and `datetime` alike: year 0 is refused too, and so is a `datetime` number, `Date` or string outside it, and the padding covers 0001..0999); every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 read back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch and #20280, in the same release, corrects by reading a MySQL `DATE` as its text. `having` reaches the same door in the same release (#20263), so a number or `Date` outside 0..9999 is refused there too. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. +- d3958ba: fix(driver-sql): a MySQL `date` field reads back the day it stores, so a year below 100 no longer comes back a century late (#20280) + + Clause-②: no + + On MySQL the driver took mysql2's JS `Date` for a `DATE` column. mysql2 rebuilds it from the three stored numbers with `new Date(Date.UTC(y, m - 1, d))`, and `Date.UTC` reads a year from 0 to 99 as 1900 + year. The write was right and the read was wrong: `where placed_on $eq '0009-03-04'` found the row, then presented it as `1909-03-04`. Measured on MySQL 8.0.46 (server `time_zone='+08:00'`), on records created through `POST /api/v1/data/:object`: + + | stored (`CAST(… AS CHAR)`) | `find` / `findOne`, the engine, `…/query`, `GET …/:id`, a `groupBy` key, `min`, `distinct`: before | now | + |:--|:--|:--| + | `0009-03-04` | `1909-03-04` | `0009-03-04` | + | `0099-03-04` | `1999-03-04` | `0099-03-04` | + | `0000-06-15` | `1900-06-15` | `0000-06-15` | + | `0999-06-15` | `0999-06-15` | `0999-06-15` | + | `2026-03-04` | `2026-03-04` | `2026-03-04` | + + The MySQL connection now asks mysql2 for a `DATE` as its `YYYY-MM-DD` wire text (`dateStrings: ['DATE']`), and the read doors present that text through `temporalStorageForm`, the rule the write and `where` paths already use. PostgreSQL has read a day as text the same way since its calendar-day parser. + + **Unchanged**, measured identical before and after on MySQL through the driver, the engine and REST: every read of a year from 1000 to 9999 on a `date`, `datetime` or `time` field, and of a `TIMESTAMP` column and a `null`, on `find`, `findOne`, `count`, `aggregate` (`min`, `max`, `groupBy`), `distinct` and a write-then-read; every `$eq` / `$gt` answer. SQLite and PostgreSQL reads do not move. + + **Also moved, on MySQL only:** + + - A raw `execute()` read, and a `DATE` column read under a field that is not declared `date`, now receive the `YYYY-MM-DD` text where they received a `Date` (a `datetime` column still arrives as a `Date`). PostgreSQL already answers a `date` column this way. + - A zero day (`0000-00-00`, storable only with `NO_ZERO_DATE` off) is presented as that text, where mysql2 made up `1899-11-30`. + - A connection whose host already set `dateStrings` is left as the host set it. + + **Not changed:** a `datetime` field. A MySQL `DATETIME` in years 0..99 still reads a century late (`0009-03-04T10:00:00.000Z` comes back as `2004-09-03T10:00:00.000Z`). ADR-0053 D-F2 keeps the client parser's `Date` for an instant, so that half stays open on #20280. +- 15bf186: fix(driver-sql): `count` / `count_distinct` / `sum` / `avg` answer JS numbers on PostgreSQL and MySQL, as they do on SQLite and on the engine's rows path + + Clause-②: no + + `SqlDriver.aggregate` handed the SQL client's answer straight through. node-postgres parses + `bigint` (`count`, and `sum` over an integer column) and `numeric` (`sum` / `avg` over the + numeric family's exact-decimal column, `avg` over an integer column) to strings, and mysql2 + does the same for `DECIMAL` (`SUM` / `AVG`). So one grouped query answered + + { "n": "2", "total": "500.000000000000000000000000000000" } + + on PostgreSQL's native path and `{ "n": 2, "total": 500 }` on SQLite and on the rows path of + every dialect. The engine's `having` compares values as they + arrive, so `having { n: { $in: [2] } }` kept no group on PostgreSQL alone, and + `having { total: { $in: [500, 20] } }` kept no group on PostgreSQL or MySQL, while a string + comparand such as `{ total: { $lt: 'not-a-date' } }` kept every group there and none anywhere + else. + + Those four functions now answer a JS number on every dialect, through `SqlDriver.aggregate`, + `engine.aggregate` and `POST /api/v1/data/:object/query`. The presentation is keyed on the + aggregate function the query asked for; it only rewrites a string, so SQLite's answers are + byte-identical to before. `min` / `max` are unchanged: they answer a value of the column and + keep that column's presentation (a declared numeric field was already a number). + Non-aggregate reads (`find()`, `distinct()`) are unchanged, and no connection-level type + parser is touched. + + **Precision policy.** The answer is one JS number (an IEEE-754 double) on every dialect. A + `sum` / `avg` whose exact value needs more than a double's 15 to 17 significant digits, or an + integer total at or above 2^53, is rounded to the nearest double. That is the same bound + `find()` already puts on a read of the same exact-decimal column, and the bound the rows path + has always had. A total that fits keeps its exact value (`500`, `30.75`, `0.375`). The answer + is never a string, including for large totals: an answer whose type depended on its size would + break the same `having` or chart for exactly those totals. + + A consumer that read these values through `Number(...)` gets the same number it computed + before. A consumer that compared them as strings, or checked `typeof value === 'string'`, + now receives a number. +- aeb0557: fix(security)!: the RLS write check refuses a field-to-field comparison the read refuses — one comparison class, one answer per policy (#20355) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on the row-level write check, shipped as `minor` + under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; + breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the + level). The hand-migration prescription is registered under protocol major 18 as + `rls-predicate-cross-class-field-comparison-refused`, one ADR-0087 D3 entry for the whole + family: the authoring arm `os validate` gained in #20347 and this write-check arm. + + **What changed.** A row-level policy that compares two fields of no shared comparison + class — `record.status != record.amount` (text and a number), `record.status != + record.photo` (text and a file field), `record.status != record.is_open` (text and a + formula field), `record.status != record.meta` (text and a json field) — already had + every read it scopes refused with `INVALID_FILTER` / 400 on the SQL drivers, because + driver-sql compiles a column-to-column comparison only within one class. The write + check did not know the rule: it compared the two raw values in-process, so an insert + or update the policy's `check` judges (or its `using`, standing in as the check) was + admitted and stored whenever that comparison happened to hold. Measured through + plugin-security and ObjectQL on SQLite, sqlite-wasm and PostgreSQL. The write check + now refuses the comparison too, with the read's envelope, `INVALID_FILTER` / 400, for + every insert (single or array), by-id update and predicate update it judges, and + nothing is stored. The same-class comparisons it always compared are compared as + before. The 400 names no column of the policy; the server log names the policy and + both columns. A comparison against a json or `multiple` field is refused by its + declared type now, where it used to be judged by the value each record held. + + **`@objectstack/formula`.** `matchesFilterCondition(record, filter, options?)` takes an + optional third argument: `options.fields`, the object's declared columns (`type` and + `multiple` per field name). Given it, every `{ $field }` comparison between two + declared columns is judged by `crossFieldComparisonVerdict` from + `@objectstack/spec/data` before any record is read, and one the platform defines no + answer for throws `INVALID_FILTER` / 400. Without it the evaluator behaves exactly as + before. Two new exports go with it: `findCrossFieldClassRefusal(filter, fields)`, the + pure judgement, and `crossFieldClassRefusalCarriedBy(error)`, which reads the refused + comparison off the error for a server-side log. + + **`@objectstack/driver-sql`.** `crossFieldComparisonClass` reads the same export + (`crossFieldColumnVerdict`) instead of keeping its own copy of the classification, and + layers above it only its internal type aliases. Every read answers as before. + + **`@objectstack/lint`.** The `rls-predicate-unenforceable` finding for such a + comparison now states the write answer the runtime gives: the in-process write check + refuses it by the same classification and stores nothing. + + **If a policy of yours is refused.** The platform defines no comparison between those + two columns on any path, so the policy never protected a read either. Compare a field + only with a field of the same class — a number with a number, text with text, a + boolean with a boolean, a date with a date, a datetime with a datetime, a time of day + with a time of day — or, if the two columns do hold comparable values, correct the + declaration of the one declared with the wrong type. `os validate` names every such + comparison. +- fc0db22: fix(driver-sql): `sum` / `avg` accumulate in double on PostgreSQL and MySQL, as they do on SQLite and on the engine's rows path + + Clause-②: no + + `SqlDriver.aggregate` let PostgreSQL (`numeric`) and MySQL (`DECIMAL`) add exact decimals, + while SQLite and the engine's rows path add JS doubles. So a `number` column holding `0.1` + and `0.2` summed to `0.3` on the PostgreSQL and MySQL native paths and to + `0.30000000000000004` everywhere else, and `having { s: { $eq: 0.3 } }` kept the group on + those two faces only. `avg` over an integer column diverged too: MySQL rounds a decimal + average to 4 places (`avg` of 1, 2, 2 answered `1.6667`), and PostgreSQL's `numeric` + average rounds to 16 places before the answer becomes a double (`11 / 9` answered + `1.2222222222222222`, where every other face answers `1.2222222222222223`). + + **The precision policy, applied to the arithmetic.** The policy already stated for the + answer's type (one JS double on every dialect, the loss beyond a double's precision declared) + now also decides how the answer is computed: + + - `avg` accumulates in double on PostgreSQL and MySQL, over every declared numeric or + boolean column. + - `sum` accumulates in double over a column that holds fractions: `number`, `currency`, + `percent`, `slider`, `progress`, `summary`, and the driver's `float` alias. + - `sum` over an integer-valued column (`rating`, the `integer` / `int` aliases, a boolean) + keeps the database's exact integer total, rounded once to the double. + - `count`, `count_distinct`, `min` and `max` are unchanged. SQLite is unchanged. + + Each value added is the column's text parsed as a double: the value the SQL client hands + `find()`, and so the value the rows path adds. For the exact-decimal columns this equals a + plain cast. For a binary `real` / `FLOAT` column, which a table created before the + exact-decimal columns still has, a plain cast would add the widened binary value + (`0.30000000447034836` for `0.1 + 0.2`). MySQL's `CAST(… AS DOUBLE)` needs MySQL 8.0.17 or + later. + + Route chosen: (a), accumulate in double on the native faces. The other route, (b), was to make + the rows path add exact decimals and round once. It was rejected because SQLite's native `sum` + adds the stored doubles (`0.30000000000000004`), so the rows path would then disagree with SQLite + for exactly `0.1 + 0.2`. + + **Residual, stated.** On PostgreSQL and MySQL the double sums are added in row order, one after + another, without compensation. SQLite 3.43 and later adds with compensated summation, and since + #20489 so does the engine's rows path. So for a group of three or more fractions, the PostgreSQL + and MySQL native answer can still differ from SQLite's and the rows path's in the last place + (`0.1 + 0.2 + 0.3`: PostgreSQL / MySQL native `0.6000000000000001`, SQLite and the rows path + `0.6`). Before #20489, SQLite's own two paths differed there too. Two addends cannot differ. + + A consumer that compared `sum` / `avg` over a fractional column with a decimal literal on + PostgreSQL or MySQL (`$eq: 0.3`) now gets the answer SQLite and the rows path already gave: + compare with a range, or with the double the arithmetic produces. +- 5b674f5: fix(driver-sql): `reclaimSpace()` returns the freed bytes from the SQLite `-wal` sidecar too, and never waits on another connection (#20426) + + Clause-②: no + + On a file-backed SQLite database in WAL mode, the default, `reclaimSpace()` returned the whole freelist but left the freed bytes in the `-wal` sidecar. At 25,754 free pages the database file went from 103,149,568 to 16,384 bytes while the `-wal` file went from 4,255,992 to 94,430,432 bytes, and it kept that size until the last connection closed. The lifecycle sweep calls this method after every sweep that deleted rows, and it reported the datasource as reclaimed. + + On better-sqlite3 (`SqlDriver`, and `TursoDriver` in local mode) the vacuum now runs in chunks of a quarter of the connection's page cache, 1,000 pages at the default cache size, with a `PASSIVE` checkpoint after each chunk. One `TRUNCATE` checkpoint closes the call, taken with a busy timeout of 0, so it never waits on another connection. On the same database, file plus `-wal` goes from 107,405,560 to 16,384 bytes while the driver is still open. + + When another connection holds a read transaction, the call still returns without waiting (47 to 66 ms measured; a `TRUNCATE` checkpoint that waits blocked the process for the connection's 5-second busy timeout). The pages are off the freelist, and their bytes leave the files at a later checkpoint. The call no longer grows the pair either: 107,405,560 bytes before and after, where the single statement grew it to 197,580,000. + + A database in rollback-journal (`delete`) mode behaves as before. The remote `TursoDriver` route and `SqliteWasmDriver` are unchanged. Nothing to migrate: `reclaimSpace()` keeps its signature. +- 40626bd: `backfillCanonicalDatetimes` / `backfillCanonicalTimes` no longer write SQLite's julian-day reading of a bare-numeric cell over the bytes on disk (#6009). + + Both migrations use the read path's own repair expression as their `SET` value, and that expression's `else` arm is `coalesce(strftime(...), col)` — on the stated understanding that a value SQLite cannot parse falls through the `coalesce` unchanged. SQLite's time-value grammar has one limb that defeats it: a **bare number** is a julian day (`DDDD.DDDD`, the last of its documented formats), and `now` is the wall clock. For those two shapes `strftime` does not return NULL, so `coalesce` never fires and a confident wrong answer is what gets written. Measured on better-sqlite3 13.0.3 / SQLite 3.53.4 against a TEXT-affinity column: + + ```text + '12' -> -4713-12-06T12:00:00.000Z '2026' -> -4707-06-11T12:00:00.000Z + '86400' -> -4476-06-15T12:00:00.000Z '2440587.5' -> 1970-01-01T00:00:00.000Z + 'now' -> whatever the clock said '2026-08-06' -> 2026-08-06T00:00:00.000Z (correct) + ``` + + On a read that was a temporary misreading and the disk was untouched. On the `SET` side it is a write, and the original value is unrecoverable afterwards. + + - **The guard is the structural complement of the parseable spellings, not a heuristic.** Every other format SQLite's date parser accepts goes through `parseYyyyMmDd` (which needs a `-`) or `parseHhMmSs` (which needs a `:`), so a cell the parser answers for while containing neither character reached it through the julian-day limb or the `now` limb — there is no third way in. `sqliteNonTemporalTextSql` is that predicate; it never inspects the magnitude of the number and never decides what the cell means, only that the migration must not rewrite it. + - **A withheld row also blocks the canonical mark, and that is what keeps every query answer identical.** Skipping the row costs nothing while the read-side repair still applies to it — it keeps reading as the same instant it always did. Marking the column clean is what would move an answer: `needsLegacyDatetimeRepair` would drop the repair and the raw `'2026'` would be compared as TEXT. Unlike the `coalesce` fixpoints, these rows are not invariant under the repair, so they cannot ride through the mark. The column stays un-marked, reads keep their (unindexed) repair, and one `warn` names the count, the reason and the remedy. + - **The read path is untouched, deliberately.** The maintainer's 2026-08-03 ruling on cloud#1005 refused teaching the shared read expression to recognise numeric-looking text: it is a public contract for every SQLite consumer, it runs on every read, and there it would misread a legitimate numeric-string column. Everything here runs once, as a migration, only on a column the metadata declares `Field.datetime` / `Field.time`, and it only ever declines to write. + - **`previewDatetimeConvergence` / `previewTimeConvergence` inherit the same exclusion**, so `os migrate plan` still promises exactly the rows `apply` rewrites. + + Reaching the julian branch needs a temporal column with TEXT affinity. A column knex creates for `Field.datetime` is declared `datetime`, which is NUMERIC affinity, so `'2026'` is converted to INTEGER on the way in and takes the epoch branch instead — the local write path mostly dodges this. A table that already existed when `initObjects` first saw it keeps whatever affinity it was created with, and `initObjects` adds missing columns without ever retyping one. +- 0f38ab0: fix(driver-memory,driver-sql): an explicit `tenancy.enabled: false` opt-out is sticky, so a partial `syncSchema` re-registration no longer flips a platform-global object's UNIQUE partition (#16729) + + ## What was wrong + + `InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever + schema THAT call happened to carry. A second registration without a `tenancy` + block — the `{ name, fields }` shape — fell through to the implicit + `organization_id` heuristic, so a `unique` field moved from **one row per + install** (`scopeField: null`, which is what `tenancy.enabled: false` declares) + to **one row per organization**. A duplicate the declaration refuses then + landed. Measured at the driver door on `origin/main` `d61139f1ba`: + + | sequence | second `key: 'K'`, different organization | + |:--|:--| + | register with `tenancy.enabled: false` | `REFUSED` — `UNIQUE_VIOLATION` / 409 | + | …then re-register with `{ name, fields }` | **`LANDED`** | + + `SqlDriver` running the same sequence refuses in **both** cases: it has kept a + sticky `tenantOptOutByTable` since #3249. `driver-memory` had mirrored the inner + `computeTenantField` and not the wrapper that consults the record, so "mirrors + `computeTenantField` arm for arm" stayed literally true while the pair diverged. + + It is silent in both directions — nothing logs the flip, and the refusal names + the field, never the partition. That is the declared-vs-enforced shape Prime + Directive #10 forbids, reached by a state change rather than by a missing check. + + ## What it does now + + - **`@objectstack/driver-memory`** gains `computeAndRecordTenantField`, the + sticky resolver, and the `TenantOptOutRecord` type for the per-instance record + a driver owns. `InMemoryDriver` holds one and resolves through it, handing + BOTH declaration surfaces — field-level `unique` and declared `indexes[]` — + the same resolved column. `uniqueConstraintsFromFields` and + `uniqueConstraintsFromDeclaredIndexes` accept that column as an optional + second argument; called with one argument they answer exactly as before. + `tenantFieldOf` is unchanged and still a pure function of its argument. + - **`@objectstack/driver-sql`**: the shard leaf resolved its tenant column with + the BARE `computeTenantField`, so a `rotateShards` sweep carrying no `tenancy` + block gave a shard an organization key part the base table's index does not + have — one object, two partitions, decided by which physical table a row + landed in. It now resolves through the record, keyed by the base table. + - **`@objectstack/objectql`**: `LifecycleObjectLike` declares `tenancy`. The + Archiver hands that object straight to `cold.syncSchema`, and the published + type refused the key while the driver below read it — so an author writing a + fresh literal was pushed into producing exactly the partial re-registration + above. Same correction #16711 made where the shard leaf narrowed the key off + the object it was handed. + + The record is deliberately narrow. Only the explicit OPT-OUT is sticky: a + declared `tenancy.tenantField` is not recorded, matching `SqlDriver`. An object + that never declared the opt-out never enters the record, so a genuinely + org-scoped object keeps its `organization_id` partition across a partial + re-registration — an implementation answering `null` more often would not be + stickier, it would be tenant isolation switched off. A carried `tenancy` block + stays authoritative in both directions and CLEARS a recorded opt-out. + + `@objectstack/driver-memory` is `minor` for the two new public-entry exports. + The behaviour repairs themselves are `patch`: each restores an implementation to + the `tenancy.enabled: false` contract (`isTenancyDisabled`, ADR-0066) it was + already declaring, rather than replacing one legal published answer with + another. The `objectql` entry is a published type WIDENING — a key the interface + refused is now accepted, and nothing that compiled before stops compiling. +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth was + `seed-tenancy-backfill`'s organization probe, which kept a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes recorded `'unknown error'` there rather than `''`; + that fallback was load-bearing — the site read an empty value as "the probe did not fail" — + and #17167 removed it in this same release, so all fourteen sites now record the channel as + is and that site carries its failure fact structurally. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/observability@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/drivers/driver-sql/package.json b/packages/drivers/driver-sql/package.json index be45e44a664..7d86bd873ff 100644 --- a/packages/drivers/driver-sql/package.json +++ b/packages/drivers/driver-sql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sql", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "SQL Driver for ObjectStack - Supports PostgreSQL, MySQL, SQLite via Knex", "main": "dist/index.js", diff --git a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md index 7c8d9f6be79..f744cd4f67b 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,773 @@ # @objectstack/driver-sqlite-wasm +## 17.5.0 + +### Minor Changes + +- 8a44ce7: fix(spec, drivers)!: a `$like` / `$ilike` pattern holding U+0000 is refused by every driver that answers `$like`, instead of being cut at the NUL on SQLite + + Clause-②: yes (narrowing) + + On the SQLite faces `$like` / `$ilike` compile to `GLOB`, and SQLite reads a pattern only up to its first U+0000. A pattern holding U+0000 was cut there, so the filter answered a different question, and nothing raised. Measured through `find` over 13 stored values (12 non-NULL), against `@objectstack/formula` on the same rows: all 20 U+0000 cases of the probe (10 patterns, bare and under `$not`) differed on `SqlDriver` over better-sqlite3, on `SqliteWasmDriver`, on `TursoDriver`'s local mode, and on its remote mode over a stub and over a real `@libsql/client` engine, with identical answers on all five. For example: + + - `$like: '%'` + U+0000 returned all 12 non-NULL rows, where `formula` returns the two ending in U+0000; + - `$like: 'a'` + U+0000 + `'b'` also returned `'a'`; + - `$ilike: 'AB'` + U+0000 also returned `'AB'` and `'ab'`. + + `driver-memory` answered all 20 as `formula` does. SQLite has no NUL-safe pattern primitive to compile to instead: `LIKE` cuts the same way, `replace()` cannot target U+0000, and `instr()` has no wildcards. So the one contract is a refusal, the way a pattern ending in a lone unpaired backslash is refused. + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A filter that answered before is now refused**: a `$like` or `$ilike` pattern holding U+0000 anywhere (at the start, in the middle, at the end, alone, or after a backslash) gets `INVALID_FILTER` / 400, on every door that already refused the lone trailing backslash: + + - `@objectstack/driver-sql`: on the filter walk, before a dialect is chosen, so SQLite, Postgres and MySQL all refuse it. `@objectstack/driver-sqlite-wasm` and `TursoDriver`'s local mode inherit it; `@objectstack/driver-sqlite-wasm`'s own code does not change. + - `@objectstack/driver-turso`: the remote transport's `$like` / `$ilike` arm, before anything is sent to the engine. + - `@objectstack/driver-memory`: the shape gate of the query path and of the reference matcher `match()`, and the QueryAST `comparison` spelling (`like` / `ilike`). + - `@objectstack/spec` exports the shared test, `hasNulInLikePattern`, beside `hasDanglingLikeEscape`, and the `$like` operator's description now names the refusal. + + On `driver-sql` and the Turso remote transport the refusal goes through the read-scope provenance seam, like every other filter-compile refusal there. On `driver-sql` (and so `driver-sqlite-wasm` and Turso's local mode), a caller whose predicate is marked `'author'` reads the operator, the field, the filter path and the pattern, with U+0000 written as `\u0000`. Any other caller gets only the class statement, and the rest goes to the server log. The remote transport withholds the same way, and through `TursoDriver` in remote mode no mark reaches it, so every caller gets the class statement there. On `driver-memory` every caller reads the full text, as for its dangling-escape refusal. + + A pattern that ends in a lone unpaired backslash AND holds U+0000 keeps the dangling-escape refusal it had before. + + **What stays accepted**, pinned per face: every `$like` / `$ilike` pattern without U+0000 answers exactly as before. + + **Not changed here:** + + - A pattern without U+0000 matched against a STORED value that holds U+0000 is not refused: it is well formed, and on the SQLite faces it reads the whole stored value, by its own entry in this release. + - `@objectstack/formula` still evaluates such a pattern. It refuses nothing, and answers `false` for a dangling escape rather than refusing it, so it is not one of these doors. + - `driver-mongodb`, objectql `having` and `service-analytics` refused every `$like` / `$ilike` before this change, and still do. + + **What an affected author does.** Remove the U+0000 from the pattern. No escape makes it portable: a backslash before it still leaves a U+0000 in the pattern. + + Blast radius, measured on this tree: no example or template writes a `$like` or `$ilike`, and the published `objectstack-query` skill and the hand-written docs that show one show no pattern holding U+0000. Whether any out-of-repo caller sends one is NOT measured and is not claimed to be zero. + + +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. + +### Patch Changes + +- fc6ddb8: fix(driver-sqlite-wasm): a text value now round-trips byte-for-byte, as it does through `SqlDriver` on better-sqlite3 — an embedded U+0000 no longer cuts the stored value short, and a leading U+FEFF is no longer dropped when the value is read (#19978) + + Clause-②: no + + `SqliteWasmDriver` changed a text value without raising, at two points in sql.js (measured on sql.js 1.14.1, the version the lockfile installs, against better-sqlite3, which round-trips every value below): + + - **Write.** sql.js binds a string with a length of `-1`, so SQLite stores it only up to the first U+0000: `'a'` + U+0000 + `'b'` was stored as the single byte `61`, and `'ab'` + U+0000 as `6162`. + - **Read.** sql.js decodes a text cell up to the first NUL byte, through a decoder that drops a leading byte-order mark: a stored `610062` read back as `'a'`, and a stored text beginning with U+FEFF read back without it. A U+FEFF was always stored, because the write keeps it. + + What changes: + + - Every text cell the driver reads is decoded from its stored bytes, so a U+0000 anywhere in it and a U+FEFF at its start come back as stored. This includes values already on disk: a stored text that begins with U+FEFF now reads back with it. + - A string value holding U+0000 is bound as its UTF-8 bytes, and the parameter that receives it becomes `+CAST( AS TEXT)`, so SQLite stores the same TEXT value better-sqlite3 stores, and compares it the same way. Positional `?`, numbered `?NNN` and named parameters are all numbered as SQLite numbers them. A statement that binds no such string runs exactly as before. + - An equality filter on such a value now compares the whole value. Before, the comparand was cut at the same U+0000 as the stored value, so `{ v: 'a' }` also matched a row written as `'a'` + U+0000 + `'b'`. It no longer does. + - The local `Field.json` storage backfill (`SqlDriver.backfillCanonicalJsonEncoding`) now converges a legacy json text cell holding a leading U+FEFF or an embedded U+0000 on this driver too: measured on the next `initObjects`, a stored `EFBBBF78` becomes `22EFBBBF7822` and a stored `610062` becomes `22615C75303030306222`, the same bytes better-sqlite3 writes, where before this change both were left as stored because sql.js did not read them back verbatim. + - If the loaded sql.js has no `Statement.getBlob` (the one sql.js read that carries a byte length), reading a text cell throws instead of decoding through the lossy path. sql.js 1.14.1 has it in its Node, browser and debug builds. + + What does not change: a value already stored cut short stays cut short. The bytes after the U+0000 were never written, so nothing can restore them. +- 9d81af7: fix(driver-sql, driver-turso): on SQLite, a `$contains` / `$notContains` / `$icontains` / `$startsWith` / `$endsWith` comparand holding U+0000 is compared whole, against the whole stored value, instead of being cut at the U+0000 by `GLOB` (#19999) + + Clause-②: no + + On the SQLite faces these five operators compile to `GLOB`, and SQLite's `glob()` reads both the pattern and the stored value only up to their first U+0000. Nothing raised, and the filter answered a different question. Measured on `SqlDriver` over better-sqlite3 (SQLite 3.53.4), on `SqliteWasmDriver` over sql.js (3.49.1), and on `TursoDriver`'s remote transport over a local libSQL engine (3.45.1). All three answered alike. Over the values `'a'` + U+0000 + `'b'`, `'ab'` + U+0000, U+0000 + `'z'`, `'plain'` and `''`: + + - `$contains: U+0000` and `$endsWith: U+0000` returned all five rows; + - `$contains: U+0000 + 'b'` returned all five rows, where the JavaScript answer is `'a'` + U+0000 + `'b'` only; + - `$startsWith: U+0000` returned `''` and U+0000 + `'z'`, where the JavaScript answer is U+0000 + `'z'` only. + + What changes: a comparand holding U+0000 now compiles to a length-aware comparison instead. `$contains`, `$notContains`, `$icontains` and `$startsWith` use `instr()`, and `$endsWith` compares the value's trailing bytes over BLOB. Such a filter now returns the rows `driver-memory` and `@objectstack/formula` return for it. The comparand is bound as written, so `*`, `?` and `[` in it are literal, as they were before. `$icontains` still folds ASCII letters only, and `$notContains` still returns a row whose value is NULL. + + - `@objectstack/driver-sql`: the SQLite arm of `SqlDriver`'s text-operator compiler. `SqliteWasmDriver` and `TursoDriver`'s local mode inherit it. + - `@objectstack/driver-sqlite-wasm`: none of its own code changes. It inherits the fix, and its exact-text bind reaches every parameter the new comparison binds. + - `@objectstack/driver-turso`: the remote transport's own emitter, changed the same way. + + What does not change: a comparand without U+0000 compiles to the same `GLOB` with the same bound pattern as before. The Postgres and MySQL arms are untouched. `GLOB` still reads a stored value only up to its first U+0000, so for a comparand without U+0000, `$contains`, `$notContains`, `$icontains` and `$endsWith` over a stored value that holds one still compare only the part before it. +- 57c2b73: fix(driver-sql, driver-turso): four filter-refusal doors stop naming a read scope's field and comparand unless the refused predicate is marked as the caller's own (#20020) + + Clause-②: no + + A read scope is the RLS, sharing or tenant predicate that `plugin-security` (ordinary reads) and `service-analytics` (the ObjectQL analytics face) AND into the caller's `where`. Both merges mark the scope `'policy'` and the caller's own predicate `'author'` (`markFilterSubtreeProvenance`, `@objectstack/spec/data`). When `SqlDriver` refused a scope at one of the four doors below, the `INVALID_FILTER` / 400 message named the scope's field, and for three of them its comparand too. It did not check the mark. Measured on both faces, through `POST /api/v1/analytics/query` and through an ObjectQL `find` under a merge shaped like `plugin-security`'s: + + - a column the table does not have. This is reachable from a real CEL rule on a field that is declared but has no column yet; + - a retired operator (`$regex`, `$options`) or an operator outside the vocabulary; + - `$and` / `$or` whose operand is not a list; + - a `$null` / `$exists` whose comparand is not a boolean. + + Each of these doors now reads the mark on the node it refused, the same way the cross-field and target-field refusals already did: + + - **`'policy'`, unmarked or ambiguous:** same `INVALID_FILTER` / 400. The message says which kind of refusal fired, but names no field, operator, comparand or filter path. Those go to the server log. For the unresolvable column, the message is the unnamed wording the driver already used when it could not parse the dialect's message. + - **`'author'`:** the full message, the same text the door answered before. + + To find the node, the unresolvable-column door looks up the column name the database reported. It discloses only when every node that names that column is marked `'author'`. A `$and` / `$or` with a primitive operand is judged by the node that carries the key. + + `SqliteWasmDriver` (`@objectstack/driver-sqlite-wasm`) and `TursoDriver` in local mode extend `SqlDriver`, so they inherit this change from it: the same four doors answer the same way there. + + **What an unmarked caller loses:** its own diagnostic from these four doors. Measured cases where the caller's own predicate reaches the driver unmarked: + + - no security plugin in the stack; + - a system-context call; + - an anonymous call; + - a `where` that holds a `{placeholder}` token, which the engine rewrites before the merge. + + That caller gets the withheld wording with the same code and status. A member's plain `where` under `plugin-security` is marked `'author'` and keeps the full text. + + The same three door classes on the Turso REMOTE transport (`RemoteTransport`) now read the mark too: the retired or unknown operator (including a non-operator key in an operator map), the non-list combinator, and the non-boolean `$null` / `$exists`. The operands go to its diagnostic sink. `TursoDriver`'s remote mode rebuilds every filter node before the transport sees it, so no mark reaches the transport there, and these refusals keep the withheld wording for every caller in that mode. The unresolvable WHERE column has no refusal on the remote face (the transport answers `[]`) and is not changed here. + + Not changed: which filters are refused, and the code and status of every refusal. The engine's declared-type, temporal-comparand and filter-token doors are not changed here. +- f09d412: fix(driver-sql, driver-turso): on SQLite, `$like` / `$ilike` read the whole stored value, instead of stopping at its first U+0000 (#20024) + + Clause-②: no + + On the SQLite faces `$like` and `$ilike` compiled to `GLOB`, and SQLite's `glob()` reads the stored value only up to its first U+0000. So a pattern without U+0000 answered a different question over a value holding one, and nothing raised. Measured on `SqlDriver` over better-sqlite3 (SQLite 3.53.4), on `SqliteWasmDriver` over sql.js (3.49.1), on `TursoDriver`'s local mode, and on its remote transport over a local libSQL engine (3.45.1). All four answered alike: + + - `$like: 'a'` returned a value stored as `'a'` + U+0000 + `'b'`, and `$like: ''` returned U+0000 + `'z'`; + - `$like: '%b'`, `$like: 'a_b'` and `$ilike: 'A_B'` did not return `'a'` + U+0000 + `'b'`; + - `$like: '_'` did not return a value that is a lone U+0000. + + Over 108 patterns and 22 `$not` / `$or` / `$and` compositions against 59 stored values, 359 of the 3380 cells over values holding U+0000 differed from `@objectstack/formula` on each face. + + What changes: a stored value holding U+0000 now has each U+0000 replaced by one stand-in character before `GLOB` reads it. The stand-in is never a literal character of the pattern, never an ASCII letter, and never U+0000. A U+0000 in the value can only be matched by `%` or `_`, and so can the stand-in, so the answer is the one the whole value gives. Such a filter now returns the rows `driver-memory` and `@objectstack/formula` return for it, under `$not`, `$or` and `$and` as well: 0 of those 3380 cells differ on any of the four faces. `$ilike` still folds ASCII letters only. + + - `@objectstack/driver-sql`: the SQLite arm of `SqlDriver`'s `$like` / `$ilike` compiler. `SqliteWasmDriver` and `TursoDriver`'s local mode inherit it. + - `@objectstack/driver-sqlite-wasm`: none of its own code changes. It inherits the fix. + - `@objectstack/driver-turso`: the remote transport's own emitter, changed the same way. + + What does not change: + + - A stored value without U+0000 gets the same answer as before: 0 of 16640 such cells moved on any face. + - A pattern that is a literal prefix followed only by `%` (`'ab%'`, `'%'`) compiles to the same `GLOB` with the same bound pattern as before. Cutting the value at its first U+0000 cannot change that answer. + - No index is lost. Under `EXPLAIN QUERY PLAN` over an indexed TEXT column on all three engines, each `$like` pattern measured that starts with a literal (`'ab%'`, `'ab_'`, `'ab%cd'`, `'abc'`, `'a%b%'`) keeps its covering-index search, and each one that starts with a wildcard still scans. A case-exact pattern with a literal prefix now leads with a `GLOB` on that prefix followed by `*`, which every matching value satisfies and which is what keeps that search. + - `_` still matches one character, as `GLOB`'s `?` does. A character outside the Basic Multilingual Plane is one character to `_` on SQLite and two to `@objectstack/formula`, which counts UTF-16 units. That difference is older than this change, and this change does not alter it. + - A pattern holding U+0000 is still refused (`INVALID_FILTER` / 400). + - The Postgres and MySQL arms are untouched. + + Cost, measured over 10,000 rows on the three engines: against a value without U+0000 the new compile adds one `instr()` per row, and those queries took 1.1 to 2.4 times as long as `GLOB` alone, at most 4.5 ms. A value holding U+0000 pays the rewrite: 10,000 rows each holding one to three U+0000 took up to 35 ms, against at most 3 ms for `GLOB`. +- adbbc5d: fix(driver-sql, driver-turso): on SQLite, `$contains` / `$notContains` / `$icontains` / `$endsWith` read the whole stored value, instead of stopping at its first U+0000 (#20024) + + Clause-②: no + + On the SQLite faces these four operators compiled to `GLOB` for a comparand without U+0000, and SQLite's `glob()` reads the stored value only up to its first U+0000. Nothing raised, and the filter answered a different question. Measured on `SqlDriver` over better-sqlite3 (SQLite 3.53.4), on `SqliteWasmDriver` over sql.js (3.49.1), on `TursoDriver`'s local mode, and on its remote transport over a local libSQL engine (3.45.1). All four answered alike: + + - `$contains: 'b'` did not return a value stored as `'a'` + U+0000 + `'b'`; + - `$endsWith: 'a'` returned that value, and `$endsWith: 'b'` did not; + - `$notContains: 'b'` returned it; + - `$icontains: 'B'` did not return `'A'` + U+0000 + `'B'`. + + What changes: these four operators now compile to the length-aware comparisons a comparand holding U+0000 already used, for every comparand. `$contains`, `$notContains` and `$icontains` use `instr()`, and `$endsWith` compares the value's trailing bytes over BLOB. An empty `$endsWith` comparand uses `instr()` too, so it still matches every non-NULL value. Such a filter now returns the rows `driver-memory` and `@objectstack/formula` return for it, under `$not`, `$or` and `$and` as well. The comparand is bound as written, so `*`, `?` and `[` in it are literal, as they were before. `$icontains` still folds ASCII letters only, and `$notContains` still returns a row whose value is NULL. + + - `@objectstack/driver-sql`: the SQLite arm of `SqlDriver`'s text-operator compiler. `SqliteWasmDriver` and `TursoDriver`'s local mode inherit it. + - `@objectstack/driver-sqlite-wasm`: none of its own code changes. It inherits the fix. + - `@objectstack/driver-turso`: the remote transport's own emitter, changed the same way. + + What does not change: + + - `$startsWith` with a comparand without U+0000 compiles to the same `GLOB` with the same bound pattern as before. The stored value's cut cannot change its answer. + - No index is lost. The SQL `SqlDriver` compiles, run under `EXPLAIN QUERY PLAN` over an indexed TEXT column on all three engines, scanned the table for these four operators under `GLOB` and still does; `$startsWith` keeps its index search. + - The Postgres and MySQL arms are untouched. + - `$like` and `$ilike` are outside this entry. Two other entries in this release cover them: on SQLite they now read the whole stored value as well, and every driver that answers `$like` refuses a pattern holding U+0000 (`INVALID_FILTER` / 400). +- 8d76c2d: fix(driver-sql, driver-turso): every filter-compile refusal stops naming a read scope's field or literal unless the refused predicate is marked as the caller's own (#20039) + + Clause-②: no + + A read scope is the RLS, sharing or tenant predicate that `plugin-security` (ordinary reads) and `service-analytics` (the ObjectQL analytics face) AND into the caller's `where`. Both merges mark the scope `'policy'` and the caller's own predicate `'author'` (`markFilterSubtreeProvenance`, `@objectstack/spec/data`). Nine more `SqlDriver` filter-compile refusals did not check the mark, so when one of them refused a scope, its `INVALID_FILTER` / 400 message named the scope's field, and for most of them its literal too. Measured through an ObjectQL `find` under a merge shaped like `plugin-security`'s, with the scope in the `'policy'` arm: + + - an empty or non-string `$icontains` comparand; + - a non-string `$like` / `$ilike` comparand; + - a `$like` / `$ilike` pattern ending in a lone backslash; + - an object or array comparand on `$contains`, `$notContains`, `$startsWith`, `$endsWith` or `$icontains`; + - an `$in` / `$nin` / `$between` member that cannot be bound; + - an `undefined` comparand, in any position; + - an element of `$and` / `$or`, or the operand of `$not`, that is not a filter condition object; + - a `$`-prefixed key in a node position that is not `$and`, `$or` or `$not`; + - a `where` that reaches the driver as an array (the message printed the whole array). + + Each of them now reads the mark on the node it was raised from, as the other compile refusals already did: + + - **`'policy'`, unmarked or ambiguous:** same `INVALID_FILTER` / 400. The message says which kind of refusal fired, but names no field, operator variant, comparand, list position or filter path. Those go to the server log. + - **`'author'`:** the full message, the same text the refusal answered before. + + With these nine, every refusal on `SqlDriver`'s filter-compile path goes through the same seam. + + `SqliteWasmDriver` (`@objectstack/driver-sqlite-wasm`) and `TursoDriver` in local mode extend `SqlDriver`, so they inherit this change from it: the same refusals answer the same way there. + + **What an unmarked caller loses:** its own diagnostic from these refusals. That is every caller whose predicate reaches the driver unmarked, for example with no security plugin in the stack, in a system-context or anonymous call, or with a `where` that holds a `{placeholder}` token (the engine rewrites it before the merge). That caller gets the withheld wording with the same code and status, and the full text is in the server log. A member's plain `where` under `plugin-security` is marked `'author'` and keeps the full text. + + The Turso REMOTE transport (`RemoteTransport`) compiles filters itself. Its copies of these refusals now read the mark the same way: the `$icontains`, `$like` / `$ilike`, lone-backslash, `undefined`, non-node element or operand, undeclared-key and non-object `where` refusals. So do its two other compile refusals that still named the field: an operator map with no operator in it, and a `$between` that reached the transport without being lowered. The operands go to its diagnostic sink. For six of these classes, the withheld sentence is the local one behind the `[RemoteTransport]` prefix. An object text comparand and an unbindable list member already answered there through its comparand refusal, which withholds. `TursoDriver`'s remote mode rebuilds every filter node before the transport sees it, so no mark reaches the transport there, and these refusals keep the withheld wording for every caller in that mode. + + Not changed: which filters are refused, and the code and status of every refusal. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [32be735] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [d46deba] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [7c2c5ae] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [be5c602] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [9ccc417] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [beac798] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [9bfbacb] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [9d81af7] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [57c2b73] +- Updated dependencies [f09d412] +- Updated dependencies [adbbc5d] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8d76c2d] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [e01d347] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [d3958ba] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [15bf186] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [fc0db22] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [5b674f5] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [40626bd] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/driver-sql@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/drivers/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json index dbc85948e0e..2174ef4de16 100644 --- a/packages/drivers/driver-sqlite-wasm/package.json +++ b/packages/drivers/driver-sqlite-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sqlite-wasm", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "WASM SQLite Driver for ObjectStack — runs in browser/WebContainer (StackBlitz) without native bindings", "keywords": [ diff --git a/packages/drivers/driver-turso/CHANGELOG.md b/packages/drivers/driver-turso/CHANGELOG.md index 845216d720e..4570b858f6c 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,1373 @@ # @objectstack/driver-turso +## 17.5.0 + +### Minor Changes + +- be5c602: fix(driver-sql,driver-turso): eight more `IDataDriver` doors publish their declared return type, not a nested `any` (#17690) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (PR #15280 for `SqlDriver.update()` and the `TursoDriver.update()` override, PR #14434 before it on `@objectstack/driver-memory`, PR #17258 for the five `SqlDriver` doors of #15267, PR #17689 for `aggregate()`). No runtime behaviour changes. + + Eight doors published an annotation whose `any` sat **inside** a wider type, while `packages/spec/src/contracts/data-driver.ts` had already declared each one narrower. A consumer holding one of these classes got `any` back and the compiler stopped checking: + + | class | door | published | now | + |---|---|---|---| + | `SqlDriver` | `find` | `Promise` | `Promise[]>` | + | `SqlDriver` | `upsert` | `Promise>` | `Promise>` | + | `SqlDriver` | `bulkUpdate` | `Promise[]>` | `Promise[]>` | + | `SqlDriver` | `temporalFilterValue` | `any` | `unknown` | + | `TursoDriver` | `find` (override) | `Promise` | `Promise[]>` | + | `TursoDriver` | `upsert` (override) | `Promise>` | `Promise>` | + | `TursoDriver` | `bulkUpdate` (override) | `Promise[]>` | `Promise[]>` | + | `RemoteTransport` | `beginTransaction` | `Promise` | `Promise` | + + The `TursoDriver` rows are separate sites, not consequences: an override re-declares the door in that package's own `.d.ts`, so the `@objectstack/driver-sql` narrowing does not reach a consumer holding a `TursoDriver`. + + **What a consumer does.** A cell read off a row now arrives as `unknown` and is typed before use (`String(row.name)`, `Number(cell)`, or a `typeof` narrowing); `Array.prototype.find` over a result set answers `… | undefined` and the absent arm is separated rather than asserted past. Measured across the whole consumer closure of both packages at this change's tree — 115 `typecheck` tasks — the repo-wide cost is **11 sites**, all inside `@objectstack/driver-sql` (9) and `@objectstack/driver-sqlite-wasm` (2), and **zero** outside the driver packages. + + `TursoDriver.beginTransaction` is deliberately NOT narrowed here and stays `Promise`. It overrides `SqlDriver.beginTransaction(): Promise` — narrower than the contract, the honest direction, and the binding declaration for an override — so the contract's `Promise` does not compile there (TS2416). That `any` masks an LSP violation, not an un-narrowed door, and closing it is a separate decision. + + +- 5ba2ec3: feat(spec,core,objectql,driver-sql,driver-turso): a transport can declare it has no transactions, and every transaction gate reads the declaration instead of method presence (#18063) + + Maintainer ruling, decision batch #148 item 3, letter B, 「同意」 2026-09-17, verbatim and untranslated: + + > `packages/spec`: the driver contract gains a way for a transport to **declare 「no transactions」** (the dev picks the smallest spelling the existing capability/contract surface already has — a capability bit is preferred over a new key), and the engine's transaction gating reads the declaration instead of method presence. + + **`DriverCapabilities` gains one live bit, `transactionsUnsupported`.** A transport sets it to say that a handle it issued would be a FALSE SUCCESS rather than a missing feature: the caller gets a handle, the writes execute and are already durable, `rollback()` resolves and undoes nothing. Absence means `false`, exactly like `batchSchemaSync`, so a driver that declares nothing keeps the behaviour it has today. + + **⛔ This is not `DriverCapabilities.transactions` un-retired, and the difference is not cosmetic.** That key was tombstoned in 17.0.0 under ADR-0049 enforce-or-remove and STAYS tombstoned — writing it is still a compile error and still a parse refusal carrying its prescription. It claimed "I support transactions" and nothing read it; this one declares "my transport cannot honour one" and the engine dispatches on it. Reviving the name would have inverted the record's own `absence = false` convention into a tri-state, turned a documented refusal into silent acceptance of a value whose meaning had changed underneath it, and made the tombstone's published text ("no code in any repository ever read it") false. A new key costs one bit; the name costs all of that. + + **Adding a bit to a record enforce-or-remove has pruned SATISFIES that ADR rather than reversing it.** The audit removed thirty-one bits for one stated reason — no code anywhere read them — and kept the three where method presence provably cannot carry the signal. This change is the creation of the missing reader: `driverSupportsTransactions()` (exported from `@objectstack/spec`) is the one definition of the gate, and all FOUR places that used to spell `typeof driver.beginTransaction === 'function'` ask it — `ObjectQL.transaction()`, `ScopedContext.transaction`, the `ScopedContext` begin/commit/rollback trio, and `@objectstack/core`'s `engineCanRollBack`. The bit arrives WITH its reader, in the same change, which is the honest order the ADR asks for. + + **Why method presence could not carry it.** `TursoDriver extends SqlDriver`, whose `beginTransaction()` opens a real knex transaction, so the inherited method reported the libSQL REMOTE transport as transactional. It is not — `RemoteTransport`'s data methods take no `options` argument at all, so a handle cannot reach the statement that would have to join it. A subclass cannot opt out of a door it did not open. This is the mirror of `batchSchemaSync`, which exists because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot. + + **What changes for a caller.** On a datasource whose driver declares the bit, `engine.transaction()` now takes the DECLARED non-transactional path (ADR-0119 D1) instead of opening a transaction it cannot honour: the degrade warns once per datasource — naming the declaration, not a missing method — and `{ require: true }` throws `TransactionUnsupportedError` before the callback writes anything. `ScopedContext.transaction` and the discrete begin/commit/rollback trio read the same predicate; the trio's `begin` returns `null`. Both are the answers a driver with no `beginTransaction` already received. + + **`driver-turso`.** The remote face declares `transactionsUnsupported: true`; local and embedded-replica inherit `false` from the base and are untouched. `TursoDriver.beginTransaction()` publishes the inherited declaration instead of `Promise` — the annotation the earlier `any` was masking an LSP violation to avoid, dissolved rather than widened: the remote arm returns `never` (it refuses), so the only arm that still returns is the base's. `SqlDriver.beginTransaction()` keeps its narrow `Promise`; nothing in the base was widened. + + **`@objectstack/core`.** `engineCanRollBack()` — the ADR-0119 D4 gate that `@objectstack/metadata-protocol` uses for `batchData` / `updateManyData` / `deleteManyData` under `options.atomic`, and that `runMigrationJournal()` uses to decide whether to start at all — reads the same predicate. It has to: it does not open the transaction itself, it vouches that `engine.transaction()` will, and on a driver that declares the bit the engine now takes its non-transactional path. A gate still reading method presence would vouch for a runtime that is about to run the callback with no transaction, so the atomic batch would answer `rollback` over writes that stayed on disk and the journal would write `chunk_done` rows its own contract says mean "committed". What a caller sees on such a datasource instead: `batchData({ atomic: true })` refuses with `501 NOT_IMPLEMENTED` — retry without `atomic`, or probe `capabilities.transactionalBatch` on `/discovery` first — and `runMigrationJournal()` refuses with `MigrationJournalRefusal('NOT_IMPLEMENTED')` before writing a single journal row. Both are the answers a driver with no `beginTransaction` already received. + + **`RemoteTransport` loses `beginTransaction()`, `commit()` and `rollback()`.** They are a published surface, and this is **minor** rather than major on the ruling's own stated ground: that transport never honoured a transaction, so no working behaviour is withdrawn. They had already become unreachable from every caller in the repository when the driver started refusing them; they are now gone, and the declaration keeps them gone by design rather than by audit. +- 62bce5c: `TursoDriver` in **remote** mode now **refuses** transactions with `NOT_IMPLEMENTED` / `501` instead of accepting them and silently doing nothing with them. Local and embedded-replica modes are unchanged — they inherit `SqlDriver`'s knex transactions and still honour `options.transaction`. + + **What was wrong.** `@objectstack/spec`'s `driver.zod.ts` states the delivery mechanism verbatim: *"A transaction handle to be passed to subsequent operations via `options.transaction`."* On the remote transport nothing could receive it. `RemoteTransport` names a transaction in exactly three members (`beginTransaction()`, `commit(t)`, `rollback(t)`) and **zero** of its data methods take an `options` argument at all — against 13 data methods present in the file, which is what makes that zero a reading. So a write issued between `beginTransaction()` and `rollback()` executed on the plain connection, was **already durable**, and the rollback resolved having undone nothing. Every step reported success. + + **What refuses now**, on the remote arm only: + + - `beginTransaction()`, `commit()` and `rollback()` — the capability is never handed out, so the sequence above cannot start. + - Any driver method that arrives carrying `options.transaction` — `find`, `findOne`, `count`, `aggregate`, `create`, `update`, `upsert`, `delete`, the three bulk methods, `updateMany`, `deleteMany`, `execute`, `syncSchema`, `syncSchemasBatch`, `dropTable`. This second door is not redundant: the engine's `buildDriverOptions` reads `execCtx.transaction` **first**, so a handle threaded through `ExecutionContext` reaches a data method without ever passing through `beginTransaction()`. + + The refusal fires on the **handle**, not on remote mode: a remote call with no transaction in it is untouched, which is every call the platform makes today. It is raised before any statement is built, so a refused call costs no round trip and leaves no partial write. + + **If this refusal now fires for you, it is telling you that you never had the transaction.** The remedies, in order: use the **local or embedded-replica** transport for work that needs atomicity; or take the non-transactional path deliberately — `engine.transaction()` without `require: true` on a driver with no transactions runs the callback with no rollback and says so (ADR-0119 D1). `NOT_IMPLEMENTED` / `501` rather than a `400` because the request is spelled correctly and the spec declares the members: the gap is the backend's, the same two-class taxonomy this driver already applies to remote `auto_number`, aggregate functions and date buckets. + + Implementing real transactions on the remote transport is a separate, larger piece of work and is deliberately **not** part of this change. +- e07843b: fix(driver-turso): a REMOTE `TursoDriver` refuses to arm deferred schema DDL instead of accepting it and running the DDL anyway (#19823) + + Clause-②: no (narrowing) + + **BREAKING for callers that arm DDL deferral on a remote Turso datasource** — `TursoDriver.setDeferredDdl(true)` in `remote` transport mode (a `libsql://`, `https://`, `http://`, `wss://` or `ws://` URL with no `syncUrl`) now throws a `NOT_IMPLEMENTED` / `501` error, where it used to be accepted and then ignored. The five `os migrate` commands that arm it — `plan`, `apply`, `duplicates`, `account-issuer` and `multi-value-columns` — therefore exit non-zero against a remote Turso database, where they used to exit 0 after changing it. Disarming (`setDeferredDdl(false)`) is accepted, and the `local` and `replica` modes defer exactly as before. + + What the refusal replaces, measured on the transport's SQLite-backed test double: arming was accepted, but none of the remote schema doors reads the flag. The engine's boot sync (`syncSchemasBatch`) ran `CREATE TABLE` and `ALTER TABLE … ADD COLUMN` through `RemoteTransport`; the `syncSchema` / `initObjects` doors ran the same DDL plus the canonical temporal backfill, rewriting stored `datetime` / `time` values in place; and `previewDeferredSchemaWork()` and `flushDeferredSchemaDdl()` both answered `[]`. So `os migrate plan` changed the database and then reported no pending work, and `os migrate apply` asked for confirmation after the schema work had already run. + + - **Refused at the setter.** Every deferring caller passes through `setDeferredDdl`, and it runs before any schema work: a refused arm sends nothing to the database and leaves the driver un-armed. + - **The driver's message is what the operator reads.** The CLI prints it verbatim. It names the `remote` transport mode, says why the promise cannot be kept, and says what to do instead. + - **No new error code.** `NOT_IMPLEMENTED` / `501` is a standard code, the envelope this transport already uses for its remote transaction and auto-number refusals. + - **Ordinary boots are unchanged.** A boot that does not arm the deferral (`os serve`, `os start`, `os dev`) syncs a remote schema exactly as before. + + **If you are refused:** to preview schema work, run the command against a local SQLite copy of the database (a `file:` URL); the local and embedded-replica faces defer DDL. To perform the additive schema work, let an ordinary boot against the remote datasource (`os serve` / `os start`) run it directly. + + +- 1f0b341: fix(driver-turso): a REMOTE `TursoDriver` refuses to detect schema drift instead of answering that there is none (#19845) + + Clause-②: no (narrowing) + + **BREAKING for callers that read schema drift from a remote Turso datasource** — `TursoDriver.detectManagedDrift()` in `remote` transport mode (a `libsql://`, `https://`, `http://`, `wss://` or `ws://` URL with no `syncUrl`) now throws a `NOT_IMPLEMENTED` / `501` error, with or without an explicit object list, where it used to answer `[]`. The `local` and `replica` modes detect drift exactly as before. + + What the refusal replaces, measured on the transport's SQLite-backed test double: the inherited detector reads the physical schema through Knex, and a remote driver's Knex connection is a placeholder in-memory database holding none of the datasource's tables. A synced table carrying an extra physical column the declaration omits therefore read `unmapped_column` / `drop_column` on the local face and `[]` on the remote one. The artifact-pinned boot gate of `os serve` (`OS_ARTIFACT_URL`), which refuses a boot on destructive drift, read that `[]` as "never drifted" and let every remote-Turso boot through. + + - **The boot gate now says it could not check.** It already treats a failed drift detection as "the check did not run": it prints a warning carrying the driver's message and the boot continues. A remote-Turso boot is therefore not refused by this change; it is told the schema was not checked, where before it was told nothing. + - **No other caller in this repository reaches it.** The `os migrate` commands that read drift (`plan`, `apply`, `multi-value-columns`) arm deferred schema DDL first, which the remote face already refuses. + - **No new error code.** `NOT_IMPLEMENTED` / `501` is a standard code, the envelope this transport already uses for its remote transaction, auto-number and deferred-DDL refusals. + + **If you are refused:** to check a remote Turso database for drift, run `os migrate plan` against a local SQLite copy of it (a `file:` URL), where the physical schema is introspected. + + +- 0142415: fix(driver-turso)!: a local or replica `TursoDriver` on a remote url, or a replica off a local file, is refused at construction + + Clause-②: no (narrowing) + + A remote `url` beside `syncUrl` was classified as an embedded replica, and the local SQLite engine that every replica read and write goes through was handed `:memory:`. Writes succeeded and read back, then vanished on restart, and none of them reached the remote. `@libsql/client` builds no embedded replica for a remote url: it routes `libsql://` / `https://` / `http://` to its HTTP client and `wss://` / `ws://` to its WebSocket client, neither of which reads `syncUrl`. A forced `mode: 'replica'` or `mode: 'local'` beside a remote url was handed the same `:memory:` engine, and so was a replica on `:memory:`. Measured before the change, with `create`, `find`, then a fresh driver on the same config: + + ``` + libsql:// + syncUrl (sync.onConnect: false) -> 1 row back, 0 rows after restart + libsql:// + mode: 'replica' or mode: 'local' -> 1 row back, 0 rows after restart + :memory: + syncUrl + a supplied client -> 1 row back, 0 rows after restart + file: + syncUrl (unchanged) -> 1 row back, 1 row after restart + ``` + + With the driver building its own client and the default `sync.onConnect`, two of these did fail at `connect()`, but on a libsql error that did not say why: `libsql://` + `syncUrl` with `SYNC_NOT_SUPPORTED`, and `:memory:` + `syncUrl` with `URL_INVALID`. + + **BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses configurations it accepted before**, at `new TursoDriver()`, ahead of the Knex base and of any client, with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`. A remote url here means one of the schemes `TursoDriver.detectMode` classifies as remote: `libsql://`, `https://`, `http://`, `wss://`, `ws://`. The #19976 entry in this same version matches them in any letter case, so an uppercase `LIBSQL://` is a remote url too. Refused: + + - a remote url beside `syncUrl`; + - a remote url under a forced `mode: 'replica'` or `mode: 'local'`; + - a replica on a url `@libsql/client` reads as in-memory (`:memory:`, or `file::memory:` with or without a query string), beside `syncUrl` or under a forced `mode: 'replica'`. `@libsql/client` refuses such an embedded replica itself. For `:memory:` and a bare `file::memory:` the local engine was a private in-memory database. With a query string it was a file literally named after the url's path (for example `:memory:?cache=shared`) in the working directory, which no sync reaches; + - under a forced `mode: 'replica'`, a `url` that is none of `:memory:`, a `file:` url or a remote url, such as a bare path or an unsupported scheme. The #19976 entry in this same version refuses such a url in every local or replica mode, and matches the `file:` scheme in any letter case: an uppercase `FILE:` url naming a file is a `file:` url and is not refused, and the replica runs on that file (`FILE::memory:` is refused as in-memory, like `file::memory:`). + + The remote-url refusal names the scheme it met. Neither refusal echoes the url, which may carry a token. Both loaders (`@objectstack/runtime`'s host factory and the datasource factory) reach this refusal through the same constructor, so a datasource declaring one of these configurations now fails by name when its loader builds the driver. + + **What stays accepted**, pinned by preservation tests: a `file:` url with `syncUrl` (the embedded replica), a `file:` or `:memory:` local database, a remote url on its own or with `mode: 'remote'`. `TursoDriver.detectMode()` still classifies a remote url beside `syncUrl` as `'replica'`: the refusal sits in the constructor, not in a re-classification. + + **Not refused by this change:** a url with no `mode` that is none of a lowercase `file:` url, `:memory:` or a lowercase remote scheme, such as an uppercase `LIBSQL://`, an uppercase `FILE:` url or a bare path like `./data/app.db`, auto-detected `'local'` with or without `syncUrl`, and the local engine was handed `:memory:`. Under a forced `mode: 'local'` the same url got the same `:memory:` engine. The #19976 entry in this same version removes that fall-through: it matches every scheme in any letter case, so an uppercase remote url is a remote url and an uppercase `FILE:` url is a `file:` url, and it refuses every other url in a local or replica mode. No configuration runs on an in-memory database it did not name. + + **What an affected author does.** Each refusal names its ways out. For a remote url in a local or replica mode: + + - to use the remote database, drop `syncUrl` (and `sync`) and any forced `mode`; the remote url alone sends every read and write to it; + - for an embedded replica, point `url` at a local file and keep the remote in `syncUrl`: `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io'`. + + For a replica off a local file, point `url` at a local `file:` path beside `syncUrl`. A throwaway in-memory database instead drops `syncUrl` (and `sync`) and any forced `mode: 'replica'`, and keeps `url: ':memory:'`. + + Blast radius, measured on this tree: no example, template, published skill, hand-written doc or factory default declares a remote url beside `syncUrl`, and the host boot path (`OS_DATABASE_URL`) passes no `syncUrl`. Outside this package's own tests, the in-repo configurations carrying the pair are test fixtures that never construct the real driver: loader fixtures that exercise the config builder or a capturing constructor, stored-row redaction fixtures and a schema-parse fixture. Whether any out-of-repo deployment declares it is NOT measured and is not claimed to be zero. + + +- a0920b4: fix(driver-turso): a REMOTE `TursoDriver` refuses to plan the ADR-0104 media column move instead of answering that there is nothing to move, and `os migrate files-to-references` reports that refusal as a column step it could not judge (#19894) + + Clause-②: yes (narrowing) + + **BREAKING for callers that plan the media column move on a remote Turso datasource** — `TursoDriver.planMediaColumnMove()` in `remote` transport mode (for example a `libsql://` URL) now throws a `NOT_IMPLEMENTED` / `501` error, where it used to answer `{ plans: [], refusals: [] }`. The `local` (`:memory:` and `file:`) and `replica` modes plan exactly as before, and the local modes plan exactly what `SqlDriver` plans for the same declaration. + + What the refusal replaces, measured on the transport's SQLite-backed test double: a table with a `file` and an `image` field, synced through each of the three remote schema doors (`syncSchemasBatch`, `syncSchema`, `initObjects`), held its two TEXT media columns on the remote database, and the remote face answered an empty scan on every door. The inherited planner walks the objects `SqlDriver`'s own schema sync registers, which no remote schema door reaches, and probes each table through the placeholder in-memory Knex connection a remote driver is built with. The local and embedded-replica faces planned two `unquote` moves for the same declaration. `os migrate files-to-references` printed that empty scan as "Column step: nothing to move — this datastore declares no single-value media column". + + - **The command reports the refusal instead of failing on it.** `os migrate files-to-references` calls the planner only after the backfill and its self-check have passed, and an `--apply` run has recorded the deployment flag by then. Measured on the command's own test doubles before this change, a planner that throws ended the run with the error alone (`--json`: `{"error": …, "code": "NOT_IMPLEMENTED"}`) and exit 1, with no backfill report, no verify report and no word about the flag it had recorded. The column step now catches a `NOT_IMPLEMENTED` refusal by its code and reports it as a skip: the text face prints `Column step: NOT JUDGED` with the driver's message, and `--json` gains `columnMoveRefused` — `{ error, code }` when the driver refused, `null` otherwise — beside `columnMove: null` and `columnsMovedAt: null`. The backfill, verify and flag reports are emitted as on any other run, nothing is stamped, and the exit code is the self-check's, as it already was for the command's other column-step skips. + - **Any other throw from the planner still fails the command**, through the same error report and exit 1 as before. + - **A genuinely empty scan still reads "nothing to move".** + - **No new error code.** `NOT_IMPLEMENTED` / `501` is a standard code, the envelope this transport already uses for its remote transaction, auto-number, deferred-DDL and drift-detection refusals. + + **If you are refused:** the backfill, its self-check and, on `--apply`, the deployment flag are unaffected. A remote Turso datasource keeps its single-value media columns on the JSON encoding: measured on the same double, the remote face writes a file id as a JSON string and reads it back as the id, and it does not read the record of a completed column move. + + +- 61609ed: fix(driver-turso)!: a url scheme matches in any letter case, and a url the driver cannot open is refused instead of running on a private in-memory database + + Clause-②: no (narrowing) + + `TursoDriver.detectMode` matched `file:` and the five remote schemes case-sensitively, and answered `'local'` for any other url with no `mode`. The local engine can open only a `file:` path or `:memory:`, so for everything else it was handed `:memory:`: writes succeeded and read back, then vanished on restart. `@libsql/client` reads a scheme in any letter case (`@libsql/core@0.17.4` routes on `uri.scheme.toLowerCase()`), so an uppercase `LIBSQL://` url that the client routes to the remote database ran on that local `:memory:` engine instead. `@objectstack/cli` and `@objectstack/runtime` select this driver for an `OS_DATABASE_URL` matching `libsql://` in any letter case and hand it the url as written, so an uppercase `OS_DATABASE_URL` reached that engine too. Measured before the change, with `initObjects`, `create`, `find`, then a fresh driver on the same config: + + ``` + LIBSQL://… (no mode) -> local, 1 row back, 0 rows after restart + FILE: (no mode) -> local, 1 row back, 0 rows after restart, file never created + .//app.db (no mode) -> local, 1 row back, 0 rows after restart, file never created + /app.db (no mode) -> local, 1 row back, 0 rows after restart, file never created + .//app.db + mode: 'local' -> local, 1 row back, 0 rows after restart, file never created + file: (unchanged) -> local, 1 row back, 1 row after restart + ``` + + **The scheme now matches in any letter case**, as it does in `@libsql/client`, in `detectMode` and in every constructor check. `LIBSQL://`, `HTTPS://`, `Http://`, `WSS://` or `Ws://` with no `mode` is remote. `FILE:` is a local file, and an embedded replica beside `syncUrl`. The url itself is passed to the client as written. + + **BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses configurations it accepted before**, at `new TursoDriver()`, ahead of the Knex base and of any client, with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`. Refused: + + - in a local or replica mode, a `url` that is none of `:memory:`, a `file:` url or a remote url (`libsql://`, `https://`, `http://`, `wss://`, `ws://`, in any letter case). That is a bare path (`./data/app.db`, `data/app.db`, `/var/lib/app.db`, `C:\data\app.db`), an unsupported scheme (`sqlite:`, `memory://`), `:MEMORY:`, a remote scheme with no `//`, a url behind leading whitespace, and an empty url. Newly refused with no `mode` (with or without `syncUrl`) and under a forced `mode: 'local'`. Under a forced `mode: 'replica'` it was already refused, and the refusal now names the `file:` spelling for the replica. `@libsql/client@0.17.4` refuses each of these urls itself, as `URL_INVALID` or `URL_SCHEME_NOT_SUPPORTED`; + - an uppercase remote url beside `syncUrl` (no `mode`), or under a forced `mode: 'local'`. Both constructed on the private `:memory:` engine before, and both now meet the refusal their lowercase spelling already met. Under a forced `mode: 'replica'` it was already refused, now with that same remote-url refusal; + - an uppercase `WSS://` or `WS://` url with a non-zero `timeout`, no `mode` and no `syncUrl`. It is now detected as remote, so the existing refusal of a `timeout` on the WebSocket transport reaches it; + - `FILE::memory:` beside `syncUrl` (no `mode`), refused as an in-memory replica exactly like `file::memory:`. Under a forced `mode: 'replica'` it was already refused. + + The unrecognised-url refusal names the `file:` spelling (`url: 'file:./data/app.db'`, or `url: 'file:./data/replica.db'` beside `syncUrl`) and never echoes the url, which may carry a token. `TursoDriver.detectMode()` now answers `'replica'` for such a url beside `syncUrl` (it answered `'local'`), which is what the declaration asks for. The refusal sits in the constructor, not in a re-classification. + + **Newly accepted:** an uppercase or mixed-case `FILE:` url naming a file, under a forced `mode: 'replica'`, with or without `syncUrl`. The #19893 change refused it, because under a forced `mode: 'replica'` it refused every url that did not start with a lowercase `file:`. With this change it is a `file:` url: the replica runs on that file, and its rows survive a restart (pinned). It is the one configuration the #19893 change refused that this change accepts. + + **What stays accepted**, pinned by preservation tests: a lowercase `file:` url, alone or with `syncUrl`; `:memory:` as a local database; a lowercase remote url on its own or with `mode: 'remote'`. A forced `mode: 'remote'` runs no local engine, so this change does not judge its url: a bare path there still constructs, and `@libsql/client` refuses it at `connect()` as `URL_INVALID`. + + **What an affected author does.** A local database file needs the `file:` prefix: `url: 'file:./data/app.db'`. For a throwaway in-memory database, `url: ':memory:'`. An uppercase remote url with no `mode` and no `syncUrl` now reaches the remote database and needs no change. Beside `syncUrl` or under a forced local or replica mode it is refused with the same ways out as the lowercase spelling. + + The #19893 entry in this same version (the constructor refusal of a remote url in a local or replica mode) describes this fall-through as not refused by that change, and names this entry as the one that removes it. + + Blast radius, measured on this tree: no example, template, hand-written doc, published skill or factory default, and no test fixture outside this package's own tests, spells a turso url with an uppercase scheme or as a bare path. Neither host url sniffer selects this driver for a bare path (both select it only for `libsql://` or an `http(s)://` url naming a `.turso.` host); only an explicit `OS_DATABASE_DRIVER=turso` or a datasource declaring `driver: 'turso'` hands it one. Whether any out-of-repo deployment declares such a url is NOT measured and is not claimed to be zero. + + +- 172b4cf: fix(spec)!: a turso datasource config the driver refuses, or ignores a key of, is refused where it is written + + Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed and no exported symbol moves. Combinations of `url`, `syncUrl`, `mode` and `timeoutMs` that the turso driver refuses when it is built, and one it builds and then ignores, are now refused at parse. + + `TursoConfigSchema` (the `turso` / `libsql` `datasource.config` contract in `@objectstack/spec`, and the published mirror in `@objectstack/driver-turso`) parsed each key on its own. So it accepted configurations that `new TursoDriver()` refuses with `VALIDATION_ERROR` / 400: a datasource published clean and then failed at boot, or at a test connection. Measured on `main` before the change, both schemas accepting every row: + + ``` + libsql:// (any scheme, any case) + syncUrl -> constructor refuses + libsql:// + mode: 'replica' or mode: 'local' -> constructor refuses + ./data/app.db (a bare path), sqlite:, :MEMORY: -> constructor refuses + :memory: or file::memory: + syncUrl -> constructor refuses + wss:// or ws:// + timeoutMs -> constructor refuses + libsql:// + mode: 'remote' + syncUrl (+ sync) -> constructs; syncUrl ignored + ``` + + On that last row the remote client is built without `syncUrl`, no sync interval starts, the driver's sync call rejects `SYNC_NOT_SUPPORTED`, and the driver still reports sync as enabled. + + **BREAKING** accept-set narrowing on a published schema, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). Refused now, each as one `custom` issue on the key it names: + + - **on `url`**, in a local or replica mode (a forced `mode: 'local'` / `'replica'`, or `syncUrl`, or a url that is not remote): a remote url (`libsql://`, `https://`, `http://`, `wss://`, `ws://`, in any letter case); a url that is none of a `file:` url, `:memory:` or a remote url, such as a bare path, another scheme, `:MEMORY:`, a remote scheme with no `//` or a blank url; and a replica on an in-memory url (`:memory:`, `file::memory:` in any case, with or without a query string); + - **on `timeoutMs`**: a window beside a `wss://` / `ws://` url in remote mode; + - **on `syncUrl`**: `syncUrl` under a forced `mode: 'remote'`. The constructor accepts this one, so it is refused at authoring only; + - **on `sync`**, in the `@objectstack/driver-turso` mirror only: `sync` with no `syncUrl`, in the words the spec contract has always used for it. + + The rules mirror the constructor's own: a scheme matches in any letter case, `:memory:` matches exactly, and the url is read trimmed, as both datasource loaders hand it to the driver. A forced `mode: 'remote'` keeps its url unjudged, as the constructor does. Nothing the constructor accepts is refused, the `syncUrl`-under-`mode: 'remote'` row aside. The mirror declares no `mode` key and strips an authored one, so it judges every config in the mode its url and `syncUrl` select. A test in `@objectstack/driver-turso` holds the constructor and both schemas to one case table of 54 rows, with messages compared byte for byte. + + The spec `url` describe named "a file path" among the accepted spellings, which is the one spelling the driver refuses. It now reads "a local file written as a file: URL (never a bare path)". + + ### Migration: FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `url: 'libsql://my-db.turso.io', syncUrl: 'libsql://my-db.turso.io'` | a remote database: `url: 'libsql://my-db.turso.io'` alone. An embedded replica: `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io'` | + | `url: 'libsql://my-db.turso.io', mode: 'replica'` (or `'local'`) | drop `mode`, or set `mode: 'remote'` | + | `url: './data/app.db'` | `url: 'file:./data/app.db'` | + | `url: ':memory:', syncUrl: …` | a replica on a file: `url: 'file:./data/replica.db'` beside `syncUrl`. An in-memory database: drop `syncUrl` and `sync` | + | `url: 'wss://my-db.turso.io', timeoutMs: 30000` | `url: 'libsql://my-db.turso.io', timeoutMs: 30000`, or drop `timeoutMs` | + | `url: 'libsql://my-db.turso.io', mode: 'remote', syncUrl: …` | drop `syncUrl` and `sync` | + + Each refusal prints these ways out and names only a remote url's scheme, never the url, which may carry a token. Stored datasource rows are not re-parsed when they load, so a stored row keeps loading as before; creating, testing or editing its `config` through the datasource admin service, `defineStack` or `os validate` is refused at the key until it is rewritten. The constructor already refuses the first five rows at boot. + + Blast radius, measured on this tree: no example, template, published skill or hand-written doc authors a refused combination. Four test fixtures spelled one and are rewritten in this change, each named in the PR: one in `@objectstack/spec`, two in `@objectstack/driver-turso` (one of them pinned a placeholder url as accepted), and the stored-row redaction fixture in `@objectstack/service-datasource`, now an embedded replica on a `file:` url. Loader fixtures in `@objectstack/runtime`, `@objectstack/cli` and `@objectstack/service-datasource` that spell a remote url beside `syncUrl` exercise only the config builder or a capturing constructor. They never parse this schema or build the real driver, and are unchanged. Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero. + + +- 8a44ce7: fix(spec, drivers)!: a `$like` / `$ilike` pattern holding U+0000 is refused by every driver that answers `$like`, instead of being cut at the NUL on SQLite + + Clause-②: yes (narrowing) + + On the SQLite faces `$like` / `$ilike` compile to `GLOB`, and SQLite reads a pattern only up to its first U+0000. A pattern holding U+0000 was cut there, so the filter answered a different question, and nothing raised. Measured through `find` over 13 stored values (12 non-NULL), against `@objectstack/formula` on the same rows: all 20 U+0000 cases of the probe (10 patterns, bare and under `$not`) differed on `SqlDriver` over better-sqlite3, on `SqliteWasmDriver`, on `TursoDriver`'s local mode, and on its remote mode over a stub and over a real `@libsql/client` engine, with identical answers on all five. For example: + + - `$like: '%'` + U+0000 returned all 12 non-NULL rows, where `formula` returns the two ending in U+0000; + - `$like: 'a'` + U+0000 + `'b'` also returned `'a'`; + - `$ilike: 'AB'` + U+0000 also returned `'AB'` and `'ab'`. + + `driver-memory` answered all 20 as `formula` does. SQLite has no NUL-safe pattern primitive to compile to instead: `LIKE` cuts the same way, `replace()` cannot target U+0000, and `instr()` has no wildcards. So the one contract is a refusal, the way a pattern ending in a lone unpaired backslash is refused. + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A filter that answered before is now refused**: a `$like` or `$ilike` pattern holding U+0000 anywhere (at the start, in the middle, at the end, alone, or after a backslash) gets `INVALID_FILTER` / 400, on every door that already refused the lone trailing backslash: + + - `@objectstack/driver-sql`: on the filter walk, before a dialect is chosen, so SQLite, Postgres and MySQL all refuse it. `@objectstack/driver-sqlite-wasm` and `TursoDriver`'s local mode inherit it; `@objectstack/driver-sqlite-wasm`'s own code does not change. + - `@objectstack/driver-turso`: the remote transport's `$like` / `$ilike` arm, before anything is sent to the engine. + - `@objectstack/driver-memory`: the shape gate of the query path and of the reference matcher `match()`, and the QueryAST `comparison` spelling (`like` / `ilike`). + - `@objectstack/spec` exports the shared test, `hasNulInLikePattern`, beside `hasDanglingLikeEscape`, and the `$like` operator's description now names the refusal. + + On `driver-sql` and the Turso remote transport the refusal goes through the read-scope provenance seam, like every other filter-compile refusal there. On `driver-sql` (and so `driver-sqlite-wasm` and Turso's local mode), a caller whose predicate is marked `'author'` reads the operator, the field, the filter path and the pattern, with U+0000 written as `\u0000`. Any other caller gets only the class statement, and the rest goes to the server log. The remote transport withholds the same way, and through `TursoDriver` in remote mode no mark reaches it, so every caller gets the class statement there. On `driver-memory` every caller reads the full text, as for its dangling-escape refusal. + + A pattern that ends in a lone unpaired backslash AND holds U+0000 keeps the dangling-escape refusal it had before. + + **What stays accepted**, pinned per face: every `$like` / `$ilike` pattern without U+0000 answers exactly as before. + + **Not changed here:** + + - A pattern without U+0000 matched against a STORED value that holds U+0000 is not refused: it is well formed, and on the SQLite faces it reads the whole stored value, by its own entry in this release. + - `@objectstack/formula` still evaluates such a pattern. It refuses nothing, and answers `false` for a dangling escape rather than refusing it, so it is not one of these doors. + - `driver-mongodb`, objectql `having` and `service-analytics` refused every `$like` / `$ilike` before this change, and still do. + + **What an affected author does.** Remove the U+0000 from the pattern. No escape makes it portable: a backslash before it still leaves a U+0000 in the pattern. + + Blast radius, measured on this tree: no example or template writes a `$like` or `$ilike`, and the published `objectstack-query` skill and the hand-written docs that show one show no pattern holding U+0000. Whether any out-of-repo caller sends one is NOT measured and is not claimed to be zero. + + +- 2491729: fix(driver-turso)!: a REMOTE `TursoDriver` no longer needs `better-sqlite3` installed, and the two inherited calls that answered from its private in-memory database now reject (#20054) + + Clause-②: no (narrowing) + + `package.json` declares `better-sqlite3` an optional peer, and the README tells a remote-only deployment (Vercel, an Edge runtime) that it does not need it. The code did not keep that promise. With `better-sqlite3` absent, `new TursoDriver({ url: 'libsql://…' })` threw knex's `Knex: run $ npm install better-sqlite3 --save` error at construction, before any remote call. + + The cause: remote mode handed the `SqlDriver` base a `better-sqlite3` Knex config on `:memory:`, and knex loads a dialect's native driver whenever the config carries a `connection`. Remote mode now builds that Knex instance with no `connection`. It loads no native module, opens no pool and holds no private in-memory database. With `better-sqlite3` absent, a remote driver constructs, connects and runs CRUD through `@libsql/client`. + + **BREAKING** — two calls on a remote driver that resolved before now reject. This is an accept-set narrowing on a published driver, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). The calls are `SqlDriver` methods that remote mode does not override, and they now reject with knex's `Unable to acquire a connection` error: + + - `introspectSchema()` used to resolve `{ tables: {} }`, "no tables", whatever the remote database held; + - `reclaimSpace()` used to resolve. + + Both old answers came from the private in-memory database, not from the remote one. The per-method answer or refusal for these inherited calls is carried by #20055. + + **Error wording only, not the narrowing.** On a remote driver: + + - `findWithWindowFunctions()` still rejects. The error is now knex's `Unable to acquire a connection` instead of a missing-table error from the in-memory database. + - `analyzeQuery()` and `explain()` still resolve the compiled SQL with an `error` field. That field now carries knex's message instead of a missing-table error. + - `distinct()` answers the same `DATABASE_ERROR` / 500 as before. + + **Unchanged:** + + - **Local and embedded-replica modes.** Their `toKnexConfig` arms are untouched and still run on `better-sqlite3`, so a local driver (`:memory:` or a `file:` url) still fails at construction without it. + - **Every call remote mode sends to `RemoteTransport` behaves as before**, including raw SQL through `execute()`. None of them used the Knex instance. + - **The `NOT_IMPLEMENTED` / 501 refusals of `detectManagedDrift()` and `planMediaColumnMove()` on a remote driver** still refuse, with the same code and status. Their messages no longer say that remote mode's Knex connection is a placeholder in-memory database; they say that remote mode has no Knex connection. + + +- 84880f9: fix(driver-turso)!: a REMOTE `TursoDriver` answers or refuses every public `SqlDriver` method it inherited, instead of failing on a Knex connection it does not have (#20055) + + Clause-②: yes (narrowing) + + `TursoDriver` extends `SqlDriver`. Before this change, 23 public `SqlDriver` members had no remote-mode arm, so a remote driver ran their Knex implementations. Remote mode builds Knex with no connection, so those calls failed with knex's `Unable to acquire a connection`, which reads as a network fault, or they answered from state no remote schema sync fills. Each one is now answered on the remote database or refused with `NOT_IMPLEMENTED` / 501, and the driver's source lists every public `SqlDriver` member with its remote answer. A method added to `SqlDriver` later fails this package's type check until its remote answer is decided. + + **BREAKING.** On a remote driver, six members that used to answer now refuse or answer differently. This narrows what a published driver accepts. It ships as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). + + - `explain()` and `analyzeQuery()` used to resolve with the SQL the local compiler would build, plus an `error` field in place of a plan. They now reject with `NOT_IMPLEMENTED` / 501. + - `applyMigrationEntries()` used to resolve. Every entry came back `skipped`, including a destructive entry that `allowDestructive` permitted. It now rejects with `NOT_IMPLEMENTED` / 501, with or without entries. + - `getKnex()` used to return a Knex instance on which every statement failed. It now throws `NOT_IMPLEMENTED` / 501. + - `supportsRotation` used to read `true`. It now reads `false`. The lifecycle service reads it, and for an object that declares a rotation storage policy it now takes its age-based reap instead of calling `rotateShards()`, which failed. + - `setFileColumnsMovedResolver()` used to return `true` ("taken"), and then never asked the resolver. It now returns `false`. Remote mode keeps writing media columns in the JSON encoding, as before. + + **Refused with a clearer error.** These calls already failed. They now reject with `NOT_IMPLEMENTED` / 501, and the message names the local or embedded-replica transport, or `execute()`, as the alternative: + + - `introspectSchema()`. The datasource connection test still answers `ok: false`, and its error now says introspection is not supported in remote mode; + - `findWithWindowFunctions()`; + - `rotateShards()`; + - `distinct()` called with `options.tenantId` on an object that has a tenant column. No remote read applies the tenant scope, so an answer would list every organization's values. + + **Now answered on the remote database.** These used to fail: + + - `distinct()` without a tenant scope runs a `SELECT DISTINCT` and answers what local mode answers for the same rows. The filter and the value presentation match, and an unknown column is refused with `INVALID_FIELD` / 400. + - `reclaimSpace()` sends the statement local mode issues, `PRAGMA incremental_vacuum`, to the remote database. The lifecycle service's sweep now counts the datasource as reclaimed instead of logging a warning. The statement returns pages only on a database whose `auto_vacuum` mode is `INCREMENTAL`. + + `RemoteTransport` gains one public method, `compileDistinct()`. It builds the statement behind a remote `distinct()`. + + **Unchanged.** Local and embedded-replica modes run the inherited Knex members as before. On a remote driver, `commitTransaction()` and `rollbackTransaction()` still reject through the `commit()` / `rollback()` refusal. The deferred-DDL readers still report nothing deferred, and `getSchemaSyncStats()` still answers `{ created: 0, existing: 0 }`, which the `IDataDriver` contract reads as "cannot say". The bookkeeping and dialect members also answer as before. + + +- dbddf02: `TursoDriver` in **remote** mode reads and writes a federated object's remote table, `external.remoteName`, the way the local and embedded-replica modes always have (#20107). + + Clause-②: yes (widening) + + **What was wrong.** `registerExternalObject` records a federated object's remote table (ADR-0015), and the local face reads that record for every statement. The remote face inherited the registration but never read it. Every remote data door handed `RemoteTransport` the object name, and the transport used it as the table. So an object `ext_customer` bound to the remote table `customers` was queried as a table named `ext_customer`. Measured on a remote face over a libSQL `file:` client, with a local driver over the same file answering every door from the mapped table: + + - `find`, `findOne`, `count` and every write door failed with a bare `LibsqlError` (`SQLITE_ERROR: no such table: ext_customer`). It carried no `status`, so REST served it as an unclassified 500. + - `aggregate` answered `[]`, because the transport reads "no such table" as "no rows". + - `distinct` answered `DATABASE_ERROR` / 500. + + **What changes, on the remote face only:** + + - Every data door (`find`, `findOne`, `count`, `aggregate`, `distinct`, `create`, `update`, `upsert`, `delete`, `bulkCreate`, `bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany`) compiles against the table the registration recorded. That is `external.remoteName` for a federated object, and the object's own name otherwise. It is read from the same `SqlDriver` registry the local face reads, and not copied. Managed objects send the same statements as before. + - A remote table name is quoted as one SQL name, with any embedded quote doubled, so a name the local face can read (`order-lines`) can be read here too. + - `find`, `findOne` and `count` now end in the local face's read-exit envelope. A statement the backend refuses, for example on a remote table that really is absent, answers `DATABASE_ERROR` / 500. The libSQL error rides under a non-enumerable `cause`, the dialect text goes to the server log, and the targeted table is declared for `isMissingTableError`. Before, the bare `LibsqlError` reached the caller. The write doors keep the local face's behaviour, where a write fault is classified at the REST boundary from its message. + - A federated object whose `external.columnMap` **renames** a column is refused with `NOT_IMPLEMENTED` / 501 on every remote data door, before any statement is sent. The remote transport addresses columns by field name and does not translate the map. With the table resolved, a filter on a renamed field would read a column the table does not have, and the transport answers that with an empty list rather than the matching rows. Before this change, an object whose `remoteName` differs from its name failed with `no such table` on every door but `aggregate`, which answered `[]`. A binding with no `remoteName`, or with `remoteName` equal to the object name, plus a renaming map is refused too: before, its unfiltered reads and its id-keyed writes answered, while its filtered reads were already silently wrong. A map whose entries rename nothing is served. **If this refusal fires for you:** use the local or embedded-replica transport for that object, or name its fields after the remote columns and drop the renaming entries. + - `RemoteTransport` (exported from the package root) gains an optional trailing `table` argument on its 14 data methods; omitted, it defaults to the object, which is what a transport used on its own always did. +- bea6d2e: fix(driver-turso)!: `new TursoDriver` refuses `syncUrl` under a forced `mode: 'remote'`, and `sync` with no `syncUrl`, instead of building and ignoring them + + Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, and no exported symbol moves. Two driver configurations that the turso driver used to build and then ignore a key of are now refused when it is built. + + `new TursoDriver()` accepted `syncUrl` beside a forced `mode: 'remote'`. Remote mode sends every read and write straight to `url`, and the remote client is created without `syncUrl`, so no replica is built and no sync ever runs. Measured on the built driver before this change, a `libsql://` or `file:` url under `mode: 'remote'` with `syncUrl` and `sync` constructed and connected, and `isSyncEnabled()` answered `true`. No sync interval started, and the sync call rejected with `SYNC_NOT_SUPPORTED` (`SyncNotSupported("File")` on the `file:` url). It also accepted `sync` with no `syncUrl`, in any mode, where nothing reads it. `@objectstack/spec`'s `TursoConfigSchema` already refused both at authoring. A datasource row stored before that, or a config a host builds itself, reached the constructor unparsed and ran with a sync setting that did nothing. + + **BREAKING** accept-set narrowing on a published constructor, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). Refused now with `VALIDATION_ERROR` / 400, before any client or database is opened: + + - `syncUrl` under a forced `mode: 'remote'`. A remote url beside `syncUrl` with no `mode` was already refused, as a replica on a remote url; + - `sync` with no `syncUrl` (or with an empty one), in local, replica and remote mode alike. + + Each refusal's message is the spec contract's issue message for that key, byte for byte, so authoring and boot say the same thing. A test holds the copies equal. The spec's `syncUrl` message said the driver "runs no sync, so the setting changes nothing". It now reads "the turso driver refuses this configuration when it starts", like its sibling refusals, and the driver's own `TursoConfigSchema` mirror follows (`@objectstack/spec` patch: message text only). The ADR-0087 entry `turso-config-transport-mismatch-refused` now also records that the constructor refuses these two shapes at boot. + + Left accepted on purpose: `mode: 'replica'` on a `file:` url with no `syncUrl` (and no `sync`). It still runs as a plain local database. Refusing it in the constructor alone would refuse a config both schemas accept, so it is tracked separately. + + ### Migration: FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `url: 'libsql://my-db.turso.io', mode: 'remote', syncUrl: …` (with or without `sync`) | a remote database: drop `syncUrl` and `sync`. An embedded replica: `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io'` and no `mode` | + | `url: 'file:./data/app.db', mode: 'remote', syncUrl: …` | the same two ways out | + | `sync: { … }` with no `syncUrl` | name the remote in `syncUrl` (with a `file:` url), or drop `sync` | + + A datasource row stored with one of these shapes is not re-parsed when it loads, so it now fails when the driver is built. `factory.create` throws the refusal. The connection service records the datasource as `failed-degraded` with the message, and a test connection answers `ok: false` ("Failed to build driver: …"). Under ADR-0062 D5, the boot fails fast when objects bind to that datasource or are routed to it, or when it is boot-critical, unless `OS_ALLOW_DRIVER_CONNECT_FAILURE` is set. Otherwise it is left unconnected with a warning. Before this change the same row booted, reported sync as enabled and never synced. The way out is the table above: drop `syncUrl` / `sync` from a remote config, or use a `file:` url with the remote in `syncUrl`. + + Blast radius, measured on this tree: no example, template, published skill or hand-written doc authors either shape, and no in-repo caller reads `isSyncEnabled()` or calls the driver's sync outside `@objectstack/driver-turso`'s own tests. Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero. + + +- 3e8b492: `TursoDriver` in **remote** mode refuses a read over a missing table or a missing column with the same code the local mode answers, instead of answering "no rows" (#20424). + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on the remote face of `TursoDriver`'s read doors, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the level). Remote-face users meet this refusal for the first time here. + + **FROM → TO.** A remote-face read (`aggregate`, `find`, `findOne`, `count`) that answered `[]` / `null` for a missing table or a missing column now refuses, as the local face does: `DATABASE_ERROR` / 500 for a table that is absent, `INVALID_FIELD` / 400 for a `groupBy` or aggregation column that is absent, `INVALID_FILTER` / 400 for a `where` column that is absent. **The fix:** run schema sync so the declared field has its column (or the object its table), or name a column the table has. `RemoteTransport.find` and `RemoteTransport.aggregate`, exported from the package root, now raise the backend's error where they answered `[]`. + + **What was wrong.** Two catches in `RemoteTransport` read a backend "no such table" or "no such column" as an empty result. `aggregate` answered `[]` for both. `find` (and `findOne` through it) answered `[]` (`null`) for a missing column once its projection retry was spent, or when there was no projection to drop. So on a remote Turso database a schema drift or a missing table read as "there is no data", while the local mode of the same driver, over the same file, refused it. Measured with a local driver over the same libSQL file as the control, for a federated and for a managed object alike: + + | read | local | remote before | + |:--|:--|:--| + | `aggregate` on a table that is really absent | `DATABASE_ERROR` / 500 | `[]` | + | `aggregate` grouped by, or aggregating, a declared field whose column is absent | `INVALID_FIELD` / 400 | `[]` | + | `aggregate` whose `where` names that field | `INVALID_FILTER` / 400 | `[]` | + | `find` / `findOne` whose `where` names that field | `INVALID_FILTER` / 400 | `[]` / `null` | + | `count` whose `where` names that field | `INVALID_FILTER` / 400 | `DATABASE_ERROR` / 500 | + | `find` ordered by that field | the rows, unordered | `[]` | + + **What changes, on the remote face only:** + + - `aggregate`, `find`, `findOne` and `count` answer each row above the way the local face does. The backend's error is classified by the local face's own inherited seam, `SqlDriver.aggregateBackendFault`, and not by a second copy: an unresolvable column named by a `groupBy` or an aggregation is `INVALID_FIELD` / 400, one named by the `where` is `INVALID_FILTER` / 400, and anything else is `DATABASE_ERROR` / 500. The dialect text goes to the server log, never to the caller. + - `find` keeps the local face's recovery ladder: a projection naming a column the table lacks is dropped first, then an ORDER BY on one, and the rows answer. A `where` is never dropped. Before, the ORDER BY rung was missing and the sort answered `[]`. + - A refusal the transport raises while it compiles the statement (a filter or aggregate-vocabulary refusal, the timeout envelope) keeps its own code and status. + - `RemoteTransport.find` and `RemoteTransport.aggregate`, used on their own, now raise the backend's error where they answered `[]`. + + This is the refusal the registered migration entry `driver-sql-unresolvable-where-column-refused` already names for `driver-sql` "and its `TursoDriver` / `SqliteWasmDriver` subclasses": the remote face of `TursoDriver` now delivers it. **If a read now refuses for you:** the table or column it names is missing from the remote database. Run schema sync so the declared field has its column (or the object its table), or correct the name the query uses. +- fb38607: feat(drivers,formula,objectql): the engine's filter faces answer the staged `$empty` operator (#20444) + + Clause-②: yes (widening) + + `$empty: true | false` is declared by `@objectstack/spec` (`FieldOperatorsSchema`) with a per-type meaning: a text-like field is empty when it is null or `''`, a multi-value field (multiselect, checkboxes, tags, or a select / radio / lookup / user / file / image with `multiple: true`) when it is null or `[]`, and every other type only when it is null. `$empty: false` is the exact complement. Until now every face in this list refused it (`INVALID_FILTER` / 400), except `matchesFilterCondition`, which answered `false` for every record. **A driver or evaluator called directly now answers it:** + + - **By the field's declared type**, through the spec's one expansion (`expandEmptyOperator`): `driver-sql`'s filter compiler (and so `driver-sqlite-wasm` and `driver-turso`'s local transport, which inherit it), `driver-turso`'s remote transport, `driver-memory`'s query path (`find` / `count` / `update` / `delete`) and `driver-mongodb`'s `translateFilter` (its `find`, its aggregate `$match`). The declaration is the one each driver already receives — `initObjects` / `registerObjectMetadata` / `registerExternalObject` on the SQL family, `syncSchema` on the others. On SQL a multi-value field's empty list is tested as stored JSON per dialect (SQLite `json_array_length` behind a `json_valid` guard, PostgreSQL a `jsonb` comparison, MySQL `JSON_LENGTH`), never as an equality comparand. + - **By value** — null, a missing value, `''` and `[]` are empty (`isEmptyFilterValue`) — on the faces that read no field declaration: `@objectstack/formula`'s `matchesFilterCondition` (the RLS write-side `check`), `driver-memory`'s reference matcher, and `@objectstack/objectql`'s `having` and per-aggregation `filter`. In `having`, a `count` or `sum` holding `0` is not empty. + + **Refused, never guessed** (`INVALID_FILTER` / 400): `$empty` on a field whose declaration the driver does not hold (a table built outside its registration, a builtin column such as `id`, a field with no `type`, or `translateFilter` / `RemoteTransport` used standalone without a declaration), a multi-value field on a SQL dialect the driver does not model, and a flag that is not a boolean. `driver-memory`'s analytics (cube) face refuses `$empty` as an operator it cannot compile, as it does `$null`. + + New optional API: `translateFilter(where, temporalKind?, valueShape?)` in `@objectstack/driver-mongodb` takes a declared-value-shape resolver (type `ValueShapeResolver`), and `buildAggregationPipeline` a `valueShape` option; `RemoteTransport.setDeclaredValueShapeResolver` in `@objectstack/driver-turso`, which `TursoDriver` wires. `@objectstack/spec`'s shared `FILTER_LOGIC_CASES` table gains seven `$empty` cases: a backend that runs it answers `$empty` or goes red, and its harness must declare the fixture's columns. + + `$empty` stays staged: it is not in `FILTER_OPERATORS`, so the engine's front door still refuses it until the flip card adds it, and the view operators `is_empty` / `is_not_empty` still lower to `$null`. +- e2c55ed: `driver-turso`: the REMOTE canonical temporal backfill no longer overwrites a bare-number cell with the date SQLite reads it as (#6009). + + `RemoteTransport.mapFieldTypeToSQL` declares every temporal column `TEXT`, so a `Field.datetime` / `Field.time` column can hold a digits-only string such as `'2026'`, `'86400'` or the keyword `'now'`. SQLite's time-value grammar accepts a bare number as a JULIAN DAY and `now` as the wall clock, so for those two shapes `strftime` answers confidently instead of returning NULL and the `coalesce(strftime(…), col)` that is supposed to preserve unparseable values never fires. The convergence `UPDATE` in `backfillRemoteCanonicalColumn` therefore wrote that answer over the stored bytes, and no later run could get them back — measured on better-sqlite3 13.0.3 / SQLite 3.53.4 with the column declared `TEXT`: + + ```text + '2026' -> -4707-06-11T12:00:00.000Z 'now' -> the wall clock, now + '86400' -> -4476-06-15T12:00:00.000Z '12' -> -4713-12-06T12:00:00.000Z + ``` + + The local (Knex) half of this was fixed for `SqlDriver` in the same tracker; the remote path is a separate module that builds its own statements and did not inherit it. + + - **The rows are withheld from the `UPDATE` and they also BLOCK the canonical mark.** Withholding alone would not be enough: a marked column drops the read-side repair, and the raw `'2026'` would then compare as TEXT instead of as the instant the repair reads it as. A withheld row is by construction `col IS NOT canonical`, so it stays inside the probe's `residual`, which the mark already requires to be zero. The column keeps its (unindexed) repair and every query answer is bit-for-bit what it was. + - **The predicate is the driver's own, handed across the module boundary — never copied.** `TursoDriver` now passes `{ canonical, nonTemporalText }` where it passed a bare canonical expression, the second arm being `SqlDriver.sqliteNonTemporalTextSql`. Nothing in the shared READ expression changes: `sqliteCanonicalDatetimeSql` / `sqliteCanonicalTimeSql` still misread a bare number exactly as before, deliberately, per the 2026-08-03 cloud#1005 ruling that refused a heuristic in a public expression that runs on every read. + - **The third position of `probeRemoteCanonicalColumns`, `backfillRemoteCanonicalColumn` and `backfillRemoteCanonicalColumns` now accepts either shape**, so a caller compiled against the earlier release keeps compiling: `RemoteBackfillSqlRules` (`{ canonical, nonTemporalText }`) is the shape to pass, and a bare `CanonicalSqlFor` is FAIL-CLOSED rather than a fallback — with no guard to withhold by, the convergence phase is refused, the column reports `error` and stays unmarked, and reads stay correct on the repair. The 后果 B epoch recovery still runs on that arm. To move off it: pass `{ canonical: , nonTemporalText: }`. + - **New on the per-column report: `nonTemporalTextRowsWithheld`** — what the guard declined to write, or `null` when no guard was supplied and the count was therefore never measured. It is deliberately NOT folded into `unresolvedEpochTextRows`, whose documented meaning is *recorded and harmless to the mark*; these rows are the opposite. + - **A documented sentence is corrected in the same change.** The module said rows outside the epoch-recovery band "do not block the canonical mark, because they are fixpoints of the shared repair". That is true only ABOVE SQLite's julian-day ceiling, where `strftime` returns NULL. Below it — `'12'`, `'2026'`, `'86400'` — the repair answers, the row is not a fixpoint, and dropping the repair changes what it matches. Both halves are now stated with the measurement behind them. + - **The two bands do not meet, so the guard costs the epoch recovery nothing.** A bare number is read as a julian day only for `0 <= v < 5373484.5` (`'5373484.4'` parses, `'5373484.5'` returns NULL); `REMOTE_BACKFILL_EPOCH_MS_MIN` is `1e12`. The epoch-text `UPDATE` is also structurally out of reach for a second, independent reason: its SET wraps `cast(col as real)`, whose `typeof()` is always `'real'`, so the canonical expression takes its `'unixepoch'` limb and never the `coalesce`/julian one. + + No read path, no filter compilation and no stored value changes for any column that holds none of this shape: a table with nothing but ordinary legacy rows converges and is marked exactly as before. +- 88a9330: feat(driver-turso): the `aggregate()` override publishes its declared return type, not `any` (#17277) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` does not merely inherit this door from `SqlDriver` — it OVERRIDES `aggregate()`, and the override was written out with its own explicit `Promise`. So this package's emitted `.d.ts` re-declared the door as `any` on its own and would NOT have picked up the `@objectstack/driver-sql` narrowing — the same shape PR #15280 had to fix separately for `update()` and PR #15267 for four more doors. + + Both branches already answered the contract's type: the remote branch passes `RemoteTransport.aggregate()`, already declared `Promise[]>`, and the local branch forwards to `SqlDriver.aggregate()`, narrowed alongside (#17277). The override now declares what it has always answered. A caller that read a cell straight off an aggregate row through the `any` now types what it reads. No runtime behaviour changes. + + Out of scope and deliberately unmoved: `upsert()` and `beginTransaction()` keep their annotations. + + +- 3cbcedb: feat(driver-turso): the overridden `IDataDriver` doors publish their honest types, not `any` (#15267) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` does not merely inherit these doors from `SqlDriver` — it OVERRIDES `findOne()`, `create()`, `bulkCreate()` and `execute()`, and each override was written out with its own explicit `Promise`. So this package's emitted `.d.ts` re-declared four of the five doors as `any` on its own and would NOT have picked up the `@objectstack/driver-sql` narrowing — the same shape PR #15280 had to fix separately for `update()`. + + Both branches of every one of the four already answered the contract's type: the local branch forwards to `SqlDriver`'s door (narrowed alongside, #15267) and the remote branch passes `RemoteTransport`'s result — already declared `Record | null`, `Record`, `Record[]` and `unknown` respectively — through the generic `formatRemoteRow` / `formatRemoteRows`. Each override now declares what it has always answered. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first. No runtime behaviour changes. + + `explain()` is not overridden here and reaches these consumers through `@objectstack/driver-sql`. Out of scope and deliberately unmoved: `upsert()`, `aggregate()` and `beginTransaction()` keep their annotations. + + +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. + +### Patch Changes + +- ef67b47: fix(objectql,driver-turso): "is this field multi-valued" is `isMultiValueField` here too — the `domain:engine` half of the one-definition ruling (#18408) + + Maintainer ruling, 2026-09-13 (decision batch #128 item 5, option 1′): there is + ONE definition of 「is this field multi-valued」, `@objectstack/spec`'s + `isMultiValueField`, and storage follows it. `driver-sql` was aligned by #17469 + and `os generate migration` by #18199. These four sites were the remainder: they + read `field.multiple` raw, which answers `true` on types the predicate calls + single-valued (`text`, `master_detail`, `tree`, `number`, …) and `false` on the + inherently-multi option types (`multiselect` / `checkboxes` / `tags`) that carry + no flag at all. + + **`@objectstack/driver-turso`** — `RemoteTransport.mapFieldTypeToSQL` short- + circuited its whole type switch on the raw flag, so a `{ type: 'number', + multiple: true }` field was declared `TEXT` in remote mode while the SAME + driver's local transport (`SqlDriver`, aligned since #17469) declared `float`: + one declaration, two storage classes, chosen by which URL the deployment + happens to hold. New columns for such a field are now declared by the field's + own type. Genuinely multi-valued fields (`lookup` / `select` / `file` / `image` + / `user` flagged `multiple`, and the inherently-multi option types with or + without it) are unchanged — still the JSON-array `TEXT` column. + + **`@objectstack/objectql`** — three sites, all deciding the SHAPE of a stored + value: + + - the option-derived insert default (`resolveOptionDefault`) assembles an array + for a multi-valued field. A `multiselect` / `checkboxes` / `tags` field with an + option marked `default: true` and no `multiple` flag was defaulted to a bare + scalar, which this engine's own validator then refused as + `invalid_type_array` on the insert the default was resolved for; + - the referential-integrity dependents probe (`referenceProbeFilter`) composes + `$contains` for a multi-valued reference and bare equality for a scalar one. A + `master_detail` flagged `multiple` is outside `MULTI_CAPABLE_TYPES`, so every + aligned storage side builds it a scalar column — the probe now asks that column + the question it can answer, instead of a substring match repaired afterwards by + a second narrowing pass; + - the cascade-delete `multiValued` verdict, which that probe, the `set_null` + write shape and the required-FK escalation all read. + + **What a deployment feels.** Only declarations that are already off-spec move: + `FieldSchema` has refused `multiple` on a non-capable type since #17469 (ADR-0087 + semantic entry 18), so these shapes now reach the engine and the driver only + through doors that never run it — `registerExternalObject` / `initObjects` and a + driver's own unvalidated input. Existing columns are untouched: the remote + transport only ever declares types for columns it is creating. A deployment + holding one of these shapes should re-declare the field — drop the flag if the + value really is single, or move the field to a multi-capable type if it is not — + which is the same prescription entry 18 already carries. + + No export is added, removed or renamed in either package, and no authorable key + changes its name, type or optionality. +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- 95fb417: **The declared `zod` floor moves from `^4.4.3` to `^4.6.1`**, because on zod below 4.6.1 the three standard error formatters — `z.treeifyError()`, `error.format()` and `error.flatten()` — cannot render a refusal these packages actually emit (#19581). + + Clause-②: no + + **What breaks below the new floor.** All three formatters walked an issue's `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming a member of `Object.prototype` was answered by the prototype and no node was ever created. Two different failures follow: + + | path shape | what happened on `^4.4.3` | + |:---|:---| + | terminal element (`['assignments','__proto__']`, `['x','toString']`) | the inherited member is adopted as the node, then `node._errors.push(...)` runs on it — `TypeError: Cannot read properties of undefined (reading 'push')` | + | non-terminal element (`['__proto__', …]`) | the walk continues **into** `Object.prototype` and writes the next segment onto it — the message is silently dropped from the returned tree and the process gains a global prototype key | + + **Why it reached this platform's consumers.** `@objectstack/spec` refuses a `__proto__` key on its open-key authoring surfaces, and that refusal's issue path is `['assignments','__proto__']` — precisely the terminal shape. Anything that formatted one of these refusals for display crashed on it, and the crash was in the formatter, not in the guard. The guards themselves are unchanged and still necessary: 4.6.1 still drops a `__proto__` key from `z.record()` and `.catchall()` output, which is what they exist to refuse. + + **What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. + + `@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. + + ## The second half the floor move needs: an unknown key refuses TERMINALLY again + + From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no + longer aborts the shape that raised it. Two things follow, and both were + measured on this package with the same bodies on 4.4.3 and 4.6.1: + + 1. **A closed shape's own refinements now run after the refusal**, adding a + second complaint that contradicts the first. + 2. **A union containing that shape loses its envelope.** zod's + `handleUnionResults` returns a single non-aborted member's issues + *unwrapped* instead of raising `invalid_union`, so the union's message + becomes whichever branch zod judged closest. + + At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong + branch's prescription. Writing `type: 'page'` on a ViewItem answered: + + ``` + Unrecognized key(s) on this view container: `viewKind`, `config`. + • `viewKind` belongs to a single VIEW, not to the container. Wrap it: … + ``` + + — naming neither `page` nor its removal. It now answers, as it did before: + + ``` + config.type: 'page' was removed from the list-view `type` enum in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … + ``` + + **What an upgrading consumer must do.** Nothing. No key or value changed + status: everything this package accepted before it accepts now, and everything + it refused it still refuses. What changed is which of several competing + complaints an author reads, and that a refusal behind a union is again + reported as `invalid_union` with its branches, which is what `z.treeifyError()` + and this package's own `formatZodError` expand. + + ⚠️ A closed shape declared with a bare `z.object(…).strict()` or + `z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT + get this and will still collapse its union. Build closed authoring shapes with + `strictObject`, or re-declare an existing one through `closedObject`. +- 1f89ba0: A remote Turso deployment now reads, writes and filters the objects it synced at boot by their declared field types. "Remote" means a `libsql://`, `https://`, `http://`, `wss://` or `ws://` URL with no `syncUrl`, or an explicit `mode: 'remote'` (#19844). + + The engine's boot schema sync (`ObjectQLPlugin`) reaches this driver through `syncSchemasBatch`, because the driver declares `supports.batchSchemaSync`. On the remote transport that method ran the DDL and stopped. It skipped the field-type registration that its sibling doors, `syncSchema` and `initObjects`, run afterwards. For every object a remote app synced at boot, that meant: + + - **Reads came back as stored.** A declared `boolean` read back as `1`/`0`, and a `json` field as its stored text. A `datetime`, `time` or `date` field read back exactly as stored, for example as an offset-bearing string or epoch text rather than the canonical `…Z` form. The `created_at` / `updated_at` audit columns were a partial exception: a zone-naive cell shaped `YYYY-MM-DD HH:MM:SS` or `YYYY-MM-DDTHH:MM:SS`, optionally with a fractional second, was read as UTC and read back canonical (the column default writes the first shape); any other cell read back as stored, including one ending in `Z` or in an offset such as `+08:00` (so a canonical cell stays canonical), an epoch number or its text, and anything that does not parse as a date. This held for records read through the driver and through the engine's `find` / `findOne`, including the rows an `afterFind` hook receives. A CEL expression or an in-memory `$ne: true` filter evaluated over such a record, such as `field != true`, was therefore true even for a stored `true`. Write-side hook contexts did see `true`/`false`, because the engine converts declared booleans there: the `afterInsert` / `afterUpdate` results and the `previous` record on update and delete hooks. + - **Writes were not converted either.** A `datetime` or `time` value in any spelling other than the canonical one (with an offset, a zone-naive wall clock, an epoch number) was stored as sent. A `Date` given to a `datetime` was the exception: it was stored in canonical form. A `date` given as a `Date` or a full timestamp was stored as a full timestamp. An object or array in a `json` field was stored as it would have been anyway. A scalar `json` value (a string, number or boolean) was stored without its JSON encoding. + - **Filters compared text as spelled.** A filter on a `datetime` or `time` field compared the stored text with the comparand exactly as the caller wrote it, converting neither side. Rows whose cell or comparand used another spelling of the same value were missed or matched wrongly. For example, a bare-day upper bound `$lte: '2025-07-28'` left out that day's rows stored as ISO text. + - **Paging was not deterministic.** A paged read with no `orderBy` got no `id` tie-breaker, so walking the pages could serve one row twice and skip another. The driver logged `Paged read of '…' is NOT deterministic`. + + `syncSchemasBatch` now finishes the way the other two doors do. It registers each object's field types, keyed by the `object` name it was given, and then runs the canonical temporal backfill once for the whole batch. A DDL failure still rejects before anything is registered. Reads, writes and filters on those objects now convert exactly as they do through `syncSchema` and `initObjects`. + + What happens on disk at the first boot after upgrading: the one write this change adds is that backfill, which the `syncSchema` and `initObjects` doors already ran. It rewrites `datetime` and `time` cells stored in a non-canonical spelling, including any this door wrote unconverted, into the canonical spelling of the same value. It leaves alone a cell it cannot safely read as a time. Nothing else on disk is touched, so two kinds of cells this door wrote unconverted stay as they are: + + - A `date` stored as a full timestamp reads back as its calendar day, but an equality filter on that day does not match it. + - A scalar `json` value stored without its encoding reads back as whatever its text parses to. A stored `true` reads back as `1`, and a numeric-looking string reads back as a number. + + Local and embedded-replica deployments are unaffected. +- 467fa76: A remote Turso deployment now converges, at its first boot after upgrading, the `date` and `json` cells that the engine's boot schema sync wrote without converting them before #19844, where the cell's original value can be read exactly from its stored text. "Remote" means a `libsql://`, `https://`, `http://`, `wss://` or `ws://` URL with no `syncUrl`, or an explicit `mode: 'remote'` (#19868). + + What a first boot now rewrites: + + - **A `date` stored as a full timestamp.** A `Date`, or a string such as `2025-07-28T10:00:00Z`, `2025-07-28T01:00:00+08:00` or `2025-07-28 10:00:00`, was stored as written, and a `Date` as its ISO text. Such a cell is rewritten to the calendar day the driver reads it as since the #19844 read fix, which is the day the write path stores today for the same value: the leading `YYYY-MM-DD` of the text, after surrounding whitespace is trimmed. A `Date` gives its UTC day. That is not necessarily the day a caller meant when it built the `Date` at local midnight east of UTC, and no stored cell records which day that was. The rewrite changes no read, because the #19844 read fix already returns that day. An equality filter on the day (`{ day: '2025-07-28' }`), or a bare-day bound such as `$lte: '2025-07-28'`, now matches these rows. The time of day in the stored text is dropped. Since the #19844 read fix, which ships in the same release, no read of a `date` field returns it. Before that fix, an object synced at boot read such a cell back exactly as stored, time of day included. + - **A `json` string stored bare whose text does not parse as JSON** (for example `hello`, or an empty string). It is rewritten as its JSON string (`"hello"`), which is what the write path stores for it today. It reads back as the same string as before. + + What it deliberately leaves as stored, because the original value cannot be told from the stored bytes: + + - **Any `json` cell whose text parses.** This includes a stored `true`, which reads back as `1`. The column is TEXT, and a boolean `true` became the text `1`. A string `'1'` left the same text, and `1` is also what the write path stores for the number `1` today. A string `'42'` reads back as the number `42`, and its text `42` is also what the write path stores for the number `42`. A string `'true'` reads back as `true`, and its text is what the write path stores for the boolean `true`. These cells keep reading as the #19844 read fix reads them. Before that fix, an object synced at boot read them back as their stored text. Correct the affected records by writing them again through the API. + - **JSON nested deeper than SQLite's JSON depth limit**, which SQLite reports as invalid although it parses. It reads back as the structure it is. + - **Single-value `image` / `file` / `avatar` / `video` / `audio` columns.** Whether their ids are stored quoted or bare depends on the deployment. Both forms read back the same. + + The pass runs after every remote schema sync. On an already converged database it costs one read round-trip, and it writes nothing. It works in batches. A large table that does not finish in one boot continues at the next schema sync. A failure is logged at `warn` and never stops a boot. Local and embedded-replica deployments are unaffected. Their schema sync registers the field types before any write, so their write path converts these values, and this pass does not run there. +- 9bfbacb: fix(driver-sql): the local SQLite `Field.json` storage backfill no longer turns a deeply nested array or object into a string on the next schema sync (#19912) + + The backfill that converges legacy json cells on their JSON-encoded form (it came with the change that made the SQLite write path JSON-encode every json value; the issue number that change cites no longer resolves, and its live record is the `SqlDriver.backfillCanonicalJsonEncoding` doc block and `sql-driver-12380-json-roundtrip.test.ts`) ran one `UPDATE … set col = json_quote(col)` over every TEXT cell SQLite's `json_valid()` rejects. `json_valid()` answers 0 for JSON nested more than 1000 levels deep (SQLite's JSON depth limit in every build this repository bundles), while the driver reads such a cell with `JSON.parse` without trouble. So a deep array written correctly through the driver was quoted into a JSON string by the next `syncSchema` / `initObjects`, and read back as a string from then on — silently, with no error. + + SQL now only pre-selects the candidate cells, a page at a time. The driver's own codec decides each one: a cell `JSON.parse` reads is left exactly as stored; a cell it cannot read is a legacy plain string and is rewritten to `JSON.stringify` of that string, byte-for-byte what the old statement wrote for it wherever the engine reads the stored bytes back verbatim. A cell the engine does not read back verbatim is left as stored and keeps reading as it did, where the old statement rewrote it: text holding invalid UTF-8, measured on better-sqlite3 and sql.js. `SqlDriver` on better-sqlite3, `TursoDriver` in local mode (which runs on better-sqlite3 too) and `SqliteWasmDriver` (sql.js) were each measured to read such a cell back with U+FFFD in place of the invalid bytes, so the text the rewrite would be decided from is not the stored text, and the cell is left alone. A legacy text with a leading U+FEFF or an embedded NUL is read back verbatim on all three faces, and is rewritten like any other plain string. Each rewrite is a compare-and-set on the text it was decided from, so a value written concurrently is never overwritten, and a re-run over a converged table still writes nothing. This covers every local SQLite face that inherits the backfill: `SqlDriver` on every client it treats as SQLite (`better-sqlite3`, `sqlite3` and its alias `sqlite`), `SqliteWasmDriver`, and `TursoDriver` in local mode. + + The decision rule is exported from `@objectstack/driver-sql` as `recoverUnencodedJsonText(stored)`, and `@objectstack/driver-turso`'s remote codec-residue backfill now imports it instead of carrying its own copy, so the local and remote backfills apply one rule. That is a new public export on `@objectstack/driver-sql`'s root entry, and the reason this package takes `minor`: the function returns `null` for text `JSON.parse` accepts and `JSON.stringify(stored)` for text it rejects, and it is exported so that `SqlDriver.backfillCanonicalJsonEncoding` and the remote backfill's `recoverResidueCell` decide each cell by one shared rule rather than by two copies that could drift apart. The remote backfill's behaviour is unchanged. +- 9d81af7: fix(driver-sql, driver-turso): on SQLite, a `$contains` / `$notContains` / `$icontains` / `$startsWith` / `$endsWith` comparand holding U+0000 is compared whole, against the whole stored value, instead of being cut at the U+0000 by `GLOB` (#19999) + + Clause-②: no + + On the SQLite faces these five operators compile to `GLOB`, and SQLite's `glob()` reads both the pattern and the stored value only up to their first U+0000. Nothing raised, and the filter answered a different question. Measured on `SqlDriver` over better-sqlite3 (SQLite 3.53.4), on `SqliteWasmDriver` over sql.js (3.49.1), and on `TursoDriver`'s remote transport over a local libSQL engine (3.45.1). All three answered alike. Over the values `'a'` + U+0000 + `'b'`, `'ab'` + U+0000, U+0000 + `'z'`, `'plain'` and `''`: + + - `$contains: U+0000` and `$endsWith: U+0000` returned all five rows; + - `$contains: U+0000 + 'b'` returned all five rows, where the JavaScript answer is `'a'` + U+0000 + `'b'` only; + - `$startsWith: U+0000` returned `''` and U+0000 + `'z'`, where the JavaScript answer is U+0000 + `'z'` only. + + What changes: a comparand holding U+0000 now compiles to a length-aware comparison instead. `$contains`, `$notContains`, `$icontains` and `$startsWith` use `instr()`, and `$endsWith` compares the value's trailing bytes over BLOB. Such a filter now returns the rows `driver-memory` and `@objectstack/formula` return for it. The comparand is bound as written, so `*`, `?` and `[` in it are literal, as they were before. `$icontains` still folds ASCII letters only, and `$notContains` still returns a row whose value is NULL. + + - `@objectstack/driver-sql`: the SQLite arm of `SqlDriver`'s text-operator compiler. `SqliteWasmDriver` and `TursoDriver`'s local mode inherit it. + - `@objectstack/driver-sqlite-wasm`: none of its own code changes. It inherits the fix, and its exact-text bind reaches every parameter the new comparison binds. + - `@objectstack/driver-turso`: the remote transport's own emitter, changed the same way. + + What does not change: a comparand without U+0000 compiles to the same `GLOB` with the same bound pattern as before. The Postgres and MySQL arms are untouched. `GLOB` still reads a stored value only up to its first U+0000, so for a comparand without U+0000, `$contains`, `$notContains`, `$icontains` and `$endsWith` over a stored value that holds one still compare only the part before it. +- 3557f85: `README.md` — the remote branch of the architecture tree no longer lists `beginTransaction`, `commit` and `rollback` as `RemoteTransport` operations. `RemoteTransport` has no transaction methods, and remote mode refuses all three with `NOT_IMPLEMENTED` / 501. A new "What remote mode refuses" section lists every `NOT_IMPLEMENTED` / 501 the remote face raises: transactions (including `options.transaction` passed to the remote data and schema methods), a record number for an empty `autonumber` field on `create`, `bulkCreate` and an `upsert` with no `id`, `_id` or `conflictKeys`, `setDeferredDdl(true)`, `detectManagedDrift()`, `planMediaColumnMove()`, and two `aggregate()` shapes that `engine.aggregate()` computes in memory instead (a `groupBy` entry with a `dateGranularity`, an `aggregations` entry with a non-empty `filter`). + + Also corrected: the remote-mode bullet no longer says every operation is delegated to `RemoteTransport`, the remote example no longer says every CRUD call works as in local mode, and the local branch no longer lists array-style filters, which the driver refuses. + + - **No behaviour moves.** The driver's source and every published export are byte-identical; only the README text shipped in this package's `files[]` changes. +- 57c2b73: fix(driver-sql, driver-turso): four filter-refusal doors stop naming a read scope's field and comparand unless the refused predicate is marked as the caller's own (#20020) + + Clause-②: no + + A read scope is the RLS, sharing or tenant predicate that `plugin-security` (ordinary reads) and `service-analytics` (the ObjectQL analytics face) AND into the caller's `where`. Both merges mark the scope `'policy'` and the caller's own predicate `'author'` (`markFilterSubtreeProvenance`, `@objectstack/spec/data`). When `SqlDriver` refused a scope at one of the four doors below, the `INVALID_FILTER` / 400 message named the scope's field, and for three of them its comparand too. It did not check the mark. Measured on both faces, through `POST /api/v1/analytics/query` and through an ObjectQL `find` under a merge shaped like `plugin-security`'s: + + - a column the table does not have. This is reachable from a real CEL rule on a field that is declared but has no column yet; + - a retired operator (`$regex`, `$options`) or an operator outside the vocabulary; + - `$and` / `$or` whose operand is not a list; + - a `$null` / `$exists` whose comparand is not a boolean. + + Each of these doors now reads the mark on the node it refused, the same way the cross-field and target-field refusals already did: + + - **`'policy'`, unmarked or ambiguous:** same `INVALID_FILTER` / 400. The message says which kind of refusal fired, but names no field, operator, comparand or filter path. Those go to the server log. For the unresolvable column, the message is the unnamed wording the driver already used when it could not parse the dialect's message. + - **`'author'`:** the full message, the same text the door answered before. + + To find the node, the unresolvable-column door looks up the column name the database reported. It discloses only when every node that names that column is marked `'author'`. A `$and` / `$or` with a primitive operand is judged by the node that carries the key. + + `SqliteWasmDriver` (`@objectstack/driver-sqlite-wasm`) and `TursoDriver` in local mode extend `SqlDriver`, so they inherit this change from it: the same four doors answer the same way there. + + **What an unmarked caller loses:** its own diagnostic from these four doors. Measured cases where the caller's own predicate reaches the driver unmarked: + + - no security plugin in the stack; + - a system-context call; + - an anonymous call; + - a `where` that holds a `{placeholder}` token, which the engine rewrites before the merge. + + That caller gets the withheld wording with the same code and status. A member's plain `where` under `plugin-security` is marked `'author'` and keeps the full text. + + The same three door classes on the Turso REMOTE transport (`RemoteTransport`) now read the mark too: the retired or unknown operator (including a non-operator key in an operator map), the non-list combinator, and the non-boolean `$null` / `$exists`. The operands go to its diagnostic sink. `TursoDriver`'s remote mode rebuilds every filter node before the transport sees it, so no mark reaches the transport there, and these refusals keep the withheld wording for every caller in that mode. The unresolvable WHERE column has no refusal on the remote face (the transport answers `[]`) and is not changed here. + + Not changed: which filters are refused, and the code and status of every refusal. The engine's declared-type, temporal-comparand and filter-token doors are not changed here. +- f09d412: fix(driver-sql, driver-turso): on SQLite, `$like` / `$ilike` read the whole stored value, instead of stopping at its first U+0000 (#20024) + + Clause-②: no + + On the SQLite faces `$like` and `$ilike` compiled to `GLOB`, and SQLite's `glob()` reads the stored value only up to its first U+0000. So a pattern without U+0000 answered a different question over a value holding one, and nothing raised. Measured on `SqlDriver` over better-sqlite3 (SQLite 3.53.4), on `SqliteWasmDriver` over sql.js (3.49.1), on `TursoDriver`'s local mode, and on its remote transport over a local libSQL engine (3.45.1). All four answered alike: + + - `$like: 'a'` returned a value stored as `'a'` + U+0000 + `'b'`, and `$like: ''` returned U+0000 + `'z'`; + - `$like: '%b'`, `$like: 'a_b'` and `$ilike: 'A_B'` did not return `'a'` + U+0000 + `'b'`; + - `$like: '_'` did not return a value that is a lone U+0000. + + Over 108 patterns and 22 `$not` / `$or` / `$and` compositions against 59 stored values, 359 of the 3380 cells over values holding U+0000 differed from `@objectstack/formula` on each face. + + What changes: a stored value holding U+0000 now has each U+0000 replaced by one stand-in character before `GLOB` reads it. The stand-in is never a literal character of the pattern, never an ASCII letter, and never U+0000. A U+0000 in the value can only be matched by `%` or `_`, and so can the stand-in, so the answer is the one the whole value gives. Such a filter now returns the rows `driver-memory` and `@objectstack/formula` return for it, under `$not`, `$or` and `$and` as well: 0 of those 3380 cells differ on any of the four faces. `$ilike` still folds ASCII letters only. + + - `@objectstack/driver-sql`: the SQLite arm of `SqlDriver`'s `$like` / `$ilike` compiler. `SqliteWasmDriver` and `TursoDriver`'s local mode inherit it. + - `@objectstack/driver-sqlite-wasm`: none of its own code changes. It inherits the fix. + - `@objectstack/driver-turso`: the remote transport's own emitter, changed the same way. + + What does not change: + + - A stored value without U+0000 gets the same answer as before: 0 of 16640 such cells moved on any face. + - A pattern that is a literal prefix followed only by `%` (`'ab%'`, `'%'`) compiles to the same `GLOB` with the same bound pattern as before. Cutting the value at its first U+0000 cannot change that answer. + - No index is lost. Under `EXPLAIN QUERY PLAN` over an indexed TEXT column on all three engines, each `$like` pattern measured that starts with a literal (`'ab%'`, `'ab_'`, `'ab%cd'`, `'abc'`, `'a%b%'`) keeps its covering-index search, and each one that starts with a wildcard still scans. A case-exact pattern with a literal prefix now leads with a `GLOB` on that prefix followed by `*`, which every matching value satisfies and which is what keeps that search. + - `_` still matches one character, as `GLOB`'s `?` does. A character outside the Basic Multilingual Plane is one character to `_` on SQLite and two to `@objectstack/formula`, which counts UTF-16 units. That difference is older than this change, and this change does not alter it. + - A pattern holding U+0000 is still refused (`INVALID_FILTER` / 400). + - The Postgres and MySQL arms are untouched. + + Cost, measured over 10,000 rows on the three engines: against a value without U+0000 the new compile adds one `instr()` per row, and those queries took 1.1 to 2.4 times as long as `GLOB` alone, at most 4.5 ms. A value holding U+0000 pays the rewrite: 10,000 rows each holding one to three U+0000 took up to 35 ms, against at most 3 ms for `GLOB`. +- adbbc5d: fix(driver-sql, driver-turso): on SQLite, `$contains` / `$notContains` / `$icontains` / `$endsWith` read the whole stored value, instead of stopping at its first U+0000 (#20024) + + Clause-②: no + + On the SQLite faces these four operators compiled to `GLOB` for a comparand without U+0000, and SQLite's `glob()` reads the stored value only up to its first U+0000. Nothing raised, and the filter answered a different question. Measured on `SqlDriver` over better-sqlite3 (SQLite 3.53.4), on `SqliteWasmDriver` over sql.js (3.49.1), on `TursoDriver`'s local mode, and on its remote transport over a local libSQL engine (3.45.1). All four answered alike: + + - `$contains: 'b'` did not return a value stored as `'a'` + U+0000 + `'b'`; + - `$endsWith: 'a'` returned that value, and `$endsWith: 'b'` did not; + - `$notContains: 'b'` returned it; + - `$icontains: 'B'` did not return `'A'` + U+0000 + `'B'`. + + What changes: these four operators now compile to the length-aware comparisons a comparand holding U+0000 already used, for every comparand. `$contains`, `$notContains` and `$icontains` use `instr()`, and `$endsWith` compares the value's trailing bytes over BLOB. An empty `$endsWith` comparand uses `instr()` too, so it still matches every non-NULL value. Such a filter now returns the rows `driver-memory` and `@objectstack/formula` return for it, under `$not`, `$or` and `$and` as well. The comparand is bound as written, so `*`, `?` and `[` in it are literal, as they were before. `$icontains` still folds ASCII letters only, and `$notContains` still returns a row whose value is NULL. + + - `@objectstack/driver-sql`: the SQLite arm of `SqlDriver`'s text-operator compiler. `SqliteWasmDriver` and `TursoDriver`'s local mode inherit it. + - `@objectstack/driver-sqlite-wasm`: none of its own code changes. It inherits the fix. + - `@objectstack/driver-turso`: the remote transport's own emitter, changed the same way. + + What does not change: + + - `$startsWith` with a comparand without U+0000 compiles to the same `GLOB` with the same bound pattern as before. The stored value's cut cannot change its answer. + - No index is lost. The SQL `SqlDriver` compiles, run under `EXPLAIN QUERY PLAN` over an indexed TEXT column on all three engines, scanned the table for these four operators under `GLOB` and still does; `$startsWith` keeps its index search. + - The Postgres and MySQL arms are untouched. + - `$like` and `$ilike` are outside this entry. Two other entries in this release cover them: on SQLite they now read the whole stored value as well, and every driver that answers `$like` refuses a pattern holding U+0000 (`INVALID_FILTER` / 400). +- 8d76c2d: fix(driver-sql, driver-turso): every filter-compile refusal stops naming a read scope's field or literal unless the refused predicate is marked as the caller's own (#20039) + + Clause-②: no + + A read scope is the RLS, sharing or tenant predicate that `plugin-security` (ordinary reads) and `service-analytics` (the ObjectQL analytics face) AND into the caller's `where`. Both merges mark the scope `'policy'` and the caller's own predicate `'author'` (`markFilterSubtreeProvenance`, `@objectstack/spec/data`). Nine more `SqlDriver` filter-compile refusals did not check the mark, so when one of them refused a scope, its `INVALID_FILTER` / 400 message named the scope's field, and for most of them its literal too. Measured through an ObjectQL `find` under a merge shaped like `plugin-security`'s, with the scope in the `'policy'` arm: + + - an empty or non-string `$icontains` comparand; + - a non-string `$like` / `$ilike` comparand; + - a `$like` / `$ilike` pattern ending in a lone backslash; + - an object or array comparand on `$contains`, `$notContains`, `$startsWith`, `$endsWith` or `$icontains`; + - an `$in` / `$nin` / `$between` member that cannot be bound; + - an `undefined` comparand, in any position; + - an element of `$and` / `$or`, or the operand of `$not`, that is not a filter condition object; + - a `$`-prefixed key in a node position that is not `$and`, `$or` or `$not`; + - a `where` that reaches the driver as an array (the message printed the whole array). + + Each of them now reads the mark on the node it was raised from, as the other compile refusals already did: + + - **`'policy'`, unmarked or ambiguous:** same `INVALID_FILTER` / 400. The message says which kind of refusal fired, but names no field, operator variant, comparand, list position or filter path. Those go to the server log. + - **`'author'`:** the full message, the same text the refusal answered before. + + With these nine, every refusal on `SqlDriver`'s filter-compile path goes through the same seam. + + `SqliteWasmDriver` (`@objectstack/driver-sqlite-wasm`) and `TursoDriver` in local mode extend `SqlDriver`, so they inherit this change from it: the same refusals answer the same way there. + + **What an unmarked caller loses:** its own diagnostic from these refusals. That is every caller whose predicate reaches the driver unmarked, for example with no security plugin in the stack, in a system-context or anonymous call, or with a `where` that holds a `{placeholder}` token (the engine rewrites it before the merge). That caller gets the withheld wording with the same code and status, and the full text is in the server log. A member's plain `where` under `plugin-security` is marked `'author'` and keeps the full text. + + The Turso REMOTE transport (`RemoteTransport`) compiles filters itself. Its copies of these refusals now read the mark the same way: the `$icontains`, `$like` / `$ilike`, lone-backslash, `undefined`, non-node element or operand, undeclared-key and non-object `where` refusals. So do its two other compile refusals that still named the field: an operator map with no operator in it, and a `$between` that reached the transport without being lowered. The operands go to its diagnostic sink. For six of these classes, the withheld sentence is the local one behind the `[RemoteTransport]` prefix. An object text comparand and an unbindable list member already answered there through its comparand refusal, which withholds. `TursoDriver`'s remote mode rebuilds every filter node before the transport sees it, so no mark reaches the transport there, and these refusals keep the withheld wording for every caller in that mode. + + Not changed: which filters are refused, and the code and status of every refusal. +- 55daf89: fix(driver-turso): in remote mode, a `$between` that is not two bounds is refused with `INVALID_FILTER` / 400 and its message names no field or value (#20094) + + Clause-②: no + + In remote mode, `TursoDriver` lowers `$between` to `$gte` / `$lte` before the filter reaches its transport. When the range was not two bounds (`[x]`, `[x, y, z]`, `[]`, a number, a string, `null` or an object), the lowering threw a plain `Error` with no `code` and no `status`. Its message named the object and field and repeated the value, and every caller got it, including when the range came from a read scope such as `plugin-security`'s RLS or sharing predicate. Local mode refuses the same filter with `INVALID_FILTER` / 400 and withholds the field. + + The lowering now passes such a range on as written, and the transport refuses it the way it refuses every other filter it cannot compile: + + - `INVALID_FILTER` / 400, the code and status local mode answers; + - the message states the refusal's class, `Operator "$between" in this filter requires a [min, max] value array.`, behind the transport's `[RemoteTransport]` prefix, and says the field is withheld; + - the field and the value go to the driver's logger, at `warn`. + + Remote mode rebuilds every filter node before the transport sees it, so no provenance mark reaches the transport. As with the transport's other refusals, a filter marked as the caller's own therefore also gets the withheld message in remote mode. Local mode gives that caller the full text. + + Not changed: which filters are refused, local mode's answers, and how a two-bound `$between` is lowered, including the whole-day upper bound for a bare `YYYY-MM-DD` on a `datetime` field. +- e01d347: fix(driver-sql, driver-turso): `reclaimSpace()` returns the whole SQLite freelist, not one page per call (#20106) + + Clause-②: no + + `reclaimSpace()` is what the lifecycle service calls after every sweep that deleted rows (ADR-0057 §3.4). On SQLite it runs `PRAGMA incremental_vacuum`, and that statement frees one page per step. Two clients stepped it once: + + - **`SqlDriver` on better-sqlite3, and `TursoDriver` in local mode.** knex's better-sqlite3 client runs a statement that declares no result columns with `Statement.run()`, which steps it once. A database with 300 free pages had 299 after the call, read from a second connection, and the file barely shrank. `incremental_vacuum(N)` freed one page too. The method now drives that binding through its own `exec()`, which steps the statement until SQLite reports done: 300 → 0, and the file shrinks by those pages. + - **`TursoDriver` in remote mode.** The libSQL client's `execute()` stepped the statement once and left it unfinished. Over a libSQL `file:` client, the issuing connection read one page fewer, but a second connection read the freelist and the file size unchanged, and a row written after the call on the same connection never reached the file. The remote route now reads `PRAGMA freelist_count`, sends nothing more when it is `0`, and otherwise runs the vacuum through the client's `executeMultiple()`: 300 → 0 from a second connection, and the later write lands. A server that refuses either statement answers `DATABASE_ERROR` / 500, as before. What a hosted libSQL server does with either call is not measured. + + `SqliteWasmDriver` was already complete: its dialect steps every PRAGMA to the end (300 → 0 before and after this change). + + Nothing to migrate: `reclaimSpace()` keeps its signature, and a database whose `auto_vacuum` mode is not `INCREMENTAL` still reclaims nothing, as before. +- bdea10a: fix(driver-turso): remote mode materializes every declared object-level index, not only field-level `unique` (#17609) + + ## What was wrong + + In remote mode (`libsql://` / `https://`), `TursoDriver` provisions tables through `RemoteTransport`, and the only index DDL that path could emit came from field-level `unique`. An object's declared `indexes: [...]` — unique or not — had no consumer there, so no remote database ever carried one. The local face (`SqlDriver`) created all of them, so nothing failed and no local test noticed: on a remote tenant database `sys_notification_delivery` (five declared indexes) and `sys_job_queue` (three) held only their primary-key autoindex, and the delivery claim query answered every poll with a full table scan (`SCAN sys_notification_delivery` + `USE TEMP B-TREE FOR ORDER BY`). + + ## What changes + + - Remote mode now creates **every** declared index: field-level `unique` plus the object's own `indexes`, unique and non-unique, including `unique: 'organization'` with its NULL-safe `COALESCE(, '__global__')` key part. Names and keys come from the same shared normalizers `SqlDriver` and the drift differ use (`uniqueIndexesFromFields`, `normalizeDeclaredIndex`, `buildIndexName`), so both faces land the same index set — pinned by a new local/remote parity suite that compares `sqlite_master` on both. + - New tables get their indexes in the same batch as `CREATE TABLE`. + - **Existing tables are retrofitted on the next schema sync** with `CREATE [UNIQUE] INDEX IF NOT EXISTS`. No row is read-modified or rewritten. + - An index the retrofit cannot create is reported once at `error`, naming the index, the table and the database's own cause. A declared `unique` index over rows that already violate it is **not** forced and no data is repaired: de-duplicate the key's values and re-run schema sync. + - Steady-state cost goes down: a sync now reads the existing index names once (one statement, folded into the column-probe batch it already sends) and issues no index DDL when every declared index exists. Before, every boot re-sent one `CREATE UNIQUE INDEX IF NOT EXISTS` per field-level unique index on an existing table. + + ## Upgrading + + Nothing to change in metadata or configuration. The first kernel build after upgrading creates the missing indexes on each existing remote database — on a large table that one build pays the index build time. Watch the boot log for `could not create the declared` lines at `error`: each names an index that is still absent and why. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [32be735] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [d46deba] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [7c2c5ae] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [be5c602] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [9ccc417] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [beac798] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [9bfbacb] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [9d81af7] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [57c2b73] +- Updated dependencies [f09d412] +- Updated dependencies [adbbc5d] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8d76c2d] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [e01d347] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [d3958ba] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [15bf186] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [fc0db22] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [5b674f5] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [40626bd] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/driver-sql@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index ef5bfff1a87..8e0ac4d6fac 100644 --- a/packages/drivers/driver-turso/package.json +++ b/packages/drivers/driver-turso/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-turso", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Turso/libSQL Driver for ObjectStack — Edge-first SQLite with embedded replicas", "keywords": [ diff --git a/packages/formula/CHANGELOG.md b/packages/formula/CHANGELOG.md index ca6b37ce71f..f348ed42171 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,1105 @@ # @objectstack/formula +## 17.5.0 + +### Minor Changes + +- ce57857: feat(spec)!: every engine-evaluated expression slot requires a non-blank `source` — the #15430 rule generalised from the flow-node ledger to the other 36 declaring positions (#15811, decision batch #122 item 2) + + + + **BREAKING** accept-set narrowing on 36 published metadata slots. Each of them + composed `ExpressionInputSchema` and now composes `EvaluatedExpressionInputSchema`, + so an envelope carrying only `ast` (`{ dialect: 'cel', ast: … }` with no `source`) + and a `source` that is blank after trimming — through the envelope key or through + the bare-string shorthand — are refused at the door instead of parsing and then + faulting at run time. The prescription is registered under protocol major 18 as + the semantic migration `evaluated-expression-slots-source-required`. + + **⚠️ Graded `minor`, not `major`, and the ruling said `major`.** Decision batch + #122 item 3 ordered a 「`major` changeset」. This repo's launch-window convention + ships breaking changes as `minor` while the fixed group versions in lockstep, and + `scripts/check-changeset-no-major.mjs` enforces it: a `major` marker here would + promote all ~70 packages to a whole-stack major release, which is a release act. + The convention's own written carriers for breaking-ness are used instead and both + are present — this **BREAKING** banner and the ADR-0087 disposition above. The + ruling's substance (a breaking narrowing, carried by an ADR-0087 semantic + migration entry) is delivered; only the marker differs, and it differs because a + repo gate forbids the marker. + + **What is NOT narrowed.** `ExpressionSchema` / `ExpressionInputSchema` remain the + persistence contract (`source` OR `ast`), by item 2 of the same ruling, and so + does `PredicateInputSchema`, which is a plain alias of the latter. A slot that + only PERSISTS an envelope is untouched; the narrowing is at the slots an engine + EVALUATES. An `ast` carried BESIDE a string `source` stays admitted everywhere. + + **The population was re-derived, not inherited.** By identity — a negative + lookaround on identifier characters, so `CronExpressionInputSchema` and + `TemplateExpressionInputSchema` cannot leak in as substrings — over + `packages/spec/src`, non-test: 34 declaring source lines, two of which are + file-local alias consts (`ui/action.zod.ts` `ActionConditionInputSchema`, + `system/settings-manifest.zod.ts` `SettingsVisibilityInputSchema`) that mount two + slots each, giving **36 declaring positions**. Three of them reach the schema as a + union member rather than head-of-declaration (`RecordAlertProps.visible`, + `ServiceLevelIndicator.successCriteria`, `TraceSamplingConfig.composite[].condition`). + + On **two of those three the sibling arm is untouched**: `RecordAlertProps.visible` + still takes a boolean literal, and `ServiceLevelIndicator.successCriteria` still + takes its structured `{ threshold, operator, percentile? }` object — including one + that happens to carry a `dialect` key. + + ⚠️ **On the third, `TraceSamplingConfig.composite[].condition`, the sibling arm + narrows too, and deliberately.** Its structured-filter arm is a bare + `z.record(z.string(), z.unknown())`, which accepted `{ dialect: 'cel', ast }` as an + ordinary filter — so swapping the expression arm changed nothing at all there. That + arm now declines any object carrying a `dialect` key, and six shapes the base + accepted THROUGH THAT ARM ALONE (measured: the base's `ExpressionInputSchema` + refused every one of them) are refused at this slot: + + | authored `condition` | base | now | + |---|---|---| + | `{ dialect: 'cel' }` | accepted | refused | + | `{ dialect: 'js', source: 'x' }` | accepted | refused | + | `{ dialect: 'nope', source: 'x' }` | accepted | refused | + | `{ dialect: 'cel', source: 5 }` | accepted | refused | + | `{ dialect: 'cel', source: 'x', meta: { rationale: 5 } }` | accepted | refused | + | `{ dialect: 'zzz', foo: 1 }` | accepted | refused | + + FROM → TO at that slot: if the value really is a **structured filter**, drop the + `dialect` key (`{ dialect: 'cel', service: 'api' }` → `{ service: 'api' }`); if it is + an **expression**, give it a dialect this platform evaluates and a non-blank `source` + (`{ dialect: 'js', source: 'x' }` → `{ dialect: 'cel', source: 'x' }`). A structured + filter that carries no `dialect` key — `{}`, `{ service: 'api' }`, + `{ attributes: { 'http.route': '/v1/orders' } }` — is accepted exactly as before. + + **Why an authoring-time refusal and not a run-time one.** Measured at the + chokepoint, `celEngine.evaluate` never silently succeeds on either shape — it + returns a `parse` fault — so what happened next was decided entirely by the + slot's fail policy, and the two halves of that population fail in opposite + directions: fail-CLOSED slots (`ObjectFieldGroup.visibleWhen`, + `RowCrudActionOverride.visibleWhen`, `BulkActionDef.visible`, the two + settings-manifest `visible` slots) hid a group, a row button, or silently excluded + every selected record from a bulk run and reported them as *skipped*; fail-SOFT + slots left a gate that had stopped gating. Nothing in between said a word: the + authoring lint `validateVisibilityPredicates` measured 0 findings on an `ast`-only + envelope and 0 on a blank `source`, against two control legs that each measured 1. + + **`@objectstack/formula` gains `printCelAst(ast)`** — the inverse of + `parseCelToAst`, and the lossless half of the migration: an `ast`-only CEL + envelope is printed back to surface syntax mechanically, with no judgment asked of + the author. It is lossless about MEANING, not bytes (the printer re-renders from + the parse tree, so `'x'` comes back as `"x"`), and it answers `null` — never a + guess — for anything it cannot round-trip through the platform's own bounded + parser. That `null`, and every blank `source`, are what the semantic migration + entry's structured TODO covers. + + **The published TypeScript interface `RowCrudPredicates` narrows with it** + (`Expression | ExpressionInput` → `EvaluatedExpression | EvaluatedExpressionInput`), + because it mirrors the two `RowCrudActionOverride` slots and a type that still + promised an `ast`-only envelope would advertise what the schema now refuses. + + **So do the four expression constructors — `expression()`, `cel`, `tmpl`, `cron` + (and therefore the `F` / `P` aliases) — which now return `EvaluatedExpression` + instead of `Expression`.** Each one assigns a `string` to `source` + unconditionally, so the wider return type described none of them; it was slop + that cost nothing until an evaluated slot began requiring `source`, at which + point ``visibleWhen: P`…` `` — the spelling the spec's own docblock teaches — + stopped type-checking, and `@objectstack/platform-objects` failed its DTS build + on exactly that. `EvaluatedExpression` is assignable to `Expression`, so every + persistence-contract slot keeps accepting these values unchanged; what the + narrower return type adds is that an evaluated slot accepts them too. An author + who genuinely has no `source` was never calling these constructors — an + `ast`-only envelope is an object literal, and an evaluated slot refuses it on + purpose. +- 9be2b59: `EvalContext` no longer declares `api?: { exists, count, lookup }` — the kernel query API behind `os.exists` / `os.count` / `os.lookup`, which `buildScope()` never bound (#18318). + + **BREAKING** for a TypeScript consumer: an `EvalContext` literal that carries `api` stops compiling. The level stays `minor` because the launch window refuses `major` outright — while it is open, breaking-ness is carried by this banner and by the ADR-0087 disposition at the foot of this changeset, not by the bump. + + The member's docblock said it was "implemented opportunistically by call sites that have a query engine", and no call site ever could: `ctx.api` was read **zero** times in this package — control in the same sweep, `ctx.user`, three reads in `stdlib.ts` — so the three functions reached no evaluation scope however completely a caller populated the member. An author who wrote a predicate to the declaration got `runtime: found no matching overload for 'dyn.lookup(string, dyn)'` instead, and because an unevaluable predicate refuses the write it guards, a validation rule authored that way locked **every** write on its object. The harm came from the declaration existing, not from the implementation missing, so it is removed rather than implemented — with the reason written at the deletion site, and with no shim, alias or reserved spelling left behind. + + **Your fix — delete the `api: { … }` property.** There is no replacement key and nothing to re-point: every implementation ever passed there was discarded before evaluation, so removing the property changes no result your predicates produce. TypeScript is where you will hear about it: an `EvalContext` literal carrying `api` now fails to compile, which is the whole of the break. Reading a related record's field from inside a predicate remains unexpressible in any spelling — that capability is tracked as its own card, relationship traversal (`record.crm_account.type`), and deliberately not as `os.lookup` queries; no schedule is implied by this removal. + + + + Clause-②: yes +- 627382b: Add `current_user.can(object, verb)` — the permission predicate — to the CEL engine, together with the data it is answered from. + + `Clause-②: yes` — a new callable name widens the authorable surface. Purely additive: nothing is removed, renamed or narrowed, and every expression that evaluated before evaluates the same way. + + **What you can write now** + + ```cel + current_user.can('crm_lead', 'edit') + ``` + + `can` is registered **receiver-only**, so it is called ON the acting subject (`current_user`, or its `user` / `ctx.user` / `os.user` aliases — the same object). A bare `can(object, verb)` is deliberately not registered and keeps faulting: a permission question with no subject has no meaning. + + The verb vocabulary is the closed table `OBJECT_PERMISSION_VERBS` in `@objectstack/spec/security` — `read`, `create`, `edit`/`update`/`write`, `delete`/`remove`, `export`, `transfer`, `import`. A verb outside it is refused loudly rather than answered `false`. The answer folds the super-user bits exactly as the enforcement door does, so a predicate and the server's 403 cannot disagree. + + **What a call site must pass** + + `EvalContext` gains `permissions` — a pure data map, object name → `EffectiveObjectPermission`, which is the `objects` map of the published `/auth/me/permissions` response, unchanged. Build it through the new `toEvalPermissions(response.objects)`, which refuses a payload that is not that shape. + + ```ts + import { toEvalPermissions } from '@objectstack/formula'; + + const permissions = toEvalPermissions(mePermissions.objects); + ExpressionEngine.evaluate(predicate, { user, record, permissions }); + ``` + + **With no permission data in the context, `can` THROWS** (`ok: false`, `kind: 'runtime'`) and names the missing input. It never answers `true` (which would reveal what the subject may not see) and never answers a silent `false` (which would hide a gated element from everyone, indistinguishable from a real denial). An *empty* map is a real answer and evaluates to `false`, as does an object the map does not mention. + + **Also new, all additive**: `EvalPermissions` and `PermissionBinding` types, `registerPermissionPredicate()`, and an optional fourth argument on `registerStdLib()` carrying the binding. Existing three-argument calls are unaffected. +- e75cc3c: `ExprSchemaHint` gains `roots` — an authoring surface naming the binding roots it mounts beyond the platform baseline, so `validateExpression` can accept them without standing down on everything else (#18554). + + A page component's `visibleWhen` binds three roots at runtime, and `ExprSchemaHint` could express neither of the two shapes it needs: `scope: 'record'` refused `page.selectedProjectId != ''` — the worked example `packages/spec/src/ui/page.zod.ts`'s own `visibleWhen` describe ends with, under a sentence naming the contract-bound roots as `record`, `current_user` and page state as `page.` — and prescribed `record.page`, which names nothing on any layer; `scope: 'flattened'` accepted that example and accepted a bare `status == 'done'` with it, which is the shorthand the narrowing exists to catch. Downstream the refusal is not cosmetic: an editor that lints a page block on the `record` face disables Save for the author who wrote the platform's own documented spelling. + + ```ts + validateExpression('predicate', "page.selectedProjectId != ''", { + scope: 'record', + roots: ['page'], // what this surface mounts beyond the baseline + }); // -> ok; `status == 'done'` at the same site is still an error + ``` + + - **It only ever adds.** A root listed in `roots` is declared alongside `SCOPE_ROOTS`, never instead of it, so passing the key can turn a refusal into an acceptance and never the reverse — a caller adopting it cannot silently lose a check it has today, and a call site that does not pass it gets the verdict and the prescription it got before, byte for byte. + - **Declaring a root is not becoming permissive.** The bare-field shorthand, an undeclared root, and a typo of a declared root are all still hard errors at a surface that declares `page`. Trading a false refusal for a silent acceptance is the worse of the two directions, so the surface says *which* roots it binds rather than asking the validator to stop checking. + - **A mistyped root is sent to the root, not to `record.`.** When a surface has declared its roots, a namespace reference within edit distance of one of them (`pge.selectedProjectId`) is named as an unbound root and pointed at `page`. Every other shape — a bare value reference, a known field used as a JSON namespace, any site with no declared roots — keeps the existing `record.` prescription, which is the right fix for the case it was written for. + - **`introspectScope` advertises what the validator accepts.** Declared roots join the roots it hands an author, from the same declaration, so a root that is accepted is never one an author has no way to discover. + - **Not a closed-set mechanism.** A surface that must *refuse* a baseline root it never mounts still says so with `collectCelRootIdentifiers`, which reads the AST and is independent of this key. The two directions stay two mechanisms. + + Clause-②: yes (widening) +- 1f05ea4: A validation rule can read one hop through a lookup — `record.account.type` on an opportunity resolves the owning account's field instead of faulting (#18682) + + Clause-②: yes (widening) + + A validation predicate could only read the record it guards. A `lookup` / + `master_detail` field carries an **id**, so the natural cross-object rule — + "a partner account may not carry an opportunity over 10000" — faulted with + `runtime: No such key: type`, and because a broken validation is fail-closed it + rejected every write on the object. The capability mainstream platforms provide + as a matter of course could not be authored at all. + + ### What you can write now + + ```ts + validations: [{ + name: 'partner_cap', + type: 'script', + message: 'Partner accounts are capped at 10000.', + condition: "record.account.type == 'partner' && record.amount > 10000", + }] + ``` + + One hop, through any reference-typed field (`lookup`, `master_detail`, `user`, + `tree`). The engine reads the related row before evaluating and binds it in + place of the id, so `record..` resolves. + + ### It is data pinned BEFORE evaluation, not a query from inside CEL + + There is no `os.lookup(...)` / `os.exists` / `os.count` — those stay removed. + The engine statically analyses the predicate, learns exactly which reference + fields it reads through and which related fields it names, and loads those + **before** evaluation. Every registered function stays pure once `now` is + pinned, so `objectstack build` artifacts stay byte-stable. + + The cost is bounded by construction: one hop, only the fields a rule actually + names, one batched read per reference field per write, and nothing at all when + no rule traverses. + + ### Read authority — system, bounded by the projection + + The related row is read under **system authority**. A validation rule's output + is a pass/fail the *system* enforces, not data handed to the caller — which is + why RLS predicates are excluded from this capability altogether. Reading as the + acting user instead made the rule unauthorable for exactly the persona it exists + to constrain: a member with CRUD on the child and no read on the parent faulted + on every write. + + What bounds the elevation is the **projection**: only the + columns the predicate names, intersected with the related object's declared + fields. A column the related object does not declare never enters the query, and + is refused as the authoring fault it is — distinct from a column that exists and + is empty, which evaluates as `null`. + + A related object no organization wall scopes — no tenant column (`sys_user` + behind a `user` field), `tenancy.enabled: false`, or `external` — is bounded by + row as well: for any caller that is not system (a user, a public-form + submitter, a caller with no principal), only a row the caller's own read of that + object returns. A reference to any other row refuses the write as not readable, + whatever that row holds. Under a walled posture (`group` or `isolated`), such a + caller with no active organization gets no related read at all: a rule reading + through a stored reference refuses the write as not found. + + ⚠️ **The accepted cost, stated plainly.** A caller can *infer* a related value + they cannot see by observing which writes are refused. The value itself never + appears — the refusal names the field and the rule, never the value — and the + channel is deliberately no wider than "this rule refused this write". + + ### Two shapes are refused, with a prescription + + Both fault at evaluation today, so neither removes anything that works: + + | Shape | Why | Write instead | + | --- | --- | --- | + | `record.account.type == 'x' && record.account == 'acc_1'` | reading through the relationship resolves `record.account` to the related RECORD, so the id comparison would stop matching — silently | `record.account.id == 'acc_1'` for the value comparison | + | `record.account.owner.email` | a second hop is not loaded | denormalise onto `account`'s object, or read it in a hook | + + A field that is **not** reference-typed is untouched: `record.address.city` on + an object-valued field traverses today and keeps traversing. + + ### `@objectstack/plugin-security` gains `canWriteObject` + + The WRITE admission — the sibling of the existing `canReadObject`, running the + middleware's own arms in the middleware's own order: system bypass; then, before + anything resolves, the ADR-0103 engine-owned write guard and the ADR-0090 D12 + delegated-administration gate, each called as the middleware's own primitive; + then no resolved permission sets, unresolvable posture, the ADR-0066 D3 + `requiredPermissions` capability AND-gate for both principals, the CRUD grant, + the ADR-0090 D10 delegator check, and — when the caller's payload is supplied — + the field-level security WRITE gate over it (`getFieldPermissions`, folded + through the D3 field-capability contract, intersected with the delegator's mask + under D10, then the forbidden-write detection); and last, the ADR-0123 D2 + no-active-organization wall, the same verdict the middleware's step 3.7 throws + on. It exists for doors that must ask + "could this caller perform this write" without running the engine middleware — + the write preview is the first. + + ⭐ What it answers, POSITIVELY — by naming what it RUNS, never a category of the + write decision: the ADR-0103 engine-owned affordance gate, the ADR-0090 D12 + delegated-admin gate, the fail-closed postures (#3545's unresolvable posture and + the D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both + principals, the `allowCreate`/`allowEdit` CRUD grant, the D10 delegator's + independent grant, the step 2.5 FLS write gate over the keys the payload + names, and the ADR-0123 D2 organization wall. It says nothing about any refusal + not in that list. `@objectstack/plugin-security`'s + `can-write-object-admission.test.ts` pins the method's answer equal to the + registered middleware's on its equivalence block's cases, and pins one D12 + UPDATE case as a direction: the method `false`, the middleware `true`. + + ⛔ `true` never means the write will succeed, and ⛔ what follows is not an + enumeration of the distance to success: the middleware refuses both before and + after `next()` for reasons this method is never asked. Nearest to hand are the + remaining pre-resolution gates that run beside the two named above — the + package-managed and system-row write gates, which judge a row's PROVENANCE; the + curated-capability-name and audience-anchor binding refusals, which judge a + payload VALUE; and the ADR-0056 public-form grant, which no caller can present + to this method and which has no extracted primitive to call; the row-level and + post-image refusals — the `using` pre-image, the ADR-0055 controlled-by-parent + master edit, the RLS `check` post-image and the Layer 0 tenant post-image, none + of which this method can judge because it is asked about no ROW; the + payload-VALUE refusals the same caller passes by simply not sending the value — + the masked echo and the `owner_id` forge, which therefore widen the caller class + by nothing; the anti-filter-oracle guard on the caller's own predicate, which + this method is handed none of; the post-`next()` assertion that the insert + `check` seam really ran, which judges an executed write; and, outside the + middleware entirely, `readonlyWhen`, the static `readonly` strip and the + validation rules themselves. + + ### Scope + + Object validation rules (`script` / `cross_field`) — and the system-authority + read is confined to that one seam. The field-level + `requiredWhen` / `readonlyWhen` / option `visibleWhen` predicates fail **open** + and are deliberately not covered here; RLS predicates are out too. Depth is one + hop. The cleanup UPDATE a `set_null` delete issues on a referencing record + resolves no relationship, so a rule there is evaluated as before this release — + against the bare id, where reading through it faults and refuses the cleanup, + and with it the delete. +- 9347c1f: A row-level or sharing-rule predicate comparing a field against a list with `!=` / `==` is refused at the CEL lowering instead of lowering to a filter that widens on driver-mongodb, and driver-mongodb refuses `$ne` with an array comparand (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/driver-mongodb`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-list-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS reads on MongoDB and RLS write checks.** A policy written `record.status != ['closed', 'archived']` (or `!(record.status == [...])`, or `!=` against a `current_user` membership set) lowered to `{ status: { $ne: [...] } }` (or `$not` around a bare-array equality). The RLS `using` clause is composed into the query after the engine's comparand-shape check, and driver-mongodb passed the shape to the server, where it selects every scalar row: the read returned the rows the policy was written to hide. A `check` written `!=` against a membership set admitted every write. + + - `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose comparand is a list (`unsupported`): a list literal, or a `current_user` variable that resolves to an array. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports the literal; a resolved array is refused per request. + - `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403). A CEL-authored `check` gets this 403; the `INVALID_FILTER` / 400 of `matchesFilterCondition` remains for a filter passed to it directly. + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. + - `@objectstack/lint`: the list-literal form is reported (`rls-predicate-unenforceable`, `sharing-rule-unlowerable-condition`). The RLS reference pass probes each kernel-resolved `current_user` key with its runtime type. + - `@objectstack/driver-mongodb`: `translateFilter` refuses `$ne` with an array comparand at any depth, with `INVALID_FILTER` / 400, as driver-sql and driver-memory already do. + - `@objectstack/spec`: the migration registry carries the entry. + + **What to change.** "One of these values" is `record.status in ['open', 'pending']`; "none of these values" is `!(record.status in ['closed', 'archived'])`. In a raw filter, use `$in` / `$nin`. `in`, scalar `==` / `!=`, `null` and field-to-field comparisons are unchanged. + + +- c164186: `matchesFilterCondition` refuses an array comparand under `$ne` and in the equality position (`{ field: [...] }`, `$eq: [...]`) with `INVALID_FILTER` / 400, before any record is judged (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula` and `@objectstack/plugin-security` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `rls-predicate-array-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for row-level write checks.** This evaluator is what `@objectstack/plugin-security` runs against the post-image of an insert or update to enforce a row-level `check`. It compared strictly, and no stored value ever equals an array, so: + + - a `check` written `record.status != ['closed', 'archived']`, or `!=` against a `current_user` membership array, lowered to `{ status: { $ne: [...] } }` and matched **every** post-image; + - a `check` written `!(record.status == ['closed', 'archived'])` lowered to `{ $not: { status: [...] } }` and did the same. + + Every write such a policy was written to refuse was admitted and stored. The positive `record.status == ['open', 'pending']` already refused every write (403). + + The message withholds the field, the operator and the value, because the filter is usually an access policy the caller did not write, and the comparand may be a resolved membership set. + + **What to change.** A `check` or `using` predicate that means "one of these values" or "none of these values" is spelled with `in`: `record.status in ['open', 'pending']`, or `!(record.status in ['closed', 'archived'])`. Those, scalar `!=` / `==`, `null`, `Date` comparands and `{ $field }` references evaluate exactly as before. + + +- 4d7e740: A row-level or sharing-rule predicate whose comparison is handed something other than one value is refused at the CEL lowering or at the write-check evaluator, instead of admitting writes and reads it was written to refuse (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing`, `@objectstack/lint` and `@objectstack/objectql` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS write checks and reads.** Each shape below was measured through the real plugin-security on driver-sql and driver-memory: + + - `!(record.status in [['closed', 'archived']])` (a list nested in an `in` list) admitted and stored every write the `check` was written to refuse, and a `using` read returned every row on driver-memory. + - `current_user.org_user_ids != 'x'` and `current_user.org_user_ids > 'a'` (a membership set on a comparison with no field) folded to "no restriction": every write admitted, every row read, on every driver. + - `record.status > ['m']` compared the list as the string `'m'` on the write check, while the analytics read scope bound the whole list as one SQL parameter. `record.reviewer_id > current_user` compared the whole caller object as a string and admitted and stored every write; in this release the RLS compiler's comparand faces (#20212) already drop that policy, and this change refuses it at the lowering for every caller of the compiler. + - `record.status != record.tags`, its negation `!(record.status == record.tags)`, and the mirror `record.tags != record.status`, with `tags` a `json` field or a `multiple` lookup, admitted and stored every write. + + What changes: + + - `@objectstack/formula`: `compileCelToFilter` refuses, with `unsupported`, a list comparand under every comparison (the ordering operators now included, and on the constant-fold branch, whichever side), the `current_user` root or a key resolving to an object under an ordering operator, and an `in` list whose member is itself a list. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports each literal form; a resolved value is refused per request. `matchesFilterCondition` refuses, with `INVALID_FILTER` / 400, an array under `$gt` / `$gte` / `$lt` / `$lte`, an array member of `$in` / `$nin`, and a `{ $field }` comparison (`$eq`, `$ne` or an ordering operator) whose column holds a list or an object on the record being judged, on either side. The message withholds the field, the operator and the value. + - `@objectstack/plugin-security`: the RLS compiler drops a policy the compiler refuses and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, and `getReadFilter` hands the analytics read scope the deny scope). A `check` comparing a field with a list-holding column is refused 400 and stores nothing. + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. + - `@objectstack/lint`: the literal forms are reported as `rls-predicate-unenforceable`, and an ordering comparison against a membership set through the reference pass. + - `@objectstack/objectql`: a `having` comparison against a `{ $field }` column whose aggregated row holds a list is refused 400 where the row carries the list itself (driver-memory); driver-sql rows carry the stored JSON text and compare as before. + - `@objectstack/spec`: the migration registry carries the entry. + + The stage 2a changeset's sentence that `{ $field }` references evaluate as before no longer holds for a column holding a list or an object: that comparison is now refused. + + **What to change.** "One of these values" is `record.status in ['open', 'pending']`, and "none of these values" is `!(record.status in ['closed', 'archived'])`, with the list flat. An ordering takes one bound (`record.status > 'm'`); a range is two comparisons joined by `&&`. Compare against one key of the caller (`record.reviewer_id > current_user.id`). A field compared with a `json` or `multiple` field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook. In a raw filter, use `$in` / `$nin` with flat lists and one bound per ordering operator. + + Not changed: a field compared with a `json` or `multiple` field still lowers and is not reported at authoring time, because the lowering sees the predicate's text and not the object's field types; driver-memory still answers a `{ $field }` comparison on a read without evaluating the reference. + + +- de091b5: A row-level write check that orders a field against a bound (`>`, `>=`, `<`, `<=`) is refused with `INVALID_FILTER` / 400 when that field holds a list or an object on the record being written, instead of comparing the list's string form and admitting the write (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula` and `@objectstack/plugin-security` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `rls-predicate-stored-list-ordering-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS write checks.** Measured through the real plugin-security on driver-sql and driver-memory: `record.tags > 'a'`, with `tags` a `json` field holding `['m']`, compared `'m' > 'a'` and admitted and stored the write. `record.meta < 'a'` with `meta` holding `{ a: 1 }` compared `'[object Object]' < 'a'` and did the same, and so did a `multiple` lookup. `using` stands in as the check for a policy that declares no `check`, so the same predicate in `using` was enforced the same way on writes. No shipped row-level or sharing-rule predicate orders a field at all. + + What changes: + + - `@objectstack/formula`: `matchesFilterCondition` refuses `$gt` / `$gte` / `$lt` / `$lte` and `$between` on a field whose value on the record is a list or a plain object, whatever the comparand, with the same `INVALID_FILTER` / 400 and the same message as the stage 2d refusals. The refusal is per record: a record whose `json` field holds one scalar is compared as before. `null` and `Date` values are unchanged, and so is every equality against a stored list (`$eq`, `$ne`, implicit equality, `$in`, `$nin`). `$between` is not produced by the CEL lowering, so it reaches this only through a filter passed to `matchesFilterCondition` directly. + - `@objectstack/plugin-security`: a check insert or by-id update whose post-image holds a list or an object in an ordered field is refused 400 and stores nothing. That includes a by-id update that edits another field of a row whose stored `json` column holds a list, because the post-image merges the stored row. + - `@objectstack/spec`: the migration registry carries the entry. The stage 2a entry `rls-predicate-array-comparand-refused` now ends "Scalar != and ==, null, Date comparands, and { $field } references between single-valued columns evaluate exactly as before", which is true since stage 2d. + + Three moves, named: + + 1. **The write check now matches driver-sql's read.** driver-sql refuses every ordering comparison, and `$between`, on a column it stores as JSON text, by declared type (400, #7398). The in-process check now refuses the same predicate on the same row (400). + 2. **driver-memory's read parts from the write check.** driver-memory, a test driver, compares a stored list element by element on a read and keeps returning those rows (`record.tags > 'a'` reads a row holding `['m']`), while the check now refuses writing it. This is declared on #15104, as for stage 2d's `{ $field }` half. + 3. **A list written into a scalar field under an ordering check now answers 400.** `status: ['m']` into a `text` field under `record.status > 'a'`, or `amount: [500]` into a `number` field under `record.amount > 10`, was admitted, and driver-sql stored it as the text `'["m"]'` / `'[500]'`. It is now refused before anything is stored. + + **The explain answer.** `security/explain` evaluates the business RLS predicate in-process on the fetched record, so it now answers `INVALID_FILTER` / 400 where the record holds a list or an object under an ordering predicate (this stage). It already answered 400 for a `{ $field }` comparison against a list-holding column (stage 2d). For both, per operation: + + | explain operation | driver | the enforced operation answers | same as explain's 400? | + |---|---|---|---| + | `read` | driver-sql | 400 `INVALID_FILTER` (the driver's refusal) | yes | + | `update` | driver-memory | 400 `INVALID_FILTER` (the post-image check) | yes | + | `update` | driver-sql | 403 `PERMISSION_DENIED`: the pre-image gate fails closed on the driver's 400 | no — both deny | + | `read` | driver-memory | the rows its element-wise read admits | no — the test driver's read | + + Explain itself is unchanged. + + **What to change.** Order a single-valued column (`record.priority > 2`), or test membership in the list with `in` (`record.status in ['open', 'pending']`). A `json` or `multiple` field has no ordering. + + +- 560b724: A row-level or sharing-rule predicate comparing with `!=` / `==` against the bare `current_user` root is refused at the CEL lowering instead of lowering against the whole caller context object (#19959). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-variable-root-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS write checks.** A policy written `record.owner_id != current_user` (or `== current_user`, or `!(record.owner_id == current_user)`) named the variable root alone, which resolved to the whole caller context, and lowered to `{ owner_id: { $ne: } }` (or the bare object, or `$not` around it). A strict compare never equals an object, so a `check` so written admitted and stored every insert and by-id update it was written to refuse, a USING-only such policy admitted every insert, and explain reported the read as narrowed with the caller's membership sets echoed in its `readFilter`. A constant comparison such as `current_user != 'guest'` folded to no restriction. + + - `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose operand is the bare variable root (`unsupported`), in both of its modes, so the authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports it before any request. A variable that resolves to an object is refused per request; a `Date` still passes. + - `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, explain answers `denies`). + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is still skipped at bootstrap, now with reason `unsupported` instead of `unresolved-variable`. + - `@objectstack/lint`: the shape is reported as `rls-predicate-unenforceable` on either RLS clause, where it was silent, and as `sharing-rule-unlowerable-condition` on a sharing condition, where it was `sharing-rule-runtime-variable-condition`. + - `@objectstack/spec`: the migration registry carries the entry. + + **What to change.** Compare against the key the predicate means: `record.owner_id != current_user` becomes `record.owner_id != current_user.id` (or `current_user.organization_id`, `current_user.email`); a membership test is `record.owner_id in current_user.org_user_ids`. Scalar keys, `in`, `null`, literals and field-to-field comparisons are unchanged. + + +- 93cfc3f: `$like` / `$ilike`: `_` matches exactly one Unicode code point on every face, so an emoji or any other character outside the Basic Multilingual Plane is one `_`, as SQL `LIKE` and SQLite `GLOB` count it (#20143). + + The SQLite faces (`driver-sql` on better-sqlite3, `driver-sqlite-wasm`, `driver-turso` local and remote) already answered by code points. The JavaScript faces did not: they compiled the spec's `likePatternToRegexSource` with no regular-expression flags, so `_` read one UTF-16 code unit, which is half of an emoji. The same REST filter returned a different row set depending on which driver backed the object. Measured at `e7f69dbb` over values holding `😀` (U+1F600) and `𝒜` (U+1D49C), 48 answer cells on the JS faces differed from the SQLite faces; after this change, none do. + + - **`@objectstack/spec`**: a new export, `likePatternToRegExp(pattern, foldAscii?)`, compiles the translation with the `u` flag, the one compilation in which `_` is one code point. `matchesLikePattern` evaluates it. `likePatternToRegexSource` is unchanged and still exported; its source means one code point per `_` only under `u`. The `$like` description now says that a character is one Unicode code point. + - **`@objectstack/formula`**: `matchesFilterCondition` answers `$like` / `$ilike` by code points, through the spec's `matchesLikePattern`. Its own CEL `size()` already counted code points. + - **`@objectstack/driver-memory`**: all three `$like` doors (the `$like` filter and the AST `like` / `ilike` node through mingo, and the reference matcher) answer by code points. + + The answer set moves in both directions on those three faces, only for values holding a character outside the BMP: + + | pattern | a stored `😀` | `a😀b` | `a😀😀b` | + |---|---|---|---| + | `_` | now matches | — | — | + | `__` | no longer matches | — | — | + | `a_b` | — | now matches | — | + | `a__b` | — | no longer matches | now matches | + + `$ilike` moves the same way. No pattern is newly refused and no refusal is lifted. Values made only of characters inside the BMP answer exactly as before. + + Clause-②: yes (narrowing) + + +- 862b6ce: Expression refusals now carry a stable `code` and typed `params` beside their English `message`, so a localized author surface can render its own words: `validateExpression` (every entry in `errors[]` and `warnings[]`), `collectCelRootIdentifiers` (its `ok: false` arm), `predicateSlotRefusal` and `structuralConditionRefusal` (#20291) + + Clause-②: yes + + Until now the only thing a refusal said was an English sentence, and a designer running in another locale could only show it verbatim, beside its own translated headings. Each refusal now also names its code — one of a closed, kebab-case set — and the values its sentence interpolates, so a consumer keys a catalogue row to the code and fills it from the params. The `message` is the same sentence, byte for byte; nothing is removed or renamed, so no existing reader changes. + + - `@objectstack/formula` exports `EXPRESSION_REFUSAL_CODES` (the closed set as a frozen list) and the types `ExpressionRefusalCode`, `ExpressionRefusalParams` (code → params), `ExpressionRefusal`, `ExprValidationCode`, `CelRootsRefusalCode`, `CelFieldRole`, `ExpressionSourceKind` and `CelRootIdentifiersResult`. `ExprValidationError` gains `code` and `params`. + - `@objectstack/spec/automation` exports `FLOW_SLOT_REFUSAL_CODES` (the closed set of both flow-slot refusal producers, as a frozen list) and the types `FlowSlotRefusalCode`, `FlowSlotRefusalParams` (code → params), `PredicateSlotRefusalCode`, `PredicateSlotRefusal`, `PredicateSlotValueKind`, `StructuralConditionRefusalCode`, `StructuralConditionRefusal` and `StructuralConditionValueKind`. `predicateSlotRefusal` returns `PredicateSlotRefusal` and `structuralConditionRefusal` returns `StructuralConditionRefusal`; each is its previous `{ message, source }` plus `code` and `params`. + - Narrowing on `code` narrows `params`. A code never changes once published: a reworded message keeps its code, and a new refusal gets a new one. + - A `detail` param is the CEL or template engine's own diagnostic, in English, passed through as the message carries it. + - These are authoring diagnostics returned as values, not ADR-0112 request error codes, which is why they are kebab-case. + - The `validate_expression` MCP tool forwards `validateExpression`'s `errors` and `warnings` as they are, so each entry in its answer now also carries `code` and `params`. +- aeb0557: fix(security)!: the RLS write check refuses a field-to-field comparison the read refuses — one comparison class, one answer per policy (#20355) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on the row-level write check, shipped as `minor` + under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; + breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the + level). The hand-migration prescription is registered under protocol major 18 as + `rls-predicate-cross-class-field-comparison-refused`, one ADR-0087 D3 entry for the whole + family: the authoring arm `os validate` gained in #20347 and this write-check arm. + + **What changed.** A row-level policy that compares two fields of no shared comparison + class — `record.status != record.amount` (text and a number), `record.status != + record.photo` (text and a file field), `record.status != record.is_open` (text and a + formula field), `record.status != record.meta` (text and a json field) — already had + every read it scopes refused with `INVALID_FILTER` / 400 on the SQL drivers, because + driver-sql compiles a column-to-column comparison only within one class. The write + check did not know the rule: it compared the two raw values in-process, so an insert + or update the policy's `check` judges (or its `using`, standing in as the check) was + admitted and stored whenever that comparison happened to hold. Measured through + plugin-security and ObjectQL on SQLite, sqlite-wasm and PostgreSQL. The write check + now refuses the comparison too, with the read's envelope, `INVALID_FILTER` / 400, for + every insert (single or array), by-id update and predicate update it judges, and + nothing is stored. The same-class comparisons it always compared are compared as + before. The 400 names no column of the policy; the server log names the policy and + both columns. A comparison against a json or `multiple` field is refused by its + declared type now, where it used to be judged by the value each record held. + + **`@objectstack/formula`.** `matchesFilterCondition(record, filter, options?)` takes an + optional third argument: `options.fields`, the object's declared columns (`type` and + `multiple` per field name). Given it, every `{ $field }` comparison between two + declared columns is judged by `crossFieldComparisonVerdict` from + `@objectstack/spec/data` before any record is read, and one the platform defines no + answer for throws `INVALID_FILTER` / 400. Without it the evaluator behaves exactly as + before. Two new exports go with it: `findCrossFieldClassRefusal(filter, fields)`, the + pure judgement, and `crossFieldClassRefusalCarriedBy(error)`, which reads the refused + comparison off the error for a server-side log. + + **`@objectstack/driver-sql`.** `crossFieldComparisonClass` reads the same export + (`crossFieldColumnVerdict`) instead of keeping its own copy of the classification, and + layers above it only its internal type aliases. Every read answers as before. + + **`@objectstack/lint`.** The `rls-predicate-unenforceable` finding for such a + comparison now states the write answer the runtime gives: the in-process write check + refuses it by the same classification and stores nothing. + + **If a policy of yours is refused.** The platform defines no comparison between those + two columns on any path, so the policy never protected a read either. Compare a field + only with a field of the same class — a number with a number, text with text, a + boolean with a boolean, a date with a date, a datetime with a datetime, a time of day + with a time of day — or, if the two columns do hold comparable values, correct the + declaration of the one declared with the wrong type. `os validate` names every such + comparison. +- fb38607: feat(drivers,formula,objectql): the engine's filter faces answer the staged `$empty` operator (#20444) + + Clause-②: yes (widening) + + `$empty: true | false` is declared by `@objectstack/spec` (`FieldOperatorsSchema`) with a per-type meaning: a text-like field is empty when it is null or `''`, a multi-value field (multiselect, checkboxes, tags, or a select / radio / lookup / user / file / image with `multiple: true`) when it is null or `[]`, and every other type only when it is null. `$empty: false` is the exact complement. Until now every face in this list refused it (`INVALID_FILTER` / 400), except `matchesFilterCondition`, which answered `false` for every record. **A driver or evaluator called directly now answers it:** + + - **By the field's declared type**, through the spec's one expansion (`expandEmptyOperator`): `driver-sql`'s filter compiler (and so `driver-sqlite-wasm` and `driver-turso`'s local transport, which inherit it), `driver-turso`'s remote transport, `driver-memory`'s query path (`find` / `count` / `update` / `delete`) and `driver-mongodb`'s `translateFilter` (its `find`, its aggregate `$match`). The declaration is the one each driver already receives — `initObjects` / `registerObjectMetadata` / `registerExternalObject` on the SQL family, `syncSchema` on the others. On SQL a multi-value field's empty list is tested as stored JSON per dialect (SQLite `json_array_length` behind a `json_valid` guard, PostgreSQL a `jsonb` comparison, MySQL `JSON_LENGTH`), never as an equality comparand. + - **By value** — null, a missing value, `''` and `[]` are empty (`isEmptyFilterValue`) — on the faces that read no field declaration: `@objectstack/formula`'s `matchesFilterCondition` (the RLS write-side `check`), `driver-memory`'s reference matcher, and `@objectstack/objectql`'s `having` and per-aggregation `filter`. In `having`, a `count` or `sum` holding `0` is not empty. + + **Refused, never guessed** (`INVALID_FILTER` / 400): `$empty` on a field whose declaration the driver does not hold (a table built outside its registration, a builtin column such as `id`, a field with no `type`, or `translateFilter` / `RemoteTransport` used standalone without a declaration), a multi-value field on a SQL dialect the driver does not model, and a flag that is not a boolean. `driver-memory`'s analytics (cube) face refuses `$empty` as an operator it cannot compile, as it does `$null`. + + New optional API: `translateFilter(where, temporalKind?, valueShape?)` in `@objectstack/driver-mongodb` takes a declared-value-shape resolver (type `ValueShapeResolver`), and `buildAggregationPipeline` a `valueShape` option; `RemoteTransport.setDeclaredValueShapeResolver` in `@objectstack/driver-turso`, which `TursoDriver` wires. `@objectstack/spec`'s shared `FILTER_LOGIC_CASES` table gains seven `$empty` cases: a backend that runs it answers `$empty` or goes red, and its harness must declare the fixture's columns. + + `$empty` stays staged: it is not in `FILTER_OPERATORS`, so the engine's front door still refuses it until the flip card adds it, and the view operators `is_empty` / `is_not_empty` still lower to `$null`. +- 5505646: fix(formula): the strict declaredness env declares `SCOPE_ROOTS` as `dyn`, so a bare reference behind a root name is no longer masked (#16412) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on published + CHECKERS, in the same sense as a route that starts refusing a request it should + always have refused — landing in the launch window as `minor` on both packages (during the window the bump level is + not the carrier of breaking-ness; this paragraph and the disposition above + are). Nothing that was already reported stops being reported, and no source + that is correct starts being reported. + + `firstUndeclaredReference` asks cel-js's checker for the first undeclared + identifier in a source. That checker returns exactly ONE error, and the helper + acts only on `Unknown variable: X`, so whenever the first error is of another + class every undeclared reference behind it in the same source went unjudged and + the helper answered `null` — which is also the value that means "every + reference is rooted". Four published call sites read that answer, and none of + them can tell the two readings apart. + + The widest way to reach that state was a disagreement between two environments + in this package about the same names. The strict env declared every + `SCOPE_ROOTS` member (`data`, `config`, `record`, `result`, `item`, `event`, + `input`, `user`, …) as `map`, while the permissive env that `celEngine.compile` + type-checks in leaves them `dyn`. `map` has no `==`, `<` or `+` overload, so an + ordinary comparison on one of those names compiled clean and then faulted `no + such overload` in the strict env only — taking the single error slot and + silencing everything behind it. An author reaches it by naming an object field + or a flow variable after a namespace root and reading it bare, which on a + metadata-editing form is not even a coincidence: that layer binds the row under + edit as `data`. + + The strict env now declares those roots `dyn`, which is what the list's own + doc-comment already claimed it was for — member access, arithmetic and + comparison on a root all deferring to runtime — and which `map` delivered only + the first of. The two environments agree about these names, so the class cannot + arise rather than being compensated for downstream. + + What starts reporting, measured on each published surface: + + - `@objectstack/formula` `validateExpression` with `scope: 'record'` — a bare + reference behind a root name is the hard error it always was for the same + identifier written first (`ok` was `true` with zero errors; it is now `false`). + - `@objectstack/formula` `validateExpression` with `scope: 'flattened'` — the + did-you-mean warning reaches a misspelled field behind a root name. + - `@objectstack/lint` `visibility-bare-identifier` — a bare identifier behind a + root name in a `visibleWhen` predicate is a finding. Per that rule's own + message the console otherwise falls open and the element renders + unconditionally. + - `@objectstack/lint` flow-variable shadowing — a shadowed field read behind a + root name is warned. That rule's documented blind spot is now name-local, as + its wording always claimed: the colliding name itself is still not reported. + + ⚠️ One published answer also WIDENS, and it is not a reporting surface. + `inferExpressionType` (`@objectstack/formula`, re-exported from the package + root; read by `@objectstack/mcp` as `validate_expression.inferredType`) infers a + formula's coarse value type through `inferCelType`, which shares this same + strict environment. While the roots were `map` there was no `==`, `<` or `+` + overload for them, so an expression using a namespace root as a DIRECT OPERAND + did not type-check at all and the answer was `'unknown'`. With the roots `dyn` + those expressions type-check and the answer is the truthful CEL type: + `result + 1` and `record ? 1 : 2` → `'number'`, `record == "x"` → `'boolean'`, + `data == "x" ? "a" : "b"` → `'text'`, uniformly for every name on the list. No + answer changes from one concrete type to another and nothing narrows to + `'unknown'` — `size(record)` and `"a" in record` still answer, and a root that + is only the base of a member access (`record.amount > 100`) never consulted this + declaration. A consumer that keys off a concrete type therefore sees strictly + more expressions classified, never a different classification; for the + motivating consumer that means a formula written as `data == "x" ? "a" : "b"` is + now correctly seen as text rather than as unprovable. Pinned on both sides in + `validate.test.ts`. + + ⛔ Two first-error classes are NOT closed by this, and both stay pinned. A CEL + TYPE name (`type`, `string`, `int`, …) is declared by CEL itself, so no + declaration this package makes can reach it; measured on the strict env, the + message for `type == 'grid'` is byte-identical under a `map` and a `dyn` root + declaration. And `has()` handed a non-select argument still faults its own + class, which `@objectstack/lint`'s visibility rule masks at its own call site + (#16118) and which nothing else masks. + + The narrowing this helper is built on is unchanged: it still acts only on + `Unknown variable`, so `type(record.x) == string`, comprehension macros, guard + idioms, optional chaining and stdlib calls report nothing, and a widening of + that regex onto the overload message remains refused. + +### Patch Changes + +- de62769: `SCOPE_ROOTS`'s docblock says it is a **baseline**, not a per-surface accept set, and points at where the per-surface verdict actually lives + + The exported `SCOPE_ROOTS` constant carried a docblock that made **a false statement about itself**. Its opening line read *"Namespace roots that a `record`-scoped CEL site may legitimately reference"* — which, read alone, is exactly the per-surface accept-set reading. Ninety lines below, the companion block asserted *"This list is a 'never faults' BASELINE, not a per-surface contract — **the doc-comment above says so**"*. The doc-comment above did not say so; it said close to the opposite. + + **This is not a docs nit, and the evidence is a card.** The accept-set reading is what a downstream seat took away, and it generated a cross-repo card filed against this package (this one) about a lint/runtime disagreement that is not a disagreement at all: the baseline declares a root, the per-surface gate refuses it, and both are correct. + + - **The opening line now states the contract it actually is**: the roots the strict check env declares, so that naming one is never itself a fault — and explicitly ⛔ *not* a claim that any surface **binds** the root. + - **It points at the per-surface authority by name**: `@objectstack/lint`'s `fieldRuleRootIssue`, judged against that surface's own closed `FIELD_RULE_BOUND_ROOTS` (`record` / `previous` / `parent`). A reader asking "may THIS surface reference this root?" is now sent one hop to the symbol that answers it, instead of reading the answer off this list. + - **It names `data` as the standing example** of a root this list declares and the field-rule surface does not bind — the two answers doing their separate jobs, ⛔ not something to repair by editing this list. + - **The self-reference is now true.** The companion block cites `SCOPE_ROOTS`'s own doc-comment, which now opens by saying exactly what the citation claims it says. + + ⛔ **Zero behaviour change.** `SCOPE_ROOTS` keeps all **27** members, byte for byte — no member is added, removed or reordered, and ⛔ `app` is not added (objectstack#16420 closed `not_planned` on that and this does not reopen it). Narrowing was refuted by measurement rather than by preference: six `*.form.ts` metadata-form modules in this repo carry live `data.` predicates. The diff is comment lines only. + + **This publishes, which is why it is `patch` rather than `skip-changeset`.** `@objectstack/formula`'s `files[]` ships `dist`, and this TSDoc is emitted into the built declarations — measured on the built artifact at three readings: the new text's distinctive phrase present at 1 in both `dist/index.d.ts` and `dist/index.d.mts`, an untouched neighbouring sentence from the same docblock present at 1 as the lit control, and a fabricated phrase at 0 as the dark control. The companion block is a plain `/* */` comment attached to no declaration and reads 0 in `dist` — it is the half that does not ship, and the half that does is the half that was wrong. +- 7465eeb: fix(formula,objectql): the two refusals a traversing validation rule on an optional lookup meets now name the repairs that work — a `conditional` wrapper or `required: true` (#20007) + + Clause-②: no + + An author who wants to refuse a write when an OPTIONAL lookup is set and its related record is secret writes `record.line != null && record.line.kind == 'secret'`. Two refusals then sent them in a circle: + + 1. That expression reads `line` both through the relationship and as a plain value, which cannot be served, and is refused. The refusal said to "compare the id explicitly" and write `record.line.id` for the value comparison. + 2. `record.line.id != null && record.line.kind == 'secret'` reads through `line` too, so an order with no line is refused before the rule is evaluated, as "no single related record". That refusal said to "guard the rule on the reference being set" and named no spelling for the guard. + + Which writes are refused is unchanged, and so are the error, the `rule_violation` field error and its `constraint` (`reason: 'unevaluable'` and the fault). `@objectstack/lint` passes the formula refusal through unchanged, so it shows the new text too. Only the prescriptions change. Both now name the two spellings measured to work for an optional reference, and the guard is worded exactly as in the delete-cleanup refusal: + + ```text + … To compare the id, write `record.line.id` for the value comparison, and keep + `record.line.` for the traversal. `record.line.id` is not a null guard: it + reads through `line` too, and a rule that reads through an empty `line` rejects the write + instead of being skipped. If the plain value tests for empty, take that test out of this + expression. To skip the rule while `line` is empty, guard it on `line` being set: make it + the `then` of a `conditional` rule whose `when` is `record.line != null`. To refuse an + empty `line`, make `line` required (`required: true`). + ``` + + ```text + … A predicate resolves ONE hop through a single reference. To skip the rule while `line` + is empty, guard it on `line` being set: make it the `then` of a `conditional` rule whose + `when` is `record.line != null` — `record.line.id != null` inside the rule is no guard, as + it reads through `line` too. To refuse an empty `line`, make `line` required + (`required: true`). For a multi-value reference, test it with a macro (`exists`, `size`) + instead of reading through it. + ``` + + The repair as an author writes it, measured end to end on insert and update. It accepts an order with no line or a public line, and refuses a secret line with the rule's own message: + + ```ts + validations: [{ + name: 'no_secret_line_when_set', type: 'conditional', + message: 'Only checked while the order names a line.', + when: 'record.line != null', + then: { name: 'no_secret_line', type: 'script', message: 'An order may not carry a secret line.', + condition: "record.line.kind == 'secret'" }, + }] + ``` + + With `required: true` on `line` instead, an order with no line is refused at the field (`required`), and the rule still judges one with a line. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index 66277a56217..cc111524e00 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack canonical expression engine — CEL (cel-js) + ObjectStack stdlib + dialect registry", "main": "dist/index.js", diff --git a/packages/lint/CHANGELOG.md b/packages/lint/CHANGELOG.md index 57df6238953..fe12dec2475 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,3169 @@ # @objectstack/lint +## 17.5.0 + +### Minor Changes + +- 0283cb9: feat(automation)!: an edge-branched `decision` is exclusive — the first out-edge whose condition holds, in declaration order, wins; `mode: 'inclusive'` takes every one (#15429) + + + + Clause-②: yes + + **BREAKING** — the run-time semantics of a shipped node type change. A `decision` node that + declares no `config.conditions` and branches on its out-edges used to take EVERY out-edge whose + condition held, one after another, while its schema, the docs and the engine's own comment all + called it an exclusive gateway; hotcrm#1555 rendered a refusal screen AND ran the conversion in + one execution. Maintainer ruling on #15429 (2026-09-23, 「跟主流对齐」): the gateway follows + BPMN's exclusive gateway, Salesforce Flow's Decision and n8n's Switch default, and taking every + true branch is a declaration the author writes down. + + | | before | after | + |:--|:--|:--| + | two conditioned out-edges, both hold | both successors run, sequentially, nothing reported | the FIRST declared one runs; the second records a `skipped` step | + | `config: { mode: 'inclusive' }` | accepted, never read | every out-edge whose condition holds runs, sequentially | + | none holds | the `isDefault` edge runs | unchanged | + | `mode` beside a non-empty `conditions` list, or outside `'exclusive' \| 'inclusive'` | refused by a direct parse only | refused at `registerFlow` and by `os validate`, with the schema's own sentence | + + ## Migration: FROM → TO + + `os migrate meta --from 17` lists the mechanical edits for existing sources and applies them + to the migrated stack: the ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit` + writes `mode: 'inclusive'` onto every decision that has no `conditions` list and two or more + conditioned out-edges, inside ADR-0031 regions included, so a migrated flow runs exactly as it + did. + + ```ts + // FROM — every true out-edge ran + { id: 'verdict', type: 'decision', label: 'Verdict?' } + // TO — what the conversion writes; delete the key where the conditions partition + { id: 'verdict', type: 'decision', label: 'Verdict?', config: { mode: 'inclusive' } } + ``` + + Then review each written key (the paired D3 entry `flow-decision-edge-branching-first-match` + carries the acceptance criteria): delete it where the conditions partition (`== 'a'` beside + `!= 'a'`, `>` beside `<=`, a guard beside `isDefault: true`), keep it where the flow relies on + more than one branch running for one record, and where the overlap was accidental narrow the + conditions into a partition and delete the key. `os validate` reports + `flow-decision-inclusive-overlap` on every decision that keeps the key with two or more + conditioned out-edges, so the review list is the lint output. + + ## BREAKING for flows stored in `sys_metadata` — maintainer ruling letter C on #15429 + + A `decision` node stored in `sys_metadata` (a flow built or edited in the Studio designer) with + **no `config.conditions`, no `mode`, and two or more out-edges carrying a `condition`** evaluates + **first-match** after this upgrade: where it took every out-edge whose condition held, it now takes + only the first one that holds, in the order the flow declares its edges. Nothing rewrites that row + — no stored-row migration, no cutoff, no read-path completion — because nothing about a stored row + says it was saved before the flip. The one-line fix, for a node that meant every branch: + + ```ts + { id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } } + ``` + + `os migrate meta --stored` (and `POST /api/v1/meta/_migrate-stored`) lists every such node under + `decisionModeReview` — flow row, node id, label and path — on a preview and an `--apply` run + alike, and writes nothing for it: the list moves no row outcome, no count and no exit code, so an + operator can review the candidates before and after the upgrade. A node leaves the list once it + declares `mode`, either member. Every such node in the measured corpus below is a partition, where + the new meaning runs exactly what the old one did. + + Authored sources and built artifacts keep the old behaviour instead, where the source's age is a + fact: `os migrate meta --from 17` writes `mode: 'inclusive'` (above), while the authoring funnel, + the automation engine's flow rehydration seam and the artifact-ingestion door all refuse the + conversion by id — a default flip replayed there would turn a decision written today against this + contract, where an omitted `mode` means exclusive, into an inclusive gateway. + + ## Reach, measured at landing + + - Release state: the npm registry's `latest` `@objectstack/spec` is `17.4.0` (`npm view`, + 2026-09-27), whose `json-schema/automation/DecisionConfig.json` declares `conditions` only — + `mode` has not shipped; `.changeset/19867-decision-config-mode.md` and + `.changeset/20168-decision-mode-beside-conditions-refused.md` are still unconsumed in this + tree. So `mode` reaches its first release together with the traversal that reads it and the + conversion that writes it; no published accept set narrows, and the registration and + `os validate` refusals narrow nothing that shipped. + - Corpus census (this repository at the branch base and `objectstack-ai/hotcrm` at `2f7b2326`, + read-only): 30 decision nodes across 48 flows; 17 have two or more conditioned out-edges and + no `mode` (the conversion's positives — every one a hand-written partition, including + hotcrm's `lead_conversion.decision_duplicate`, the #1555 node), 13 have one conditioned + out-edge (left alone), and no node of any other type carries a conditioned out-edge, so the + exclusive traversal is scoped to `decision` with nothing else to migrate. + - What the published surface gains: the D2 conversion and its D3 entry in the protocol-18 + chain (`spec-changes.json`, the upgrade guide), `DecisionConfigSchema.mode`'s describe and + docblock now state the run-time semantics, and `@objectstack/lint` gains + `flow-decision-mode-invalid` (gating) and `flow-decision-inclusive-overlap` (advisory). + + The traversal change is scoped to `decision` nodes: conditioned out-edges of any other node + type keep the every-true-edge traversal they had (none was measured to exist). +- c88fa2c: fix(lint)!: an orphaned locale key now FAILS the run — `translation-target-unknown` is an `error` (#16310) + + `validate-translation-references` reported every orphan translation key precisely + — the id named, the locale named, the remedy printed — and failed nothing. + `os lint` exits 0 on warnings, the rule hard-coded `severity: 'warning'`, and no + per-rule severity is configurable by a consuming app. So a PR that deletes a + navigation entry, a form section or a view and leaves its locale keys behind was + green on every pipeline on the platform, and the dead keys are actively + misleading afterwards: grepping the id returns a confident-looking hit in every + locale, which reads as "this exists and is translated". + + The forward half of this parity — `i18n/missing-*`, an authored surface with no + translation — already fails, and apps already gate on it. The orphan half now + fails too, so the two halves of one parity have the same enforceability instead + of opposite ones. + + **BREAKING** — a stack carrying an orphan locale key stops passing `os lint`, + `os validate` and `os build`. Measured on one stack with 8 orphan keys planted, + `objectstack lint --json`: + + | `@objectstack/lint` | findings | errors | warnings | `passed` | exit | + | :-- | --: | --: | --: | :-- | --: | + | before this release | 20 | 0 | 18 | `true` | 0 | + | after this release | 20 | 8 | 10 | `false` | 1 | + + The findings themselves are unchanged — same count, same paths, same message and + hint text. Only the severity moves, and with it the exit code. + + **What an author does about it.** In a clean stack, nothing: a tree with no + orphan key reports exactly what it reported before, at the same severities, with + the same exit code (measured — the report is identical field for field apart + from its wall-clock `duration`). In a stack the rule already names findings on, + delete each locale key it names. The key resolves to nothing — the object, + field, view, section, tab, action, param, app, nav item, dashboard, widget or + flow screen it was written for is not in the stack — so removing it changes no + rendered string in any locale. Where the target was renamed rather than removed, + key the translation to the new name instead; the finding prints the declared + names to choose from. + + **This is ONE rule, not "warnings are errors now".** Measured on a planted tree + carrying findings from 13 distinct rules: exactly 1 changed severity, 12 did not, + and the finding set is identical modulo that one severity. + `translation-option-key-unknown` — raised by the same function — stays `warning` + on purpose: a mis-keyed option translation names something real and its remedy is + a rename, not a deletion. `validateTranslatableSections`, the sibling asking "is + there a key at all?", is untouched. + + **Unchanged: the runtime publish gate.** `validateTranslationReferences` reaches + the runtime door on a `flow` write, but the per-write snapshot carries only + `objects` / `permissions` / `books` / `datasets` — `RuntimeStackContext` has no + `translations` member for a host to fill — so the rule sees no bundle and returns + nothing there. Measured: a flow write through `runRuntimeAuthoringRules` yields + 0 errors and 0 advisories from this rule. No publish that used to succeed is + refused. + + `TranslationRefSeverity` widens from `'warning'` to `'warning' | 'error'` + accordingly. + + +- c3a95d9: `field-no-consumers` now reads two consumers that name the field nowhere in metadata — a declared field group placing it on the synthesized layout, and the column a seed or import mapping matches on (#17135). + + The rule's first run on a real application reported 12 fields, and all 12 were on screen or load-bearing that day. Both misses are now read off the spec rather than off a hand-kept list, the way the rule's other two exemptions already are: + + - **The synthesized layout.** `deriveFieldGroupLayout` (ADR-0085 §5) is the one derivation every renderer applies — form, detail, drawer and designer — and it places a field by its `group` membership, not by naming it in a `fields: [...]` array. A field the derivation puts in a **declared** group is therefore drawn, and is credited as a display site. The derivation's trailing untitled bucket is deliberately **not** credited: it collects everything the author did not place, so crediting it would hand the display verdict to every visible field in every app. + - **An upsert identity.** A carrier root holds values that are written and labels that are carried, and the root decided the bucket before anything else could ask. But a seed's `externalId` and an import mapping's `upsertKey` name the column the loader **matches on** — it reads that column on every row to decide insert from update. A seeder-only identity column is consumed by being an identity. + + ⛔ Nothing exempts `hidden` as a category. A `hidden` field no upsert matches on and nothing reads is still reported, and a `hidden` field in a declared group earns nothing from the layout, because the derivation never draws one. + + Measured on `hotcrm@965933b` (the tree the 12 were reported on): **12 findings → 0**, with the synthesized layout accounting for 11 and the upsert identity for 2 (they overlap on one field). Against the same application with six deliberately unconsumed fields injected — ungrouped, undeclared-group, hidden-in-a-group, hidden + readonly, a field on an object declaring no groups, and the matched pair of a seeded identity against an identical declaration nothing matches on — all six are still reported and only the identity goes quiet. +- 23fc5d6: An action can now **declare which bulk dispatch contract its body is written for**, and a list view that wires it the other way is refused at authoring time instead of handing the body the opposite input in silence. + + A list view has always been able to wire the same declared action two ways, and the two deliver opposite shapes to the same body: `bulkActions: ['']` promotes the action to a def and dispatches it **once per selected row** (that row's `recordId`, no `_selectedIds`), while a `bulkActionDefs` entry with `execution: 'aggregate'` makes **one** dispatch for the whole selection (every id in `params._selectedIds`, no `recordId`). The action declared neither, so both mismatches failed quietly and in opposite directions — an aggregate body wired bare-string read `_selectedIds` as `undefined`, fell into its single-record branch and reported success for one row out of ten; a per-record body wired aggregate found no `recordId` and threw its own "nothing selected", which reads like a selection bug. Nothing caught either: `recordId` and `_selectedIds` are both built-in action params (ADR-0104), so the strict params gate admits either bag without a word, and the wiring lives on the view while the declaration would live on the action, so no single parse has both halves. + + - **`ActionSchema` gains `execution`**, and it is `bulkActionDefs`' own vocabulary — the same key, the same two values (`'perRecord' | 'aggregate'`), the def's `BulkActionExecutionSchema` **imported rather than re-declared**, so there is no second spelling to drift. The near-miss keys (`dispatch`, `dispatchContract`, `bulkExecution`, `bulkDispatch`) rename onto it; ⛔ `mode` deliberately does **not**, because on an action `mode` is a declared key of its own. + - **`@objectstack/lint` gains `action-dispatch-contract-mismatch`** (severity `error`), a member of the reference-integrity suite, so it runs on `os validate`, `os lint` and `os compile` at once. It names the action, the view and **both** contracts — the declared one and the wired one — and offers both ends of the fix, because which end is wrong is the author's call. It judges every list tier: a view's `list`, each `listViews.`, and an object's own `listViews`. + - **⛔ No silent default.** `execution` is optional and an action that omits it is *undeclared*, never defaulted to a contract — which is also the honest state of a body written to serve both (it reads `recordId` *and* `_selectedIds`), and why no third enum member was added. Existing sources are migrated by the new ADR-0087 semantic entry `action-bulk-dispatch-contract-undeclared`, which derives the declaration from the view wirings where they are unambiguous and hands back a structured TODO where one action is wired both ways. + + Nothing about dispatch changes: this release adds a declaration and a build-time refusal measured against it. Existing apps are unaffected until they declare the key — the new rule has nothing to judge on an undeclared action, by construction. +- 0fb6f97: fix(lint)!: `absolute-colspan-discouraged` is withdrawn — its premise was measured false in a browser, and the alternative it recommended measured worse than the thing it warned about (#17328) + + + + **BREAKING** — `@objectstack/lint` no longer exports `FORM_COLSPAN_ABSOLUTE`, and + `validateFormLayout` no longer emits the `absolute-colspan-discouraged` finding. A + TypeScript consumer that imported that constant (to suppress the rule, or to route it) + stops compiling on the import, and the compiler names the site — a more precise channel + than any release note. Authored metadata is untouched: `FormField.colSpan` is unchanged + and still valid. + + The rule fired on **every** authored `colSpan`, `colSpan: 1` included, and asserted a + rendering consequence: the form's column count is derived per surface (mobile 1 / modal 2 + / page 3-4), so a fixed span "only aligns at one width". Measured in Chromium on a real + authored 3-column section at all three of the widths that sentence names (390 / 720 / + 1700), that misalignment does not happen. The renderer emits one container-query-scoped + span class clamped to the section's declared column count, so the cell starts at a real + column boundary at every width and rendered overflow is 0px in every configuration — + including `colSpan: 4` in a 3-column section, the case that would overflow if the clamp + did not work. The clamp is precisely why the claim was false, and the rule's own file + already recorded the clamp a few lines above the claim. + + The hint was the sharper defect. It steered authors to `span: 'full'`, which compiles to + the same class as `colSpan: 4` (`@2xl:col-span-3`) and measures byte-identically: the rule + warned about one spelling and recommended the other, and they are the same thing. At the + modal width `span: 'full'` renders pixel-identical to authoring nothing at all, so an + author who complied was left worse off than one who ignored it. + + With no authored `colSpan` shape left that misbehaves there was nothing to re-ground, so + the rule is withdrawn rather than narrowed: `colSpan: 1` emits no class at all, a + `colSpan` within the column count renders exactly as authored, and one above it clamps. + Every test that pinned the rule's wording or its firing set was re-judged in place with + the reason recorded, never deleted, and each re-judged pin is paired with a live finding + on the same fixture so that a walk which stopped reaching the site could not pass as a + withdrawal. +- 8271c81: **BREAKING** — a dataset-bound dashboard widget's `chartConfig` carries appearance only: `type`, `xAxis`, `yAxis` and `series` are refused by name, each refusal naming the dataset selection the intent belongs in. + + Clause-②: yes (narrowing) + + `DashboardWidgetSchema.dataset` is REQUIRED, so **every** dashboard widget is dataset-bound, and ADR-0021 already made the dataset the owner of the chart's structure: it decides which series exist and which column each one reads. `chartConfig` nonetheless declared `type` / `xAxis` / `yAxis` / `series`, and the two answers met with no rule between them. That was not merely inert. An authored `yAxis[].field` was a live MEMBERSHIP channel — the renderer synthesised a series from the authored axes when the chart declared none — so one authored axis could silently re-point a dataset-bound series at a different column while the chart still drew, which reads as a true statement about the data. Maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」, on options C+D together: state the ownership split in the protocol AND refuse the four keys by name. + + ## FROM → TO + + | you wrote inside `chartConfig` (17.4 and earlier) | write instead | + | --- | --- | + | `type: 'line'` | `type: 'line'` on the WIDGET, beside `dataset` — the widget's own `type` is the chart family and it always won; nothing on this face ever read the chart config's | + | `xAxis: { field: 'stage' }` | `dimensions: ['stage']` on the widget — the dataset dimension the category axis plots | + | `yAxis: [{ field: 'amount' }]` | `values: ['amount']` on the widget — the dataset measures, one entry per mark. A second axis is a second measure, not a second axis declaration | + | `series: [{ name: 'amount' }]` | `values` (plus a second `dimensions` entry to split) — series membership follows the selection; an entry naming a measure outside it was already being ignored | + + **The one-line fix:** delete the four keys; the widget's `type` and its `dimensions` / `values` are the chart's structure. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + + ## What is NOT retired + + The keys stay authorable on `ChartConfigSchema` itself, and that is the half a blanket refusal would have broken. A react-tier `` binds inline rows with no dataset behind them, so its axes are the author's and are unchanged — `react-blocks.ts` still publishes all four in that block's `dataProps`. `ReportChartSchema` keeps its own `xAxis` / `yAxis`, narrowed to its bound dataset's dimension and measure names. The refusal lives on a new per-carrier `DashboardWidgetChartConfigSchema` (`ChartConfigSchema.extend(…)`, the `ReportChartSchema` spelling) precisely so it cannot reach those two. + + ## What this costs, stated rather than discovered + + `xAxis` / `yAxis` / `series` carried presentation alongside the binding — axis titles, number formats, bounds, grid lines, log scale, and per-series labels, colours, stacking and mark types. Refusing the keys takes the presentation with the binding: a dataset-bound chart takes those from the dataset's own dimension and measure declarations, and `colors` on the chart config remains the palette channel. **The combo chart a dataset-bound widget could author through `series[].type` has no authoring channel on this face any more.** That capability loss is ruled, not incidental — the option that kept it was on the table and was not taken. + + ## Accept-set movement, both directions + + Narrowing, on a dataset-bound widget: the four keys move from accepted to refused. **And one widening, which is forced by the ruling rather than chosen:** `ChartConfigSchema.type` is REQUIRED, so before this change a `chartConfig` without a `type` was refused as incomplete. Refusing `type` while keeping the bag authorable for appearance — which ruling item 1 requires in as many words — means absence must now be legal. So `chartConfig: { title: 'Revenue' }` on a dashboard widget moves from refused to accepted. That is why the declaration reads `yes (narrowing)` rather than `no`. + + ## The retirement kit + + - **Four `retiredKey()` tombstones on the widget carrier**, registered as `ui/DashboardWidgetChartConfig:type` / `:xAxis` / `:yAxis` / `:series` under protocol 18. `tsc` types each key `never`, so every authoring site in a consumer's tree fails to compile before anything runs, and a value that reaches a parse raises the prescription rather than a bare unrecognized-key report. + - **The ADR-0087 pair.** The D2 conversion `dashboard-widget-chart-config-structure-removed` strips the four keys from stored dashboard widgets (dashboards only — reports and the react tier keep theirs); the D3 semantic entry `dashboard-widget-chart-config-structure-refused` carries the judgement, because moving what the keys MEANT into the dataset selection needs facts the widget does not hold — an authored axis field can name a dataset dimension the widget never selected. + - **The liveness rows stay and are regraded `dead`**, the `retiredKey` route's discipline: the tombstone keeps the key in the walked shape, so the row remains and records why. Three of them were graded `live` on their presentation half on 2026-09-12 and that measurement is recorded as overridden, not withdrawn. + - **`chart-config-missing` is withdrawn from `@objectstack/lint`.** It advised a `combo` widget with no `chartConfig` to declare `chartConfig: { series: [{ name, type }] }` — metadata the schema now refuses — and after the ruling there is nothing a `combo` author can do about the finding. The rule ID stays exported, so an existing `suppressWarnings: ['chart-config-missing']` entry keeps parsing. `chart-field-unknown` still fires on a legacy document and its hints now say delete-and-migrate instead of describing what the keys used to carry. + + ## What an operator with a STORED dashboard sees + + A `sys_metadata` `dashboard` row written before this release can carry any of the four. Nothing breaks at read: the conversion replays on rehydration and strips them, so the row is served canonical, and `os migrate meta --stored --apply` rewrites the rows. ⚠️ The strip is the mechanical half only. A widget whose authored axes AGREED with its selection renders identically afterwards — that is the expected case. A widget that renders differently was relying on the membership channel this removes, which is the case the ruling was made about. + + +- 6ec467b: feat(lint): `permission-retired-lifecycle-residue` — the retired `allowRestore` / `allowPurge` bits are now named at the authoring door (#17425) + + `ObjectPermissionSchema` accepts `allowRestore: false` / `allowPurge: false` as inert residue and strips them silently. That tolerance is #12840's class ruling and is unchanged here: the accept set does not move, no schema is touched, and every other value keeps the tombstone's loud refusal. + + The silence is deliberate — every artifact the published 17.x toolchain built has the retired default materialized in every permission entry, and a per-occurrence notice would be a storm. But `acceptRetiredDefaultResidue`'s own docblock names the channels that stay loud for authored sources — tsc `never`, `os migrate meta`, the ADR-0087 D2 conversion — and against a non-TypeScript author that list is one entry short. `tsc never` is a TypeScript channel. The conversion and `os migrate meta` are the same channel twice, and `permission-allow-restore-purge-removed` is declared `retiredFromLoadPath`, so it never fires while a stack loads. An author who writes the key in a JSON or YAML source and does not run the migration gets a clean parse and no signal at all — which is what a tombstone exists to prevent. + + `os validate`, `os build` and `os lint` now emit one advisory `warning` per carrying entry, on the raw pre-parse stack where the key is still present and still attributable to a line somebody wrote. The hint is the retirement's own prescription, read from the tombstone's published description rather than retyped, so it cannot drift from the parse-time wording the same author sees through the other door. + + It fires on the captured residue value and on nothing else: `true`, `"false"`, `0` and `null` are already refused at the parse with the prescription attached, and the surviving enforced lifecycle bit `allowTransfer: false` is not residue and is never named. + + New published exports on `@objectstack/lint`: `validateRetiredPermissionResidue`, `PERMISSION_RETIRED_LIFECYCLE_RESIDUE` and the `RetiredPermissionResidueFinding` type. Nothing is removed and no existing finding changes shape or severity. +- 2c1011b: fix(spec)!: a blank string in a flow node's predicate slot — a `decision` branch `expression`, a screen field `visibleWhen` — is refused at authoring (#17493) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on two authored flow-node slots, shipped as + `minor` under the launch-window convention (`check-changeset-no-major` refuses + `major` until GA; breaking-ness is carried by this banner and the ADR-0087 + disposition above, not by the level). + + **What changed.** A `decision` node's `config.conditions[].expression` and a + `screen` node's `config.fields[].visibleWhen` are declared bare CEL text. A string + that is blank after trimming (`''`, `' '`, a tab or a newline) used to be + accepted there by `FlowSchema.parse`, `AutomationEngine.registerFlow` and + `objectstack validate`, and was then read as "no predicate": the evaluator answers + a blank decision predicate `false`, so that branch was not taken, and nothing said + so. It is now refused at those doors — by `FlowSchema.parse` with a `custom` issue + anchored at the slot (for example `nodes.1.config.conditions.0.expression`), and + by `registerFlow` and `objectstack validate` through that same parse — with a + message that leads with the published `PREDICATE_SLOT_STRING_REFUSAL` sentence, + the one these slots already answered with for a non-string value. Where such a + value already sits, the whole flow is refused: registered from the metadata + registry or `sys_metadata` at boot, it is skipped with a + `failed to register flow` warn naming it while the flows beside it register; a + `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole + stack; an artifact file is refused whole at load. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `conditions: [{ label: 'high', expression: ' ' }]` on a `decision` node | the predicate you meant — `{ label: 'high', expression: 'record.amount > 10000' }` — or, to keep what the blank did, `expression: 'false'` | + | `fields: [{ name: 'reason', visibleWhen: '' }]` on a `screen` node | the predicate you meant — `visibleWhen: "status == 'rejected'"` — or, to keep what the blank did, drop the `visibleWhen` key | + + **One-line fix:** write the predicate, or keep what the blank did — `'false'` on + a decision branch (the value the blank evaluated to), no `visibleWhen` on a + screen field (a blank one was read as absent). ⚠️ Do not drop a decision's only + branch: the node then routes by its out-edges alone, and the out-edge that branch + labelled is no longer held back. A blank structural `condition` is another case — + see the `flow-edge-condition-evaluated-slot-source-required` migration entry. + + **Unchanged.** A non-blank predicate parses, registers and validates as before; + a non-string in these slots keeps its existing refusal at `registerFlow` and + `objectstack validate`; `edges[].condition` and a node's `config.condition` keep + their own rule and sentence (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`); and + `AutomationEngine.evaluateCondition` still answers a blank predicate `false` for + a caller that reaches it directly. The `PREDICATE_SLOT_STRING_REFUSAL` constant + keeps its name and now also names the blank string, so code matching the + constant rather than a copy of its text is unaffected. +- b0eb9a5: Approval nodes gain a fourth empty-slate policy — `onEmptyApprovers: 'fallback'` with a sibling `fallbackApprovers` list — so a rung that expands to nobody opens the request on people you named instead of on a slot nobody can act on. + + Until now an approval node whose approvers resolved to nobody had three endings, and none of them named anyone: `admin_rescue` (the default — the request opens on a dead `type:value` slot and waits for a privileged admin), `fail` (the run dies) and `auto_approve` (the record is waved through). All five graph approver types reach that dead end, and `{ type: 'manager' }` reaches it without anybody authoring a wrong value: `manager` omits `value`, so the literal the expansion falls back to is `manager:undefined`. + + ```ts + { + approvers: [{ type: 'manager' }], + onEmptyApprovers: 'fallback', + fallbackApprovers: [{ type: 'org_membership_level', value: 'owner' }], + } + ``` + + - **`fallbackApprovers` is the approver shape you already write** — the same entries as `approvers`, resolved by the same expansion, so every approver type, OOO delegation and `per_group` tagging behaves identically on it. It is not a second, reduced approver dialect. + - **The pairing is enforced in both directions.** `'fallback'` without a list is refused; a list under any other policy is refused too, because nothing would ever read it — a node that declares a rescue slate and silently ignores it is the failure this config shape is `.strict()` against. Both messages name both keys. + - **A fallback that itself resolves to nobody degrades to `admin_rescue`.** The run is never killed and the record is never waved through by a policy whose author only asked for different people; the log says both that the fallback fired and that it found nobody. + - **This is on the node, not on the `manager` rung** — the node is already where emptiness is decided, and a fallback is wanted for every approver type, not one of them. + - **`os lint` names the new escape and keeps firing without it.** `approval-approvers-may-resolve-empty` still reports a manager-only slate even when a fallback is declared: the rule reads shape, and a static check can no more prove a `fallbackApprovers` list resolves than it can read `sys_user.manager_id`. A seeded manager chain remains the one silencer. +- 3da78cc: `validateRetiredPermissionResidue` now runs at the runtime authoring door on `permission` writes, at advisory tier — so a Studio / REST `/meta` / MCP author who writes `allowRestore: false` or `allowPurge: false` and never runs `os lint` is told the line has no effect (#17936, out of #17425 ruling D). + + Clause-②: no + + The rule was registered `CLI_ONLY` on an open question its own `surfaceReason` recorded: does the gate's `body` reach it BEFORE the per-type `safeParse`, whose residue stage strips the only evidence it reads? Wiring it without that reading would have published a phantom check. **Measured: it does reach it.** `saveMetaItem` keeps the AUTHORED body verbatim on purpose — `parsed.data` would strip the Studio-only auxiliary fields an overlay rides with — and grafts back exactly two normalizations (filter `operator` spellings, the form `groups` → `sections` key move), each a walk over the authored keys that adds and removes nothing else. So `assertRuntimeAuthoringRules` is handed the raw document, the gate passes it through as `item`, and the residue is present in the snapshot the rule reads. + + What changes for a caller: + + - A `permission` publish carrying either retired key **still succeeds** and now returns one `advisories[]` entry per occurrence, in the door's existing six-key diagnostics envelope (`{severity, rule, where, path, message, hint}`) — the shape Studio and MCP already render for a 422's `issues[]`. `rule` is `permission-retired-lifecycle-residue`, `path` is the name-keyed `permissions..objects..`, and `hint` is the tombstone's own prescription, read from the schema rather than retyped. + - ⛔ **Never a refusal.** The rule is advisory tier; the accept set is untouched, and a value that is *not* the retired default (`true`, `0`, `null`) is still refused by the tombstone at the parse, with its prescription attached, exactly as before. + - **Draft saves are unchanged** (#4463 D1), and so is every other metadata type: `permission` is the only declared `runtimeTypes` member, because `stack.permissions` is the only collection the rule reads. + - **The CLI door is unchanged** — `os validate` / `os build` / `os lint` run the rule exactly as they did, with the same positional `permissions[i]…` path. The name-keying is the runtime gate's wire rewrite and does not reach the commands. +- e64ae15: A `reference` carrier that no reader can read is now **REFUSED** where it is read, instead of coming back as `undefined`. The source-level gate that guarded the same shape (`check:reference-carrier-shape`) is retired in the same change (#18095, executing a maintainer ruling). + + `FieldSchema.reference` is `z.string().optional()`, so `ObjectSchema.safeParse` already refuses an object- or array-valued carrier at the contract door with a located `invalid_type` issue. Measured on the pre-change tree: + + ``` + ObjectSchema.safeParse({ fields: { invoice: { type: 'lookup', + reference: { object: 'shop_invoice' } } } }) + -> success = false, issue invalid_type at path ["fields","invoice","reference"] + control: the same object with reference: 'shop_invoice' + -> success = true (so the refusal is about the carrier's SHAPE) + ``` + + What was missing was the other door — the one a value reaches only when it never went through parse at all. #13053's fixture spelled `reference: { object: … }` inside `fields:`, and the rule reading it answered `undefined`: refused where it was written, read as absent where it was consumed, reported nowhere. The fixture passed, and would have kept passing. + + **New export — `referenceCarrierOf(def, reader?)` in `@objectstack/spec/data`.** It answers the carrier as the string the contract declares, and throws a `TypeError` naming the shape and the fix when the key is present in any other shape. `null`, `undefined` and `''` are ABSENCE, not a wrong shape, and still answer `undefined` — a field is allowed to name no target. + + **`referenceTargetOf` reads through it**, so the single arbiter of "what does this field expand into" refuses rather than answering "no target". Every consumer that already asks the arbiter — `$expand`, the record-title deriver, the dangling-reference audit, the analytics dimension labeller — inherits the refusal with no edit. + + **`@objectstack/lint`** routes its own target readers through the same accessor: `refOf` in `validate-security-posture.ts` (the reader in the #13053 incident) and in `data-model-rules.ts`, plus the object-graph slice every other rule downstream reads. + + Upgrading: nothing conformant changes. A non-string `reference` could not be authored, stored or parsed before this release either; what changes is that a hand-built fixture or a raw registry entry carrying one now fails loudly at the read instead of being silently treated as targetless. If a test asserted the old silence, assert the refusal instead — `packages/cli/test/data-model-rules.test.ts` is the worked example. +- f2044ef: fix(lint)!: the ADR-0090 D3 vocabulary freeze visits `objects[].fieldGroups[]` (#18306) + + + + **BREAKING** in the accept-set sense — a declaration that passes today can fail tomorrow. + Landing in the launch window as `minor` (the lockstep convention: `major` is refused by + `check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087 + disposition above). + + **Clause-②: no (narrowing)** — the rule refuses more than it did; no key is added to any + published payload and no public surface grows, so `lanes/spec.md`'s widening test is not + met. Narrowing is still a semantic-surface change, which is why it is declared here rather + than shipped silently. + + `security-role-word` (ADR-0090 D3) judged an object's name, field names and labels, action + names and labels, permission sets, positions, apps and books — and not the field-group + heading that renders directly above the fields it was already judging. So on one record page + a field labelled `Role Of Record` was refused while the group header above it, + `Account & Role`, was admitted: the author renames the field and the heading keeps the word. + That is the exact "refused on one surface, admitted on another" shape (#7220) that this + rule's own split was made to avoid, one grain finer. + + Both halves of the group declaration are judged, as on every other surface: `key` is an + identifier (`Field.group` assigns membership by it, and a layout section's `group` inherits + the group by it, ADR-0085 §5), `label` is the header an admin reads. ADR-0090 D3 bans the + word in "identifiers, UI copy, and documentation", and a field group declares both. + + Pages, views and components stay out, unchanged: `role` there is the HTML/ARIA attribute — a + machine word with a fixed foreign meaning, not a word the author picked. `listViews`, + `recordTypes` and the other label-bearing surfaces are deliberately not swept in with this; + each needs its own reading first. + + **What an author does.** Nothing is renamed for you and nothing is auto-rewritten: the + platform vocabulary is `permission_set` (capability), `position` (distribution), + `business_unit` (hierarchy), and the refusal itself names it at the exact path + (`objects[i].fieldGroups[j].key` / `.label`). A group heading reading `Account & Role` + becomes `Account & Assignment`; a group keyed `role_info` becomes `assignment`, and the + member fields' `group` pointers move with it. + + Unaffected: a system object (`sys_*` / `isSystem: true`) keeps the better-auth exemption on + its field groups exactly as it keeps it on its fields, and a group carrying no reserved word + is silent. +- 21b7c12: `security-anchor-high-privilege` now reads the stack's own `capabilities:` declarations, so a declared app capability token on an `isDefault` set lints clean (#18535). + + The rule holds an `isDefault: true` set to the `everyone`-anchor tier at authoring time, and ADR-0090 D5 puts 「带 package provenance 的应用声明 capability 令牌」 outside that tier's offending list. The rule called `describeAnchorForbiddenBits(ps, 'everyone')` with no `AnchorBindingContext`, so it reported an error for a set the runtime — once it reads the same declarations — binds without complaint. A lint that refuses what the runtime accepts is the drift ADR-0049 says not to ship, in the direction that is hardest to notice: the author never gets to the runtime. + + `validateSecurityPosture` now builds the context from `stack.capabilities` and passes it at that one call site. Nothing else about the rule moves: + + - an **undeclared** `systemPermissions` token still errors — membership in the declaration list is what excuses a token, not the presence of a `capabilities:` collection; + - a **platform** capability still errors even when the stack declares a capability of that name: the platform floor lives inside the predicate, shared with the runtime gate; + - a stack that declares nothing gets the pre-#17811 verdict verbatim. + + **What changes for a consumer:** `os validate` (and any other caller of this rule) stops reporting `security-anchor-high-privilege` on an `isDefault` set whose `systemPermissions` names only capabilities the same stack declares. A stack that was editing its set to silence this rule can declare the capability instead — which is what the ADR asks for, since the declaration is what the runtime reads at boot. + + Clause-②: yes (widening) +- a675ad4: The remaining raw `FieldSchema.reference` readers now **REFUSE** a carrier they cannot read, instead of answering "no target" (#18550). The previous release routed the arbiter (`referenceCarrierOf`) and the lint target readers; these were the measured residue of the same ruling — every reader, not just the arbiter. + + `FieldSchema.reference` is `z.string().optional()`, so `ObjectSchema.safeParse` refuses an object- or array-valued carrier at the contract door. These reads are the other door: the one a value reaches only when it never went through parse — a hand-built fixture, a raw `registerObject`, a stored row rehydrated past its schema. + + **`@objectstack/objectql`** — both of the delete cascade's carrier reads (`planCascadeAtomicity` and `cascadeDeleteRelations`). This is the one with a measurable runtime consequence, and it is why the level is not `patch`: + + ``` + before acct=1 task=1 + delete RESOLVED true <- success reported to the caller + after acct=0 task=1 <- an ORPHANED master_detail row + ``` + + An unreadable carrier made the relation invisible to the cascade, so the parent was deleted, the detail row stayed, and the caller was told the delete succeeded — no `restrict` refusal, no `set_null`, nothing logged. It now refuses before any row is touched. + + **`@objectstack/rest`** — the public-form lookup picker's field-def fallback. The field def is also hoisted out of the metadata fetch's `catch {}`, so an unreadable carrier is no longer reported as `LOOKUP_TARGET_MISSING`: "no target is declared" and "the declared target cannot be read" want different fixes from whoever owns the metadata. + + **`@objectstack/metadata-protocol`** — the seed dependency graph, which also retires an `as string` cast that asserted exactly what its truthiness guard had not checked. + + **`@objectstack/lint`** — the four remaining target readers: `masterDetailCount` (`validate-expressions`), the `displayField` consumer edge (`validate-field-consumers`), the field and action-param targets (`validate-object-references`), and `masterOf` (`validate-sharing-rule-enforceability`). + + **`@objectstack/verify`** — `relationTarget`, which no longer degrades an unreadable carrier to the generic "has no `reference` target" an object with no relationship metadata at all receives. + + `null`, `undefined` and `''` are ABSENCE, not a wrong shape, and still answer `undefined` at every one of these sites — a field is allowed to name no target, and `StrictField` declares `reference` nullable. Each site's absence answer is pinned alongside its refusal. + + Upgrading: nothing conformant changes. A non-string `reference` could not be authored, stored or parsed before this release either; what changes is that one now fails loudly at the read instead of being read as an absent target. If a test asserted the old silence, assert the refusal instead. +- 939f3ea: fix(lint): `list-view-field-unknown` walks `kanban.titleField` — the one item-titled face the position table never listed (#18565) + + Clause-②: no + + `POSITIONS` in `validate-list-view-field-refs.ts` declares, per view face, which field-reference keys are walked and at what level, and `kanban` was the only item-titled face with no `titleField` row. From #16894 the key is authorable on `KanbanConfigSchema`, so from that release a misspelt field name cleared the schema door, was walked by nothing, and the board fell back to the ADR-0079 record display name — a title the author did not ask for, on a board that renders correctly, with no gate reporting the miss. The byte-identical typo one block away on `calendar` or `timeline` was reported. + + Measured on this branch, one list view carrying every walked position, one mutation at a time: + + | probe | before | after | + |:--|:--|:--| + | `kanban.titleField` naming a field that does not exist | silent | `warning` `list-view-field-unknown` at `views[0].list.kanban.titleField` | + | `kanban.titleField` naming a real field | silent | silent | + | the other 51 walked positions | 51 reported, 1 silent (this one) | the same 51, each at its same severity | + + Over the repo's own example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`) the findings count is **0 before and 0 after**: five kanban blocks are authored there and none carries `titleField`, so nothing existing starts reporting. Injecting `titleField: 'zz_no_such_field'` into those same boards flips 0 → 1 warning in `app-crm` and `app-showcase`. + + **`warning`, the level `calendar` takes — not the level of the two siblings that spell the key required.** `KanbanConfigSchema` declares `titleField` OPTIONAL (#16894 copied `CalendarConfigSchema` for this exact key and names `TimelineConfigSchema` / `GanttConfigSchema`, the two that spell it required, as the siblings it deliberately does not copy), and the board resolves an unresolvable name through the ADR-0079 display-name chain: measured in objectui `dda8f3815`, `resolveKanbanTitleField` returns the written name, the card reads `rec[titleField]`, finds nothing and falls to `getRecordDisplayName`. Every card still renders — the warning tier's own case in this rule's module note ("the renderer drops one decoration and renders the rest: an optional colour / title / tooltip / cover binding"), where `kanban.groupByField` is the error tier's, collapsing every card into one uncolumned lane. + + No rule id, no severity and no message shape changes for any other position; `list-view-field-unknown` gains one more place it can be reported from. +- 1f05ea4: A validation rule can read one hop through a lookup — `record.account.type` on an opportunity resolves the owning account's field instead of faulting (#18682) + + Clause-②: yes (widening) + + A validation predicate could only read the record it guards. A `lookup` / + `master_detail` field carries an **id**, so the natural cross-object rule — + "a partner account may not carry an opportunity over 10000" — faulted with + `runtime: No such key: type`, and because a broken validation is fail-closed it + rejected every write on the object. The capability mainstream platforms provide + as a matter of course could not be authored at all. + + ### What you can write now + + ```ts + validations: [{ + name: 'partner_cap', + type: 'script', + message: 'Partner accounts are capped at 10000.', + condition: "record.account.type == 'partner' && record.amount > 10000", + }] + ``` + + One hop, through any reference-typed field (`lookup`, `master_detail`, `user`, + `tree`). The engine reads the related row before evaluating and binds it in + place of the id, so `record..` resolves. + + ### It is data pinned BEFORE evaluation, not a query from inside CEL + + There is no `os.lookup(...)` / `os.exists` / `os.count` — those stay removed. + The engine statically analyses the predicate, learns exactly which reference + fields it reads through and which related fields it names, and loads those + **before** evaluation. Every registered function stays pure once `now` is + pinned, so `objectstack build` artifacts stay byte-stable. + + The cost is bounded by construction: one hop, only the fields a rule actually + names, one batched read per reference field per write, and nothing at all when + no rule traverses. + + ### Read authority — system, bounded by the projection + + The related row is read under **system authority**. A validation rule's output + is a pass/fail the *system* enforces, not data handed to the caller — which is + why RLS predicates are excluded from this capability altogether. Reading as the + acting user instead made the rule unauthorable for exactly the persona it exists + to constrain: a member with CRUD on the child and no read on the parent faulted + on every write. + + What bounds the elevation is the **projection**: only the + columns the predicate names, intersected with the related object's declared + fields. A column the related object does not declare never enters the query, and + is refused as the authoring fault it is — distinct from a column that exists and + is empty, which evaluates as `null`. + + A related object no organization wall scopes — no tenant column (`sys_user` + behind a `user` field), `tenancy.enabled: false`, or `external` — is bounded by + row as well: for any caller that is not system (a user, a public-form + submitter, a caller with no principal), only a row the caller's own read of that + object returns. A reference to any other row refuses the write as not readable, + whatever that row holds. Under a walled posture (`group` or `isolated`), such a + caller with no active organization gets no related read at all: a rule reading + through a stored reference refuses the write as not found. + + ⚠️ **The accepted cost, stated plainly.** A caller can *infer* a related value + they cannot see by observing which writes are refused. The value itself never + appears — the refusal names the field and the rule, never the value — and the + channel is deliberately no wider than "this rule refused this write". + + ### Two shapes are refused, with a prescription + + Both fault at evaluation today, so neither removes anything that works: + + | Shape | Why | Write instead | + | --- | --- | --- | + | `record.account.type == 'x' && record.account == 'acc_1'` | reading through the relationship resolves `record.account` to the related RECORD, so the id comparison would stop matching — silently | `record.account.id == 'acc_1'` for the value comparison | + | `record.account.owner.email` | a second hop is not loaded | denormalise onto `account`'s object, or read it in a hook | + + A field that is **not** reference-typed is untouched: `record.address.city` on + an object-valued field traverses today and keeps traversing. + + ### `@objectstack/plugin-security` gains `canWriteObject` + + The WRITE admission — the sibling of the existing `canReadObject`, running the + middleware's own arms in the middleware's own order: system bypass; then, before + anything resolves, the ADR-0103 engine-owned write guard and the ADR-0090 D12 + delegated-administration gate, each called as the middleware's own primitive; + then no resolved permission sets, unresolvable posture, the ADR-0066 D3 + `requiredPermissions` capability AND-gate for both principals, the CRUD grant, + the ADR-0090 D10 delegator check, and — when the caller's payload is supplied — + the field-level security WRITE gate over it (`getFieldPermissions`, folded + through the D3 field-capability contract, intersected with the delegator's mask + under D10, then the forbidden-write detection); and last, the ADR-0123 D2 + no-active-organization wall, the same verdict the middleware's step 3.7 throws + on. It exists for doors that must ask + "could this caller perform this write" without running the engine middleware — + the write preview is the first. + + ⭐ What it answers, POSITIVELY — by naming what it RUNS, never a category of the + write decision: the ADR-0103 engine-owned affordance gate, the ADR-0090 D12 + delegated-admin gate, the fail-closed postures (#3545's unresolvable posture and + the D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both + principals, the `allowCreate`/`allowEdit` CRUD grant, the D10 delegator's + independent grant, the step 2.5 FLS write gate over the keys the payload + names, and the ADR-0123 D2 organization wall. It says nothing about any refusal + not in that list. `@objectstack/plugin-security`'s + `can-write-object-admission.test.ts` pins the method's answer equal to the + registered middleware's on its equivalence block's cases, and pins one D12 + UPDATE case as a direction: the method `false`, the middleware `true`. + + ⛔ `true` never means the write will succeed, and ⛔ what follows is not an + enumeration of the distance to success: the middleware refuses both before and + after `next()` for reasons this method is never asked. Nearest to hand are the + remaining pre-resolution gates that run beside the two named above — the + package-managed and system-row write gates, which judge a row's PROVENANCE; the + curated-capability-name and audience-anchor binding refusals, which judge a + payload VALUE; and the ADR-0056 public-form grant, which no caller can present + to this method and which has no extracted primitive to call; the row-level and + post-image refusals — the `using` pre-image, the ADR-0055 controlled-by-parent + master edit, the RLS `check` post-image and the Layer 0 tenant post-image, none + of which this method can judge because it is asked about no ROW; the + payload-VALUE refusals the same caller passes by simply not sending the value — + the masked echo and the `owner_id` forge, which therefore widen the caller class + by nothing; the anti-filter-oracle guard on the caller's own predicate, which + this method is handed none of; the post-`next()` assertion that the insert + `check` seam really ran, which judges an executed write; and, outside the + middleware entirely, `readonlyWhen`, the static `readonly` strip and the + validation rules themselves. + + ### Scope + + Object validation rules (`script` / `cross_field`) — and the system-authority + read is confined to that one seam. The field-level + `requiredWhen` / `readonlyWhen` / option `visibleWhen` predicates fail **open** + and are deliberately not covered here; RLS predicates are out too. Depth is one + hop. The cleanup UPDATE a `set_null` delete issues on a referencing record + resolves no relationship, so a rule there is evaluated as before this release — + against the bare id, where reading through it faults and refuses the cleanup, + and with it the delete. +- a43b9d0: fix(lint)!: `list-view-field-unknown` walks the four field-naming keys that had no position row at all + + Clause-②: no (narrowing) + + **BREAKING** — an accept-set narrowing on the list-view authoring surface. Four declared, authorable field-naming keys had no row in `POSITIONS` in `validate-list-view-field-refs.ts`, so a misspelt field name at any of them cleared the schema door, was walked by nothing, and was dropped by the renderer. From this release each is judged, and two of the four gate `validate` and `build`, so a stack that built yesterday with one of those two misspelt does not build now. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. + + + + ## The keys, and why the tier is not the same for all four + + All four are `z.string().optional()` on their config schema, and the schema shape is deliberately not what tiers them — the tier is the consequence, read per key off its own `.describe()` and its renderer (measured in objectui `dda8f3815`). + + | key | declared at | tier | what a misspelt name does | + |:--|:--|:--|:--| + | `calendar.allDayField` | `CalendarConfigSchema` | `warning` | `ObjectCalendar` maps each event with `allDay: allDayField ? Boolean(record[allDayField]) : !endDate`, so every row reads `undefined` and no event is banded — and the renderer's own no-end-date inference is switched off by the key's mere presence. Every event still renders, at its start time: one decoration dropped. | + | `gantt.borderColorField` | `GanttConfigSchema` | `warning` | `borderColorRaw = borderColorField ? record[borderColorField] : undefined` leaves `borderColor` undefined for every task. Every bar keeps its fill and renders without its alert outline — `colorField`'s case. | + | `gantt.lockField` | `GanttConfigSchema` | **`error`** | A declared WRITE GUARD that fails OPEN. `locked: lockField ? !!record[lockField] : undefined` reads `undefined` on every row, and the drawer's `recLocked` falls the same way, so every row the author froze becomes draggable, resizable, progress-draggable, link-able, inline-editable and deletable — and the drag persists. | + | `gantt.objectField` | `GanttConfigSchema` | **`error`** | `isSyntheticRow` is `!!objectField && !String(rec[objectField] ?? '').trim()`, so a name no record carries answers TRUE for every row. `onTaskClick` never calls `navigation.handleClick` and `renderRecordOverlay` returns null: no bar in the chart opens a drawer or a detail page. | + + The two `error` rows are a consequence the rule's own severity note did not name and now does: a binding whose job is to RESTRICT or to ROUTE, where the miss is read as "no restriction" / "no route" on every row. Nothing is missing from the picture, which is exactly why it gates — it is the shape Prime Directive #10 names, a capability advertised in the metadata and not delivered by the runtime. Both are also worse DECLARED than omitted, because each renderer guards its behaviour on the key's mere presence. + + ## Measured, one list view carrying every walked position, one mutation at a time + + | probe | before | after | + |:--|:--|:--| + | `calendar.allDayField` naming a field that does not exist | silent | `warning` at `views[0].list.calendar.allDayField` | + | `gantt.borderColorField` naming a field that does not exist | silent | `warning` at `views[0].list.gantt.borderColorField` | + | `gantt.lockField` naming a field that does not exist | silent | `error` at `views[0].list.gantt.lockField` | + | `gantt.objectField` naming a field that does not exist | silent | `error` at `views[0].list.gantt.objectField` | + | each of the four naming a REAL field | silent | silent | + | the other 53 walked-position probes | 53 reported, each at its severity | the same 53, each at its same severity | + | the clean fixture carrying all four bound to real fields | 0 findings | 0 findings | + + Over this repository's own tree the finding count is **0 before and 0 after**: no example app, fixture or seed authors any of the four keys at all (`git grep` over every tracked file finds the spec declaration, its own schema tests and the generated reference docs, and nothing else), so nothing existing starts reporting. + + ## What an author does about a report + + Nothing is renamed and nothing is removed — every spelling that was valid is still valid, and no stored value has to be rewritten to a different one. What changes is that a name which resolves to no field on the bound object is now reported instead of being dropped in silence. + + There is no mapping to apply, and deliberately so: the correct spelling is whatever the bound object declares, which only that object knows. The remedy is always the same — name a field the object actually has, or drop the key — and the finding carries the object's own field list plus a "did you mean" suggestion, so the message itself names the spelling to write. + + ## Scope — what is deliberately NOT changed + + - **No dotted verdict.** The four positions join `POSITIONS` and deliberately not `DOTTED_AXIS`: none of them reaches a query door this rule measured, so a dotted name at one of them is unjudged, exactly as every other renderer binding is. Pinned. + - **No other surface.** `listViews`, `recordTypes` and the other label/field-naming surfaces are untouched; widening to them is a measurement, not a corollary. + - **No new rule id, no message shape change, no severity change for any existing position.** `list-view-field-unknown` gains four more places it can be reported from. +- 58644ad: The dataset publish door now judges the dataset. A runtime-created `dataset` reached ZERO author-time rules; it now dispatches the existence rules that were already written for it (#19143). + + `dataset` is a registered metadata type declaring `allowRuntimeCreate: true`, so Studio's designer, REST `/meta` item CRUD and an MCP/AI author may all mint one — and at that door nothing judged it. Measured on `origin/main`: no rule declared `dataset` in `runtimeTypes` (zero, against a lit control returning every other declared type), and `TYPE_TO_STACK_KEY` in `runtime-gate.ts` carried no `dataset` row. The two absences were **consistent rather than contradictory** — the gate filters by `runtimeTypes` before it consults the table — so nothing was mis-wired and CI was green, correctly. What they summed to is that a dataset write built no per-write snapshot and dispatched no rule at all, while the rules that judge a dataset state their own failure mode as a surface that *"renders successfully with empty or wrong numbers"*. An author working only through Studio or MCP has no `os lint` step to fall back on, so for them that door is the only one there is. + + ADR-0049's 「声明即强制」 admits two resolutions and the card chose neither; this takes the first because the measurement says so. The author-time rules for `dataset` **exist**: `validateDatasetReferences` (#14105, `packages/lint/src/validate-dataset-references.ts`), `validateDatasetMeasureAggregates` (#16354) and `validateObjectReferences`' `datasets[].object` rung. The declaration is honoured rather than retired. + + - **`TYPE_TO_STACK_KEY` gains `dataset: 'datasets'`**, and the rules that READ that collection are declared in the same commit — never a mapping ahead of its rules, which is the inert state the table's own `seed: 'data'` note records paying for. Every crossed rule has a door control that fires it through the real gate. + - **`validateDatasetMeasureAggregates` crosses to `CLI_AND_RUNTIME` with `runtimeTypes: ['dataset']`.** Its previous `surfaceReason` named this exact gap as what held it off the door. + - **The reference-integrity suite entry gains `dataset`**, and its per-member axis admits exactly two members — `validateDatasetReferences` and `validateObjectReferences`. Both resolve only against `stack.objects` and `stack.datasets`, the two collections a per-write snapshot carries, so neither opens a missing-collection false-positive channel. They cross together on #7220's reading: an author refused for a dangling dimension field and waved through for a dangling base object cannot predict the door. + - **No new rule and no new finding class.** The rule ids (`dataset-field-unknown`, `dataset-field-not-included`, `dataset-filter-field-unknown`, `dataset-include-unknown`, `measure-aggregate-field-type-refused`, `object-reference-unknown`) and their severities are unchanged — they now reach the door where the author actually is. + - **Findings from a dataset write are name-keyed on the wire** (#10064): `datasets..dimensions[0].field`, never the gate's private snapshot index. `datasets` entered the derived name-keyed set by derivation, with no second edit to remember. + - **Measured before crossing**, at the door's own snapshot shape and differential, over every dataset shipped in this monorepo — **11 datasets** (`platform-objects` 5 over `sys_*`, showcase 4, crm 1, todo 1) judged against 52 platform objects plus each app's own (showcase 22, crm 6, todo 1): **0 findings, 0 advisories, `rulesRun` 2 on every one** — so the zero is a fact about the corpus and not about a door that ran nothing. The same harness's synthetic probe IS refused, with both ids and both name-keyed paths. + + ## Migration + + **A dataset publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id and the offending path, name-keyed on the wire — for example `datasets.invoice_metrics.dimensions[0].field` or `datasets.invoice_metrics.measures[1].aggregate` — plus the string that was written. + + To clear a refusal, do one of: + + - point the `dimensions[].field` / `measures[].field` path at a column the base object actually declares (after a Studio label edit the derived API name is the one to use); or + - add the relationship the path traverses to the dataset's `include[]`, for `dataset-field-not-included`; or + - correct the filter KEY, for `dataset-filter-field-unknown`; or + - for `measure-aggregate-field-type-refused`, either aggregate a field of an accepted type or choose an aggregate the field's type accepts (`count` / `count_distinct` accept every type) — the compile leg already refuses that same pair with `400 DATASET_INVALID` once a query is built, so this is the same fix made earlier; or + - for `object-reference-unknown`, point `object` at an object this stack defines, or at a platform object by its full name. + + `os validate` / `os build` / `os lint` already reported every one of these findings at the same severity, so a code-authored stack can be repaired before it ever reaches a publish. A dataset over an object this stack does not define, one that declares no readable field map, and a registry-injected system column are all skipped exactly as they were on the CLI — the door adds no verdict the commands did not already make. A stored dataset already in violation is never charged to an unrelated publish, and republishing a dataset under its own name with the defect removed is clean (#4463 D4). +- c9b23cd: `lintLivenessProperties` now reports a liveness ledger it could not read, instead of going silent. + + `loadWarnMap` returned the same empty map for two different facts: "this metadata type's ledger classifies nothing as warn-worthy" and "there is no ledger". A missing `.json` and a file whose JSON is broken both returned an empty map with no log, no throw and no other signal, so losing or corrupting ONE file under the `liveness/` directory `@objectstack/spec` ships switched every author warning for that type off in silence — indistinguishable from that type simply having no warnings. + + The contrast that makes it a defect rather than a design sits one frame up: the DIRECTORY-level failure is loud by construction (the rule returns `[]` and everything depending on it goes red). Loud by directory, silent by file. + + What changes for consumers: + + - A new rule id, `LIVENESS_LEDGER_UNREADABLE` (`'liveness-ledger-unreadable'`), exported from the package root beside the four verdict ids. It is not a fifth verdict: the other four grade a property the ledger DID classify, this one says the classification never arrived, so a finding carrying it means no other finding about that metadata type can be trusted. Compare `f.rule` against the constant rather than retyping the slug. It cannot be silenced per finding: the CLI has no per-rule suppression, and `suppressWarnings` is a dashboard-widget key (`spec/src/ui/dashboard.zod.ts`) while this finding's subject is a ledger rather than an authored item, so there is nothing to carry it. The remedy is the one the finding's own hint names — repair or reinstall `@objectstack/spec`. + - `lintLivenessProperties` raises exactly one such finding per unreadable type, per run — never one per authored item — ahead of the walk's own findings, and keeps walking every type whose ledger IS readable. On an intact installation nothing changes: no ledger is missing, so no finding is added. + - A ledger that parses but is not a ledger (a bare `null`, an array, a scalar, or a document with no `props` record) is the same reported fault. Reading `.props` off a parsed `null` used to be a `TypeError` — a throw from a rule whose contract is that it never throws, through the one input an author cannot influence. + + `authorWarnedProperties` still answers the empty set for a ledger it cannot read — a decision procedure returning a set has no way to report a failed read — and that is unchanged for a missing file and for broken JSON. One input does move: a ledger document that parses to `null` used to make it THROW, and it now returns the empty set like the other two. `os lint` runs both halves in one pass, so the run states the fault once rather than never. +- a227afa: **BREAKING for runtime metadata writes** — the ADR-0090 D3 vocabulary freeze (`security-role-word`) now runs at the runtime publish gate for all six collections it judges, so `position` and `app` writes are gated for the first time (#19370) + + Clause-②: no (narrowing) + + `validateSecurityRoleWord` moves from `surfaces: ['cli']` to + `['cli', 'runtime-publish']` and declares + `runtimeTypes: ['object', 'permission', 'book', 'position', 'app']` — the write + type of every collection it judges. `position` and `app` join + `TYPE_TO_STACK_KEY` in `runtime-gate.ts` so the gate can build a per-write + snapshot for them. + + **The refusal set grows.** A runtime metadata write — Studio's designer, REST + `/meta`, an MCP/AI author — that carries the reserved word `role` in a + security-relevant identifier or label is now refused with the 422 lint envelope + instead of stored. Concretely, these used to succeed at that door and no longer + do: + + - an object, field, action or field-group header named or labelled for `role`; + - a permission set named or labelled for `role` (e.g. `role_manager`); + - a documentation book named or labelled for `role`; + - a **position** named or labelled for `role` (e.g. `sales_role`); + - an **app** named or labelled for `role` (e.g. `role_hub`). + + The platform vocabulary the rule freezes is unchanged and so is its fix-it text: + `permission_set` for capability, `position` for distribution, `business_unit` + for hierarchy. Nothing is renamed, retired or added — this is the same rule, + with the same rule id and the same findings, now enforced at the fourth door as + well as by `os validate` / `os build` / `os lint`. + + **Nothing changes for the three CLI commands.** Both security entries have run + on all three since the #8310 split, and their union is byte-identical to before. + + **Stored rows are untouched** (#4463 D4: the gate blocks new writes, never the + read path), and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` remains the migration-window + escape hatch for a tenant that authored one of these names before this landed. + + Why the two types were held back until now, and why the wait ended: `position` + and `app` are `allowRuntimeCreate: true`, so a position called `sales_role` could + be minted through the one entrance a tenant has while an object of that name was + refused. Under #7220 one rule id sits on ONE side of the wall, so the rule was + split out and held back whole rather than wired for a subset of its collections. + Mapping the two write types is what lets it cross, also whole. + + Deliberately NOT done: carrying `positions` / `apps` as `RuntimeStackContext` + collections. A collection joins that context because some rule resolves + references into it; this rule resolves nothing — it judges each identifier and + label on its own — so a sibling position tells it nothing about the written one + and its finding cancels in the gate's differential either way. Carrying them + would cost the publish door one indexed `sys_metadata` read per write for no + verdict change. + + +- 1f69917: **BREAKING for runtime metadata writes** — five metadata write doors that dispatched NOTHING now dispatch the rules already written for them, and three of the five judge what walks through. `action`, `hook`, `report`, `email_template` and `mapping` each declared `allowRuntimeCreate: true` and reached ZERO author-time rules at the runtime publish gate; `action`, `hook` and `report` publishes that used to succeed can now be refused, while `email_template` and `mapping` are wired to a ledger-driven rule that warns on nothing today (#19542) + + Clause-②: no (narrowing) + + Each of these is a registered metadata type declaring `allowRuntimeCreate: true`, so Studio's designer, REST `/meta` item CRUD and an MCP/AI author may all mint one — and at that door nothing judged any of them. Measured on `origin/main`: no rule declared any of them in `runtimeTypes`, so the gate filtered them out before it ever consulted `TYPE_TO_STACK_KEY`. `action` and `hook` already HAD their stack-key rows, which made the two absences **consistent rather than contradictory** — the gate filters by `runtimeTypes` first — so nothing was mis-wired and CI was green, correctly. What they summed to is that a write of any of them built no per-write snapshot and ran no rule at all. An author working only through Studio or MCP has no `os lint` step to fall back on, so for them that door is the only one there is. + + ADR-0049's 「声明即强制」 admits two resolutions — honour the declaration, or retire it — and the ruling on #19275 took the first for these six, by evidence group. The rules exist; this is the wiring that reaches them. + + - **`validateStackExpressions` crosses to `action` and `hook`.** It judges the written action's own `visible` / `disabled` CEL and the written hook's own `condition`, resolving `record.` against `objects` — the one collection every snapshot carries. The action/hook BODY rules deliberately do **not** cross with them: they parse authored JS through `typescript`/`sucrase`, the two dependencies `runtime-lazy-deps.test.ts` pins off the kernel boot path outright, and an action/hook write is exactly the snapshot that would carry a body for them to parse. + - **`validatePresetComparands` and `validateEmptyCombinators` cross to `report`, together.** Both judge the same authored filter literal on the same `reports` scan surface, so on #7220's reading they cross or they do not — an author refused for a bad preset comparand and waved through for a literal `$and: []` on the same report could not predict the door. + - **The reference-integrity suite entry gains `report`**, and its per-member axis admits exactly ONE member: `validateChartBindings` (it resolves the report's `dataset` / `rows` / `columns` / `values` against `stack.datasets`, a carried collection). + - **`lintLivenessProperties` crosses to `email_template` and `mapping` only.** The `RUNTIME_OBJECT_ADVISORY_VOLUME` reason that held it back is about the OBJECT write door (~8 advisories per object write, rendered in Studio); `object` is deliberately not declared, so that reason is untouched and still holds for every type left off. + - **`TYPE_TO_STACK_KEY` gains `report` / `email_template` / `mapping`**, never ahead of their rules — the inert state the table's own `seed: 'data'` note records paying for. Every crossed rule has a door control that fires it through the real gate (`runtime-gate.inert-type-writes.test.ts`), and each control was shown to be load-bearing by reverting its declaration and watching it go red. + - **No new rule and no new finding class.** The rule ids (`expression-invalid`, `chart-dataset-unknown`, `chart-dimension-unknown`, `filter-empty-combinator`, `filter-preset-comparand`) and their severities are unchanged — they now reach the door where the author actually is. + - **Measured before crossing**, at the door's own snapshot shape and differential, over every item of these types shipped in this monorepo: **79 actions** (showcase 70, todo 8, crm 1), **6 hooks** (showcase 4, todo 1, crm 1), **9 reports** (showcase 4, todo 5 — 5 of them carrying an authored filter key, so the filter rules were non-vacuously exercised), **1 email template** and **1 mapping** — **0 findings** on every one, with lit synthetic probes refused per rule. + + ## Two readings that are part of the deliverable, not omissions + + **`email_template` and `mapping` are wired and SILENT.** Their bridge, `lintLivenessProperties`, is ledger-driven and skips a type whose warn map is empty; `packages/spec/liveness/email_template.json` is 13 props / **0** warn keys and `mapping.json` is 7 / **0** (lit control on the same instrument: `tool.json` 6/1, `object.json` 35/1). The ruling dispatched the wiring and **no ledger-population work** — 「the empty warn maps stay empty until a real property needs a row — zero pull, the wiring is the whole deliverable」 — so this is the ruled end state. Both halves are pinned: that the rule is dispatched, and that it judges nothing today. The day a property earns an `authorWarn` row the door lights up with no second edit. + + **`skill` — the fourth type of group A — is NOT wired, and for it that IS the deliverable.** Its bridge, `validateAiToolReferences`, resolves into `stack.tools` and `stack.actions`; a per-write snapshot carries `objects` (so an object-level `action_NAME` resolves) but neither of those, so at that door the rule has no truthful `unresolved` verdict at all — only its clean answers are reliable. Measured on the shipped corpus rather than synthetically: `app-showcase`'s single AI-exposed action exists at STACK level only, and a skill naming it is advised `ai-skill-tool-unresolved` at the door while the same rule over the whole stack answers `[]`. That advisory reaches `SaveMetaItemResponseSchema.advisories` and renders in Studio, with a hint prescribing exactly what the author had already done — so the card's own acceptance («a good write passes») does not hold for `skill`. The type therefore takes the ruling's own group B treatment of `tool`, the same universe obstacle read from the other side: **a reading first, not a wiring**. Crossing it needs `actions` / `tools` carried in `RuntimeStackContext` plus a `CLOSURE_CONTEXT_KEY_BY_TYPE` row and two more door gathers in `@objectstack/metadata-protocol` — a second package, a snapshot widening paid on every gated write, and its own card. Both halves of the wiring are held ABSENT by pins, with the measurement kept executable beside them. + + ## The refusal set grows — and there is no FROM → TO, because nothing changed spelling + + A runtime metadata write — Studio's designer, REST `/meta`, an MCP/AI author — of an `action`, `hook` or `report` that carries one of the defects below is now refused with the 422 lint envelope instead of stored. Concretely, these used to succeed at that door and no longer do: + + - an action whose `visible` / `disabled` CEL does not parse, or names a field its bound object does not declare; + - a hook whose `condition` does the same; + - a report binding a dataset nothing declares, or grouping by a dimension or measure its dataset does not declare; + - a report whose filter carries a literal empty combinator (`$and: []`, `$or: []`, `$not: {}`); + - a report filtering by a dashboard date-range PRESET name (`last_30_days`, …) as if it were a value. + + ⚠️ **No metadata needs rewriting to a new spelling, and none is being retired.** Every one of those was ALREADY refused by `os build`, `os validate` and `os lint` — the rules, their ids, their severities and their fix-it text are unchanged since they landed. What widens is the set of doors each runs at. A tenant whose stored metadata carries one of these defects has metadata that was never valid; the refusal envelope names the rule id, the path and the offending string, and the rule's own `hint` carries the correction at the moment it is needed. There is nothing for `objectstack migrate meta` to reach and no ledger entry to make. + + `skill` writes are unchanged — the type is not gated by this change. `email_template` and `mapping` writes are unchanged in behaviour today: their rule is dispatched and judges nothing until a ledger row lands. + + The gate's differential keeps all of this honest in the one direction that matters: a STORED sibling already in violation is never charged to this write (#4463 D4). + + +- f77b806: The runtime publish gate now judges `datasource` writes: `lintLivenessProperties` declares the type in `runtimeTypes` and `TYPE_TO_STACK_KEY` maps it onto `datasources`, so a datasource minted through Studio, REST `/meta` or an MCP/AI author reaches an authoring rule for the first time. + + Clause-②: yes (widening) — one metadata type joins an existing rule's declared roster. No schema key, export or closed-set member is added, nothing previously admitted is refused, and no authored document changes meaning. + + `DEFAULT_METADATA_TYPE_REGISTRY` has declared `datasource` with `allowRuntimeCreate: true` since ADR-0015's Addendum, and no rule named it in `runtimeTypes` — so the gate filtered a datasource write out before it consulted the stack-key table, and the write built no snapshot and ran no rule at all. It is the ADR-0049 declared-not-enforced shape, missed by the census that graded ten sibling types because its registry entry is the only multi-line one and a single-line reader of the registry cannot see it. + + - **The group was measured, not inherited**, because this type sat outside the ten the ruling graded. **Retirement is refuted**: that arm is for a declaration with no stack collection to create into, and `datasources` is a first-class collection with a live runtime create path. **The `skill` hold-out is refuted too**, which is the half that decided it — `skill` stayed out because its bridge resolves references into `stack.tools` / `stack.actions`, collections the door's snapshot does not carry, so the door reached a verdict the whole stack does not share. This rule resolves into nothing: it judges each written item's own top-level keys against that type's liveness ledger, and the door's verdict and the whole-stack verdict are pinned as the identical value. + - **⚠️ Dispatched and silent, on purpose.** `packages/spec/liveness/datasource.json` carries 0 warn keys, so no datasource document can be advised at this door today — the `email_template` / `mapping` end state exactly, under the same fence: the wiring is the whole deliverable and ⛔ no ledger-population work rides with it. The day a datasource property earns an `authorWarn` row the door lights up with no second edit. The silence is pinned beside a lit control on the same instrument in the same process, so it can never be read as a broken dispatch or an unresolvable ledger directory. + - **The stack key is proved behaviourally**, which its two ledger-driven siblings could not manage: `runtime-gate.datasource-writes.test.ts` drives the real rule through its ledger-directory seam over a stack built at `stackKeyForType('datasource')` itself, with the wrong-key leg asserted beside it, so the `seed: 'data'` failure shape — a mapping onto a collection nothing reads — reds a case here rather than riding on one string assertion. + - **Nothing else widened.** A datasource write reaches this one rule and no reference-integrity member; `translation`, `tool`, `doc`, `external_catalog` and `skill` keep their empty rosters, each awaiting its own reading or retirement. +- 9347c1f: A row-level or sharing-rule predicate comparing a field against a list with `!=` / `==` is refused at the CEL lowering instead of lowering to a filter that widens on driver-mongodb, and driver-mongodb refuses `$ne` with an array comparand (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/driver-mongodb`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-list-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS reads on MongoDB and RLS write checks.** A policy written `record.status != ['closed', 'archived']` (or `!(record.status == [...])`, or `!=` against a `current_user` membership set) lowered to `{ status: { $ne: [...] } }` (or `$not` around a bare-array equality). The RLS `using` clause is composed into the query after the engine's comparand-shape check, and driver-mongodb passed the shape to the server, where it selects every scalar row: the read returned the rows the policy was written to hide. A `check` written `!=` against a membership set admitted every write. + + - `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose comparand is a list (`unsupported`): a list literal, or a `current_user` variable that resolves to an array. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports the literal; a resolved array is refused per request. + - `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403). A CEL-authored `check` gets this 403; the `INVALID_FILTER` / 400 of `matchesFilterCondition` remains for a filter passed to it directly. + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. + - `@objectstack/lint`: the list-literal form is reported (`rls-predicate-unenforceable`, `sharing-rule-unlowerable-condition`). The RLS reference pass probes each kernel-resolved `current_user` key with its runtime type. + - `@objectstack/driver-mongodb`: `translateFilter` refuses `$ne` with an array comparand at any depth, with `INVALID_FILTER` / 400, as driver-sql and driver-memory already do. + - `@objectstack/spec`: the migration registry carries the entry. + + **What to change.** "One of these values" is `record.status in ['open', 'pending']`; "none of these values" is `!(record.status in ['closed', 'archived'])`. In a raw filter, use `$in` / `$nin`. `in`, scalar `==` / `!=`, `null` and field-to-field comparisons are unchanged. + + +- 4d7e740: A row-level or sharing-rule predicate whose comparison is handed something other than one value is refused at the CEL lowering or at the write-check evaluator, instead of admitting writes and reads it was written to refuse (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing`, `@objectstack/lint` and `@objectstack/objectql` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS write checks and reads.** Each shape below was measured through the real plugin-security on driver-sql and driver-memory: + + - `!(record.status in [['closed', 'archived']])` (a list nested in an `in` list) admitted and stored every write the `check` was written to refuse, and a `using` read returned every row on driver-memory. + - `current_user.org_user_ids != 'x'` and `current_user.org_user_ids > 'a'` (a membership set on a comparison with no field) folded to "no restriction": every write admitted, every row read, on every driver. + - `record.status > ['m']` compared the list as the string `'m'` on the write check, while the analytics read scope bound the whole list as one SQL parameter. `record.reviewer_id > current_user` compared the whole caller object as a string and admitted and stored every write; in this release the RLS compiler's comparand faces (#20212) already drop that policy, and this change refuses it at the lowering for every caller of the compiler. + - `record.status != record.tags`, its negation `!(record.status == record.tags)`, and the mirror `record.tags != record.status`, with `tags` a `json` field or a `multiple` lookup, admitted and stored every write. + + What changes: + + - `@objectstack/formula`: `compileCelToFilter` refuses, with `unsupported`, a list comparand under every comparison (the ordering operators now included, and on the constant-fold branch, whichever side), the `current_user` root or a key resolving to an object under an ordering operator, and an `in` list whose member is itself a list. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports each literal form; a resolved value is refused per request. `matchesFilterCondition` refuses, with `INVALID_FILTER` / 400, an array under `$gt` / `$gte` / `$lt` / `$lte`, an array member of `$in` / `$nin`, and a `{ $field }` comparison (`$eq`, `$ne` or an ordering operator) whose column holds a list or an object on the record being judged, on either side. The message withholds the field, the operator and the value. + - `@objectstack/plugin-security`: the RLS compiler drops a policy the compiler refuses and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, and `getReadFilter` hands the analytics read scope the deny scope). A `check` comparing a field with a list-holding column is refused 400 and stores nothing. + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. + - `@objectstack/lint`: the literal forms are reported as `rls-predicate-unenforceable`, and an ordering comparison against a membership set through the reference pass. + - `@objectstack/objectql`: a `having` comparison against a `{ $field }` column whose aggregated row holds a list is refused 400 where the row carries the list itself (driver-memory); driver-sql rows carry the stored JSON text and compare as before. + - `@objectstack/spec`: the migration registry carries the entry. + + The stage 2a changeset's sentence that `{ $field }` references evaluate as before no longer holds for a column holding a list or an object: that comparison is now refused. + + **What to change.** "One of these values" is `record.status in ['open', 'pending']`, and "none of these values" is `!(record.status in ['closed', 'archived'])`, with the list flat. An ordering takes one bound (`record.status > 'm'`); a range is two comparisons joined by `&&`. Compare against one key of the caller (`record.reviewer_id > current_user.id`). A field compared with a `json` or `multiple` field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook. In a raw filter, use `$in` / `$nin` with flat lists and one bound per ordering operator. + + Not changed: a field compared with a `json` or `multiple` field still lowers and is not reported at authoring time, because the lowering sees the predicate's text and not the object's field types; driver-memory still answers a `{ $field }` comparison on a read without evaluating the reference. + + +- d498113: A row-level-security predicate that compares a field with a `json` or `multiple` field is refused when it is authored, at `os validate` / `os build` / `os lint` and at the metadata save door, instead of only when it runs (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is already registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`, which names this class. + + Clause-②: no (narrowing) + + `record.status != record.tags`, with `tags` a `json` field or a `multiple` lookup, lowers to a legal filter shape, because the CEL lowering sees the predicate's text and not the object's field types, and the engine's filter admission does not judge a `{ $field }` reference against the referenced column's type either. Measured before this change: 400 cells (`==`, `!=`, `!(==)`, `>`, `<=`; a `json`, `address`, `multiselect`, `multiple` lookup and `multiple` user field; both operand orders; `using` on `select` / `all` / `update` / `delete` / `insert` and `check` on `insert` / `update` / `all`) were all accepted by the real `os validate` and by the save door. The runtime refused every one of them, measured through the real plugin-security on driver-sql: a read the `using` scopes answered `INVALID_FILTER` / 400, a by-id update or delete it scopes `PERMISSION_DENIED` / 403, and every insert or by-id update judged by the `check` (or by a `using` standing in as the check) `INVALID_FILTER` / 400, with nothing stored. + + What changes: + + - `@objectstack/lint`: `validateRlsPredicateEnforceability` reports `rls-predicate-unenforceable` for every lowered field-to-field comparison (`==`, `!=`, `>`, `>=`, `<`, `<=`, on either side, under `!` too) in which either column is DECLARED to hold a list or an object. The declaration is read from the stack's own objects through the spec's value-shape classes, the same two driver-sql refuses such a comparison by: a structured JSON type (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`), or a multi-valued field (`multiselect`, `checkboxes`, `tags`, or `select` / `radio` / `lookup` / `user` / `file` / `image` with `multiple: true`). It judges `using` and `check` on every operation. The finding names each comparison and the declaration behind it, and states the clause's run-time consequence. A clause it refuses is not also handed to the engine's filter judge, so one defect earns one finding. + - Both doors run this rule already, so both refuse: `os validate` / `os build` / `os lint` fail, and a publish through the metadata save door answers `422 INVALID_METADATA` with the same sentence in `issues[]`. `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns the save-door refusal into a logged warning. + + Not changed: a field compared with a single-valued field (`record.status != record.owner`, `record.amount > record.budget`), a `json` or `multiple` field compared with a literal or tested against `null`, and any column the stack does not declare (an object from another package, an external object with no field map), which the rule does not judge. The runtime refusals of stages 2d and 2e stay as the backstop. The stage 2d changeset's sentence that a field compared with a `json` or `multiple` field "is not reported at authoring time" no longer holds: it is now reported at both doors. + + No shipped predicate moves: 0 of the 187 `using` / `check` / `condition` strings in this repository's packages and examples, and 0 of the 3 in the cloud repository, compare a field with a `json` or `multiple` field, and the real `os validate` over `app-crm`, `app-multi-package`, `app-showcase` and `app-todo` reports no `rls-predicate-*` finding. + + **What to change.** A field compared with a `json` or `multiple` field has no row-filter form: compare with a single-valued column, or with a literal or a `current_user` value ("one of these values" is `record.status in ['open', 'pending']`, or `record.owner in current_user.org_user_ids`), or move the condition into a validation rule or a hook. + + +- eee0974: A sharing rule whose `condition` compares a field with a `json` or `multiple` field is refused when it is authored, at `os validate` / `os build` / `os lint`, instead of being seeded and then granting nothing (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is already registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`, whose surface names `sharingRules[].condition` and this class. + + Clause-②: no (narrowing) + + `record.status != record.tags`, with `tags` a `json` field or a `multiple` lookup, lowers to a legal filter shape, because the CEL lowering sees the condition's text and not the object's field types, so the seeder seeds the rule. Measured before this change: 72 conditions (`==`, `!=`, `!(==)`, `>`, `>=`, `<`, `<=`; a `json`, `address`, `multiselect`, `multiple` lookup and `multiple` user field; both operand orders; plus a list-against-list and a compound spelling) were all accepted by the real `os validate`. The runtime refused every one of them, measured through the real plugin-sharing on driver-sql and driver-sqlite-wasm: the rule was seeded into `sys_sharing_rule`, every criteria query it ran answered `INVALID_FILTER` / 400, `SharingRuleService` read that as matching no record, and no `sys_record_share` grant was written, at boot or on a later insert or update. The recipient read nothing. The only signal was one WARN line per rule in the server log. + + What changes: + + - `@objectstack/lint`: `validateSharingRuleEnforceability` reports `sharing-rule-unlowerable-condition` for a condition that lowers but compares two fields (`==`, `!=`, `>`, `>=`, `<`, `<=`, on either side, under `!` too) where either column is DECLARED to hold a list or an object. It uses the same classification as the row-level-security rule's arm for this class (`listHoldingComparisons`, now exported from `validate-rls-predicate-enforceability.ts`), which reads the spec's value-shape classes, the same two driver-sql refuses such a comparison by: a structured JSON type (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`), or a multi-valued field (`multiselect`, `checkboxes`, `tags`, or `select` / `radio` / `lookup` / `user` / `file` / `image` with `multiple: true`). The finding names each comparison and the declaration behind it, and states the run-time consequence. It keeps the unlowerable id because the fix is the same rewrite of the condition, and the literal spelling of the same class (`record.status == ['a', 'b']`) is already reported under that id. + - Inactive rules are judged too, as the rule already does for every condition: the seeder seeds them regardless of `active`. + + Not changed: a field compared with a single-valued field (`record.status != record.owner_name`, `record.amount > record.budget`), a `json` or `multiple` field compared with a literal or tested against `null`, and any column the stack does not declare (an anchor object from another package, an object with no field map, an undeclared name), which the rule does not judge. No row-level-security verdict changes. The rule still runs only at the CLI doors; the metadata save door for a `sharing_rule` does not run it, as before. + + No shipped condition moves: the 3 declared sharing-rule conditions in this repository's packages and examples compare a field with a literal, the cloud repository declares none, and the real `os validate` over `app-crm`, `app-multi-package`, `app-showcase` and `app-todo` reports no new `sharing-rule-*` finding. + + **What to change.** A field compared with a `json` or `multiple` field has no row-filter form: compare with a single-valued column, or with a literal ("one of these values" is `record.status in ['open', 'pending']`), or keep the value the rule keys on in a single-valued field and compare with that. + + +- e462186: `create_record` / `update_record` field values accept the CEL value envelope, declared and evaluated together. + + A value in a `create_record` or `update_record` node's `fields` map may now be a CEL value envelope, `{ dialect: 'cel', source: '…' }`, with the same shape and dialect rules the `assignment` node's `assignments` map already has. The envelope is evaluated by the expression engine that flow conditions use, so the whole CEL stdlib is reachable from a field value, and the result is written with its type kept: + + ```ts + fields: { + subject: 'Quote for {account.name}', // `{token}` template — unchanged + total: { dialect: 'cel', source: 'round(amount * 100.0) / 100.0' }, // CEL, evaluated to the value written + } + ``` + + Clause-②: yes (widening) — a published authoring slot's accept set grows (a valid envelope in `fields.*` is newly evaluated), and the one newly refused shape is the edge the `assignments` map accepted when it gained the envelope: a malformed one. + + **What newly passes.** A valid CEL value envelope as a top-level `fields` value, on both nodes. Before this release the executor wrote such an object into the record verbatim: a text or JSON column stored `{"dialect":"cel","source":"…"}` and the run reported success, and a number column was refused by the data engine. + + **What newly refuses.** A top-level `fields` value that is a plain object with a string `dialect` key and is NOT a valid CEL value envelope. That covers a missing, empty or whitespace-only `source`, an `ast` with no `source`, a `template` or `cron` dialect, and a `source` that does not parse as CEL. Every door refuses it, located at `config.fields.`: `AutomationEngine.registerFlow` refuses the flow, `objectstack validate` reports an `expression-invalid` error, the runtime publish gate answers `422 INVALID_METADATA`, and the node's execute-time contract parse refuses it. Such an object used to be written as data. + + **The rule for nested and literal values.** Only the top-level value of each field is judged. An object nested inside a JSON value or an array is data, whatever keys it carries, and strings inside it still interpolate. A plain string is always a `{token}` template with its existing meaning, and every other literal is written as before. A JSON column whose intended literal value is itself an object with a string `dialect` key is now read as an envelope. To write such an object as data, bind it to a flow variable and write `'{thatVariable}'` (a sole token keeps its type). Measured: no flow in this repository or in HotCRM writes an envelope-shaped object into `fields`. + + **The refusal sentence is slot-neutral.** A refused field value used to be told it was "an assignment value". The sentence every value-slot refusal leads with is now `VALUE_ENVELOPE_REFUSAL`: "A value carrying a `dialect` key is read as an expression envelope, and this one is not a valid CEL value envelope." The published `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` is kept and is the same string, so code that matches on the constant keeps matching. Code that matched the old literal text ("An assignment value carrying…") does not. + + **New in `@objectstack/spec/automation`** (5 exports, 0 removed): + + - `VALUE_ENVELOPE_REFUSAL`, the slot-neutral refusal sentence. + - `FlowValueSlotSchema` / `FlowValueSlot` / `FlowValueSlotParsed`, the value contract every value slot shares (`AssignmentValueSchema` is the same rule under the assignment map's description). + - `resolveFlowNodeValueSlots(nodeType, config)`, which returns every authored value in the ledger's value slots, strings included. + - The expression ledger `FLOW_NODE_EXPRESSION_PATHS` has two new rows, `create_record.fields.*` and `update_record.fields.*` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries both CRUD contracts. + + **Author-time hint (`@objectstack/lint`).** `objectstack validate` warns when a value slot holds a `{…}` template expression, meaning arithmetic or a call to `round` / `floor` / `ceil` / `abs` / `min` / `max`, and points it at the envelope. The warning never fails a build, and the template form keeps working unchanged. Plain references, the `NOW()` / `TODAY()` macros and `$User` paths are not hinted. CEL's `now()` / `today()` are timestamps rather than the strings those macros write, and the flow's CEL scope binds no user. + + **Corrected guidance: `/ 100.0`, not `/ 100`.** The template dialect's `round()` arity refusal used to call `round(x * 100) / 100` the CEL authoring pattern. In CEL that expression truncates: `round()` returns an int, and int / int is integer division, so `x = 1234.5678` gives `1234` instead of `1234.57`. The refusal now prescribes `round(x * 100) / 100.0`, which is correct in both dialects. In the template dialect `/ 100` and `/ 100.0` give the same value. +- af32cf9: `rls-predicate-unenforceable` now reports the RLS predicates the runtime refuses on every request, which the shape check cannot see (#19951). + + `validateRlsPredicateEnforceability` judged a predicate's shape with every `current_user` value replaced by a placeholder, so a predicate whose refusal depends on a value passed `os validate` cleanly and enforced nothing. Its reference pass now reports two such classes as `error`, so **a previously green `os validate` can fail** on a stack that declares one of these predicates. Measured over this repository, 0 of the 126 authored `using` / `check` predicates (77 of them `current_user` predicates) change verdict. + + **A `current_user` value of the wrong type for its position.** The kernel resolves `positions`, `org_user_ids` and `accessible_org_ids` as lists and `id`, `organization_id` and `email` as one value on every request, and `current_user` alone is the whole caller context. The compiler refuses a mismatch on every request, and the RLS compiler drops the policy: reads return no rows and `check` writes are refused 403. + + - FROM silent TO `rls-predicate-unenforceable`: `record.f != current_user.org_user_ids` (any list key). Write `!(record.f in current_user.org_user_ids)`. + - FROM silent TO `rls-predicate-unenforceable`: `record.f == current_user.positions` and `!(record.f == current_user.positions)`. Write `record.f in current_user.positions`, keeping any enclosing `!(...)`. + - FROM silent TO `rls-predicate-unenforceable`: `record.f in current_user.id` (any one-value key). Write `record.f == current_user.id`. + - FROM silent TO `rls-predicate-unenforceable`: `record.f in current_user`, `record.f.startsWith(current_user)`, `.endsWith(current_user)` and `.contains(current_user)`. Name the key that holds the value, for example `record.f in current_user.org_user_ids` or `record.f.startsWith(current_user.email)`. + - FROM silent TO `rls-predicate-unenforceable`: `record.f.startsWith(current_user.org_user_ids)` (a list handed to a string method). Write `record.f in current_user.org_user_ids`, or pass a one-value key. + + **A `null` list member or `null` ordering bound.** The platform refuses both in every filter it is sent. The RLS compiler runs that check on its own filter too (#20212) and drops the policy on every request: reads return no rows and `check` writes are refused 403. + + - FROM silent TO `rls-predicate-unenforceable`: `record.f in ['a', null]` and `!(record.f in ['a', null])`. Write `(record.f in ['a'] || record.f == null)`, or drop the `null` member. + - FROM silent TO `rls-predicate-unenforceable`: `record.f in [null]`. Write `record.f == null`. + - FROM silent TO `rls-predicate-unenforceable`: `record.f > null`, `>= null`, `< null` and `<= null`. Write `record.f != null` or `record.f == null`, or compare against a real bound. + + Each finding's hint carries the rewrite with the predicate's own field and key. + + **Unchanged.** A comparison whose answer depends on which caller asks, such as `current_user.email == 'ops@acme.com'`, stays silent: it grants everything to the caller it names and is refused for everyone else, so no probe value can stand for it. The list literal (`record.f != ['a', 'b']`) and the bare root under `==` / `!=` (`record.f != current_user`) were already reported. `field == current_user.id`, `field in current_user.org_user_ids` and `field == null` stay clean. +- 560b724: A row-level or sharing-rule predicate comparing with `!=` / `==` against the bare `current_user` root is refused at the CEL lowering instead of lowering against the whole caller context object (#19959). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-variable-root-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS write checks.** A policy written `record.owner_id != current_user` (or `== current_user`, or `!(record.owner_id == current_user)`) named the variable root alone, which resolved to the whole caller context, and lowered to `{ owner_id: { $ne: } }` (or the bare object, or `$not` around it). A strict compare never equals an object, so a `check` so written admitted and stored every insert and by-id update it was written to refuse, a USING-only such policy admitted every insert, and explain reported the read as narrowed with the caller's membership sets echoed in its `readFilter`. A constant comparison such as `current_user != 'guest'` folded to no restriction. + + - `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose operand is the bare variable root (`unsupported`), in both of its modes, so the authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports it before any request. A variable that resolves to an object is refused per request; a `Date` still passes. + - `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, explain answers `denies`). + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is still skipped at bootstrap, now with reason `unsupported` instead of `unresolved-variable`. + - `@objectstack/lint`: the shape is reported as `rls-predicate-unenforceable` on either RLS clause, where it was silent, and as `sharing-rule-unlowerable-condition` on a sharing condition, where it was `sharing-rule-runtime-variable-condition`. + - `@objectstack/spec`: the migration registry carries the entry. + + **What to change.** Compare against the key the predicate means: `record.owner_id != current_user` becomes `record.owner_id != current_user.id` (or `current_user.organization_id`, `current_user.email`); a membership test is `record.owner_id in current_user.org_user_ids`. Scalar keys, `in`, `null`, literals and field-to-field comparisons are unchanged. + + +- 16c5473: fix(spec)!: a `decision` branch with no `expression` — the key absent, or `null` — is refused at authoring (#19961) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on one authored flow-node slot, shipped as + `minor` under the launch-window convention (`check-changeset-no-major` refuses + `major` until GA; breaking-ness is carried by this banner and the ADR-0087 + disposition above, not by the level). + + **What changed.** `DecisionConditionSchema` declares a branch `{ label, expression }` + with `expression` a required `z.string()`. Nothing enforced that: a decision node's + `config` is an open record no schema is parsed against, and the expression ledger's + resolver skipped an absent value as "not authored". So `conditions: [{ label: 'y' }]` + passed `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate`, + and the run then failed at that branch — the executor evaluates every branch it + reaches, and a branch with no `expression` is a condition with no `source`, which + `evaluateCondition` refuses. The ledger now marks the slot `required` (reconciled + against the schema's own `required` list), and the branch is refused at all three + doors through the walk and the function that already refuse a blank one — by + `FlowSchema.parse` with a `custom` issue anchored at the slot (for example + `nodes.1.config.conditions.0.expression`), by `registerFlow` and `objectstack validate` + through that same parse, and by `validateStackExpressions` for a stack handed to it + directly — with one message, led by the published `PREDICATE_SLOT_STRING_REFUSAL` + sentence. `expression: null` is refused the same way, and so is a branch that wrote + its predicate under `condition` (the edge's spelling), which has no `expression` + either. The Studio flow designer writes the refused shape when a branch row's + expression cell is left empty. Where such a branch already sits, the whole flow is + refused: registered from the metadata + registry or `sys_metadata` at boot, it is skipped with a `failed to register flow` + warn naming it while the flows beside it register; a `defineStack({ flows })` source + throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused + whole at load. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `conditions: [{ label: 'high' }]` on a `decision` node | the predicate you meant — `{ label: 'high', expression: 'record.amount > 10000' }` | + | `conditions: [{ label: 'high', condition: 'record.amount > 10000' }]` | the same predicate under `expression` | + | `conditions: [{ label: 'high', expression: null }]` | the predicate you meant, or `expression: 'false'` to keep the branch and never take it | + + **One-line fix:** write the predicate under `expression`. `expression: 'false'` keeps + the branch and its label and never takes it — a change of behaviour, not a preserved + one: a run that reached the branch used to FAIL there, and now routes on to the next + branch or the declared fallback. ⚠️ Do not drop a decision's only branch: the node + then routes by its out-edges alone, and the out-edge that branch labelled is no + longer held back. + + **Unchanged.** A branch carrying a non-blank predicate parses, registers and + validates as before; a blank one keeps its refusal and its own prescription + (`flow-predicate-slot-blank-string-refused`); a `decision` with no `conditions`, or + an empty list, still routes by its out-edges; an absent screen field `visibleWhen` + is still legal (that slot is not required); and `PREDICATE_SLOT_STRING_REFUSAL` + keeps its name and its text. +- e7f69db: A record-scoped filter token, `{record_id}`: the id of the record a `type: 'record'` page is showing. It resolves where a record is in context, and is refused by name everywhere else (#20003). + + On a record page, `record:related_list` was the only component that could scope itself to the record in view. Every other data-bearing component takes a `FilterCondition`, and the only dynamic values a filter could hold named the signed-in viewer. So "open tasks" on a person's record page counted the whole organisation's tasks, under that person's name. `{ assignee: '{record_id}' }` now says "this record's". + + **Where it is accepted, and where it is refused:** + + - **Accepted:** a filter on a component of a `type: 'record'` page (`regions[].components[]`, `slots`, and any filter key inside them). `os lint` / `os validate` pass it there. A page with no `type` is a record page by `PageSchema`'s default. + - **Refused by `os lint` / `os validate`** (rule `filter-token-unknown`, `error`), with the reason "no record in context on this surface" rather than the unknown-token message: list views (top-level `views` and an object's list views and field filters), dashboard widgets and dashboard filters, reports, datasets, app navigation filters, and every page whose `type` is not `'record'` (`home`, `app`, `utility`, `list`, including a list page's `interfaceConfig.filterBy`). + - **Refused on every server path.** `resolveFilterTokens()` in `@objectstack/core` throws `UnresolvedFilterTokenError` (`FILTER_TOKEN_UNRESOLVED` / 400, `token: 'record_id'`) on the ObjectQL read path (`find`, `findOne`, `count`, `aggregate`), the write path (`update` / `delete`, by id or `multi`), the analytics query door and the dataset executor. That is the same envelope a session token gets when the request has no value for it. It happens whatever the request carries, because no server path knows which record a page is showing. The token never becomes `null` (a count "about nobody"), is never dropped (a count "about everybody"), and never reaches the driver. + + **What is in `@objectstack/spec/data`:** + + - `RECORD_CONTEXT_TOKENS` (`['record_id']`), `RecordContextToken` and `isRecordContextToken()`: a sibling of `CONTEXT_TOKENS`, not a member. `CONTEXT_TOKENS` resolves against the caller's session, and `{record_id}` resolves against the surface. So `isContextToken('record_id')`, `ContextTokenSchema` and `ContextTokenPlaceholderSchema` are unchanged and still reject it, and a client resolver that fills `CONTEXT_TOKENS` from the session does not pick it up. + - `classifyFilterToken('{record_id}')` returns the new kind `{ kind: 'record-context', token: 'record_id' }` instead of `unknown`. A consumer that switches exhaustively on `kind` gets a compile error until it handles the new kind. + - `isKnownFilterToken('record_id')` stays `false`. That predicate answers "can the server resolve it?", and its one consumer, the flow engine's filter hand-off, is a server position. A flow addresses its own record as `{record.id}`. + - Near misses are still refused, now with `{record_id}` suggested: `{recordId}` (the URL / flow-template placeholder), `{record.id}`, `{record-id}`, `{current_record_id}`. `CONTEXT_TOKEN_SUGGESTIONS`' value type widens to `ContextToken | RecordContextToken`. + + **Presentation scope, not access.** Like `{current_user_id}`, `{record_id}` narrows what a component shows. It decides nothing about which rows the caller may read; that is still RLS. + + **What you do:** on a record page, filter a component on the record in view with `{ : '{record_id}' }`. If `os validate` refuses it with "no record in context on this surface", the filter is on a surface with no record: move it onto a component of a `type: 'record'` page, or filter on a concrete id. Until the renderer you run resolves `{record_id}`, a record-page query that carries it is refused by the server with `FILTER_TOKEN_UNRESOLVED` rather than answered with a wrong number. +- e4471e6: fix(lint)!: a field-level predicate that reads through a reference field is refused at `objectstack validate` (#20078) + + + + **BREAKING** in the accept-set sense — a stack that validates today can fail tomorrow. Landing in + the launch window as `minor` (`major` is refused by `check-changeset-no-major`); breaking-ness is + carried by this banner, the `!` above and the ADR-0087 registration. + + Clause-②: no + + A field `requiredWhen` / `readonlyWhen`, or a select option's `visibleWhen`, that reads THROUGH a + `lookup` / `master_detail` / `user` / `tree` field — `record.account.tier` — passed `objectstack + validate`, `build` and `lint`. It cannot work: the field level is never hydrated, so the reference + holds the related record's bare id and every read through it faults. At run time a traversing + `requiredWhen` refuses every write that reaches it, a traversing `readonlyWhen` refuses every + update that writes its field (ADR-0137 D2), and a traversing option predicate is never enforced + (option visibility is fail-open). The authoring pass now refuses all three as + `expression-invalid`, naming the slot, the reference path and the related column, before deploy. + + What to write instead — the refusal says the same: + + - **A `record..` read** — express the check as a `validations[]` rule of + `type: 'script'`. Its `condition` is the one predicate the server reads one hop through a + reference, and it states the FAILURE: for `requiredWhen: P` on `po_number`, + `P && (record.po_number == null || record.po_number == '')`; for `readonlyWhen: P` on + `discount`, `P && record.discount != previous.discount` with `events: ['update']`; for an + option gated by `P`, that option picked while `P` does not hold (the option is then offered to + everyone and refused on save). Or read a column the object itself declares. + - **A `previous..` or `parent..` read** — no seam hydrates + either root, a validation rule included, so read a column the bound record declares. + + Unchanged: the same traversal inside a `validations[]` `script` rule is accepted, as is reading + the reference itself (`record.account == 'acc_1'`, `record.account != null`), an object-valued + field that is not a reference (`record.ship_to.city`), and an option gated on `current_user` + (including `current_user.can(…)`). The runtime is untouched, and an object already stored in + `sys_metadata` is not re-validated by this. `@objectstack/spec` states the rule on the three + slots' `.describe()` text and registers the ADR-0087 semantic entry + `field-predicate-reference-traversal-refused`. +- 443b2f4: feat(spec,rest,lint): an import mapping target may name a declared part of a compound field (`mailing_address.street`), and the importer assembles the parts into one value (#20149) + + Clause-②: yes + + **What was missing.** A mapping could write each source column to one flat + field only, so nothing could build an `address` value from the separate + street / city / state / postal code / country columns a spreadsheet carries. + A dotted target such as `mailing_address.street` named no field and was + refused at `objectstack validate`, on the dry run and on the commit. + + **What changes.** + + - `@objectstack/spec`: `ImportFieldMappingSchema.target` declares the part + path. A target may name `field.part` when `field` is a declared field whose + stored value schema is a closed object of optional strings (today: + `address`), and `part` is a key that schema declares: `street`, `city`, + `state`, `postalCode`, `country`, `countryCode`, `formatted`. The part names + are read from the value schema, never listed by hand. The one verdict, + `judgeImportMappingTarget`, answers the new `{ kind: 'part', field, part }`; + `indexImportMappingTargets` carries each compound field's parts on + `parts`; `unknownImportMappingTargets` gives each refused target a `reason` + (`unknown` or `collides`) and, for a dotted target, what its `head` names. + `location` is not compound for import: its parts are required numbers, so a + value assembled from text cells would be the wrong type. + - `@objectstack/rest`: `applyMappingToRows` assembles every part target of a + row into one value under the field's key, before the engine sees the row, + whatever transform produced the part (`none`, `map`, `constant`, `join`, + each element of a `split`). A blank part cell (empty, whitespace or a + `nullValues` token) is left out, string parts are trimmed under + `trimWhitespace`, and a row whose parts are all blank leaves the field + unset, as a blank flat cell does. On an update the assembled value replaces + the stored one. The dry run and the commit judge the same assembled row. + - `@objectstack/lint`: `mapping-target-field-unknown` accepts a declared part + and reports what stays refused, naming the legal parts each time. + + **Still refused, at `objectstack validate`, on the dry run and on the commit + (`400 INVALID_FIELD`, before any row):** + + - a part the value does not declare (`mailing_address.stret`); the refusal + lists the declared parts; + - a dotted path on a field with no parts (`full_name.first`). A dotted target + never traverses a reference (`account.name`): map the column to the + reference field with transform `lookup`; + - a mapping that writes a field both whole and by part (`mailing_address` and + `mailing_address.street`): one row carries one value for the field. Map it + whole or by its parts, not both. + + **What to do.** Nothing, unless you want the capability: point each address + column at `field.part`, for example `{ source: 'Zip', target: + 'mailing_address.postalCode' }`. +- 7e7fab7: fix(spec,rest,lint): an import mapping target that names no field is refused on the dry run, on the commit and at `objectstack validate` alike (#20150) + + Clause-②: yes (narrowing) + + + + **BREAKING** in the accept-set sense only, landing in the launch window as + `minor`: the import route and `objectstack validate` now refuse a mapping they + used to pass, and every such mapping already failed on the commit. + + **What was wrong.** `ImportFieldMappingSchema.target` is declared as "Target + object field(s)", and nothing held a mapping to it. A mapping whose target named + no field of its `targetObject` (measured with `mailing_address.street` on an + object whose address field is `mailing_address`): + + - passed `objectstack validate`, `os lint` and `os build` with no diagnostic; + - answered `ok` for every row on `POST /api/v1/data/:object/import` with + `dryRun: true`; + - then failed every row on the commit with `INVALID_FIELD` ("Unknown field + 'mailing_address.street' on object '…'"). + + The dry run promised what the commit refused. + + **What changes.** + + - `@objectstack/spec` exports ONE verdict on what a target may name, beside the + schema it judges: `unknownImportMappingTargets(fieldMapping, objectDef)`, with + `indexImportMappingTargets`, `judgeImportMappingTarget`, + `importMappingEntryTargets` and `IMPORT_TARGET_ALWAYS_ADDRESSABLE_COLUMNS` + (from `@objectstack/spec/data`). A target may name a declared field, a column + the platform provisions on that object (`resolveInjectedSystemColumns`), or one + of `id` / `created_at` / `updated_at`, which the engine's write door admits on + every object. An object with no readable, non-empty field map is not judged. + - `@objectstack/rest`: `prepareImportRequest` refuses a named mapping (`mappingName`) + with a target that names no field, before any row, with `400 INVALID_FIELD` — + the code the commit's per-row refusal already carried. The dry run and the + commit give the same answer, and so does the async import-job route. + - `@objectstack/lint`: the reference-integrity suite (`os validate`, `os lint`, + `os build`) gains `validateMappingTargetFields`, rule id + `mapping-target-field-unknown` (`MAPPING_TARGET_FIELD_UNKNOWN`), severity + `error`, located at `mappings[i].fieldMapping[j].target`. It asks the same + spec verdict, so it never refuses a target the import door accepts. + + **What to do.** Point each reported target at a field the object declares. An + array target (`split`) is judged element by element. +- 1207baf: RLS policies are admitted when they are authored: the engine judges every read-scope `using`, at the save door and at `os validate` / `os build` / `os lint` + + A row-level-security policy (`rowLevelSecurity[]` on a permission set) could carry a `using` predicate that lowers cleanly and that the engine then refuses to run: a text operator (`startsWith` / `endsWith` / `contains`) aimed at a number field, a date field compared against a value its storage cannot read, a filter on a virtual (formula) field, or a `{…}` placeholder string. Nothing refused it when it was written. The first answer was a refused analytics query, long after the author had moved on. And the metadata save door (Studio, REST `/meta`, MCP) did not run the RLS predicate rule at all, so a predicate `os validate` already refused was accepted there. + + - **The engine's own verdict.** `validateRlsPredicateEnforceability` takes the engine's judge-only filter admission (`IObjectQLEngine.judgeFilter`) as an optional input and judges the lowered `using` of every `select` / `all` policy with it. A refusal is reported under the existing id `rls-predicate-unenforceable`, and the message quotes the engine's code, status and sentence verbatim. The rule never models the engine's checks: without the input it answers exactly as before. + - **Both doors hand in a real engine.** The metadata save door probes its host engine for `judgeFilter` and passes the bound method through the publish gate. The CLI commands build an engine with no driver from the stack's own objects and pass its method. + - **The save door now runs the rule for `permission` writes** (`surfaces: ['cli', 'runtime-publish']`, `runtimeTypes: ['permission']`), so every predicate `os validate` refuses is refused there too, as a `422 INVALID_METADATA` whose `issues[]` carries the same sentence. + - **New optional inputs.** `AuthoringRuleContext.judgeFilter` (and so `AuthoringRuleRun.judgeFilter` for `runAuthoringRules`), the `judgeFilter` argument of `runRuntimeAuthoringRules`, and an optional second parameter of `validateRlsPredicateEnforceability`. A caller that passes nothing gets the previous verdicts. + + **BREAKING**: a permission set whose read-scope `using` the engine cannot run now fails `os validate` / `os build` / `os lint`, and a publish of it through the metadata save door is refused with `422`. Stored rows keep being read, and a re-save of one is judged like any other publish. `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns the save-door refusal into a logged warning for a migration window. The refusal's hint names the fix for each class: write the caller's value as a `current_user` key rather than a `{…}` placeholder, point a text operator at a field that holds a string, compare a date field against a value its storage reads, or denormalise a computed value onto a stored field. Every policy authored in this repository, in its examples and in the default permission sets was measured, and none is refused. + + Two edges are not closed here, both deliberately: + + - The judge sees only `using` clauses in the read scope. A `check` is matched in memory against the post-image and never reaches the engine's filter admission. + - At the save door, the judge reads the engine's live registry. An object that exists only in the same publish batch, or only in an organization overlay, is one that registry does not hold, so it gets the engine's unknown-object answer: no field-type verdict, while the placeholder and comparand checks still run. At the CLI door, an object the stack does not define gets the same answer. + + Clause-②: yes (narrowing) + + +- 5f9d7d7: `packages/lint`'s five `stack.packages` readers (four named by #20206, plus one added by #20208 after that card's site census) now refuse a PRESENT non-array `packages` — `{}`, `0`, `'x'`, a keyed object, and (as of this round) `null` too — instead of silently reading it as "no packages" (#20206, ruling A on #15293 comment 5634034754; the `null` leg is ruling A on #19926, comment 5805260775: `null` is malformed, everywhere). For every shape other than `null`, this is the same way `@objectstack/core`'s `resolveArtifactPackageOrder` already refuses it, with the same registered code; `@objectstack/core`'s `resolveArtifactPackageOrder` refuses `null` the same way (#19926). + + Clause-②: no (narrowing) + + + + - **What changes**: `validateObjectReferences`, `validateTranslationReferences` and `validateMappingTargetFields` (the three public `@objectstack/lint` functions these readers sit behind) now throw an `INVALID_ARTIFACT_PACKAGES` error (ADR-0112, `status: 422`) instead of returning findings, when the stack they are handed carries a `packages` key that is present but not an array — `null` included. Only `os lint` reaches this refusal — exit 1, the message on stdout (`printError`), `code` under `--json`; `os validate` and `os build` already refuse a malformed `packages` earlier, at `ObjectStackDefinitionSchema.safeParse`, before these rules ever run. + - **What does not change**: an absent `packages` (the key omitted, or explicitly `undefined`) is still read as "no packages" — unchanged. A well-formed `packages[]` array is read exactly as before, junk entries dropped exactly as before. + - **Fix**: write `packages` as an array of `{ manifest: … }` entries, or omit the key entirely for a single-package stack. +- ae8e3ca: fix(lint)!: a view container whose `object` names no object is refused by `os validate`, `os build` and `os lint` (`object-reference-unknown`), and the refusal names the namespace-prefixed object when that is the one the stack declares (#20216) + + Clause-②: no (narrowing) + + **BREAKING** — an accept-set narrowing on one authored key, shipped as `minor` under the + launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness + is carried by this banner and the ADR-0087 disposition below, not by the level). + + **What changed.** `ViewSchema.object` is how a stack-level `views: [...]` container says + which object its views belong to, and it is the key the runtime indexes views by + (`getViewsByObject()` / `GET /meta/view?object=`). The schema declares it `z.string()`, and + nothing resolved it: `defineStack`'s cross-reference check reads a container's + `list.data` / `form.data` bindings, never the container's own key. So a container bound to a + name no object carries passed: `os validate` printed "Validation passed" and exited 0, + saying nothing about the view, and `os build` / `os lint` run the same rule table. At + runtime none of its views was found for any object. The common case is not a typo but a + missing namespace prefix — `object: 'order_line'` in a project whose object is + `my_app_order_line` — which is exactly what `os generate view` wrote in every namespaced + project until its template learned the prefix. + + The key now joins `validateObjectReferences` and rides the ladder every other object-name + site on that rule uses, resolved against the same set as a field's relationship target: + + 1. the stack's own objects, or an object an entry of the artifact's `packages[]` provides → ok; + 2. a known platform object (`PLATFORM_PROVIDED_OBJECT_NAMES`) → ok; + 3. unresolved and not platform-prefixed → **`error`** `object-reference-unknown` at + `views[N].object`, so `os validate` / `os build` / `os lint` exit 1; + 4. unresolved, platform-prefixed, registered by nothing → the existing + `object-reference-unregistered-platform` advisory. + + The refusal lists the objects the stack does declare, and when the bound name is exactly a + declared object minus the stack's `manifest.namespace` prefix, the hint names that prefixed + object outright. Not judged, on purpose: a container that carries no `object` (its binding + then falls back to `list.data.object` / `form.data.object` / its `name`, a different + reference), and a container authored at runtime (this rule does not run on a `view` write at + the runtime publish gate; that door is unchanged). + + ## The accept set, before and after + + This is a behaviour table, not a rewrite: the FROM column is what the door did, the TO + column is what it does now. + + | where | FROM | TO | + |:--|:--|:--| + | `os validate`, `os build`, `os lint` on a view container bound to a name no object carries | exit 0, no finding | exit 1, `object-reference-unknown` at `views[N].object` | + | the same, on a platform-prefixed name nothing registers | exit 0, no finding | the `object-reference-unregistered-platform` advisory, exit unchanged | + | a runtime `view` write | unchanged | unchanged | + + Nothing an author writes changes spelling, and no key or value is retired. A container that + is refused was already dead at runtime; the finding's own hint says which object to bind it + to. + + +- 7dc45eb: fix(spec)!: a flow node config its executor cannot run — a key its contract requires, left out, or a decision branch list it cannot read — is refused at authoring (#20316) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on authored flow-node `config`, shipped as + `minor` under the launch-window convention (`check-changeset-no-major` refuses + `major` until GA; breaking-ness is carried by this banner and the ADR-0087 + disposition above, not by the level). + + **What changed.** A flow node's `config` is an open record, so what its executor + requires was checked by no build door. `FlowSchema.parse`, `AutomationEngine.registerFlow` + and `objectstack validate` all admitted a node that left out a key its executor + contract requires — and the executor's own contract parse then refused the node on + every run that reached it. A `decision` branch with no `label` was worse: it never + failed, the matched branch reported no label, and traversal took EVERY out-edge, so + the flow ran green down the wrong paths. All three doors now refuse these shapes + through one judge, `flowNodeConfigRefusals` (new in `@objectstack/spec/automation`): + + - **A key a builtin's executor contract requires, left out.** Each builtin node's + config is parsed against the very contract its executor parses against + (`getBuiltinNodeConfigContracts()`, new, reconciled against the executors' own parse + calls), and only the keys left out are kept — a present value of the wrong type, and + an undeclared key, are judged where they were before. The keys: `objectName` on + `get_record` / `create_record` / `update_record` / `delete_record`; `recipients` on + `notify` (and `title` when there is no `template`); `url` on `http`; `function` on + `script`; `flowName` on `subflow`; `collection` and `flowName` on `map`; + `collection` on a `loop` that has a `body`; `branches` on `parallel`; `try` on + `try_catch`; and on `screen`, each field's `name`, each option's `value` and + `label`, and a `lookup` field's `reference`. A key a rule of the contract requires + (the `notify` title, the `lookup` reference) is refused in the contract's own words. + - **A `decision` branch list its executor cannot read.** `conditions` present and not + `null` must be an array; every branch must be an object; every branch's `label` must + be a non-blank string (absent, `null`, blank or non-text all name no out-edge). + + Each refusal is a `custom` issue anchored at the key (`nodes.1.config.objectName`, + `nodes.1.config.fields.0.name`, `nodes.1.config.conditions.0.label`, or the region + path `nodes.1.config.body.nodes.0.config…`), met at `registerFlow` and + `objectstack validate` through that same parse, and reported by + `validateStackExpressions` for a stack handed to it directly. The refusal codes join + `FLOW_SLOT_REFUSAL_CODES`: `node-config-key-missing`, `node-config-key-required-by-rule`, + `decision-conditions-not-array`, `decision-branch-not-object`, + `decision-branch-label-missing`. + + The Studio flow designer writes refused shapes when a node is added and saved before + it is configured, when a decision branch row's label cell is left empty, and when a + screen field row's name cell is left empty. Where such a node already sits, the whole + flow is refused: registered from the metadata registry or `sys_metadata` at boot, it + is skipped with a `failed to register flow` warn naming it while the flows beside it + register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the + whole stack; an artifact file is refused whole at load. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ type: 'get_record', config: { outputVariable: 'rows' } }` | the object it reads — `config: { objectName: 'account', outputVariable: 'rows' }` (the same for `create_record` / `update_record` / `delete_record`) | + | `{ type: 'loop', config: { body: { … } } }` | the array it iterates — `config: { collection: '{rows}', body: { … } }` | + | `{ type: 'map', config: { flowName: 'per_row' } }` | `config: { collection: '{rows}', flowName: 'per_row' }` | + | `{ type: 'http', config: { method: 'GET' } }` | `config: { url: 'https://api.example.com/v1/items', method: 'GET' }` | + | `{ type: 'script' }` | the registered function it calls — `config: { function: 'recalc_totals' }` | + | `{ type: 'notify', config: { recipients: ['{record.owner}'] } }` | a content source — `title: 'Deal won'`, or a `template` | + | `conditions: [{ expression: 'record.amount > 1000' }]` on a `decision` | the out-edge it routes to — `[{ label: 'large', expression: 'record.amount > 1000' }]`, beside an out-edge labelled `large` | + | `conditions: ['record.amount > 1000']` | `[{ label: 'large', expression: 'record.amount > 1000' }]` | + + **One-line fix:** write the key the node was meant to carry. To branch on the + out-edges instead of on `conditions`, delete `conditions` and put each predicate on its + edge's `condition`. + + **Unchanged.** A node carrying every key its contract requires parses, registers and + validates as before; a legacy flat-graph `loop` (no `body`) still needs no + `collection`; a `decision` with no `conditions`, `conditions: null` or an empty list + still routes by its out-edges; `assignment`, `wait`, `connector_action` and plugin node + types are not judged by this rule; and a key spelled by a D2 alias (`object`, `flow`, + `functionName`, …) is still canonicalized before `registerFlow` and `objectstack + validate` judge it. +- 2c31070: A row-level-security predicate or a sharing-rule condition that compares two fields of different comparison classes — a text field with a number field, a field with a single image or file field, a field with a formula field — is refused when it is authored, at `os validate` / `os build` / `os lint` and, for a permission set, at the metadata save door (#20347). The classification it is judged by is exported once, from `@objectstack/spec/data`. + + **BREAKING** — an accept-set narrowing in `@objectstack/lint`, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. `@objectstack/spec` gains exports only. + + Clause-②: yes (narrowing) + + `record.status != record.amount` (text vs number) and `record.status != record.photo` (text vs a single image) lower to a legal `{ status: { $ne: { $field: … } } }` filter and hold no list, so no authoring rule refused them. Measured before this change, through the real `os validate` and the real plugin-security and ObjectQL on driver-sql: `os validate` reported both valid; the read a `using` scopes answered `INVALID_FILTER` / 400 and a by-id update or delete it scopes `PERMISSION_DENIED` / 403, because driver-sql compiles a column-to-column comparison only between two columns of one comparison class; and a single-record insert judged by the `check` — or by a `using` standing in as the check — was admitted and stored, because the in-process write check compares the two raw values. A formula field (`record.status != record.is_open`) answered the same three ways. The same-class control (`record.status != record.note`) read, updated, deleted and inserted normally. For a sharing rule, the condition lowers and is seeded, and every criteria query it runs meets the same driver-sql refusal. + + What changes: + + - `@objectstack/spec/data` (`filter-cross-field-comparison-class.ts`): the cross-field comparison classification. `CROSS_FIELD_COMPARISON_CLASSES` names the six classes (`numeric`, `text`, `boolean`, `date`, `datetime`, `time`); `CROSS_FIELD_NO_CLASS_REASONS` the three families with none (`list-or-object`, `file`, `formula`); `CROSS_FIELD_COMPARISON_TYPE_CLASSES` classifies every `FieldType` member exactly once, by reference to the existing value-class sets; `crossFieldColumnVerdict` answers one declared column (a multi-capable type flagged `multiple: true` holds a list); and `crossFieldComparisonVerdict` answers two (`comparable`, `cross-class`, `no-class`, or `unjudged` for a type outside `FieldType`). It is lifted case for case from driver-sql's cross-field boundary, and a pairwise parity test in driver-sql holds the two equal over every declared field type. + - `@objectstack/lint`: `validateRlsPredicateEnforceability` reports `rls-predicate-unenforceable`, and `validateSharingRuleEnforceability` reports `sharing-rule-unlowerable-condition`, for every lowered field-to-field comparison (`==`, `!=`, `>`, `>=`, `<`, `<=`, either side, under `!` too) whose two declared columns are not `comparable`. It judges `using` and `check` on every operation, and sharing-rule conditions. The finding names each comparison, each column's declared type and class, and the clause's run-time consequence; the hint lists every class with the declared types it holds, read from the spec. A comparison either side of which holds a list or an object stays the existing list-holding finding, and a clause either arm refuses is not also handed to the engine's filter judge, so one defect earns one finding. + + Not changed: driver-sql and the in-process write check keep their own behaviour here; moving both onto the exported classification is the engine-lane half. A comparison between two columns of one class (`record.amount > record.budget`, `record.stage == record.account`), a file or formula field compared with a literal or tested against `null`, and any column the stack does not declare or declares with a type outside `FieldType`, are not reported. + + No shipped predicate moves: of the 163 `using` / `check` / `condition` string literals in this repository's packages and examples, the 105 that lower hold two field-to-field comparisons, both same-class (`spent > budget`, a hook condition; `a > b`, a gate fixture), and neither is an RLS predicate or a sharing-rule condition. + + To keep such a rule, compare a field only with a field of the same class, or with a literal or a `current_user` value; test a file field with `!= null`; or store the value the rule keys on in a field of the right type. If the two columns really hold comparable values, one of them is declared with the wrong type, and the declaration is what to fix. + + +- 4b2d904: fix(lint)!: `object-field-ref-unknown` judges the field-name lists on a field — `relatedListColumns`, `lookupColumns`, `lookupFilters[].field`, `dependsOn` — and an object's `indexes[].fields` + + + + **BREAKING** in the accept-set sense — a declaration that passes today can fail tomorrow. + Landing in the launch window as `minor` (the lockstep convention: `major` is refused by + `check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087 + disposition above). + + **Clause-②: no (narrowing)** — the rule refuses more than it did; no key is added to any + published payload and no public surface grows. Narrowing is still a semantic-surface change, + which is why it is declared here rather than shipped silently. + + Each of these five lists holds bare field names that the schema cannot judge, and until now no + authoring door read them for existence, so a misspelling surfaced only when a user opened the + view or the picker — or never: + + - a misspelt `relatedListColumns` entry asked the child object for a column it does not have, + when the parent's detail page opened; + - a misspelt `lookupColumns` entry rendered an empty picker column; + - a misspelt `lookupFilters[].field` filtered the picker's query by a field the referenced + object lacks; + - a misspelt `dependsOn` name kept its field gated for good; + - a misspelt `indexes[].fields` column made the SQL driver skip the WHOLE index at sync, with a + warning, and drift dropped it too — so a `unique` index was silently unenforced while + everything looked normal. + + `os validate`, `os build` and `os lint` now refuse each of them at `error` (exit 1), under the + existing rule id `object-field-ref-unknown`, and so does the runtime publish door on an object + write (`422`), exactly as they already did for `highlightFields` and + `publicSharing.redactFields`. The finding sits at the exact path — + `objects[i].fields..lookupColumns[j].field`, `objects[i].indexes[j].fields[k]`, and so + on — names the string that was written and the object it was judged against, offers the + nearest name when one is close, and lists that object's fields. + + **Which object a name is judged against** — read off each key's runtime reader, not assumed: + + | Position | Judged against | + |:---|:---| + | `relatedListColumns[]` | the object that owns the field — the related list shows that (child) object's rows | + | `lookupColumns[]`, both arms | the referenced object — the picker lists its records | + | `lookupFilters[].field` | the referenced object — the picker's query runs on it | + | `dependsOn[]` name, or `{ field }` | the object that owns the field — the form gate reads this record | + | `dependsOn[]` `param` (or the bare name, on a picker) | the referenced object — the picker filters its candidates by that key | + | `indexes[].fields[]` | the object itself, including the columns the platform injects (`created_at`, `organization_id`, …) | + + The referenced-object positions are judged on `lookup`, `master_detail` and `user` fields (a + `user` field references `sys_user`), and only when the referenced object is in the stack being + checked. `lookupColumns`, `dependsOn` and index columns are read verbatim by their readers, so a + dotted name there is refused as a name that is not a field. The family's three skips hold + unchanged: an object outside the stack, an object with no readable field map (ADR-0015 + `external`), and a registry-injected column resolved per object. + + **What an author does.** Nothing is renamed or rewritten for you. Fix the name the finding + points at, or drop the entry. On a lookup whose `dependsOn` field is spelled differently on the + two records, write the entry with its `param` naming the referenced object's field. An existing + object carrying one of these misspellings is refused when it is next republished through the + publish door, and `os validate` reports it on the next run. + + Unchanged: the object schema's own parse still admits these names, so a draft save does not + judge them. An index column that resolves to a real but virtual field (a `formula`) passes this + rule; whether the column is materialized stays the SQL driver's question at sync. +- 86f4246: `approval-approvers-may-resolve-empty` now covers the `manager` rung, not just the group-routed ones. + + The rule exists for the empty-slate dead-end (#3424): an approver slate that resolves to nobody, with `lockRecord` turning that into a stranded record. It reasoned about `position` / `team` / `department` and said nothing about `{ type: 'manager' }` — which has the same failure shape and a strictly worse cause. A `position` rung resolves empty because the position is unstaffed, and an operator can staff it. A `manager` rung resolves empty because `sys_user.manager_id` is unset, and an operator **cannot** set it: the managed-update whitelist for `sys_user` is exactly `{name, image, locale}` (ADR-0092), the auth admin endpoints do not accept the column, and the Console renders no field for it. So the rule warned about the rung an author can rescue and stayed silent on the one they cannot — and `manager` is the canonical first rung of a tiered approval ladder, so the silent case was also the common one. + + - **What fires.** A node whose approver slate is made up ENTIRELY of `{ type: 'manager' }` rungs now draws one `approval-approvers-may-resolve-empty` finding, at the same `info` tier as its `position` sibling. `manager` resolves through `sys_user.manager_id` of the record's owner and yields nobody when that column is unset; when nothing else is on the node, the request waits forever, and under the default `lockRecord` the record stays locked. + - **What it does not claim.** The message states in as many words that this is a static check which cannot read the column, and that it does not assert the slate IS empty — it reports that nothing else on the node can approve if it is. A lint rule must not claim a runtime fact it did not read. + - **The remedy it prescribes, with the routes graded rather than listed.** An exact diagnosis whose prescription cannot be carried out is worse than no prescription, so the hint separates what this platform provides from what it does not. A **seed, or any other system-context write**, populates the column here — both write guards gate on `isUserContextWrite` (`userId && !isSystem`), so a system-context write bypasses the managed-update whitelist by construction. **SCIM provisioning and directory sync** are named too, because a deployment running a real one may well populate the column through it — but named as a path the deployment itself supplies: this repo declares the SCIM Enterprise `manager` attribute without projecting it onto the column, and the admin bulk import does not write it either (`SYS_USER_IMPORT_UPDATE_FIELDS` is `{name, image, locale}` plus `phone_number` and `role`, and `manager_id` is listed there among the admin-surface-only columns). Editing the user in the Console is explicitly ruled out, since it cannot write the column at all. And the escape that depends on none of this stays on offer: add a fallback approver that cannot resolve empty, such as `{ type: 'org_membership_level', value: 'owner' }`. + - **When it stays quiet — and on which surface.** A stack whose own seed data wires `sys_user.manager_id` on any seeded row has shown the linter that it populates the column, and the advisory is suppressed. Seed rows are the only manager-chain evidence a stack can carry, so that is the whole of what this check reads on the question. ⚠️ That suppression is **CLI-side only**. The runtime publish gate hands rules a `RuntimeStackContext` whose collections are fixed — `objects`, `permissions`, `books`, `datasets`, `pages`, and no `data` — so a Studio publish of a manager-only flow carries no seeds to read and draws the advisory however the tenant's users are wired. That is a surface asymmetry, not a broken suppressor: an `info` finding never blocks a publish, it rides the 2xx `advisories`. Noted here so a reader who seeds correctly and still sees it fire on publish does not go looking for a bug in the rule. + + Existing verdicts are unchanged. The new arm is scoped to slates that are entirely `manager` rungs, which keeps it disjoint from the group-routed arm by construction — no node can draw both findings — and leaves every `position` verdict exactly as it was, mixed slates included: a `[position, manager]` node stays silent, as it is pinned to. + + This is a purely additive widening of a published package's public surface — the rule begins covering a case it was silent on — so it is graded `minor`, the floor that act carries regardless of the commit type. + + No severity moved. The finding is `info`, so it lands in the advisory channel on every consumer: `os lint` renders it as a suggestion and its exit code is unchanged (a suggestion does not fail a run even under `--strict`), and the runtime publish gate returns it on the 2xx `advisories` array rather than refusing the write. What changes is the report, not any verdict. +- 362dcc3: Refuse a dataset measure whose `aggregate` the field's declared type cannot carry, at authoring time + + A dataset measure pairs an `aggregate` with a `field`, and + `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec`) declares which of those pairs every + backend answers the same way. Nothing in the authoring path read that table, so `avg` over a + `datetime` field validated clean and shipped: one SQL family coerces the column's canonical UTC + text and returns a plausible number (the average *year*), another has no such function and fails at + query time — the answer decided by the deployment rather than by the document. The analytics service + refuses the pair when a query is built (`400 DATASET_INVALID`); this is the same verdict, from the + same table, at the door the author is standing in front of. + + New rule `measure-aggregate-field-type-refused`, gating (`error`), on `os validate` / `os build` / + `os lint`. It resolves the field's declared type on the object graph lint already indexes — including + a dotted `relationship.field` path, whose leaf type the compile leg cannot see — and refuses the + pair when `isAggregateCompatibleWithFieldType` says no. The message names the aggregate, the field, + its declared type and the accepted set, and the hint names the aggregates that type *does* accept, + both computed from the table rather than restated. It stays silent wherever the type cannot be + resolved (an object this stack does not define, a field path that resolves to nothing, an untyped + field, an aggregate outside the closed `AggregationFunction` vocabulary) rather than guessing. + + **BREAKING**: metadata that passed `os validate` / `os build` / `os lint` before can now fail. Every + pair this refuses is one the analytics service already refuses at query time, so nothing that + *worked* stops working — but a build that did not fail now does. + + Migration, per refused pair — FROM the aggregate the field's type cannot carry, TO one it accepts: + + - `avg` / `sum` over a `date` / `datetime` / `time` field → `min` / `max`, which return a real + instant of the field's own type, or `count` / `count_distinct`. A DURATION is not recoverable from + an aggregate over instants: store it as a number (a computed "days open" field) and aggregate that. + - `sum` over a `percent` field → `avg`. A rate does not add; the total routinely exceeds 100%. + - `min` / `max` over the string, option, reference, file, structured-JSON or `formula` classes → + `count` / `count_distinct` for "how many distinct values", or a SORT on the record list for "the + first / last record". String order is collation-dependent, so two backends answer two different + "smallest" values for one document. + - Any other refused pair → read the row for your aggregate in + `AGGREGATE_FIELD_TYPE_COMPATIBILITY`; the refusal message prints it. + + A `derived` measure whose `of` names a refused measure is fixed by fixing that measure, not the + `derived` one. A `date` / `datetime` / `text` field used as a DIMENSION — grouping, bucketing, + filtering — is untouched: this is about aggregation only. + + Clause-②: yes (narrowing) + + +- 31064ca: fix(lint): a field-typed rule reads the registry's own type for an injected column, so `created_at` / `updated_at` stop escaping the preset-comparand refusal (#16340) + + `@objectstack/lint`'s object graph recorded the registry-injected system columns by NAME only. A path resolving to one came back `{ kind: 'ok', injected: true }` with no `meta`, so every rule asking a SECOND question about the leaf — "is it temporal?" — had to treat it as unanswerable and stay silent. That silence landed on the two most-filtered columns in the platform. + + Measured on `origin/main` `d57611dfd3`, one dashboard widget over one object declaring `close_date: date` and authoring no `created_at`: + + | authored filter | before | after | + |:--|:--|:--| + | `close_date: 'last_30_days'` (authored `date`) | refused | refused | + | `created_at: { $gte: 'last_30_days' }` (ordering — arm 1) | refused | refused | + | `created_at: 'last_30_days'` | **silent** | refused | + | `created_at: { $eq: 'last_30_days' }` | **silent** | refused | + | `updated_at: { $in: ['last_30_days'] }` | **silent** | refused | + | `stage: 'this_quarter'` (a `select` column) | silent | silent | + + The engine already refused all three of those at query time (`INVALID_FILTER` / 400, the registry's field map in hand), so the gap was purely author-time: `objectstack lint` and the runtime publish gate passed a filter the runtime then refused with a 400 on first render — and an AI author's correction loop only sees what fails the build. + + ## What changed + + `GraphObject.injected` is now a `ReadonlyMap` rather than a `ReadonlySet`: each injected column carries the registry's own definition. Both halves are DERIVED from one plan — membership from `resolveInjectedSystemColumns`, the slice from `injectedSystemColumnDefs` (`@objectstack/spec/data`, the same tables `applySystemFields` spreads at registration) — so lint never hand-copies "`created_at` is a datetime" and cannot drift from the runtime that provisions it. `resolveFieldPath` populates `meta` for an injected leaf accordingly, and `filter-preset-comparand`'s field-type oracle lost its `verdict.injected` bail: the marker says WHO wrote the column, and the ruling turns on what the column IS. + + `id` is the one addressable column with no definition behind it — the DRIVER provisions the primary key — so its slice is empty and a second question about it is still unanswered, truthfully and only there. The `select`-column reading arm 2 exists to protect is untouched: no injected column is a picklist. + + **Behaviour change for authors**: a stack that filtered an injected `date` / `datetime` column against one of the thirteen dashboard date-range preset names in an equality or membership position now fails `objectstack lint` and the runtime publish gate where it previously passed. Every such filter was already refused by the engine at query time; the error simply moves to where the filter is written. Write the `{date-macro}` window the message names, or an ISO date. + + **Type change for direct consumers of the seam**: `GraphObject.injected` changed from `ReadonlySet` to `ReadonlyMap`. `.has(name)` answers exactly as before; code that iterated the set or spread it into one needs `.keys()`. Shipped as `minor` under the repo's launch-window convention. + + ## Two more rules inherit it, in the same edit + + The type reaches every rule that asks a second question about a resolved leaf, which is the whole reason it was fixed at the seam rather than inside `filter-preset-comparand`: + + - **`list-view-field-dotted`** now refuses a dotted list-view filter key whose head is an injected column, on the same axis as an authored one. `created_at.x` reads as the `datetime` scalar it is (nothing beneath it for a path to reach) and `owner_id.name` as the `lookup` it is (it stores an id, not an embedded document). `assertFilterIsMaterializable` and the REST ingress have always answered `400 INVALID_FIELD` for both — the linter was silent only because the type was missing here. + - **`dataset-include-unknown`** now judges an `include[]` entry naming an injected column instead of bailing on the marker: `include: ['owner_id']` joins (it is the registry's `lookup`), `include: ['created_at']` is refused (a `datetime` derives no join, so every dimension written against that prefix addresses nothing). + + `id` falls through the untyped branch of all three rules — the DRIVER provisions the primary key and no definition table describes it, so an unreadable head is what the door sees too, and none of them invents a refusal there. + + A relationship HOP through an injected column stays a skip (`unknowable` / `injected-hop`), deliberately: the slice now carries `reference`, and traversing it would newly judge every path through a platform anchor wherever `sys_user` is compiled into the stack — a widening with its own findings to measure. +- f89dd33: `object-reference-unknown` now judges a field's `reference` — the target of `Field.lookup()` / `Field.masterDetail()` / `Field.user()` — with the same four-rung ladder it applies to every other object-name site, and `os build`'s per-package run resolves those names across the artifact's `packages[]` + + `FieldSchema.reference` is `z.string()`: the schema holds it present and non-empty on `lookup` / `master_detail`, and nothing anywhere asked whether the name resolved. So `os validate`, `os lint` and `os build` all exited 0 — no diagnostic of any severity — on `Field.lookup('zzz_object_that_does_not_exist')` (measured on 17.3.0), and the miss surfaced only at runtime: the record picker asking the REST layer for an object that is not registered (404 `OBJECT_NOT_FOUND`), `$expand` failing on the field, the form rendering a control that can never resolve a value. + + The site joins `validateObjectReferences` and rides its existing ladder, so the three commands judge it identically: + + 1. resolves in the stack's own objects, or in the objects an entry of this artifact's `packages[]` provides → ok; + 2. resolves in `PLATFORM_PROVIDED_OBJECT_NAMES` (`sys_user`, the target `Field.user()` writes) → ok; + 3. unresolved and not platform-prefixed → **`error`** — `os validate` / `os build` / `os lint` exit 1; + 4. unresolved, platform-prefixed, registered by nothing (`sys_approval_process`) → the existing `object-reference-unregistered-platform` advisory. + + Judged: `lookup`, `master_detail`, `user`. Not judged, on purpose: `tree` (the object schema already refuses any target but the own name), a `reference` on a non-relationship type (inert), and `objectExtensions[].fields` (an extension targets an object another package owns, routinely one this artifact does not carry). + + ## Migration + + **A build that used to pass can now fail.** Rung 3 is a new `error`-level refusal on a published accept set. Point the field at one of the stack's own objects, at an object another package of the same artifact ships, or at a platform object by its full name (`sys_user`, not `user`); the finding names the objects that resolve and suggests the nearest one. + + **A reference into a sibling package of the same release artifact resolves — it needs no annotation.** ADR-0130 makes the release artifact the co-ownership boundary, so `os build`'s per-package leg now hands each package's stack the artifact's `packages[]` as resolution context (`compile.ts`). A module's `crm_order.account` → its App package's `crm_account` is an ordinary rung-1 resolution on all three commands. This changes what a rule can resolve, never what it judges: the collections judged per package are still that package's own, and a name no entry of `packages[]` provides still errors on the per-package run exactly as it does on the union one. + + **A reference into another RELEASE ARTIFACT still has no rung** — an app naming an object a separate product ships (HotCLM's `clm_contract.crm_contract` → HotCRM). It is unresolved and unprefixed, so rung 3 refuses it. The declared escape for that case resolves against declared manifest dependencies and is its own change; ⛔ it is deliberately not an authored per-field marker, which would be a one-line switch that silences the gate. +- 5b5bd36: fix(objectql)!: the create-side static-`readonly` strip judges the user-writable `managedBy` buckets, as update already did (#15719) + + + + **BREAKING** for a non-system caller that CREATES a static `readonly` column on an + object declaring `managedBy: 'platform'`, `'config'` or `'system-data'` under a name + outside the reserved `sys_` namespace: the forged value used to be persisted and is + now stripped, with the field's own `defaultValue` re-derived (#3043) and the drop + reported on the usual channels (`readonlyStripWarning` at `warn`, `onFieldsDropped` + under reason `readonly`, `strictReadonlyWrites` refusing before any driver dispatch). + That is exactly what the same caller's UPDATE of the same column already did. Shipped + as `minor` under the repo's launch-window convention. + + ## The census, both halves — neither one is the whole reading + + **(b) is greater than zero, so the affected objects are named.** 20 shipped objects sit + in the three now-judged buckets and carry a static `readonly` column between them — 64 + columns in all: + + - `platform` (6 objects, 14 columns): `sys_attachment`, `sys_business_unit`, + `sys_business_unit_member`, `sys_comment`, `sys_report_schedule`, `sys_saved_report` + - `config` (6 objects, 29 columns): `sys_capability`, `sys_email_template`, + `sys_permission_set`, `sys_position`, `sys_sharing_rule`, `sys_webhook` + - `system-data` (8 objects, 21 columns): `sys_approval_delegation`, + `sys_notification_preference`, `sys_notification_subscription`, + `sys_notification_template`, `sys_position_permission_set`, + `sys_user_permission_set`, `sys_user_position`, `sys_user_preference` + + **And the shipped behaviour delta is ZERO.** Of the 81 object declarations in this tree + carrying `managedBy`, **none** is named outside `sys_` — every one of the 20 above + included — so the namespace test, which this change does not touch, keeps all of them + exempt exactly as before. `sys_metadata_history.recorded_by`, seeded by a direct + non-system `engine.insert` from the metadata repository, is doubly exempt + (`engine-owned` bucket **and** `sys_`) and is pinned as such. + + ⚠️ **Read both halves together.** "Behaviour-free" on its own overstates it — the + population the narrowing reaches is real and named above, and an app that declares one + of those buckets on its own object gets the strip. The population on its own + understates it — not one shipped object changes behaviour on this release. What moves + is the contract for **app-authored** objects, which is the population the ruling is + about. + + ## What was wrong + + `staticReadonlyInsertSubject` returned `null` for `managedBy` set to **anything**, + carried over byte-for-byte from the deleted DataProtocol ingress copy on ADR-0086 / + #3004 grounds: those columns have their own 403 guards, and a silent strip must not + swallow the payload the guard exists to reject. The argument is sound and the bucket + list was not. `managedBy: 'system-data'` means "platform-defined schema, + **admin/user-writable data**" by its own definition, and `object.zod.ts` says in the + same breath that it "carries no such guard; its writes are adjudicated by the + delegated-admin gate / RLS / permission sets". So the create side skipped the strip on + objects whose data is the user's, while the update side stripped them — and #14147's + "one semantics, one enforcement point" was not literally true on that population. + + ## What it does now + + The exclusion follows its reason. `null` is returned for the `sys_` namespace, and for + the three buckets whose columns really do carry a fail-closed refusal: + + | bucket | its own refusal | the create-side strip | + |:--|:--|:--| + | `engine-owned` | ADR-0103 engine-owned write guard | steps around it | + | `append-only` | ADR-0103, same guard (locked default) | steps around it | + | `better-auth` | ADR-0092 identity write guard | steps around it | + | `platform` | none — full user CRUD by default | judges it | + | `config` | none — admin-authored, writable by default | judges it | + | `system-data` | none — "admin/user-writable DATA" | judges it | + + An **unrecognised** bucket value is deliberately not read as platform-internal: the one + legacy value that can still arrive is `'system'`, retired in protocol 17 (#3355) and + converted to `'system-data'` — a judging bucket — so exempting unknowns would exempt + precisely the rows that conversion targets. The partition is pinned against + `@objectstack/spec`'s own enum, so a seventh bucket fails a test instead of landing + silently on one side. + + The ruling's fallback ("leave it, if those buckets' readonly columns already carry + their own 403") does not apply: of the 64 columns above, 14 are the ADR-0086 + package-provenance family (`package_id`, `managed_by`, `customized`, `drift_status`, + `drift_detail`, `is_system`, all on `config` objects) and the other 50 are `id` / + `created_at` / `updated_at` stamps, which that guard does not reach. + + `@objectstack/lint` mirrors this predicate to decide which objects its create-verb + `flow-update-readonly-field` / `hook-api-update-readonly-field` findings may describe, + and is narrowed in the same stroke — a lint that kept the wider exemption would go on + suppressing findings for a strip that now really happens. + + ⛔ The UPDATE path is untouched, and so is `beforeInsert`'s post-hook strip position. + The asymmetry is closed by moving CREATE toward UPDATE. +- d4f5232: **BREAKING** — retire the `type: 'page'` list-view mount and its `pageName` binding. + + A list view could declare `type: 'page'` and name a published page in `pageName`, + and the view was to render nothing of its own and delegate to the page renderer. + Only the spec half of that was ever built. **No renderer ever routed the member**: + objectui's list-view switch shares its `default:` arm with `case 'grid'`, so a page + view has always drawn an empty table where the page was supposed to be, and the + three parse refusals that policed the binding policed a mount that never mounted + anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `{ type: 'page', pageName: 'sales_home', columns: [] }` on a list view | nothing on the view. Delete it, and reach the page from the app's `navigation`: `{ id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }` | + | `pageName` beside any other list-view `type` | delete the key — it was refused already, and is now a tombstone | + | a list view that wanted rows | pick a row-drawing `type` — `grid` and its siblings, all unchanged | + + **The one-line fix:** delete `type: 'page'` and `pageName` from the list view; put + the page behind an app navigation item, which is a different key on a different + surface (`PageNavItem.pageName`) and is the page mount that has always rendered. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply + them by hand. + + ## The retirement kit + + - **`pageName`** — a `retiredKey()` tombstone on `ListViewSchema` and + `ObjectListViewSchema`. `tsc` types the key `never`, and a value reaching a parse + raises the prescription rather than a bare unrecognized-key report. + - **`'page'`** — an enum VALUE, so there is no tombstone to hang a prescription on + (the def survives, one value lighter, and the four generated-surface ratchets are + blind to that by construction). The `type` enum's own `error` map carries it, + keyed on `issue.input` so only the value that used to be legal gets the + "was removed" message; every other invalid `type` keeps zod's default text. + - **`checkListViewPageMount`** — the exported object-level refinement existed only + to police this mount, so it is removed with it, along with its three refusal + messages. A downstream mirror that re-attached it (the reason it was exported) + should drop the `.superRefine` line; the compiler delivers this one. It held no + `ERROR_CODE_LEDGER` row — the three refusals were message constants, not codes. + - **`validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`** (`@objectstack/lint`) — the + `os validate` and publish-gate rule that resolved a mount against `stack.pages`. + Removed: there is no reference left to resolve. Its nav twin + (`validateNavTargetRefs`, on the app navigation item) is **untouched**. + - **`RuntimeStackContext.pages`** (`@objectstack/lint`) and the `page` row of + `CLOSURE_CONTEXT_KEY_BY_TYPE` (`@objectstack/metadata-protocol`) — the live page + universe joined the per-write snapshot for that one rule, and leaves with it. A + `PUT /api/v1/meta/view` publish no longer pays a `sys_metadata` round trip for a + collection nothing consults. Hosts calling `runRuntimeAuthoringRules` / + `evaluateRuntimeAuthoringGate` with an explicit `context.pages` drop that key. + - **`defineStack`** — the `validateCrossReferences` branch that resolved a mount's + `pageName` against `stack.pages` is gone. The surviving three page references in + that function (an app nav item's `pageName`, a modal action's `target` at two + rungs) keep their own policy. + - **The metadata form** — `view.form.ts`'s `page` section, whose one input was + `pageName`, is removed. A form input for an unwritable key is the false-compliant + UI half of a retirement. + + ## What an operator with a STORED page view sees + + A `sys_metadata` `view` row written before this release can carry `type: 'page'` and + a `pageName`. Nothing breaks at read: the ADR-0087 conversion + `view-page-mount-removed` (protocol 18) replays on rehydration and strips both keys, + so the row is served canonical. `type` is **stripped, not rewritten** — it defaults + to `grid` in the schema, so the row lands on exactly what it already rendered + without the platform guessing a view type. + + The strip is announced once per row per process, on whichever seam served it. + Grep for `carries a pre-protocol shape` — there are **three** emitters, one per + rehydration seam, and they differ: + + - `[DatabaseLoader] stored view/ carries a pre-protocol shape; ` + - `[ObjectQLPlugin] stored view/ carries a pre-protocol shape; ` + - `[Protocol] stored view/ carries a pre-protocol shape; The row + itself is unchanged — re-save it (Studio edit -> save, or run + "os migrate meta --stored --apply") to persist the canonical shape.` + + `os migrate meta --from 17` lists the same edits for authored sources; + `os migrate meta --stored --apply` rewrites the stored rows so the warn stops, and + the next save through `PUT /api/v1/meta/view` heals one row the way it heals any + pre-protocol shape. + + ⚠️ The conversion walks `stack.views[]` in all three persisted spellings; it does + **not** reach `objects[].listViews.*`, which no conversion in the registry reaches. + An object body still carrying a page mount is refused at its own door with the + prescription rather than converted. Measured population for both at the ruling: + **zero** authored `type: 'page'` list views in this repository or any consuming app + the seats can read — the in-tree `type: 'page'` hits are all app nav items. + + +- e4fd55d: Two new gating rules — `rls-predicate-unknown-field` and `rls-predicate-unknown-user-variable`: an RLS predicate that lowers correctly but names a column the object does not declare, or a `current_user.*` value nothing pre-resolves, is now an authoring-time `error`. + + The three shipped `rls-predicate-*` rules judge a predicate's **shape** — does it parse, does it lower, does it fit the platform's CEL bounds. Nothing judged what it **points at**. Measured as four injections at one site, in one run: `billing_address.country == "US"` reported `rls-predicate-unenforceable` and `is_private == = false` reported `rls-predicate-unparseable`, while `is_private_nope == false || owner_id == current_user.id` and `is_private == false || owner_id == current_user.nope` reported **nothing at all** — from the same site the linter had just reported twice. + + Both silent shapes are expensive rather than cosmetic, and they do **not** fail in the same direction — which is the part the card's own measurement did not reach. + + An unresolved `current_user.*` is refused by the pushdown compiler in **every** position, including under `!` and in a trailing `||` arm, so that half always fails **closed**: `RLSCompiler` drops the policy, the layer falls back to the `RLS_DENY_FILTER` sentinel, and the object disappears for every holder of the permission set — not because they were denied but because the narrowing they were granted resolves to nothing. + + An unknown **field** takes its direction from **position**, and one of the two is fail-**open**. `SecurityPlugin`'s field-existence safety net recognises only a *leading* `field ==` / `=` / `in` (`extractTargetField` is that shape match), so a miss there drops the policy and arms the deny sentinel — zero rows. A miss the net does not recognise — a negation (`nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])`) or any arm after the first — leaves the policy **kept**, and the phantom column lowers to a negated constraint that a row without that column *satisfies* (`noValueSatisfiesNegation`: `$ne` / `$nin` / `$notContains`). The authored narrowing is then **defeated rather than enforced**: measured at 3 of 3 rows, against 1 of 3 for the real narrowing and 0 of 3 for the same phantom column in a positive position, on the read path and on the write path's `matchesFilterCondition` alike. + + ⛔ That is **not** a cross-tenant leak — tenancy is a separate layer and it holds; what is defeated is the narrowing authored inside the wall. Measured on driver-memory; driver-mongodb follows the same shared ruling; **driver-sql is NOT MEASURED** and is expected to fail closed by raising `no such column`. The runtime repair is tracked separately as #17042 and is deliberately not attempted here — these rules report the miss, in both directions, and the diagnostic says which direction applies so an author is not told "this denies everything" about a predicate that in fact matches everything. + + - **Two rules beside the three, not a widening of them.** The existing ids say *unenforceable* / *unparseable* / *over-budget* and are correct inside that scope; they are untouched, and the two controls above still report under them and under neither new id. The prescriptions differ (rewrite the predicate / fix the column name / pre-resolve the variable), and an author who suppresses one must not thereby suppress the other. The guards are disjoint by construction: the reference pass runs only where `isSupportedRlsExpression` has already said yes. + - **Where the existence answer comes from.** Field paths are read off the pushdown compiler's **own output** — the lowered `FilterCondition`'s keys are the columns the driver will be handed — and resolved through `object-graph.ts`, the shared index every field-existence rule in this package already uses. No new input path, no second parse of the predicate. The rule therefore inherits that module's three skips, each the difference between a finding and a false one: an object this stack does not define, an object with no readable field map (an ADR-0015 `external` object, an introspected datasource), and registry-injected system columns such as `created_at`, which are real at runtime and appear in no authored `fields`. + - **The `current_user` set is derived, not transcribed.** It is `RESERVED_RLS_MEMBERSHIP_KEYS` from `@objectstack/spec/contracts` — the keys an `IRlsMembershipResolver` may never supply *because the kernel already owns them*. A key added there stops being reported the same day, with no edit in this package. + - **§7.3.1 membership keys are left alone, and that boundary is the reason this rule can exist.** An app stages arbitrary sets into `ExecutionContext.rlsMembership` and references them as `field in current_user.`; the spec documents the pattern and `rls-predicate-unparseable`'s own hint recommends it. In an `in` position an unknown key is indistinguishable from a correct one and is never reported. It is decidable in the other positions only because the merge is array-only — the sole value an app-staged key can ever hold is an array, which a scalar position cannot use on any request — so `owner_id == current_user.nope` is refused while `assigned_to_id in current_user.team_member_ids` stays silent. A key used in both positions takes the membership answer. + + **What moves for consumers.** A stack whose RLS predicate names a renamed column or an un-pre-resolved context value built clean before and now fails `os validate` / `os lint` / `os compile`. That is the point — the policy had already stopped doing what it was written to do, denying the whole object in one position and granting every row in the other. + + A stack whose predicates all resolve is byte-identically clean. The reading is the shipped showcase: 3 RLS clauses, all 3 judgeable against declared objects, **zero** findings — with three firing controls at the real site (an injected dangling column, an injected unknown variable, and an injected fail-open negation shape each produce exactly one finding) and two nonsense controls (an injected membership test against an unknown key, and a real-field/real-variable predicate, stay silent). `plugin-security`'s seed sets and hotcrm's built-permissions fixture also emit zero, but ⛔ **those two are not readings**: every policy target in the seeds is an object that package does not declare, and the hotcrm fixture carries no `objects` key at all, so all 71 and all 4 clauses respectively are skipped by construction. Declaring one of their objects makes the fixture report 2 — which is what a control is for. +- ba17017: `os lint` now refuses a `min`/`max` roll-up whose answer cannot be stored in the column it rolls up into — `rollup/non-numeric-aggregand`, at `error`. + + `FieldSchema.summaryOperations` admits `min`/`max` over ANY child field, and the engine's `aggregateSummaryValue` returns the driver's answer verbatim (only an empty-set fallback stands between the backend and the stored value). A `summary` field is a member of the spec's `NUMERIC_VALUE_TYPES`, so `valueSchemaFor` answers `z.number().finite()` for it and `driver-sql`'s `createColumn` emits a float column. An ordinary "latest shipment" roll-up — `max` over a `datetime` child field — therefore computes an instant into a column the value contract says holds a finite number, and nothing between author and driver correlated the two. It is refused at authoring time rather than tolerated in a consumer (Prime Directive #12). + + - **The accept set** is the numeric class union the boolean class, read from `NUMERIC_VALUE_TYPES` and `BOOLEAN_VALUE_TYPES` rather than typed out. The first is the set that DEFINES the criterion — it is the membership `valueSchemaFor` consults to answer `z.number().finite()`, so a type joining it moves the value contract and this door together. The second is admitted on the authority of the `min(flag)=0` / `max(flag)=1` ruling pinned by the spec's own `AGGREGATION_CASES` (#11152): the answer is a number, so it fits. + - **It is NOT `isAggregateCompatibleWithFieldType`.** That table deliberately accepts `min`/`max` over the temporal class, because there the answer is returned to a caller and "return[s] a value of the field's OWN type" (#15768). Reusing it here would accept the very declaration this rule exists to refuse. The two questions look alike and are not — "can every backend give one answer" versus "does that answer fit the column this roll-up is stored into" — so this predicate is that table's `min`/`max` row narrowed by exactly the temporal class, and a test pins the disagreement. + - **Scope.** `min`/`max` only. `count` reads no value off the field; `sum`/`avg` over a non-numeric child is a different shape, whose accept set the aggregate table's own rows already exclude, and is not widened into here. + - **Silent where it cannot resolve.** An unknown child object, a field the child does not declare, or a field with no declared type produce no finding — the aggregate table's own consumer tier ("a consumer that cannot resolve a field's type must NOT call the predicate with a guess"). A partially-loaded model cannot draw a false refusal. + + No export moves: the rule id is an inline literal inside the already-exported `lintDataModel`, beside `rollup/missing-summary`. Measured across this repository, no declaration trips the new refusal — all three `min`/`max` roll-ups aggregate a `number` child field — so this adds a door rather than migrating anything. +- ecdfc94: fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization behind a tenancy wall, and both its query and its run are confined to it (#16659, narrowed by #17396) + + + + > ⚠️ **Read this banner with #17396's ruling applied — it NARROWS everything below, and the narrowing shipped in the same launch window, so no released version ever saw the wider rule.** Two deployment facts now sit in front of every statement here, and neither is metadata: (1) package-authored scheduled work is gated by `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` and is **OFF by default in every tenancy posture and every kernel** — while it is off NOTHING below happens, because nothing arms; (2) with it on, the declaration requirement below applies under a **walled** posture (`group` / `isolated`) only. Under `single` an armed time-triggered flow declares nothing, carries no organization, and resolves the deployment's one organization beneath it exactly as it did before #16659. ⇒ Wherever this banner says "a time-triggered flow MUST declare", read "under a wall, with scheduled work switched on". The lint finding it announces, `flow-schedule-organization-missing`, is **deleted**: lint can see neither fact. + + **Registered as an ADR-0087 semantic migration** + (`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable + is renamed, retired or re-typed — no `packages/spec` key changes its name, its + type or its optionality, no stored shape moves, and every flow, node and + start-node `config` that parses today parses byte-identically afterwards, + because the start node's `config` is an OPEN record (ADR-0018) and the new + `organization` key is an addition to a slot that already accepted anything. So + `objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a + value only the deployment holds, a `sys_organization.id` minted at runtime, with + no authored artifact and no stored representation a rewrite could act on — and + inventing one is precisely what the ruling forbids. ⚠️ That is the argument + against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a + migration that cannot be expressed declaratively gets a structured TODO + (surface, reason, acceptance criteria) rather than nothing, and what follows IS + a prescription in that sense — declare `config.organization` once per + organization, no fan-out, then act on the three consequences of the split named + below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — + behaviour-only, no shape moved, a deployment judgement no transform can make, + registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before + this narrowing landed, so the enforcement rides the 17.x line by the + launch-window convention while the prescription belongs at the major boundary + where `migrate meta` users look. + + **BREAKING** in the accept-set sense, and in TWO places rather than one — + landing in the launch window as `minor` on all four packages (the lockstep + convention: during the window the bump level is not the carrier, this banner and + the disposition above are). Nothing that was refused becomes admitted. ⚠️ #17396 + changes that last sentence in one direction: under `single` with the switch on, + a flow that this changeset would have left unarmed **binds and runs**. That is a + widening, it lands in the same window, and it is why #17396's own changeset is + also a `minor`. + + 1. **Bind time.** A `schedule` or `time_relative` flow that declares no + `organization` is no longer armed. + 2. **Run time — the DATA PLANE.** A time-triggered run now carries a + `tenantId`, and a `time_relative` sweep now carries one on its own query. + Where a run previously read, updated and deleted across every organization, + it is now confined to the one it declares. + + ⚠️ **Read (2) as a narrowing that can stop something that was working**, because + it is one. Two shapes to plan for, and neither is hypothetical: + + - **A deployment running ONE time-triggered flow to cover ALL organizations must + now declare one flow per organization.** That is the ruling + (「不允许跨组织的定时任务」) and it is the whole point, but it is migration + work: there is no fan-out, and a sweep wanted in N organizations is N + declarations. Nothing detects the shape for you — the flow simply starts + seeing one organization's rows. + + ⚠️ **And the split has three effects the sentence above does not carry.** Each + is deployment work, and none of them is detected for you either: + + 1. **A NULL-organization row fans out N-fold.** The driver's scope is + `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no + tenant column value stays visible to a *scoped* read — this PR's own + negative control fixture selects exactly that row under scope, on purpose. + After the split every `organization_id IS NULL` row in a swept object is + therefore matched **once per flow**: N runs, N notifications, each acting + as a different organization. Before the split it was matched once. ⇒ Either + backfill the tenant column on swept objects or declare the object + platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the + scope rather than multiplying under it. + 2. **The current window's dispatch claims are abandoned.** The dedup key + embeds the FLOW NAME — `schedule::` and + `time-relative:::` — so N differently-named + flows claim under N different keys. A window already delivered under the + old name can deliver again, once, under each new one. ⇒ Cut over at a + window boundary, or accept one duplicate window. + 3. **A run suspended before the upgrade is not retroactively confined.** + Resume rebuilds the run's context from `context_json` + (`suspended-run-store.ts`), and a row written before this change carries no + `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills + it. Not a regression (that is how it already ran), but the banner would + otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight + suspended time-triggered runs, or accept that the tail of them is + unconfined. + - **On a SINGLE-organization install a time-triggered flow WAS delivering** — + the #8844 guard derives the only organization there — and after this change it + is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` + that install loses nothing at run time once the line is added: the scope is + `org = :tenant OR org IS NULL` and its one organization is the only scope there + was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss + has no legal configuration.** That driver refuses *any* call handed a tenant + scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) + — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / + `bulk*` / `aggregate`, one call at a time, regardless of how many + organizations the install holds. So a time-triggered flow that touches + per-organization data on that driver is refused per call if it declares an + organization and unarmed at boot if it does not. The declaration is not what + breaks it — the driver has no row-level tenant isolation to offer either way — + but this change is what moves such a flow from the "no organization context at + all → served" case into the refused one. Multi-organization deployments use + `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are + genuinely platform-global can declare them so (`tenancy: { enabled: false }`, + ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the + refusal on data that really is per-organization. + + A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. + + Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 + + A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. + + - **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. + - **`@objectstack/lint`** ⚠️ **nothing, after #17396.** This changeset originally added `flow-schedule-organization-missing` at `warning`; that id is deleted in the same window and was never published. The reason is the rule family's own criterion — *is this stack enough to know the flow is dead?* — answered honestly: it is not, because the deployment switch and the tenancy posture decide it and neither is in any stack. The near-miss diagnostic it shared with the triggers stays at BIND, where both facts are readable. + - **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. + - **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. + + **What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. + + ⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. + + **Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. + + No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. + + **What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: + + - **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. + - **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. + + **The four flows this repo itself ships** — ⚠️ this paragraph is superseded by #17396 and kept for the record of what was measured. Their answer is now the deployment switch, not an authoring repair: off, they are listed as *disabled by deployment policy*; on under `single`, they run as written; on under a wall, they still need a declaration no package can carry. The original measurement follows. + + **They stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. +- f04be62: feat(types,triggers,service-automation,runtime,cli,spec,lint)!: package-authored scheduled work is a deployment decision — `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, off by default everywhere (#17396) + + + + Maintainer ruling, 2026-09-12, verbatim, untranslated: + + > schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,有没有可能定义为一个环境变量,根据环境变量控制? + + > 如果多租户暂时只接禁用定时任务,完整的考虑一下影响面。 + + > group 默认也关,云端每库一租户全局默认关 + + **A new deployment variable, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, decides whether this deployment runs PACKAGE-AUTHORED scheduled work at all** — time-triggered flows (`type: 'schedule'` with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. It is read at boot beside `resolveTenancyPosture` and is ⛔ **not** a metadata concept and ⛔ **not** a new spec key: whether a clock-driven workload is affordable is a fact about the deployment — its database, its tenants, its budget — that no package author can know, and a metadata key would ask them to. + + **OFF by default, in every posture and in every kernel.** Unset means off; `true` / `1` / `on` / `yes` (case-insensitive) means on. ⛔ Deliberately not the opt-out `!== 'false'` shape `OS_MULTI_ORG_ENABLED` uses, which reads a typo as "on" — here that would arm exactly the workload an operator meant to refuse. + + ⛔ **Platform-internal scheduled work is NOT gated** and runs either way: approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill. The boundary is **authored by a package**, not "runs on the job service" — the platform's own maintenance is part of the runtime a deployment asked for. + + **BREAKING**, in two directions, and both land inside the same launch window as #16659 / PR #17334, so no published version ever saw the rule this narrows. + + 1. **A NARROWING, and it is the one to plan for.** A deployment that upgrades and does nothing runs **no** packaged time-triggered flow and **no** packaged `defineJob`. Anything that was firing from a package stops. ⇒ Set `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` if you depend on it. Nothing detects the shape for you at authoring time, by design — but nothing is silent either: every such flow is listed in `getTriggerBindingAudit()` and the `os dev` / `os start` startup summary with a DISTINCT reason, **disabled by deployment policy**, ⛔ never as "binding failed"; the packaged-job loop says so once per app at `info` with the count; and `os doctor` prints the effective value in both states. + 2. **A WIDENING of what binds.** With the switch on and tenancy posture `single`, a time-triggered flow that declares **no** `config.organization` now binds and runs — under #16659 alone it was refused. That posture holds exactly one organization (a second is refused), so the run carries **no** organization and every tenant-scoped insert beneath it resolves that one the way a single-organization install always did; a `timeRelative` sweep there runs **unscoped**. ⛔ Nothing is invented: the key is OMITTED, never filled from the install, the platform organization, or the swept record's own `organization_id`. + + **Under a walled posture (`group` / `isolated`) the 2026-09-08 ruling on #16659 stands unchanged**: a time-triggered flow declares `config.organization` or it is not armed, there is no fan-out, and no organization is ever chosen for it. `group` is walled here for a measured reason rather than by analogy — `resolveSystemWriteOrganization` refuses an organization-less system insert under any wall and `TenancyService.defaultOrgId()` answers `null` (ADR-0093 D3), so an organization-less group-wide sweep could read the whole group while every row it inserts is refused. Which organization such a sweep's inserts belong to is not yet decided; until it is, `group` behaves as walled. + + **`flow-schedule-organization-missing` is DELETED** from `@objectstack/lint` (the id and its exported constant, `FLOW_SCHEDULE_ORGANIZATION_MISSING`; both are unreleased — they were introduced by the still-unconsumed #16659 changeset in this same window, so no consumer can be holding either). The rule family's criterion is *is this stack enough to know the flow is dead?*, and the honest answer here is no: the deployment switch and the tenancy posture decide it, and neither is in any stack. A finding that is false for the default deployment is noise. ⛔ The near-miss diagnostic did **not** go with it — `describeMissingScheduleOrganization` and its `organizationId` / `tenantId` / … scan still fire at BIND, the one door that can read both facts, and only where the key is actually required. + + **ADR-0087 semantic entry 18 (`schedule-flow-acting-organization-required`) is REWRITTEN, not added.** Its acceptance criteria required every time-triggered flow to declare; that is no longer the rule. It now prescribes the two decisions in order — decide the switch, then declare per organization under a wall — and records that `os lint` reporting nothing is the criterion being met rather than a check that was skipped. The unconsumed `.changeset/schedule-trigger-acting-organization.md` carries a banner saying the same, so a reader of either one cannot get the narrower half alone. + + **Where the switch is read, and where it is not.** Both triggers gate at `start()`, ahead of the descriptor and the declaration, so an operator on a deployment that was never going to run a flow is not sent to fix a descriptor nothing would have read. `AutomationEngine.activateFlowTrigger` reads the same resolver and does not call `start()` at all when it is off — that is what keeps the audit's reason precise, since a refusal arriving as a THROW can only be reported through the catch that says "Failed to bind". Neither read is cached: the resolver reads `process.env` live, so a host that rebinds after the environment changes sees the value current at the bind. The scope is `schedule` and `time_relative` only — `record_change` and `api` are fired by a caller that already exists and already carries an identity, and a kind added to `FlowTriggerKind` later is OUTSIDE the switch until someone decides otherwise, because a new capability that disappears on arrival is the worse default. +- 131851f: New gating rule `security-fls-unknown-field`: an object-qualified field-permission key naming a field the object does not declare is now an authoring-time `error`. + + `security-fls-unqualified-key` has always caught the *bare* spelling — `fields: { budget: … }` — because the runtime evaluator matches FLS keys by their `.` prefix and a bare key matches nothing. The qualified-but-dangling spelling (`fields: { 'crm_account.description_nope': { readable: false } }`) has the identical runtime consequence and was reported by nothing: `PermissionEvaluator.getFieldPermissions` strips the prefix and looks the remainder up as a column, so a remainder no column answers to contributes nothing to the merged permission map. The masking the author declared **never enforces**, and the field stays as readable and as editable as the object-level grant leaves it — for every holder of the set. + + The failure direction is **fail open**, and this spelling is the one that accumulates: unlike a bare key it looks correct in review, survives rename refactors invisibly, and is exactly what a field rename leaves behind. + + - **A second rule, not a widening of the first.** `security-fls-unqualified-key` is correct inside its declared scope and is untouched; the two defects have different prescriptions (add the object prefix / fix the field name) and suppressing one must not suppress the other. Two ids, two messages. + - **Where the existence answer comes from.** The rule resolves through `object-graph.ts`, the shared index every field-existence rule in this package already uses — no new input path. It therefore inherits that module's three skips, each of which is the difference between a finding and a false one: an object this stack does not define (it may be another installed package's), an object with no readable field map (an ADR-0015 `external` object, an introspected datasource), and registry-injected system columns such as `created_at` or `owner_id`, which are real at runtime and appear in no authored `fields`. + - **A truncated key is the same defect and is reported by the same rule.** `fields: { 'crm_account.': … }` passes the runtime's prefix test and resolves to the empty column name, so it matches nothing exactly as a dangling name does. `PermissionSetSchema.fields` is `z.record(z.string(), FieldPermissionSchema)` — a bare string key with no pattern and no refinement — and this rule is the only reader of those keys, so before this change nothing reported it at all. A key naming an object this stack does not declare still falls to skip 1, truncated or not. + - **It mirrors the evaluator, including on a multi-dot key.** Only the first dot separates object from field, because `ObjectSchema.name` is `/^[a-z_][a-z0-9_]*$/` and cannot contain one. `'crm_account.owner.name'` therefore asks for a column literally named `owner.name` and is reported: FLS keys address columns, never joins, and resolving that as a relationship hop would have been a fail-open divergence from the gate the rule mirrors. + + **What moves for consumers.** A stack carrying a dangling FLS key built clean before and now fails `os validate` / `os compile`, and is refused at the runtime publish door for `permission` and `object` writes (this rule joins the existing `validateSecurityPosture` registration; no new registry entry). That is the point — the key was never enforcing anything. A stack whose FLS keys all resolve is byte-identically clean: measured on the shipped showcase, whose six authored keys emit zero findings, with a firing control (one injected dangling key produces exactly one finding) beside the zero. +- 5505646: fix(formula): the strict declaredness env declares `SCOPE_ROOTS` as `dyn`, so a bare reference behind a root name is no longer masked (#16412) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on published + CHECKERS, in the same sense as a route that starts refusing a request it should + always have refused — landing in the launch window as `minor` on both packages (during the window the bump level is + not the carrier of breaking-ness; this paragraph and the disposition above + are). Nothing that was already reported stops being reported, and no source + that is correct starts being reported. + + `firstUndeclaredReference` asks cel-js's checker for the first undeclared + identifier in a source. That checker returns exactly ONE error, and the helper + acts only on `Unknown variable: X`, so whenever the first error is of another + class every undeclared reference behind it in the same source went unjudged and + the helper answered `null` — which is also the value that means "every + reference is rooted". Four published call sites read that answer, and none of + them can tell the two readings apart. + + The widest way to reach that state was a disagreement between two environments + in this package about the same names. The strict env declared every + `SCOPE_ROOTS` member (`data`, `config`, `record`, `result`, `item`, `event`, + `input`, `user`, …) as `map`, while the permissive env that `celEngine.compile` + type-checks in leaves them `dyn`. `map` has no `==`, `<` or `+` overload, so an + ordinary comparison on one of those names compiled clean and then faulted `no + such overload` in the strict env only — taking the single error slot and + silencing everything behind it. An author reaches it by naming an object field + or a flow variable after a namespace root and reading it bare, which on a + metadata-editing form is not even a coincidence: that layer binds the row under + edit as `data`. + + The strict env now declares those roots `dyn`, which is what the list's own + doc-comment already claimed it was for — member access, arithmetic and + comparison on a root all deferring to runtime — and which `map` delivered only + the first of. The two environments agree about these names, so the class cannot + arise rather than being compensated for downstream. + + What starts reporting, measured on each published surface: + + - `@objectstack/formula` `validateExpression` with `scope: 'record'` — a bare + reference behind a root name is the hard error it always was for the same + identifier written first (`ok` was `true` with zero errors; it is now `false`). + - `@objectstack/formula` `validateExpression` with `scope: 'flattened'` — the + did-you-mean warning reaches a misspelled field behind a root name. + - `@objectstack/lint` `visibility-bare-identifier` — a bare identifier behind a + root name in a `visibleWhen` predicate is a finding. Per that rule's own + message the console otherwise falls open and the element renders + unconditionally. + - `@objectstack/lint` flow-variable shadowing — a shadowed field read behind a + root name is warned. That rule's documented blind spot is now name-local, as + its wording always claimed: the colliding name itself is still not reported. + + ⚠️ One published answer also WIDENS, and it is not a reporting surface. + `inferExpressionType` (`@objectstack/formula`, re-exported from the package + root; read by `@objectstack/mcp` as `validate_expression.inferredType`) infers a + formula's coarse value type through `inferCelType`, which shares this same + strict environment. While the roots were `map` there was no `==`, `<` or `+` + overload for them, so an expression using a namespace root as a DIRECT OPERAND + did not type-check at all and the answer was `'unknown'`. With the roots `dyn` + those expressions type-check and the answer is the truthful CEL type: + `result + 1` and `record ? 1 : 2` → `'number'`, `record == "x"` → `'boolean'`, + `data == "x" ? "a" : "b"` → `'text'`, uniformly for every name on the list. No + answer changes from one concrete type to another and nothing narrows to + `'unknown'` — `size(record)` and `"a" in record` still answer, and a root that + is only the base of a member access (`record.amount > 100`) never consulted this + declaration. A consumer that keys off a concrete type therefore sees strictly + more expressions classified, never a different classification; for the + motivating consumer that means a formula written as `data == "x" ? "a" : "b"` is + now correctly seen as text rather than as unprovable. Pinned on both sides in + `validate.test.ts`. + + ⛔ Two first-error classes are NOT closed by this, and both stay pinned. A CEL + TYPE name (`type`, `string`, `int`, …) is declared by CEL itself, so no + declaration this package makes can reach it; measured on the strict env, the + message for `type == 'grid'` is byte-identical under a `map` and a `dyn` root + declaration. And `has()` handed a non-select argument still faults its own + class, which `@objectstack/lint`'s visibility rule masks at its own call site + (#16118) and which nothing else masks. + + The narrowing this helper is built on is unchanged: it still acts only on + `Unknown variable`, so `type(record.x) == string`, comprehension macros, guard + idioms, optional chaining and stdlib calls report nothing, and a widening of + that regex onto the overload message remains refused. +- 4ecfd2b: fix(lint)!: `objectstack validate` refuses a blank structural `condition`, the rule `registerFlow` has carried since #17322 (#17495) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition): + `validateStackExpressions` — the pass behind `objectstack validate` — now + reports an `error` for a structural `condition` whose source is blank after + trimming. It reported nothing at all before. + + The value was already refused by two of the three doors. `FlowEdgeSchema.condition` + composes `EvaluatedExpressionInputSchema` (#15807), so `' '` on an edge is + refused at `FlowSchema.parse`; #17322 rebound `AutomationEngine.registerFlow` to + that same rule, so the same value on a node's `config.condition` stops the flow + registering. `objectstack validate` was the door that still said nothing — so an + author got a clean bill, deployed, and the flow never registered: each boot path + in `service-automation`'s plugin wraps `registerFlow` in `try`/`catch`, logs one + `warn` naming the flow, and continues. On a `start` node that key is the + **trigger gate**, so the whole flow is armed by nothing. + + FROM → TO, for a build that used to pass and now fails: + + ```yaml + # FROM — validate said nothing; registerFlow refuses it at boot + nodes: + - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: ' ' } } + - { id: branch, type: decision, config: { condition: { dialect: cel, source: ' ' } } } + + # TO — either write the predicate you meant… + nodes: + - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: 'record.active == true' } } + - { id: branch, type: decision, config: { condition: { dialect: cel, source: 'record.rating >= 4' } } } + + # …or drop the key. An ABSENT condition is still not a malformed one: a start + # node with no `condition` is an ungated trigger, and that is unchanged. + ``` + + The refusal is the edge door's own sentence, not a second one — the finding + carries `EVALUATED_EXPRESSION_SOURCE_REQUIRED` verbatim, located at the node and + slot the author wrote (`flow 'f' · node 'gate' (start) condition`), because all + three doors now ask one imported schema. + + Unchanged, deliberately: the **evaluator**. A condition already stored blank + still answers `false` at run time — #15662's ruling on that half stands. What + moved is that it can no longer be authored past validate. + +### Patch Changes + +- 1e496f9: `filter-preset-comparand` enters its `publicPicker` object-binding reader by SCHEMA POSITION rather than by key name, so a node that merely spells `publicPicker` no longer takes its whole filter subtree out of the field-typed arm (#16403). + + `bindAncestors` walked out through a filter's ancestors and matched `if (key === PUBLIC_PICKER_KEY)` on the property NAME. `walkAuthoredFilters`/`scanForFilters` recognise a filter by key at ANY depth on all eight scanned collections, so that reader was reachable from any node named `publicPicker`, anywhere. Its unresolvable exit is `undefined` — no bound object, so arm 2's field-type oracle answers `false` for every key and the subtree is judged by nobody. + + - **No live defect today**: `publicPicker` is declared exactly once as a schema key, on `FormFieldBaseSchema` (`packages/spec/src/ui/view.zod.ts`), and there the reader is correct. What changed is the failure mode the day a second schema declares the same name: it would have inherited this branch silently. Under-reporting is this rule's only permitted failure direction, so the hole would never have VIOLATED that invariant — it would have quietly spent it, where no test asking "was the invariant violated?" could see it. + - **The guard is on the entry, not the exits**: the branch now requires the enclosing ancestor to be a form field (`field`, required on `FormFieldBaseSchema`) — the same read the branch already had to make one line later, so no new coupling between the lint package and the form-view schema. Two of the three exits `#16106`'s review pinned are verbatim untouched: the `picker.object` override (`if (override) return override;`) and both `undefined` legs of the `reference` resolution (`if (!formObject) return undefined;` and the `verdict?.kind === 'ok' ? … : undefined` tail). The third — `!formField` returning `undefined` — is DELETED, and deleting it IS the fix: outside the declared position that line was the silent exit this card is about, while inside the declared position it is unreachable by construction (the guard holding means `formField` is truthy). So the behaviour P3's QUIET pin holds did not move. + - **The `#16106` B1 false refusal stays closed**, measured: a form field's picker filter over a referenced `select` column that shares its name with a parent `date` column still reports nothing, and the positive control — the same filter where the REFERENCED object declares the field as a `date` — still reports at `views[0].sections[0].fields[0].publicPicker.filter[0].value`. +- 4f1a56b: `sys_user.manager_id` gains an admin write surface: `POST /api/v1/auth/admin/set-user-manager` + + `{ type: 'manager' }` is the canonical first rung of a tiered approval ladder, + and it resolves `sys_user.manager_id` — a column **no product surface could + write**. Measured: the generic data path refuses it (the ADR-0092 D2 + managed-update whitelist for `sys_user` is `{name, image, locale}`), the admin + bulk import does not carry it (`admin-import-users.ts` matches `manager_id` 0 + times, against a control of `phone_number` 8), and the column is `readonly` on + the user form. So on any install without a directory sync the rung expanded to + nobody, the request opened on a slate no one could act on, and under the + default `lockRecord: true` the record stayed locked. + + **The endpoint.** A platform admin posts `{ userId, managerId }`; `managerId: + null` clears the link. It is an ObjectStack mount on the raw app ahead of the + better-auth catch-all — the same family as `POST /api/v1/auth/admin/unlock-user` + — platform-admin gated (ADR-0068) and ledgered in `auth-route-ledger.ts`. + + **It is not a new editable profile column, and that is the design.** The + handler runs under a **system context**, so it reaches the column by context + rather than by a whitelist entry — the same way `admin-import-users` already + reaches `phone_number` and `role`. `SYS_USER_PROFILE_EDIT_FIELDS` is + untouched, `MANAGED_EXTENSION_EDITABLE_FIELDS.sys_user` stays `{locale}`, and + `sys_user.manager_id` keeps `readonly: true`, so ADR-0092 D4 still holds by + construction. Since ADR-0092 D5's amendment made Tier-1 membership imply + self-editability, admitting the column to Tier 1 would have handed every member + their own first-rung approver and a widening of their own `own_and_reports` + read scope; it is not admitted. + + **Five refusals, every one enforced at the write** — the only manager-chain + walkers in the open tree are single-hop, so nothing downstream catches a bad + link: self-assignment; a link that closes a cycle (the walk is itself + cycle-safe, so a pre-existing loop is reported rather than hung on); a chain + past the depth cap that ADR-0057 D3's bounded rollups require; a manager + provably outside every organization the user belongs to (beside, not instead + of, the existing routing-time screen); and any identity whose `sys_user.source` + is `idp_provisioned`, where the directory stays the one authoring surface. + + **`@objectstack/lint`** keeps the `approval-approvers-may-resolve-empty` + advisory and its `stackWiresManagerChain` silencer — the dead end it reports + survives the write surface, because a static check still cannot read the + column; only its *cause* became recoverable. What changed is the remedy text, + which named a column with no route and now names the endpoint, its body, how to + clear the link, and what it refuses. The Approvals guide carries the same + rewrite in prose. + + **Why `patch` and not `minor`.** No new exported symbol is reachable from + either published entry: `admin-set-user-manager.ts` is deliberately not + re-exported from `plugin-auth/src/index.ts` and is not named in the package's + `exports` map, so none of `runSetUserManager`, `MAX_MANAGER_CHAIN_DEPTH`, + `SetUserManagerDeps`, `SetUserManagerEngine`, `SetUserManagerResult` or + `SetUserManagerRefusalReason` appears in the built `dist/index.d.ts`. No + already-published payload gains a key — the endpoint's response is a new + payload, not a new field on an old one. A new **route** is wire, and wire + compatibility is not the grading floor. +- ca78860: A flow's `edges` list no longer takes the whole authoring gate down when one of its members is not a record — the sibling list #16751's repair did not reach (#16910). + + `lintFlowPatterns` read `.label` off each member of `flow.edges` behind nothing but an `Array.isArray` check, which proves the LIST and never its MEMBERS. A YAML `edges:` list item left empty deserialises to `null`, so hand-written metadata turned `objectstack validate` into an uncaught `TypeError` out of a function contractually typed `(stack) => FlowLintFinding[]`: + + ``` + edges:[null, valid] threw=YES TypeError: Cannot read properties of null (reading 'label') + edges:[undefined, valid] threw=YES TypeError: Cannot read properties of undefined (reading 'label') + ``` + + A linter that throws instead of reporting fails hardest on exactly the documents it is most needed for, and the author gets a stack trace where a diagnostic belongs. + + - **The junk member is DROPPED, silently**, through `recordsOf` — the same coercion, from the same one home (`object-graph.ts`), that #16751 chose for the seven flow-NODE-list readers, so two sibling lists on one flow member cannot disagree about what a malformed member means. + - ⭐ **The valid edge beside it is still JUDGED.** "No longer throws" is half a contract: a guard that abandoned the list would satisfy it and would have traded the crash for silence. Measured against a control holding the same flow without the junk member, the surviving finding is identical in rule and location, and no finding is invented about an entry no author wrote. + - **Two rules, not one.** `os validate` runs the rule TABLE, so one throwing reader takes every other rule's verdict down with it: once `lintFlowPatterns` stopped throwing, the identical defect surfaced one file over in `validateStackExpressions`, which read the same list through the same double cast. Both are repaired here; repairing only the filed one would have left the gate down on the same document. + - ⭐ **Which reader actually carried the crash, measured by ablation** — both edge walks read `graph.edges`, not the flow's own list, because `collectFlowGraphs` re-exposes whatever array it is handed. Reverting `graph.edges` alone in either file reds the new cases (10 failures in `lintFlowPatterns`, 6 in `validateStackExpressions`); reverting either `flow.edges` coercion alone leaves them green. The two `flow.edges` coercions are therefore **defence in depth, not the load-bearing fix**, and are kept deliberately: they hand the COERCED array to `collectFlowGraphs` rather than the raw one, which is the discipline the node lists already follow, and they keep two sibling lists on one flow member reading the same way. ⛔ Read them as belt and braces, not as one repair written twice. + - **The producer's edge side is still member-blind.** `collectFlowGraphs` filters the nodes it hands out and forwards edges untouched, so `FlowGraph.edges` is declared `FlowEdgeParsed[]` and can contain a non-record. It does not dereference them today, which is why the consumer coercion is sufficient; that asymmetry is filed separately rather than widened here. + - **No new finding id and no new diagnostic.** On every well-formed document the output is byte-identical; the only behaviour that changes is on input that previously crashed. +- 3ab1508: `translation-target-unknown` no longer calls a locale key for a CONTRIBUTED navigation item an orphan — the remedy it printed deleted a translation the runtime honours (#18203) + + `validateTranslationReferences` built the `apps..navigation.*` universe from the app's authored `navigation` array alone. An item injected by another package through `manifest.navigationContributions` (ADR-0029 D7, ADR-0130) is never in that array, so every locale key for it was reported as naming an item *"which app X does not declare"*, at `error` since 17.4.0, with the remedy *"Match the key to the navigation item's `id`, or drop it."* + + ⚠️ **That remedy is wrong in the worst direction a false positive can point: following it deletes a working translation.** Measured on `objectstack-ai/hotcrm` `be11c07` (pin 17.4.0), where a service module contributes five items into `crm_enterprise`: + + | | measured | + | :-- | :-- | + | `os build` | **15** findings — 5 contributed items × 3 non-default locales | + | `GET /api/v1/meta/app?id=crm_enterprise` | returns all 5 items, `zh-CN` labels **resolved** from the app's own pack | + + The universe now folds in every contribution aimed at the app, walked by the same `walkNav` a declared subtree gets, so what the rule judges is the population the runtime serves rather than the array the author typed. + + **Both carriers are read**, because a stack in hand has two shapes and `os build` runs the rule table over both: + + - `packages[].manifest.navigationContributions` — the ADR-0130 D4 artifact entry. This is the shape the per-package leg needs (`compile.ts` step 3b-ii): the app's owning package declares no contribution of its own, and the union run above it de-duplicates, so a fix reading only the union would have left that leg reporting the finding alone. + - `manifest.navigationContributions` — the stack's own `StackSchema.manifest`, where a single-`defineStack` project's contributions live. `os validate` judges only the union stack, so reading the artifact form alone would have left the fast inner-loop command still reporting what the build no longer does. + + **The runtime's fold is deliberately not imported, and the union is faithful anyway.** `@objectstack/lint` depends on `@objectstack/spec` and never on a runtime; `applyNavContributions` is a `SchemaRegistry` method in `@objectstack/objectql`. A second implementation would normally be exactly the drift this class of defect is made of — except that the fold pushes the contributed items in *every* branch: into a `group` that resolves, at the app top level when the `group` id names nothing (a `nav_contribution_group_missing` diagnostic, never a refusal), and at the top level when `group` is omitted. It chooses **where** an item lands and never **whether**, so the set of addressable ids is invariant under it. All three placements are pinned side by side so that invariant cannot quietly stop holding. + + **The control, which is the point of the change.** Widening a universe trades a false positive for a blind spot unless the genuine orphan still reports. A key that nothing contributes is still an `error` carrying `translation-target-unknown`, its path and its message; a contribution aimed at app B does not make its ids addressable under app A; and the contributed ids join the population the hint enumerates, so the remedy an author is handed lists what they may actually key to. + + **What this still cannot see, stated rather than implied.** Contributions registered imperatively by plugin code (`engine.registerAppNavContribution` from a plugin's `init`) are not metadata, and no static rule can read them — that is the population `pnpm check:app-nav-i18n` has to boot a composition to judge. A locale key for one of those is still reported here. +- 6f8d751: `translation-target-unknown` no longer reports the locale keys a package ships for what it CONTRIBUTES into metadata another package owns — `objectExtensions[]`-injected fields and validation rules, and the navigation items it contributes into an app it does not declare (#18441, #18442). + + Both were `error`, so each one FAILED the run it appeared in, and both carried the orphan remedy — *"Point the key at a declared field, or drop it"*, *"Match the key to an app's `name`, or drop it"* — which deletes a translation the runtime resolves. Measured on the two probe stacks: + + - `objects: [crm_lead { name }]` + `objectExtensions: [{ extend: 'crm_lead', fields: { sla_tier } }]` + a `zh-CN` key for `sla_tier` produced one `error` at `translations[0]["zh-CN"].objects.crm_lead.fields.sla_tier`. A genuinely undeclared field on the same stack produced a finding identical but for the name, so **a correct author and a real typo were indistinguishable in the output** — an author who extended an object correctly was told their correct key was wrong, in a run that failed. + - in `os build`'s per-package leg, a contributor package carrying `navigationContributions` and no apps of its own was told app `crm_enterprise` is one *"which this stack does not define"* — whether or not the app's owner was an entry of the same artifact. Declaring that app is the owning package's job; the contributor cannot do it. + + Both folds widen what a key may RESOLVE against and nothing else, so every genuine orphan still reports at `error` with the rule id intact: a typo on an extended object, a `_validations` name no layer declares, an object neither defined nor extended, an app neither defined nor contributed into, and a contributed navigation id nothing contributes are each pinned as a control beside the case they neighbour. + + Two bounds worth reading before widening either fold further: + + - **The extension fold is exactly two rungs wide because `ObjectExtensionSchema` is.** The declared entry keys are `extend`, `priority`, `fields`, `validations`, `indexes`, `label`, `pluralLabel` and `description`; `views`, `listViews`, `actions`, `fieldGroups`, `sections`, `tabs` and `hooks` are refused BY NAME at the extension level with authoring guidance. So `fields.*` and `_validations.*` are the only rungs of this rule an extension can reach, and a `_views` / `_sections` / `_tabs` / `_actions` key on an extended object is an orphan exactly as before. A new pin asserts that surface against the schema, so the sizing cannot silently stop being true. + - **An extension target this stack does not DEFINE is rung 2b of the cross-package ladder**: the object key resolves — the extension is proof the stack means that name — and the subtree is skipped WHOLLY, for the reason rung 2 skips a registered platform object's. The owner's field set is not visible from a package that only extends it, and judging the subtree against the injected names alone would report the owner's own field keys as orphans, which is the same defect one level up. + + No schema moved, no export moved, and no accept set moved: this is a lint rule's false-positive set narrowing. `Clause-②: no` +- ef256e6: `PermissionEvaluator.checkObjectPermission` and `buildAccessMatrix` now ASK `@objectstack/spec`'s `objectPermissionGrants` instead of restating the super-user fold — one rule, one definition (#18785). + + "Does this effective object permission grant this verb?" had three independent implementations: the spec helper published in 17.4, the enforcement door in `@objectstack/plugin-security`, and the access-matrix snapshot in `@objectstack/lint`. A differential over the full input space — every declared object-permission bit (`allowCreate` / `allowRead` / `allowEdit` / `allowDelete` / `allowTransfer` / `allowExport` / `viewAllRecords` / `modifyAllRecords`) in all three authorable states, 6561 entries by 6 verbs — found **zero** disagreements, so this is a structural convergence and **no behaviour changes**. + + - **No API change, no bit changes meaning.** The read bypass is still `viewAllRecords || modifyAllRecords`, the write bypass is still `modifyAllRecords` alone, `allowCreate` still has no super-user bypass, and `export` is still `grant ∧ read`. + - **The export door keeps its cross-set shape.** `checkObjectPermission('export', …)` still asks `(∃ set granting export) ∧ (∃ set granting read)` across the resolved set list — the same answer the `/me/permissions` most-permissive merge hands the client. Folding it per set would have narrowed the door. + - **Both consumers are pinned to the fold independently of the helper**, so a change to one cell of `objectPermissionGrants` reddens them rather than propagating silently. +- 2b3eb17: `translation-target-unknown` no longer reports the locale keys a package ships for an object a SIBLING package of the same artifact declares — the OBJECT rung's universe read `stack.objects` alone, while this rule's own docblock already declared the wider `artifactProvidedObjectNames` reach (#19064). + + `os build` runs the rule table per PACKAGE as well as over the union (`compile.ts` step 3b-ii): each package body is judged as its own stack with the artifact's `packages[]` beside it as resolution context (`packageBodyAsStack`, #16611). On that leg `stack.objects` holds ONE package's objects, so a bundle key naming a sibling's object resolved against nothing. Measured on the probe stack (`examples/app-multi-package`'s shape — `core` owns `crm_account`, `orders` reads it and here also translates it), the key produced one `error` at `translations[0]["zh-CN"].objects.crm_account`: + + > Translations are keyed to "crm_account", which no object in this stack defines. The resolver looks up keys derived from the metadata, so this whole subtree is dead weight — every label it carries renders untranslated. + > + > *Rename the key to the object it was written for, drop it, or ignore this if the object is contributed by another installed package. Defined objects: crm_order.* + + That is the remedy that deletes a translation the runtime resolves, at `error`, so the run FAILED on it — and it is byte-identical, but for the name, to the finding a genuine typo produces. ADR-0130 makes the release artifact the co-ownership boundary, so the miss is the RUN's blind spot and not the author's mistake. + + **The precedent is followed, not re-decided.** `validateObjectReferences` closed this exact shape on this exact carrier for object NAMES (#16611 — `artifactProvidedObjectNames` folded into its `resolvable` set). What differs here is the RETURN, and two pins hold it: this rule's universe is keyed by FACTS, not names, so the sibling's fields, options, views, sections and rules are folded WITH the name through the same collector the declaration loop uses. A name-only fold would resolve the object key and then judge the owner's own field keys against an empty fact set — the same false positive one level up — and a wholesale subtree skip (rung 2b's answer, for a target whose declaration is genuinely invisible) would leave the per-package leg unable to see a typo the union leg reports. + + **The control, which is what makes this a narrowing and not a hole.** Widening a universe trades a false positive for a blind spot unless every genuine orphan still reports, so both directions are pinned side by side: the same package judged ALONE still errors (the context is what does the work); a name no entry of the artifact declares is still an `error` with its rule id, and the remedy now enumerates what the artifact provides; a field the sibling does not declare is still an `error` under the now-resolved object; one bundle carrying both a sibling key and a typo reports exactly the typo; an entry with no readable body (a segment reference) makes nothing addressable; and the single-`defineStack` shape is untouched, because `objects` is a stack collection with no `stack.manifest` form to read. + + No schema moved, no export moved, and no accept set moved: this is a lint rule's false-positive set narrowing. `Clause-②: no` +- 2b321a4: Four consumers of the implicit-reference-target contract resolve a reference field's target through `referenceTargetOf` instead of the materialized `reference` carrier, so a `{ type: 'user' }` field authored without one seeds, serves, and lints as the fully specified metadata the spec says it is (#19289). + + `IMPLICIT_REFERENCE_TARGETS` (`@objectstack/spec/data`) says a `user` field's target is "a CONSTANT OF THE TYPE, so `reference` on a `user` field materializes that constant; it does not supply it. Metadata authored without it (hand-written JSON, an AI author, a Studio form) is **fully specified, not under-specified**." Two arbiters answer two different questions — `referenceCarrierOf` what the carrier says, `referenceTargetOf` what the field points at — and for `user` only the second matches that text. #18550 standardized a population of readers on the first, which is correct wherever a site's own type gate excludes `user` and wrong wherever it does not. This is the census of that population: 17 carrier call sites judged one by one, four repaired. + + Clause-②: no + + Not a widening. It deletes a mistaken refusal of metadata the published contract already declares complete, which the charter files as `no` — 「删已发布契约文本本就否定的误拒本身是 `no`」. No key, alias or spelling is newly accepted anywhere: the target comes from the spec's own constant, never from a second way of writing it. + + - **`@objectstack/rest` — the loud one.** A `publicPicker` on a spec-complete `{ type: 'user' }` field answered `500 LOOKUP_TARGET_MISSING`, so opening a reference picker on a "responsible person" column returned an error page. It now answers `200` over `sys_user`. ⛔ This is not a re-widening of #12920's narrowing: a stored def spelling the target `referenceTo` / `target` / `options.objectName` still resolves nothing and still answers `500`, pinned in both directions. + - **`@objectstack/metadata-protocol` — the silent one, and the one that stored a wrong value.** A seed row's `{ type: 'user' }` field contributed no `dependsOn` edge and never reached `references`, so its natural key was written **verbatim** into a column that holds a record id — the dangling reference `buildDependencyGraph`'s own docblock names as the cause of broken parent joins. ⚠️ Upgrading seed authors: such a field now takes the same path the explicit `reference: 'sys_user'` spelling always took, which includes the failure path — a natural key that resolves to no `sys_user` row now DROPS the whole record, counted, reported and logged at `error`, where it was previously written verbatim. Seed `sys_user` before the referencing object, enable `multiPass`, or fix the key. + - **`@objectstack/lint` — the widest.** `object-graph`'s field slice fed `resolveFieldPath`, whose `RELATIONSHIP_FIELD_TYPES` admits `user`; a carrier-less one answered `hop-untargeted`, which `isUnjudgeable` treats as "the graph could not answer". Every rule in the package that resolves a field path therefore stopped judging any path through such a field, reporting nothing. `validate-field-consumers` separately dropped the `displayField` consumer edge onto `sys_user`, so a field that column displays was reported consumed by nobody. + - **Nothing else widens.** `user` is the only member of `IMPLICIT_REFERENCE_TARGETS`, so a `lookup` / `master_detail` / `tree` whose author-chosen target is absent still names nothing, exactly as before — pinned at every repaired site. + - **The unreadable-carrier behaviour is unchanged.** `referenceTargetOf` reads the carrier through `referenceCarrierOf` **before** it judges the type, so #13053/#18550's `TypeError` on an object- or array-valued `reference` still fires everywhere it fired before. The implicit target is not a fallback that swallows it. + - **No authoring change.** Metadata that already spells `reference: 'sys_user'` resolves to the same target it always did; nobody has to restate the constant, and nobody has to stop restating it. +- c3a4c74: `translation-target-unknown` no longer reports the locale keys a package ships for a view, page, action, app, dashboard or flow a SIBLING package of the same artifact declares. Six collection rungs built their universe from the top-level collection alone, while the same file already read `packages[].manifest.…` for `navigationContributions` (#18442), `objectExtensions` (#18441) and `objects` (#19064) — so the capability was present and these rungs did not use it (#19349). + + `os build` runs the rule table per PACKAGE as well as over the union (`compile.ts` step 3b-ii): each package body is judged as its own stack with the artifact's `packages[]` beside it as resolution context (`packageBodyAsStack`, #16611). On that leg each `stack.` holds ONE package's declarations. Measured on a throwaway probe before anything was touched, each level produced exactly one `error`, at `translations[0]["zh-CN"].dashboards.crm_overview`, `….flows.lead_conversion`, `….globalActions.export_all`, `….objects.crm_order._views.board`, `….objects.crm_order._tabs.mine` and `….apps.crm_app` — each carrying a remedy (`or drop it`) that deletes a translation the runtime honours, at a severity that FAILS the run. + + **The carrier is proven rather than assumed.** Every one of these keys carries disposition `concat` in `COMPOSE_KEY_DISPOSITIONS`, which is precisely what puts it inside `ASSEMBLED_PACKAGE_BODY_DISPOSITIONS` and so inside an ADR-0130 D4 entry's assembled body — the same proof the `objectExtensions` and `objects` folds rest on. ADR-0130 makes the release artifact the co-ownership boundary, so the miss is the RUN's blind spot and not the author's mistake. + + **Records, not names — and for three different reasons, not one assumption applied six times.** `dashboards`, `flows` and `apps` are keyed by their own name and carry a sub-rung derived from the record (widget ids and header `actionUrl`s, screen node ids and their field names, navigation ids), so a name-only fold would resolve the top key and then judge that sub-rung against an empty set. The `actions` record is itself read downstream by `checkActionParams`, and it carries the owner that keeps an object-bound action under `objects.._actions` instead of making it globally addressable. `views` and `pages` have no bundle rung of their own at all — they contribute `_views`, `_sections` and `_tabs` facts under the object they bind to — so the record is the only thing carrying both the fact and its binding. + + **`apps` is the half #18442 did not cover.** That change reads `navigationContributions`, so an app became addressable only where THIS package contributes into it; a sibling's `apps[]` declaration was invisible either way. With no contribution the app NAME was the orphan; with one, the name resolved through #18442 while the owner's own navigation ids were orphans — and were diagnosed "this stack contributes no such item", advising a move to a package sitting in the same artifact. Folding the records before the contributed-only pass closes both halves and restores the declared-app diagnosis, while an app owned OUTSIDE the artifact keeps #18442's wording. + + **The controls, which are what make this a narrowing and not a hole.** Every rung pins both directions side by side: the same bundle judged ALONE still errors (so "no findings" cannot be confused with the rung going quiet); a name no entry of the artifact declares still errors with its rule id and a remedy that now enumerates what the artifact provides; every sub-rung stays judged against the sibling's declaration, so a typo under a resolved dashboard, flow, screen, app or object is still an `error`; an object-bound sibling action keyed under `globalActions` still errors with its routing message; where both packages declare the same name the declaration being judged keeps the slot, so a sibling's widget ids do not become addressable under this package's dashboard; an entry with no readable body (a segment reference) makes nothing addressable on any rung; and the single-`defineStack` shape is untouched, because all six are stack collections with no `stack.manifest` form to read. + + No schema moved, no export moved, and no accept set moved: this is a lint rule's false-positive set narrowing. `Clause-②: no` +- fd920ca: `ai-skill-tool-unresolved` is a whole-stack verdict by design: `os validate` / `os lint` / `os build` report it, and the runtime publish gate never does (#19527) + + A skill's `tools[]` entries resolve against three sources: `stack.tools`, the + platform tool registry, and the `action_` tools materialised from + AI-exposed actions. The runtime publish gate judges one written item against a + per-write snapshot that carries neither `stack.tools` nor `stack.actions`, and + no snapshot can carry a tool that a runtime plugin registers outside the + registry. At that door the rule could only ever produce a false + `ai-skill-tool-unresolved`: a skill naming a real stack-level action would be + told the tool does not exist. The ruling on #19527 (letter B) places this + check at the whole-stack rule, following ADR-0109 Decision §3, and keeps the + per-write snapshot as it is. + + `skill` was already outside the runtime gate. The earlier comments called that + a temporary hold-out, pending a wider snapshot. They now describe it as the + design, so no later change should add `actions` / `tools` to the snapshot in + order to move this check onto the door. New tests pin both halves: + + - a `skill` write through the runtime gate gets no tool-reference finding. This + holds for a stack-level action tool, a plugin-registered tool, and a tool + that exists nowhere. No other write type can put a skill into a door + snapshot; + - `validate`, `build` and `lint` still report a tool that exists nowhere, at + warning severity, and do not report the stack-level action that resolves. + + ⛔ No behaviour changes. Before and after this change, the runtime gate runs no + rule for a `skill` write, and the whole-stack commands report the same + findings. No authorable key, accept set or export changes. + + **This ships, so it carries a changeset rather than `skip-changeset`.** + `@objectstack/lint` publishes `dist`, and its build keeps source comments. A + rebuilt artifact shows the reworded block in `dist/index.js`, + `dist/index.cjs`, `dist/runtime.js` and `dist/runtime.cjs`, and the old + wording (「held out on a measurement」) in none of them. So the published JS + bytes change, but behaviour and the declaration surface do not. +- 5dba7f3: fix(objectql)!: a field-level `requiredWhen` / `readonlyWhen` predicate that cannot be evaluated now REFUSES the write, naming the field and the rule, instead of letting it through (ADR-0137 D2) + + Clause-②: no (narrowing) + + **BREAKING**: shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA). The banner and the ADR-0087 + disposition below carry the breaking change, not the level. + + **Writes that used to save now fail.** ADR-0137 D2 says: "At submit time, a + field-rule predicate that cannot be evaluated refuses the write and names the + field and the rule. Nothing is persisted." The server now enforces that on the + two arms that let such a write through: + + - **`requiredWhen`**: a predicate that faults used to be logged + (`requiredWhen for '' failed to evaluate — skipped`), and the record + saved with the field empty. It now refuses the insert or update. This covers + every fault, including a `parent`-scoped rule whose master-detail header + could not be resolved for the write. + - **`readonlyWhen`**: a predicate that faults used to be logged + (`failed to evaluate — change allowed through`), and the field the author + declared frozen was written. It now refuses the update. On a bulk update, a + fault in any matched row refuses the whole write, and the refusal names that + row. One case is unchanged: a predicate that faults because the header it + reads as `parent` could not be resolved still holds the lock, as before. + + The refusal is the same `ValidationError` a broken validation rule has thrown + since #4649: `VALIDATION_FAILED`, served as `400`. Its entry for the field + carries `code: 'rule_violation'` and + `constraint: { rule: 'requiredWhen' | 'readonlyWhen', reason: 'unevaluable', fault }`, + with `missingKey` or `hint: 'null-comparison'` when the fault is one of those. + The message names the field and the rule. It is refused before anything is + written, on insert, single-id update and bulk update alike. The operator also + gets a `warn` line saying the write was rejected. + + The refusal applies to the whole submit. A `requiredWhen` whose predicate + faults refuses the write even when the write supplies the field, because the + rule has no verdict to judge that value against. + + **What starts refusing.** A stored predicate that faults on the writes it + judges: + + - a key the object does not declare, usually a typo (`record.statsu`); + - an ordering comparison or arithmetic over a `null` (`record.amount > 100` + where `amount` is empty). Guard it with `!= null`. `has(x)` is true for a + declared field holding null, so it does not guard this; + - a column read through a lookup (`record.account.tier`). The field level never + reads the related record, so the reference holds a bare id there. The refusal + says so, and names the reference and its target object; + - an envelope with no evaluable `source`: blank, or `ast`-only. + + Nothing in this repository's own metadata is affected. A census of every + `requiredWhen` / `readonlyWhen` under `packages/`, `examples/` and `apps/` + found none that faults on a write it judges. How many stored predicates in a + deployment fault is unknown, and ADR-0137 names that as the point: the loud + state is what finds them. + + **Fix.** Read the refusal. It names the field, the rule, and the key or + overload that faulted. Then correct the predicate: fix the key's spelling, + guard the null operand with `!= null`, or move a check that reads through a + lookup into a `validations[]` `script` rule, whose condition does read one hop + through a reference. + + Unchanged: a predicate that evaluates is judged exactly as before, in both + directions. So is the ADR-0113 legacy-row rule for an evaluated `requiredWhen`. + Option-level `visibleWhen` is not a field-rule predicate, so D2 does not reach + it, and it stays fail-open. The render side is not touched here (ADR-0137 D3 + keeps its directions for display). + + `@objectstack/lint`: the build-time messages for a field `requiredWhen` no + longer say the server "skips" a faulting predicate. The unbound-root message, + the `parent`-without-a-master message and the null-guard message now say the + server refuses the write. + + +- 01df025: `os validate` and `os build` now check an `autonumber` field's `format` when its `autonumberFormat` is an empty string (#19772). + + An empty `autonumberFormat` counts as not set: the platform numbers records with the field's `format` instead, and with `{0000}` when neither is set. The build-time check stopped at the empty `autonumberFormat` and looked no further, so a `format` naming a field the object does not have — `{ type: 'autonumber', autonumberFormat: '', format: '{nope}{000}' }` — passed `os validate` and `os build`, and then every record create failed with `Cannot generate autonumber … referenced field(s) [nope] are empty on the record`. + + The check now reads the format the platform numbers records with. That field now fails the build with the same `autonumber-references-unknown-field` error it gets when `format` is written alone, and publishing the object at runtime is refused with the same error. The optional-field, self-reference and unrecognised-token checks follow the same format. A field that sets a non-empty `autonumberFormat`, sets only `format`, or sets neither is checked exactly as before. +- 6696056: `filter-preset-comparand` now judges a list page's `interfaceConfig.filterBy` and a lookup field's `lookupFilters`, consumed filter carriers the shared filter walk never entered (#19791). + + Both carriers are rule arrays (`{ field, operator, value }`) whose values reach the engine's `where` verbatim, and neither schema carries a preset check. So `{ field: 'close_date', operator: 'gt', value: 'last_30_days' }` in either one parsed green and linted green, then the engine refused it at query time (`INVALID_FILTER` / 400). The same rule on a component `dataSource.filter` or a view `filter` was already refused. `filterBy` and `lookupFilters` join `FILTER_KEYS`, so `os lint`, `os validate` and the runtime publish gate (for `page` and `object` writes) now refuse it where it is written. Each finding carries its path (`pages[0].interfaceConfig.filterBy[0].value`, `objects[2].fields.account.lookupFilters[0].value`). + + - **Which object a condition addresses.** The field-typed arm, which refuses a preset under equality or membership on a `date` / `datetime` field, binds `filterBy` to `interfaceConfig.source`. Without a `source` it falls back to the page's `object`. It binds `lookupFilters` to the field's `reference` and never to the object that owns the field, because the picker queries the referenced object. A `relatedListFilter` on the same field still binds to the owner. + - **`filter-token-unknown` reaches the same two carriers.** An unresolvable placeholder such as `{current_user}` in `filterBy` or `lookupFilters` is now reported, as it already is in a view's `filter`. `{current_user_id}` and the date macros stay clean. + - **What you do:** in a `filterBy` or `lookupFilters` rule, replace a preset name with the `{date-macro}` window the message names (`{ operator: 'gte', value: '{30_days_ago}' }`) or with an ISO date. +- b5853da: fix(spec, lint): the RLS `check` → `using` default is stated per operation across the applicable policies, as the runtime applies it, not per policy (#19953) + + Clause-②: no + + Text only. No schema shape, accepted value or runtime behaviour changes. + + `RowLevelSecurityPolicySchema.check` said the clause "defaults to USING clause if not specified", which reads as a rule for each policy on its own. The write gate decides the default once per write operation, across every applicable policy: + + - When any applicable policy for the operation declares `check`, only the declared checks decide, OR-combined. A policy with only a `using` beside them adds nothing to the check. + - Only when none declares `check` does each applicable policy's `using` stand in as its check, OR-combined. + + A policy is applicable when it is not `enabled: false`, its `object` is the written object or `'*'`, its `operation` is the write's own or `'all'`, and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated. + + When this text change was written, the check ran on the new row of a single-record insert and of a by-id update only, and the texts said so. The same release extends it: every row of an array insert and of a `multi: true` update is judged (#19964, #19950), and a by-id update is also judged on the row its `beforeUpdate` hooks leave (#19989). The texts now state that every row an insert or an update writes is judged (#19967). + + - **`@objectstack/spec`**: the `check` describe and TSDoc state this composition and that scope. The `rowLevelSecurity[].priority` refusal no longer gives "applicable policies OR-combine (most permissive wins)" as its reason, which is not true of the write check, and the file overview limits "OR-combine" to reads. The generated reference pages (`references/security/rls`, `references/security/permission`) are regenerated from the describe. + - **`@objectstack/lint`**: the `rls-predicate-*` findings on a `using` now also say what the dropped `using` does to an insert. On an `insert` or `all` policy, when no applicable policy for the insert declares a `check`, that `using` is also the single-record insert check. If nothing else in that set compiles, every single-record insert the policy governs is refused with `PermissionDeniedError`. The findings on a `check` now say the refusal is a blanket one only when no other applicable policy declares a `check` that compiles. +- a243cfb: fix(lint): `action-name-undefined` now resolves the `record:alert` call-to-action and the `page:header` action ids (#20105) + + `action-name-undefined` is the authoring gate for "a surface names an action that no action in the stack defines". It already walked list-view row/bulk menus, the `record:quick_actions` bar (`properties.actionNames`) and app navigation, but two page surfaces that bind an action by id were never read: + + - `record:alert` → `properties.action.actionName` (the banner's call-to-action button); + - `page:header` → `properties.actions` (the header's action ids). + + Both renderers resolve the id against the object's declared actions and draw nothing when it resolves nowhere: the alert keeps its banner and silently loses its button, and the header renders one button fewer with only a browser-console warning. The spec types both as plain strings, so a misspelled id passed spec validation and lint and vanished at runtime. + + The rule now walks both keys, each scoped to its component type (`element:button`'s `action` is an inline definition and `record:related_list` declares its own `actions`, so neither is read), and resolves them exactly as it resolves `actionNames`: against every action defined in the stack, global or object-embedded, with the same did-you-mean. A `page:header` array's inline-object elements are skipped (the spec refuses them on its own; they are definitions, not references), and every id is reported at its authored index. Each finding says what the author will actually see — a banner with no button, a header with no button — and the hint names the placement each surface needs: none for the alert, which runs its call-to-action by name, and `record_header` or `record_more` in `locations` for the header. + + **What moves for consumers.** A stack whose `record:alert` or `page:header` names an undefined action built clean before and now fails `os validate` / `os lint` / `os build` with `action-name-undefined` (severity `error`). That id never rendered a button, so nothing that worked stops working. The rule still does not run at the runtime publish door for `page` writes. A stack whose ids all resolve is unaffected: measured on the platform's own `sys_user` page, whose `resend_verification_email` call-to-action resolves clean. +- 6a6a17b: fix(spec): a `joined` report draws no chart — `blocks[].chart` is removed and a container `chart` on a joined report is refused (#20161) + + Clause-②: no (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, + never by the level). + + A `joined` report draws each of its blocks as a table. The renderer's joined + branch returns before its one read of the report's `chart`, and nothing ever + read a block's `chart` at all. So a chart on a joined report, on the container + or on any block, parsed green, passed the `validate-chart-bindings` lint, and + plotted nothing. Both coordinates now answer at parse: + + ``` + FROM ReportSchema.safeParse({ name: 'overview', label: 'Overview', type: 'joined', + chart: { type: 'bar', xAxis: 'status', yAxis: 'task_count' }, + blocks: [{ name: 'open_block', dataset: 'tasks', rows: ['status'], values: ['task_count'], + chart: { type: 'pie', xAxis: 'status', yAxis: 'task_count' } }] }) + -> { success: true } // both charts silently never drawn + + TO -> { success: false, issues: [ + { code: 'unrecognized_keys', path: ['blocks', 0], + message: '… `report.blocks[].chart` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … Delete the key. …' }, + { code: 'custom', path: ['chart'], + message: 'a `joined` report draws no chart — it draws each block as a table and never reads `chart`, on the container or on a block. Delete `chart`; …' } ] } + ``` + + **Fix.** Delete the `chart`. The report renders exactly as before, because + neither value was ever drawn. To plot one of the slices a block shows, give it a + non-joined report of its own with that `chart`. + `os migrate meta --from 17` lists the mechanical edits for existing sources. + + **What does not change.** `chart` on a `tabular` / `summary` / `matrix` report is + untouched: it is that report's live embedded chart. A joined report with no + `chart` parses byte-identically to before, and a block keeps every other key. + + ### The retirement kit + + - **Schema.** `JoinedReportBlockSchema` is closed (`strictObject`), so `chart` is + removed from its shape and answered by its `guidance` table with the + prescription (build-schemas check (c) proof 4). `ReportSchema.chart` stays + declared; the joined arm of its refinement refuses it, beside the + `dataset` / `rows` / `columns` / `values` / `order` refusals already there. + - **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `ui/JoinedReportBlock:chart`, and + the D2 conversion `report-joined-chart-removed` (protocol 18, retired from the + load path) strips a block's `chart` and a joined container's `chart` from old + sources and stored `sys_metadata` rows as a lossless delete. Stored rows can + carry them: the Studio report form offered a block `chart` input until this + change. The family's D3 semantic entry, `ui-report-joined-chart-retired`, states + what the strip cannot decide: whether the chart was wanted. If it was, it moves + to a non-joined report of its own, because a joined report has no chart channel. + - **Form.** `reportForm` drops the block `chart` input and shows the container + `chart` only when `type` is not `joined`; the `platform-objects` metadata-form + translation bundles drop the `blocks.chart` label in all four locales. + - **Lint.** `validate-chart-bindings` no longer resolves the axes of a block chart + or of a joined container's chart against a dataset: it would be vouching for a + chart that is refused at parse and never drawn. A block's own `dataset` / + `rows` / `columns` / `values` are still checked. + - **Ledger and docs.** `liveness/report.json` names a reader for `chart` only on + non-joined reports and drops `chart` from the `blocks` row; + `content/docs/ui/reports.mdx` lists what a joined container refuses. + + +- 0d3ec47: fix(plugin-security)!: a row-level policy whose compiled filter carries a `null` list member or a `null` ordering bound now fails closed on both clauses, so its read and its write check agree (#20212) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what a row-level policy grants. A policy that returned rows on a read, or admitted a write, can now return no rows and refuse the write with 403. It ships as `minor` under the launch-window convention for accept-set narrowings. + + `RLSCompiler.compileFilter` now runs the platform's two shared comparand faces (`assertListComparandShapes` and `normalizeFilterComparandTypes` from `@objectstack/spec/data`) on every compiled policy filter, for `using` and `check` alike. These are the functions the engine already runs on a caller's own `where`. It runs them before the middleware chain adds the RLS filter to that `where`, so until now a compiled policy filter reached the driver unjudged. A policy they refuse is dropped the way a policy with an unresolved `current_user` variable, or a list under `==`, is already dropped. When no other applicable policy compiles, the clause answers `RLS_DENY_FILTER` and logs one `[RLS] DENY (fail closed)` WARN with `reason: 'refused-comparand'`. + + **Why.** The rulings refuse a `null` member of `$in` / `$nin` and a `null` comparand of `$gt` / `$gte` / `$lt` / `$lte` in every filter, because no two backends agree on what they match. On the RLS path they reached the backend, and the `check` clause of the same policy was evaluated in-process by another matcher. One policy then gave two answers. Measured with rows `open`, `closed` and a NULL status: + + | predicate | `using` read before, SqlDriver / InMemoryDriver | `check` insert `closed` / `open` before | after, both clauses | + | --- | --- | --- | --- | + | `!(record.status in ['open', null])` | the NULL row / the `closed` row | admitted / 403 | no rows, 403 | + | `record.status in ['open', null]` | the `open` row / the `open` and NULL rows | 403 / admitted | no rows, 403 | + | `record.status > null` | no rows / no rows | 403 / 403 | no rows, 403 | + | `record.status <= null`, `record.status in [null]` | no rows / the NULL row | 403 / 403 | no rows, 403 | + + On SqlDriver the first row's read hid the `closed` row that its own write check admitted. PostgreSQL answered as SQLite. + + **What now answers differently.** + + - A read (`find`, `findOne`, `count`) under such a policy returns no rows when no other applicable policy compiles, and logs the WARN. Beside another policy that compiles, this policy no longer contributes rows: the read returns what the other policies grant, with no WARN. + - A `check` (declared, or defaulted from `using`) refuses every insert and update it governs with the row-level CHECK denial, `403 PERMISSION_DENIED`, when no other applicable `check` compiles. + - `explain` reports the RLS layer as `denies` instead of `narrows`. + - Analytics: `getReadFilter` hands the deny sentinel to the analytics faces, which answer zero rows. They previously refused the whole query with `READ_SCOPE_COMPILE_FAILED` / 500. + + **Who is affected.** A deployment whose stored policies carry one of these shapes, for example a policy saved without `os validate`. No policy in this repository does: every `using` / `check` string under `examples/` and `packages/` (outside tests) that names `null` is a null check (`== null`, `!= null`), which is unchanged. + + **Fix.** Test for no value with `== null` and for a value with `!= null`. "One of these, or no value" is `record.status in ['open'] || record.status == null`. "Has a value" is `record.status != null`. `os validate` prints the rewrite for each finding. + + **Unchanged.** A policy whose compiled filter the faces accept compiles to the same filter as before, with the same WARNs. A caller's own `where` carrying these shapes is still refused `INVALID_FILTER` / 400 by the engine. The null checks `record.f == null` / `record.f != null` lower to `$null` and are not refused. + + `@objectstack/lint`: the `rls-predicate-unenforceable` finding for a `null` list member or `null` ordering bound now says what the runtime does: the policy is dropped on every request, with the clause's own fail-closed consequence. It used to say the policy survived and the backend answered. +- aeb0557: fix(security)!: the RLS write check refuses a field-to-field comparison the read refuses — one comparison class, one answer per policy (#20355) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on the row-level write check, shipped as `minor` + under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; + breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the + level). The hand-migration prescription is registered under protocol major 18 as + `rls-predicate-cross-class-field-comparison-refused`, one ADR-0087 D3 entry for the whole + family: the authoring arm `os validate` gained in #20347 and this write-check arm. + + **What changed.** A row-level policy that compares two fields of no shared comparison + class — `record.status != record.amount` (text and a number), `record.status != + record.photo` (text and a file field), `record.status != record.is_open` (text and a + formula field), `record.status != record.meta` (text and a json field) — already had + every read it scopes refused with `INVALID_FILTER` / 400 on the SQL drivers, because + driver-sql compiles a column-to-column comparison only within one class. The write + check did not know the rule: it compared the two raw values in-process, so an insert + or update the policy's `check` judges (or its `using`, standing in as the check) was + admitted and stored whenever that comparison happened to hold. Measured through + plugin-security and ObjectQL on SQLite, sqlite-wasm and PostgreSQL. The write check + now refuses the comparison too, with the read's envelope, `INVALID_FILTER` / 400, for + every insert (single or array), by-id update and predicate update it judges, and + nothing is stored. The same-class comparisons it always compared are compared as + before. The 400 names no column of the policy; the server log names the policy and + both columns. A comparison against a json or `multiple` field is refused by its + declared type now, where it used to be judged by the value each record held. + + **`@objectstack/formula`.** `matchesFilterCondition(record, filter, options?)` takes an + optional third argument: `options.fields`, the object's declared columns (`type` and + `multiple` per field name). Given it, every `{ $field }` comparison between two + declared columns is judged by `crossFieldComparisonVerdict` from + `@objectstack/spec/data` before any record is read, and one the platform defines no + answer for throws `INVALID_FILTER` / 400. Without it the evaluator behaves exactly as + before. Two new exports go with it: `findCrossFieldClassRefusal(filter, fields)`, the + pure judgement, and `crossFieldClassRefusalCarriedBy(error)`, which reads the refused + comparison off the error for a server-side log. + + **`@objectstack/driver-sql`.** `crossFieldComparisonClass` reads the same export + (`crossFieldColumnVerdict`) instead of keeping its own copy of the classification, and + layers above it only its internal type aliases. Every read answers as before. + + **`@objectstack/lint`.** The `rls-predicate-unenforceable` finding for such a + comparison now states the write answer the runtime gives: the in-process write check + refuses it by the same classification and stores nothing. + + **If a policy of yours is refused.** The platform defines no comparison between those + two columns on any path, so the policy never protected a read either. Compare a field + only with a field of the same class — a number with a number, text with text, a + boolean with a boolean, a date with a date, a datetime with a datetime, a time of day + with a time of day — or, if the two columns do hold comparable values, correct the + declaration of the one declared with the wrong type. `os validate` names every such + comparison. +- d753744: `component-props-invalid` no longer hides a wrong `object` prop on a component that also carries a `dataSource` binding + + Clause-②: no + + A page component whose `dataSource.object` names the object may leave the flat + `properties.object` shorthand out: the binding supplies it, so the rule does not + report the props schema's required `object` as missing. That waiver was matched + on the issue's path alone, so it also swallowed every other issue the props + schema raised at `object`. A present but wrong value, such as `object: 7` or + `object: null`, was reported without a binding and silently passed with one. + + The waiver now covers what its contract says: a missing `object`, meaning no + key or an explicit `undefined`. A value the author did write is judged as + written, and it is reported at `properties.object` exactly as it is on the same + component without a binding. + + Effect on `os validate`, `os lint` and `os build`: a document that sets both a + `dataSource` binding and a wrong-typed `properties.object` now gets one + `component-props-invalid` warning it did not get before. The rule stays + advisory: without `--strict` nothing that validated before is refused; under + `os validate --strict` or `os lint --strict` the new warning fails the run, as + every warning does. A component that binds + through `dataSource` and omits `properties.object` is still clean. +- c7ad16f: fix(driver-sql): a declared index that can never be built is logged at `error` and reported in drift + + **Clause-②: yes (widening)**: the exported `DriftOp` union gains one member, `unbuildable_index`. + No accept set changes. Nothing an author could write before is refused now. + + A declared index names a column that no declaration will ever create when: + + - the name is not a field of the object, for example a misspelling that the Studio save door + admits (`os validate` / `os build` already refuse it); or + - the name is a virtual `formula` field, which is computed on read and has no column. The same + applies to a field-level `unique` on a formula field. + + The SQL driver skips such an index at every sync. It used to say so at `warn`, and the drift + report dropped the index from the expected set, so `os migrate plan` showed nothing. For a + `unique` index, the declared constraint was not enforced and duplicate rows were accepted, + while everything looked normal. + + - **The sync logs the skip at `error`**, on the same durability channel as the duplicate-row + refusals in the same loop. One line per skipped index per sync names the object, the index, + each missing column with its reason (not a field of the object, or a formula field), and + whether the index is `UNIQUE`. The structured meta carries `index`, `missing` and `unique`. + - **Drift reports it** as a report-only entry: `kind: 'index_mismatch'`, `actual: '(absent)'`, + `category: 'needs_confirm'`, `severity: 'error'` for a unique index and `'warning'` otherwise. + Its op is the new member: + + ```ts + { type: 'unbuildable_index'; table: string; column?: string; indexName: string; + unique: boolean; missingColumns: string[] } + ``` + + `missingColumns` lists only the columns that will never materialize. A declared column that + is merely not added yet is pending additive work, not this finding. + + **What a consumer that reads `op.type` now sees.** A new value, `'unbuildable_index'`. It has + no reconciler arm, and none can exist, because there is no column to build over. The remedy is + a metadata edit. It is in `INDEX_DRIFT_OPS`, so `isIndexDriftOp` answers `true` and it never + triggers a SQLite table rebuild. `applyMigrationEntries` reports it `skipped` on every dialect. + `os migrate plan` lists it under "Needs confirmation", addressed by its index name. `os migrate + apply` counts it like any `needs_confirm` entry (so it asks for `--yes`), and then reports it + skipped. The artifact-pinned boot warns about it and still starts, because + only `destructive` entries refuse a boot. A `switch` over `op.type` that treats unknown values + as "not applied" needs no change. An exhaustive `switch` with a `never` check gets one more case + to handle. + + **The object form's help text follows.** The `indexes` → Fields help in the Studio object form + said the skip left "a warning in the server log". It now says an error, in English and in the + zh-CN, ja-JP and es-ES translations. Nothing else in the text changes. + + **The lint message follows too.** `object-field-ref-unknown`, on a misspelt `indexes[].fields` + name, said the SQL driver skips the index "with only a warning, and drift drops it too". It now + says the skip is logged at error and `os migrate plan` reports the index as unbuildable. The rule, + its severity and its prescription are unchanged. + + **Upgrade note:** on a database that already carries such an index, `os migrate plan` now + reports one entry per index, and so does the boot's drift warning. That entry clears only when + the metadata names stored fields or drops the index. +- 4bd2c60: fix(lint): `validate-flow-template-paths` resolves flow-variable template roots, and gates the record trigger on the `record` root alone (#17305) + + The build-time guardrail against a template token that renders a silent empty + string could resolve exactly one root — `record` — and skipped any flow that was + not record-triggered. Both limits hid the failure it exists to catch, and both + are resolvable from the authored metadata alone: + + - A `get_record` node declares `objectName` **and** `outputVariable` in one + config, so the name it binds holds a record of a known object. `limit > 1` + switches the executor to a multi-record read, so that name holds an array and + is tracked as a list rather than a record root. + - A `loop` declares `collection` **and** `iteratorVariable`, so when the + collection names one of those lists, each element is a record of that object. + + `{caseRecord.owner_id.manager}` (a `get_record` output) and + `{currentCase.owner_id.manager}` (a `loop` iterator) are now judged by the same + two rules `{record..}` already was — `flow-template-unknown-field` + and `flow-template-lookup-traversal` — at the same position-based severity: an + `error` inside a filter-guarded CRUD node's `filter` (the node refuses to run, + framework#3810), a `warning` everywhere else. + + The record-trigger gate now applies to the `record` root alone. A `schedule` + flow's `get_record` output is as statically typed as a record-change flow's, so + such a flow is no longer skipped whole; `{record.…}` on it stays unjudged + exactly as before. + + **Newly reported, not newly refused by anything else.** No authorable key + changes, no export is added or removed, and no shape that parsed stops parsing. + What changes is that a flow whose template reaches through a variable can now + produce a finding. A root resolves only when nothing else in the flow can bind + that name — an assignment target, another node's `outputVariable`, an + `indexVariable` / `errorVariable`, a node id, or a trigger field flattened to + top level all make it ambiguous, and ambiguous stays silent. A `flow.variables` + declaration is deliberately **not** a second binder: it declares the slot the + node then fills, which is the shape `examples/app-todo`'s sweep flows ship. +- e958468: fix(lint): a hook write-set finding on a handler-authored hook reports `path: hooks[i].handler` — a key the author actually wrote — instead of the lowered `hooks[i].body.source` (#16546) + + `hook-api-update-readonly-field` / `hook-api-update-readonly-when-field` + (`validate-readonly-hook-writes.ts`) and `hook-body-write-unknown-field` / + `hook-body-write-unprovisioned-anchor` / `hook-body-source-unparseable` + (`validate-hook-body-writes.ts`) all report their `path` against `hook.body`, + because that is the shape they parse. For a hook authored as an inline + `handler: async (ctx) => { … }` (39 of 39 hooks in the reference app), + `hooks[i].body` is not something the author wrote at all — `lowerCallables` + mints it from the handler before `os build` / `os lint` hand the stack to + these rules (#16095). The reported `path` therefore named a key that does not + exist in the author's own source file; grepping for `body.source` there finds + nothing. + + **What changed.** `lowerCallables` now records, per `lowerCallables()` call, + which `hooks[*].handler` ref strings got their `body` minted this way (as + opposed to a `body` the author wrote directly). The CLI's four lowering doors + (`os build`, `os lint`, `os validate`, `os init`/`dev`'s scaffold validation) + pass that set through `runAuthoringRules`'s `ctx.loweredHookRefs`, and the two + hook write-set rules use it to redirect a finding on a lowered hook to + `path: hooks[i].handler` — the key that replaced the function the author + wrote — with a message suffix ("judged on the metadata body lowered from the + inline handler") explaining why. A hook whose `body` the author wrote directly + is unaffected: `path` stays `hooks[i].body.source`, unchanged. + + **No verdict changed.** Which hooks are flagged, at what severity, and why is + untouched — #13653 and #4271 are unmoved by a word. Only the location a + finding points at, and the wording explaining it, are different. `os build` + and `os lint` continue to report the identical `path` and message for the + same hook (#16095's "one implementation, both commands agree" — now including + this). + + No `--json` field was added or removed: `path` and `message` keep their + existing shape (string), and this is a within-type value correction for the + one subclass whose old value could never be resolved against the author's + source in the first place. +- 522f612: fix(lint): `component-type-unknown` reports an EXACT retired component type, relaying the spec's own prescription + + A retired component type is `isKnownComponentType` on purpose — its + `ComponentPropsMap` row is kept so the props door can dispatch the retirement + prescription — and this rule read that as "accepted". So a caller linting a + **raw stack** got silence on a name `PageComponentSchema.type` refuses at the + parse: the author's earliest feedback channel was the one that stayed quiet, + and the refusal landed later, at the parse door, or in front of an end user. + + ``` + FROM validateComponentTypes({ pages: [{ … components: [{ type: 'element:filter' }] }] }) + -> [] // silence, on a name the parser refuses + + TO -> [{ rule: 'component-type-unknown', severity: 'error', + path: 'pages[0].regions[0].components[0].type', + message: '`element:filter` was removed in @objectstack/spec 17 (ADR-0049) …' }] + ``` + + **No new prose.** The finding's `message` is the `RETIRED_PAGE_COMPONENT_TYPES` + entry **verbatim** — the same string the enum error map and the kept props row + already carry — pinned by byte equality in the rule's test, so the three doors + cannot drift and a type retired tomorrow arrives reported on the day it lands. + + Two things deliberately unchanged: `isKnownComponentType` still answers `true` + for a retired type (flipping it would MOVE the refusal out of the props door + rather than add a report), and the typo suggester still never proposes a retired + name. + + The new arm is judged **before** the reserved-namespace guard, because a + retirement can take its namespace with it: `user:profile` was the `user:` + namespace's only member, so `hasReservedComponentNamespace('user:profile')` is + `false` and a check placed after that guard would have stayed silent on the + member that has been refused longest. + + Measured before landing: **zero** authored instances of any retirement-map + member across the in-repo page sources, with live component types as the lit + control in the same query — so no existing authored stack turns red. +- 8fe5cb8: **Docs:** the 17.3.0 entry for #13935 no longer claims `FIELD_RULE_AMBIENT_ROOTS` and `FIELD_RULE_JUDGED_ROOTS` are exported — `src/index.ts` exports neither (#18169). + + `CHANGELOG.md` is in this package's `files[]`, so that sentence ships inside the npm tarball and is the text an upgrading agent greps. Measured on the published `@objectstack/lint@17.4.0` tarball (read 2026-09-16T12:25Z): the export block of `dist/index.js` names `FIELD_RULE_BOUND_ROOTS` and neither of the other two, and the export clause of `dist/index.d.ts` is the same — `FIELD_RULE_JUDGED_ROOTS` occurs in that file only inside two `{@link}` docblocks, and `FIELD_RULE_AMBIENT_ROOTS` not at all. A consumer who wrote `import { FIELD_RULE_AMBIENT_ROOTS } from '@objectstack/lint'` on the strength of the entry got a resolution failure. + + Per AGENTS.md, a factual error in a released entry is amended **in place**, in a dedicated docs-only PR, never by an erratum in a later entry — the reader greps the symbol and lands on the old entry, so a correction anywhere else is one they never reach. The correction therefore lives in the 17.3.0 entry itself, which now states what `src/index.ts` actually exports, verified at the export statement. This changeset is not that correction; it exists so the corrected text reaches the registry at all. Published tarballs are immutable, so the amendment becomes published text on the next publish of this package and not before. + + No code, no export, and no behaviour moves. +- 7e05b9d: **Fix:** a field-level `*When` predicate reading `app` no longer tells the author the root is mounted by the renderer — decision batch #67 ruled that away, and the diagnostic now says `app` binds nowhere at all. + + `FIELD_RULE_AMBIENT_ROOTS` is renamed `FIELD_RULE_NOWHERE_BOUND_ROOTS` and keeps its single member. The name and the docblock were the false part: #13935 added `app` on the premise that objectui's app-shell bound it at the renderer, so the honest verdict was "bound somewhere, just not here". Batch #67 (2026-09-07) ruled the engine's `SCOPE_ROOTS` to be the contract and ObjectUI aligned to it, so nothing binds `app` any more — and the constant's cited source of truth, the page-component schema's ambient-roots section, no longer names `app` either. + + What an author reads changes; what lints clean does not. Before and after, both `app` spellings earn exactly one `error`. + + - **Before:** `` `app` is NOT declared platform-wide — it is an AMBIENT root, mounted only by the renderer (…) So it resolves in a form VIEW's own field predicate and on no server path at all … `` and, at the end, an offer to *"leave the `app`-dependent decision on the view's own field predicate where `app` IS bound"* — a destination that no longer binds it. + - **After:** `` `app` is NOT declared platform-wide, and no evaluation site binds it — not this one, and not any other … The predicate therefore faults wherever it is written, and there is no surface to move it to. `` + + The ``⛔ Do NOT write `record.app` `` refusal is kept verbatim, and that is the point of the repair. Emptying the constant — the obvious reading of "nothing is ambient any more" — drops the root through to `@objectstack/formula`'s generic bare-reference check, whose prescription is to rewrite the root as a member of the record; following that earns ``unknown field `app` `` one pass later. That is the exact two-step wrong correction #13935 existed to remove, so the membership stays and only its grounds move. Four pins now assert, on both the bare and the dotted spelling, that no path produces that prescription. + + No published export moves: `FIELD_RULE_AMBIENT_ROOTS` was never re-exported from this package's entry (only `validateStackExpressions`, `fieldRuleRootIssue` and `FIELD_RULE_BOUND_ROOTS` are), so the rename is internal and no import breaks. +- 7026141: fix(plugin-security)!: an RLS predicate naming an undeclared column now denies in EVERY position and polarity, on the read face and the write face alike (#17042) + + + + **BREAKING** — a fail-open-to-fail-closed narrowing on row-level security. A policy that widened yesterday denies today. Shipped as `minor` under the launch-window convention, the same grading the insert-side `check` post-image narrowing used. + + A predicate naming a column the object does **not declare** could not narrow, and in a **negation-carrying position** it did not deny either — it **widened** the policy to every row inside the tenant wall, and on the write path it **permitted** the write the policy was authored to refuse. + + ⛔ It is **not** a cross-tenant leak. Tenancy is a separate layer and it holds. What was defeated is the narrowing the policy author wrote *inside* the wall — an owner-only or private-record policy silently becoming "every row". + + Two independent sites, each with its own reason, each measured against the same two controls (a real column must still narrow; the *same* phantom column in a **positive** position must still refuse): + + - **Read face.** `extractTargetField` is a **leading-only** `==` / `=` / `in` shape match, so `nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])` and any arm after the first returned `null`; the policy was **kept**, the drop counter never incremented and the deny sentinel never armed. The kept filter then met the settled include-direction ruling — a row that *has* no such column satisfies "column != x". Measured on the matcher: **3 of 3** rows for each negated shape, against **1 of 3** for the real narrowing and **0 of 3** for the same phantom column in a positive position. + - **Write face — the worse one.** `computeWriteCheckFilter` compiled `check` clauses with **no field-existence check at all**, and the ADR-0058 D4 post-image gate evaluates that filter in-process. Measured end to end on both SQL drivers: every negated phantom **permitted** the insert, in both post-image polarities, while a positive phantom refused (by accident of an absent value comparing unequal) — which is why a suite that only ever exercised the positive shape stayed green over the hole. + + **The repair is one seam, not two.** `RLSCompiler.compileFilter` — the single choke point both the read layer and the write gate already pass through — now takes the object's declared-column set and judges every column the policy names on the **compiled** `FilterCondition` tree. That is positional-agnostic by construction: the pushdown compiler lowers `!` to `$not`, `||` to `$or` and `&&` to `$and`, so a column lands as a plain object key whatever position it was authored in, and there is no spelling of negation left for a shape match to miss. Widening the regex instead was rejected: a matcher that must enumerate every spelling of negation is the same "recognises only what it was told about" defect one level over, and it would additionally have broken the ADR-0095 carve-out that *depends* on the regex recognising only the leading shape. A policy dropped this way joins the existing fail-closed path — same deny sentinel, same WARN line — rather than growing a parallel mechanism. + + ⛔ **The matcher's include-direction ruling is untouched.** A row lacking a column *does* satisfy "column != x" for an ordinary user query, and re-semanticing every filter in the repo to fix one caller is not the trade. The defect was that a policy compiler lowered an undeclared column into a filter at all; the matcher now never sees a phantom, and a regression test pins the raw matcher still answering 3 of 3 for the same filter so a later reader can see which half moved. + + **Who is affected.** Only a permission set carrying an RLS policy whose predicate names a column its object does not declare — an authoring mistake `@objectstack/lint` already reports on all of these shapes. For such a policy the object now returns **zero rows** for every holder of the set (read) and refuses every governed insert / update (write), where before a negated spelling returned everything and permitted everything. ⚠️ **An installation relying on such a policy to grant access will lose that access at the upgrade, and that is the intended direction**: what it was "granting" was the absence of enforcement. Correct the column name; the linter names the miss and offers the object's real field list. + + **driver-sql, previously unmeasured, is now measured, and it refines the picture.** On the **read** face `driver-sql` and `driver-sqlite-wasm` never widened — they failed closed by **raising** `INVALID_FILTER` / 400 when the phantom column reached the statement builder, so the read-face defect was driver-dependent (in-process matchers widened; SQL raised). On the **write** face they failed open exactly like every other driver, because the `check` is evaluated in-process and never reaches SQL. After this change both faces answer uniformly on both drivers. `driver-mongodb` remains inferred from the shared ruling rather than measured. + + `@objectstack/lint`'s diagnostic for this miss is corrected in the same change. Its **detection is unchanged** — all the negated shapes were already reported. Its consequence text was stale in one half and misattributed in the other: it described the field miss as having two directions decided by position, and it credited the write leg's fail-closed to a safety net that path never had. It now states one direction for both clauses, and records the older runtime's fail-open write behaviour explicitly so an operator reading it against a deployment that predates this guard is not told the wrong thing. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [2e0401a] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [fbc12be] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [338feda] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [7465eeb] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [f55922f] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/sdui-parser@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index 1cb16c08427..b4c47798953 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Static, build-time validation for an ObjectStack metadata graph — dashboard widget bindings, CEL/predicate expressions, and more. Pure (stack) => Issue[] functions shared by the CLI's `os validate` and any other consumer (e.g. AI authoring). Depends on @objectstack/spec; never on a runtime.", "type": "module", diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index fdef8d61c4e..e3a1274bc5f 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,778 @@ # @objectstack/plugin-mcp-server +## 17.5.0 + +### Minor Changes + +- 08b213e: feat(mcp): `resume_run` continues a flow run that paused on a screen, behind the same gates as `run_action` (#15705) + + Clause-②: yes + + **What changed.** `run_action` on a flow action whose flow stops on a `screen` node answers `status: "paused"` with a `runId` and the `screen` to fill in. Until now nothing on the MCP surface could submit that screen, so the run stayed parked: an agent could start such an action but never finish it. The new MCP tool `resume_run({ runId, values?, confirm? })` submits the screen's field values (keyed by the names in `screen.fields`) and the run continues. It answers with `run_action`'s envelope, `{ ok, action, objectName, recordId?, result }`. A run that pauses on its next screen comes back paused again, so a multi-screen wizard is walked by calling `resume_run` once per screen. + + **Which runs it continues, and no others.** The runtime's bridge admits a call only where `run_action` would admit starting the same flow on the same record for this caller now: + + - **Only the caller's own run.** The run's trigger identity must be the caller. Another user's run, an unknown id and a finished run all answer the same `404 RESOURCE_NOT_FOUND`. A resumed run continues under the identity of the user who started it, so only that user may continue it. + - **`run_action`'s gates, with `run_action`'s helpers.** A `type: 'flow'` action whose `target` is the run's flow, on the run's object, must be AI-exposed (`ai.exposed`), must pass the caller's `requiredPermissions` and must not be switched off (`ACTION_DISABLED`, `409`). An action flagged `ai.requiresConfirmation` needs `confirm: true` on the resume too (`ACTION_CONFIRMATION_REQUIRED`, `428`), because the flow's writes happen after the screen. The exposure and permission refusals answer `403 PERMISSION_DENIED`. So does a run that no flow action targets. + - **The subject record is read again as the caller.** A record the caller can no longer read is refused `404 RECORD_NOT_FOUND`, which is how `run_action` refuses it. + - **Screen pauses only.** A run waiting on anything else (a timer `wait`, an approval) is refused `409 RESOURCE_CONFLICT` and left as it is. + + Every refusal happens before the engine is asked, so the run stays parked. The engine's own answers (a screen submission missing a required field, a concurrent resume, a run that resumed and then failed) reach the caller with the code, status, message and `details` that `POST /api/v1/automation/:name/runs/:runId/resume` gives for the same result. The two doors now share one classification of the engine's answer. It was moved out of the REST route unchanged, and the route's answers are byte-identical. + + **For hosts.** `McpActionBridge` gains an OPTIONAL member, `resumeRun(runId, { values?, confirm? })`. A bridge that implements it gets `resume_run` beside `run_action`, under the same `actions:execute` OAuth scope, on both the HTTP and the stdio transport. A bridge without it is unchanged and does not list the tool. `run_action`'s description names `resume_run` only where it is registered. `@objectstack/runtime`'s MCP bridge implements the member. +- 76ddab7: fix(runtime,mcp): `action.ai.requiresConfirmation` is ENFORCED at the AI-facing action door — an unconfirmed call is refused, and `run_action` grows the `confirm` member that satisfies it (#15942) + + **Behaviour change — read this if any of your actions declare `ai.requiresConfirmation: true`.** An AI-facing invocation of such an action (`invokeBusinessAction`, reached from the MCP `run_action` tool) is now REFUSED unless the request carries the confirmation member. A call that succeeded before starts answering `428 ACTION_CONFIRMATION_REQUIRED`, and nothing dispatches: the action body does not run, and the subject record is not even read. + + FROM → TO, for a caller of a gated action: + + ``` + run_action({ actionName: 'archive_lead', recordId: 'lead_1' }) // was: ran + run_action({ actionName: 'archive_lead', recordId: 'lead_1', confirm: true }) // now: required + ``` + + The refusal is machine-readable so the retry is mechanical rather than guessed — `error.details` carries `{ actionName, objectName?, confirmationMember }`, and `confirmationMember` echoes the member's exact spelling (`AI_ACTION_CONFIRMATION_MEMBER`, `@objectstack/spec/contracts`). The `run_action` tool schema advertises `confirm` as an optional boolean, so an agent discovers the retry from the tool definition rather than from prose. + + **What is NOT gated**, because this narrows a published accept set and the narrowing is deliberately as small as the author's own declaration: + + - Only the DECLARED flag gates. `ai.requiresConfirmation: true`, set by the action's author, and nothing else. The wider `list_actions` heuristic — `mode: 'delete'` / `variant: 'danger'` on an action whose author declared nothing — still reports `requiresConfirmation: true` to advise a client, and still does NOT refuse. An explicit `ai.requiresConfirmation: false` never refuses. + - Only the boolean `true` confirms. `'true'`, `1` and `false` are not attestations. + - Only the AI-facing doors. The enforced set is the doors that enforce `ai.exposed` — today `invokeBusinessAction` via MCP `run_action`. REST `/actions` is not `ai.exposed`-gated and sits outside this gate. + - `list_actions` is unchanged. + + **A gate, not a queue.** Nothing is parked, nothing is held for an operator, and there is no resume path: a refused call simply did not run, and the caller confirms with its human and retries. And `confirm: true` is an unverifiable caller claim — an agent that always sends it bypasses the gate. The gate makes FORGETTING loud; it does not prove a human. + + Why it is worth the break: the flag was read once and consumed once, to fill a field of the `list_actions` summary. It stopped nothing. That is the failure ADR-0049 retired `tool.requiresConfirmation` for — "a SAFETY flag that is merely accepted is false compliance" — reappearing on the very key the retirement's own ledger entry told authors to move to. The contract this implements landed in `@objectstack/spec` first (#16293). +- 331a1a2: fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) + + Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** + + **The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: + + | identity path | `crm_account` | `crm_opportunity` | `crm_task` | + |:--|--:|--:|--:| + | API key, `principalKind: human` | 9 | 23 | 45 | + | OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | + + The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. + + **The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. + + **(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. + + ⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. + + **(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. + + **(3)** The Setup page's promise is untouched — it is now true rather than rewritten. + + Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. + + `DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: + + | direction, after release | consumer cost | + |:--|:--| + | ship optional fields, later tighten them to required | a compile break | + | ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | + + The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. + +### Patch Changes + +- f19dbcf: Connect an Agent is reachable from the Account app, so a non-admin can mint their own key + + `POST /api/v1/keys` mints a `sys_api_key` bound to the **caller**, and the + Connect-an-Agent page says the key "acts as you". But the page's only navigation + entry sat in the Setup app, which declares `requiredPermissions: + ['setup.access']` — so every non-admin following the shipped two-step guide, and + every reader of the runtime's own error text (`packages/mcp/src/plugin.ts`: + *"mint an API key (Setup → Connect an Agent, or POST /api/v1/keys)"*, and + `README.md`), stopped at step 1 while the endpoint behind the button had accepted + them all along. Measured before: a principal with no system permissions gets + `403 PERMISSION_DENIED` on `GET /api/v1/meta/apps/setup` and `nav_connect_agent` + is absent from the wire. + + `CONNECT_AGENT_UI_BUNDLE` now carries a **second** `navigationContributions` + entry, targeting the `account` app's `grp_account_developer` group beside the + `nav_account_api_keys` entry already shipping there. Measured after, over the + real composition (real `SETUP_APP` / `ACCOUNT_APP` / `SETUP_NAV_CONTRIBUTIONS`, + the real fold and the real RBAC-by-route filter): the same permissionless + principal gets `200` on `GET /api/v1/meta/apps/account` with + `grp_account_developer` carrying `['nav_account_api_keys', + 'nav_account_oauth_apps', 'nav_connect_agent']`, while `apps/setup` still + answers `403 PERMISSION_DENIED` with `connect_agent` absent from that body. + + **Nothing else moves.** No backend change, no authorization change, no change to + which permissions exist, and the published "acts as you" promise is unchanged — + it simply becomes keepable for the users it was written for. The Setup entry + stays exactly as it was, so admins keep the page where the guide points, and no + gate is added or removed anywhere: a navigation contribution registers exactly + when the page registers, so an opted-out deployment + (`OS_MCP_SERVER_ENABLED=false`) still gets no page and neither entry. + + ⛔ Ungating Setup was **not** the fix, and was measured rather than assumed: the + app-level `setup.access` gate fires before the group gate, so dropping the group + gate alone changes nothing, and dropping both serves 14+ unrelated Setup + surfaces (Users, Organization, Business Units, Branding, Feature Flags, …) to + every signed-in user. ⛔ Nor was a `requiresService: 'mcp'` gate on an + `account.app.ts` entry: the `mcp` service registers unconditionally in `init()` + while this bundle registers behind `isMcpServerEnabled()`, so such an entry + would outlive its page and 404 for every signed-in user on an opted-out + deployment. + + Both entries deliberately share the item id `nav_connect_agent` — one + destination, one identity. That is scoped, not a collision: `SchemaRegistry` + keys contributions by target app and `applyNavContributions(app)` consults only + that app's bucket, so a nav item id is unique within one app's navigation tree, + and the translation bundles are keyed `apps..navigation.`. +- 4af758d: refactor(runtime,mcp): the last two admission doors classify the `tenancy` rejection through the shared `classifyAdmissionTenancyPosture` (#17114) + + `@objectstack/core`'s `classifyAdmissionTenancyPosture` is the one place the + #13906 decision 1 option A classification lives: a branded "never registered" + rejection is the supported no-tenancy composition and answers a quiet + `undefined`, while every other rejection becomes + `AuthzStoreUnavailableError('tenancy', err)` — ADR-0112 `SERVICE_UNAVAILABLE` / + 503 — because the posture is an authorization INPUT and admission was never + decided. + + Two admission doors were still hand-writing that classification, out of the + declared scope of the fold that extracted it: + + - `@objectstack/runtime`'s `resolveExecutionContext` — the REST/dispatcher + entry-point identity resolver; + - `@objectstack/mcp`'s `resolveStdioTenancyPosture` — the stdio door's **async + kernel** leg. + + Both now call the shared function. ⛔ **No behaviour changes at either door.** + Tenancy posture decides which rows a caller may see, so a divergence between + copies would be two answers to "whose data is this", and the copies are the + stale ones by construction — the shared version is the one that will be + maintained. + + **The resolution stayed at each seam, deliberately.** The extractable part is + the classification, not the resolution: each door keeps its own accessor guard + and hands its own former accessor expression in as the thunk, so the helper + never learns *how* a seam reaches the service. A helper that owned the wiring + too would be wrong for one seam or grow a flag per seam. + + **One neighbouring leg is deliberately NOT folded.** The stdio door's **sync** + fallback is taken only on a `KernelBase`-shaped host with no `getServiceAsync`, + whose accessor reports its one possible fault — nothing registered under that + name — **unbranded**. Routing it through the shared classification would mint a + 503 outage out of a supported composition, so its bare `catch` remains that + seam's recorded decision. A test arm now fails if that leg is ever folded. + + Shipped rather than `skip-changeset`: both packages publish `files[]: ["dist"]`, + and the built `dist` of each carries the new call (2 files each, measured after + a real build, with a symbol known-absent scoring 0 and + `isServiceNotRegisteredError` scoring 4 in `runtime/dist` as the lit control). + `@objectstack/mcp`'s `dist` no longer mentions `isServiceNotRegisteredError` at + all. +- c5d270a: Point Connect-an-Agent instructions at the Account door too, so a non-admin is told a path they can actually take + + #17646 made the Connect-an-Agent page reachable for every signed-in user by + adding a second `navigationContributions` entry into the **`account`** app's + `grp_account_developer` group. It deliberately did **not** ungate Setup — that + was measured to expose 14+ unrelated Setup surfaces — so the same principal + still gets `403 PERMISSION_DENIED` on `GET /api/v1/meta/apps/setup`. + + The shipped instructions never moved. The stdio transport's refusal message and + this package's README both said *"Setup → Connect an Agent"*, naming the one app + a non-admin cannot open — read, in the refusal's case, at exactly the moment the + user is stuck. Both now name **both** doors: **Account → Developer** for any + signed-in user, **Setup → Connect an Agent** for platform admins. The Setup + entry is unchanged and stays where admins already look. + + Text only — no behaviour, no gate, no authorization change. +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- 95fb417: **The declared `zod` floor moves from `^4.4.3` to `^4.6.1`**, because on zod below 4.6.1 the three standard error formatters — `z.treeifyError()`, `error.format()` and `error.flatten()` — cannot render a refusal these packages actually emit (#19581). + + Clause-②: no + + **What breaks below the new floor.** All three formatters walked an issue's `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming a member of `Object.prototype` was answered by the prototype and no node was ever created. Two different failures follow: + + | path shape | what happened on `^4.4.3` | + |:---|:---| + | terminal element (`['assignments','__proto__']`, `['x','toString']`) | the inherited member is adopted as the node, then `node._errors.push(...)` runs on it — `TypeError: Cannot read properties of undefined (reading 'push')` | + | non-terminal element (`['__proto__', …]`) | the walk continues **into** `Object.prototype` and writes the next segment onto it — the message is silently dropped from the returned tree and the process gains a global prototype key | + + **Why it reached this platform's consumers.** `@objectstack/spec` refuses a `__proto__` key on its open-key authoring surfaces, and that refusal's issue path is `['assignments','__proto__']` — precisely the terminal shape. Anything that formatted one of these refusals for display crashed on it, and the crash was in the formatter, not in the guard. The guards themselves are unchanged and still necessary: 4.6.1 still drops a `__proto__` key from `z.record()` and `.catchall()` output, which is what they exist to refuse. + + **What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. + + `@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. + + ## The second half the floor move needs: an unknown key refuses TERMINALLY again + + From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no + longer aborts the shape that raised it. Two things follow, and both were + measured on this package with the same bodies on 4.4.3 and 4.6.1: + + 1. **A closed shape's own refinements now run after the refusal**, adding a + second complaint that contradicts the first. + 2. **A union containing that shape loses its envelope.** zod's + `handleUnionResults` returns a single non-aborted member's issues + *unwrapped* instead of raising `invalid_union`, so the union's message + becomes whichever branch zod judged closest. + + At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong + branch's prescription. Writing `type: 'page'` on a ViewItem answered: + + ``` + Unrecognized key(s) on this view container: `viewKind`, `config`. + • `viewKind` belongs to a single VIEW, not to the container. Wrap it: … + ``` + + — naming neither `page` nor its removal. It now answers, as it did before: + + ``` + config.type: 'page' was removed from the list-view `type` enum in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … + ``` + + **What an upgrading consumer must do.** Nothing. No key or value changed + status: everything this package accepted before it accepts now, and everything + it refused it still refuses. What changed is which of several competing + complaints an author reads, and that a refusal behind a union is again + reported as `invalid_union` with its branches, which is what `z.treeifyError()` + and this package's own `formatZodError` expand. + + ⚠️ A closed shape declared with a bare `z.object(…).strict()` or + `z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT + get this and will still collapse its union. Build closed authoring shapes with + `strictObject`, or re-declare an existing one through `closedObject`. +- 3977410: docs(mcp): the README no longer promises that Claude Desktop reaches intranet deployments — *Add custom connector* is the claude.ai connector system and dials from Anthropic's servers (#16882) + + `packages/mcp/README.md` grouped the clients by **where the client application runs**: "Local clients (Claude Code / Desktop) can reach intranet deployments; claude.ai web connectors additionally need the endpoint publicly reachable." That grouping is wrong for Claude Desktop. Its *Settings → Connectors → Add custom connector* flow is the same claude.ai connector system, and the connection to the MCP server is made **from Anthropic's servers** — Anthropic's custom-connector documentation requires the server to be reachable over the public internet from Anthropic's IP ranges and states that a server on a private corporate network, behind a VPN, or blocked by a firewall will not connect. An operator following the old sentence pointed Claude Desktop at an intranet address and the failure surfaced inside a third-party client, with nothing to connect it back to our instructions. + + The README now groups by **where the connection is made from**, which is the mechanism and does not go stale when a client's dialog is redesigned: + + - **Claude Code** (`claude mcp add`, or the plugin) dials the endpoint from your own machine, so `localhost` and intranet-only deployments work — this is the door that genuinely reaches a private deployment, and the README now names it as such. + - **claude.ai (web) and Claude Desktop** go through the one claude.ai custom-connector system and need public HTTPS; a locally trusted certificate does not make a private address reachable. + + Documentation only — no exported symbol, endpoint, schema or runtime behaviour changes. The `patch` bump is because `README.md` is in this package's published `files[]`, so the corrected text ships to the npm page. +- 46cf705: fix(mcp): refuse undeclared argument keys on every MCP tool instead of stripping them + + `query_records` answered `{"objectName":"crm_opportunity","sort":"-amount","limit":3}` with `200` + and rows in seed order, and `{"objectName":"crm_opportunity","filters":[["name","contains","Meridian"]]}` + with `200` and the full unfiltered set. Neither key is declared, and zod's strip default — reached + through the MCP SDK's raw-shape wrap — deleted both before the handler ran, so the handler could not + report what it never received. Nothing in either payload distinguished it from a real answer, and the + consumer of these tools is an AI agent: it reads a successful response and reports the wrong answer + confidently. A dropped sort key answers a differently ORDERED set; a dropped filter key answers a + WIDER one. + + All eleven tools held that posture; none refused. Each tool's `inputSchema` is now a built strict + object, so an undeclared key is refused before dispatch, the data bridge is never reached, and + `tools/list` advertises `additionalProperties: false` — the closed set is readable off the schema + rather than discoverable only by being refused. The refusal names the offending key and, where the + spelling is recognisable, the declared one to send instead. + + Spellings that used to be accepted-and-ignored, and what to send now. Every one of them was already + inert: it was dropped, and the call proceeded exactly as if it had never been sent. + + | previously sent and ignored | send instead | on | + | :-- | :-- | :-- | + | `sort`, `sortBy`, `order`, `order_by` | `orderBy` | `query_records` | + | `filters`, `filter`, `conditions`, `criteria` | `where` | `query_records` | + | `select`, `columns`, `projection` | `fields` | `query_records` | + | `pageSize`, `top`, `take` | `limit` | `query_records` | + | `skip`, `start` | `offset` | `query_records` | + | `filters`, `filter`, `conditions` | `where` | `aggregate_records` | + | `metrics`, `aggregates`, `aggs` | `aggregations` | `aggregate_records` | + | `group_by` | `groupBy` | `aggregate_records` | + | `tz`, `timeZone` | `timezone` | `aggregate_records` | + | `object`, `table` | `objectName` | every object-scoped tool | + | `id`, `record_id` | `recordId` | `get_record`, `update_record`, `delete_record`, `run_action` | + | `record`, `values`, `fields` | `data` | `create_record`, `update_record` | + | `action`, `name`, `action_name` | `actionName` | `run_action` | + | `args`, `input`, `arguments`, `parameters` | `params` | `run_action` | + | `formula`, `expr`, `cel` | `expression` | `validate_expression` | + + A key outside this table is refused with its name echoed back and a closest-declared-key suggestion + when one is within a length-relative edit distance. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [7465eeb] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 5daf0eeda42..d3a488ea36e 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack as an MCP server — exposes your app's objects (and AI tools) over the Model Context Protocol (stdio + Streamable HTTP)", "type": "module", diff --git a/packages/metadata-core/CHANGELOG.md b/packages/metadata-core/CHANGELOG.md index 490fc200c5b..8cab94d404a 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,949 @@ # @objectstack/metadata-core +## 17.5.0 + +### Minor Changes + +- 502f179: **BREAKING** — retire `object.tenancy.organizationField`, the stamp-only column + declaration the whole protocol declared exactly once, on a table this platform ships. + + The key answered "which column says who this platform row is ABOUT", where + `tenancy.tenantField` answers "what is this object WALLED by". The spec's own docblock + stated the consequence: *"For ordinary objects the two coincide and `organizationField` + is never needed."* Measured on `main` before this change, the entire repository declared + it **once** — `packages/platform-objects/src/identity/sys-api-key.object.ts`, the + better-auth credential table — and zero business objects declared it anywhere. Its + readers were three platform-row writers, scope-pinned **by name** (audit stamping, the + approval-row writer, the automation-run recorder), so an application declaration was + inert by construction while still being authorable on every object, which made every + future piece of organization logic owe the question "what if somebody set this?". + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18, verbatim and untranslated: + 「organizationField 撤出可授权面 同意你的建议」. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `tenancy: { enabled: false, organizationField: 'active_organization_id' }` | `tenancy: { enabled: false }` — delete the key. Nothing read it on an application object | + | `tenancy: { enabled: true, organizationField: 'about_org_id' }` on an object whose tenant column really is `about_org_id` | `tenancy: { enabled: true, tenantField: 'about_org_id' }` — the surviving key both walls the object and stamps its platform rows | + | you declared it to make one platform table's rows stamp differently | nothing to write. That divergence is a platform fact now, not a knob | + + The `tenancy` block is `.strict()`, so the key is **refused** with its prescription + rather than stripped, and `os migrate meta --from 17` lists the mechanical edits for + existing sources. + + ## What does NOT change + + The `sys_api_key` divergence is intact, and that is the point of the shape this takes. + The credential table is `managedBy: 'better-auth'`, so `resolveInjectedSystemColumns` + bails before tenancy is consulted and no `organization_id` is ever injected; the column + it really carries is better-auth's `active_organization_id`. Its audit, approval and + automation-run rows still stamp that column. What moved is only where the fact is + written: `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, one row, + keyed by object name and read by the STAMP face alone. The WALL face + (`resolveRecordWallOrganizationField`) never read the key and is untouched, so the + stamp/wall divergence pin stands unchanged. + + ⛔ The column is **not** renamed to `organization_id` and must never be: in this platform + "has an `organization_id` column" IS the wall, so the rename would wall the credential + table on an equality that excludes NULL and every pre-existing key would vanish from its + own owner's key list. + + ## For `@objectstack/metadata-core` consumers + + `resolveRecordOrganizationField` and `createRecordOrganizationResolver` keep their + signatures and their four-limb precedence. Limb 0 is now keyed by the object's + registered NAME against the platform table instead of by a declaration on the definition: + the engine-bound resolver passes the name it was asked about, and the two-argument + function reads `objectDef.name` when the definition carries one. A caller that fed it a + hand-built definition carrying `tenancy.organizationField` — only reachable by + reimplementing a platform writer — now gets limbs 1 to 4. + + The retirement kit, in the shape the playbook prescribes: + + - the key is DELETED from `TenancyConfigSchema` (the block is a `strictObject`), and a + `TENANCY_RETIRED_KEY_GUIDANCE` row carries the prescription beside the two v15.0 + precedents (`tenancy.strategy`, `tenancy.crossTenantAccess`) + - D2 conversion `object-tenancy-organization-field-removed` (`toMajor: 18`, + `retiredFromLoadPath: true`) strips the key from authored sources and stored + `sys_metadata` rows; D3 wires it into the protocol-18 chain step, and + `RETIRED_KEYS_BY_MAJOR[18]` declares `data/TenancyConfig:organizationField` + - the `authorable-surface/data.json` row is deleted in this same commit — the strict + route's tripwire — with the build computing the guidance-route proof for itself + - the liveness ledger row is deleted, since the key leaves the walked shape entirely + - pin tests: the authored shape is refused with its prescription, and the `sys_api_key` + stamp is pinned end to end beside the closed-set control (the same shape under any + other object name takes the ordinary limbs) + + Clause-②: no + + +- e956924: feat(spec,metadata-core)!: every retired ADR-0087 conversion carries `retiredAfter`, and the artifact door opens its window per entry (#20390) + + Clause-②: yes + + + + **BREAKING** for code that implements `MetadataConversion` itself — shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above). `MetadataConversion` is now a type alias of a live-or-retired union: an entry with `retiredFromLoadPath: true` must also carry `retiredAfter`, a stable `x.y.z` string, and a live entry carries neither. tsc names the missing member (`Property 'retiredAfter' is missing`). No in-repo conversion is left unstamped, and no metadata an author writes changes. + + **What the field means.** `retiredAfter` is the last published `@objectstack/spec` version whose authoring surface still accepted the entry's old shape. It is a fact when the entry lands: the package's own version label at that moment, because `main` carries the last release's label until the next release is cut. Every published retired entry is stamped from the published tarballs — the stable release just before the first tarball that carries it retired — and each entry not yet in any published tarball carries the current label, `17.4.0`. + + **Why the artifact door needed it.** Between two releases, `main` refuses keys that the next release retires while its label still reads the last release. The artifact-ingestion door (`applyArtifactForwardConversions`) compared an artifact's `engines.protocol` floor with that label alone, so an artifact built by the last published CLI — floor `^17.4.0`, dashboard `chartConfig.type`/`xAxis`/`yAxis` and page `assignedProfiles` — read as "authored current": nothing was converted and the strict parse refused the boot. The door now replays a registry entry when the floor is below the runtime label, **or** at or below that entry's `retiredAfter`. After a release the rule reduces to the old one, and an artifact whose floor is above an entry's `retiredAfter` still meets that entry's tombstone — a floor of `^17.5.0` on a 17.5.0 runtime is refused, not converted. `DEFAULT_FLIPS_NOT_REPLAYED_HERE` is still read first. + + **`@objectstack/metadata-core`.** `ArtifactForwardConversionVerdict` gains `'converted-retired-after'`: the floor is at or above the runtime label, but at or below the `retiredAfter` of at least one retired entry, and only those entries are replayed. `ArtifactForwardConversionResult` gains `replayedRetirements` (exported element type `ArtifactReplayedRetirement`): under that verdict, each retirement this runtime enforces past the artifact's floor, with its `retiredAfter`; empty for every other verdict. A consumer that switches exhaustively over the verdict adds that arm. + + **`@objectstack/metadata`, the artifact door — the arm added.** `MetadataPlugin` now reads which verdicts open the window from one total table over `ArtifactForwardConversionVerdict`, with `'converted-retired-after'` on the open side. The #12915 unbound form-predicate notice rides that same reading, so a 17.4.0-built artifact carrying a bare-root form predicate on `main` is announced now, rather than only once the package label moves past 17.4.0. A verdict added later fails to compile until it is placed on one side of the window. Under the new verdict the conversion summary no longer says the artifact "predates this runtime's spec" beside a runtime version equal to its floor: it names the retirement this runtime enforces past the artifact's floor, with the release that last accepted the shape, and says the artifact converts again on every boot until it is rebuilt with tooling from a release that ships the retirement. Summaries are still one per conversion per artifact, naming the site count. + + **Census.** 94 retired entries when this landed: 73 published (first retired in 15.1.0: 5, 17.0.0: 45, 17.1.0: 5, 17.2.0: 2, 17.3.0: 8, 17.4.0: 8) and 21 unpublished. `packages/spec/src/conversions/retired-after.census.json` holds the raw per-release facts, and `retired-after.census.test.ts` pins every value against it, offline. `packages/spec/scripts/build-retired-after-census.ts` re-derives the census from the npm registry (tarball integrity checked). Run it after each stable publish; `docs/releases-maintenance.md` lists that step in the GA release flow. +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- 0a56d3b: feat(spec,types,triggers)!: `group` runs package-authored scheduled work without a declaration, owning each run's writes per record (#18378) + + + + `Clause-②: yes (widening)` + + **ADR-0087 disposition — `not-required (already-registered)`, not `registered`.** + The ledger entry this change belongs to already exists + (`schedule-flow-acting-organization-required`, entry 18) and predates this diff + at the merge base, so `registered` would assert a registration this PR did not + make. The entry's `surface`, `replacement`, `reason` and `acceptanceCriteria` + each gained their `group` row here, the rejected bootstrap-organization arm + included — recorded because it is the one a later reader will re-propose. + + **Marked breaking (`!`) for the behaviour change, not for a narrowing.** Nothing + that worked stops working and nothing that was admitted becomes refused — the + accept set WIDENS in one cell. What earns the banner is the other direction: on a + `group` deployment with the switch already on, flows that were refused at bind + now arm and run, so clock-driven work appears where an operator had none. That is + worth reading before upgrading even though no consumer has to change anything. + + ## What changes + + With `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` on and tenancy posture `group`, a + time-triggered flow that declares no `config.organization` now **binds and + runs**, where it was previously refused at bind. The organization its writes + carry follows the record: + + | posture | declaration | a bound run's writes act as | + |---|---|---| + | `single` | not read | nothing — the install's one organization resolves beneath each write | + | `group` | **optional** | declared ⇒ the declaration; undeclared ⇒ **the swept record's own organization** | + | `isolated` | **required** | the declaration; undeclared ⇒ not armed, unchanged | + + A `timeRelative` sweep under `group` reads group-wide — inherent to the posture + (ADR-0105 D1) — and stamps each run it launches with that record's organization: + sweep contracts across four plants and each plant's contract yields a run acting + as that plant, whose notifications reach that plant's inboxes. + + ## Why this is not a fallback that guesses + + It is the order `sys_automation_run` was **already** ruled to use. + `ObjectStoreSuspendedRunStore` resolves a run's organization as + `organizationOf() ?? ctx.tenantId` — subject first, acting + context as the fallback and never the primary. Before this change those two + halves disagreed under `group`: the history row was stamped from the record while + the inbox and delivery rows followed an acting context that could not exist + there, so they were refused while the tick summarised itself as healthy. + + ⚠️ With one stated exception, because the two halves ask different questions: + the history row is STAMPED (`tenancy.organizationField` wins there) while the + run's acting organization is a WALL reading that never consults that key. They + agree on every object where the two coincide — which is every ordinary object, + since a declared stamp column is what makes them differ and one shipped object + declares one (`sys_api_key`, deliberately unwalled). Sweeping that object under + `group` stamps its history row while the run itself acts as nothing: the correct + pair of answers, not a residue of the old disagreement, and recorded rather than + smoothed over. + + ⛔ A record-less run under `group` that declared nothing still resolves + **nothing** and is refused at its first tenant-scoped write (`walled-posture`, + ADR-0112), loudly and by name. The rejected alternative was a fallback to the + bootstrap organization (`slug='default'`): under a wall that organization is + minted admin-keyed by the enterprise organizations runtime and may not exist at + all, and where it does it is whichever organization the platform owner + registered under — plausibly one plant of many, not the group's head office. + + ## Upgrading + + **Most deployments: nothing to do.** The switch this depends on is OFF by default + and ships unreleased alongside this change, so the `group`-is-walled behaviour + being amended has never appeared in a published version — no released consumer + can be relying on it. + + If you run posture `group` **and** turn the switch on, read your boot log: each + time-triggered flow's bind line now names which of the three shapes it bound as + ("as organization '…'", "with per-record acting organization", or "with NO + acting organization"). Two things to check: + + - A flow you expected to act as ONE organization but which binds per-record is + missing its `config.organization`. Add it — declaring still narrows, bounding + the sweep's query as well as its identity. + - A plain `schedule` cron flow that binds "with NO acting organization" has no + record to derive one from. If it writes notifications, inbox messages or any + other per-organization row, declare `organization` on its start node; the bind + line says so, and so does the refusal at the first tick. + + ## Which organization a record belongs to — the WALL question, not the stamp one + + `@objectstack/metadata-core` gains a second face on the record→organization + resolver, and the split is the point: `resolveRecordOrganizationField` / + `createRecordOrganizationResolver` answer **"who is this row ABOUT"** (the STAMP + question, whose `tenancy.organizationField` limb stays pinned to the three + sanctioned platform-row writers), while the new + `resolveRecordWallOrganizationField` / `createRecordWallOrganizationResolver` + answer **"what is this row WALLED by"** — `tenancy.enabled: false` ⇒ nothing, + then a declared `tenancy.tenantField`, then the kernel's `organization_id`. + + The sweep uses the WALL face, because "which organization does this run act as" + is a question about the wall. ⛔ It never reads `tenancy.organizationField`: that + key is declared on exactly one shipped object (`sys_api_key`, deliberately + unwalled, #8287), and reading it here would turn "the audit trail should follow + this row's own organization even though nothing walls it" into an acting + identity. A sweep over such an object resolves **nothing** and takes the + `walled-posture` refusal at its first tenant-scoped write, which is the honest + answer. Limbs 1 to 4 are one implementation shared by both faces, pinned as + such, so the half they agree on cannot drift apart. + + **API:** `ScheduledWorkPolicy` gains `runOwnership: 'unscoped' | 'per-record' | + 'declared'`, and `requiresActingOrganization` narrows from "any walled posture" + to `isolated` only. The two are deliberately separate axes: the boolean decides + whether BIND refuses, `runOwnership` decides what a run that DID bind carries. + Inside `@objectstack/trigger-schedule`, both triggers share one bind-line + vocabulary (`describeScheduleRunOwnership`) so they cannot describe one + deployment differently. ⚠️ That helper is module-level, NOT a package export: it + is not re-exported from the package barrel, whose own note says an export whose + only consumers live inside its own package belongs in a non-barrel module. The + new PUBLIC surface in this change is `ScheduledRunOwnership` and the + `runOwnership` key on `@objectstack/types`, plus + `resolveRecordWallOrganizationField` and + `createRecordWallOrganizationResolver` on `@objectstack/metadata-core` — and + those four are what put `Clause-②` at `yes`. Nothing existing is renamed or + re-typed: both stamp-face exports keep their names, their signatures and their + answers, limb 0 included. +- cca1dc0: + + feat(cli,metadata-core)!: the protocol version is emitted under `protocolVersion`, never under a `runtime`-shaped name (#15585) + + **BREAKING** — two published machine surfaces change a key name. There is **no alias + and no dual-key transition window**: one axis, one name. + + | Surface | Was | Now | + |:--|:--|:--| + | `os migrate meta --json` payload | `runtime` | `protocolVersion` | + | `OS_PROTOCOL_INCOMPATIBLE` diagnostic (`ProtocolIncompatibleError.diagnostic`) | `runtimeVersion` | `protocolVersion` | + | `checkProtocolCompat()` / `assertProtocolCompat()` 2nd parameter | `runtimeVersion` | `protocolVersion` | + + The **value** is unchanged on every one of them: it is `PROTOCOL_VERSION`, the protocol + major padded to a semver (`'17.0.0'`), exactly as before. Nothing else on either payload + moves — no other key is added, removed or reshaped, and both text faces are byte-identical. + The parameter rename is positional, so no call site changes. + + ## Why the name had to move + + `PROTOCOL_VERSION` is the protocol major padded to a semver and never tracks the installed + `@objectstack/cli` or runtime package version. Printed or emitted under the word *runtime* + it read as one: on a 17.3.0 install `runtime: "17.0.0"` reads as an apparent downgrade or + a stale install, next to the real package versions of the same upgrade session. + + The human line was repaired first and now reads + `Chain: protocol 17 → 17 (this runtime implements protocol 17)`. The machine face is the + worse half and was left standing, because a key on a published payload is a contract + change: an agent scripting an upgrade has no prose to disambiguate at all, and the + diagnostic's own `message` — which *is* unambiguous — is the one part a machine consumer + does not parse. + + ## What a consumer should do + + Read the new key. The old one is absent, so a consumer that does not move reads + `undefined` rather than a wrong value. + + ```diff + - const v = payload.runtime; // os migrate meta --json + + const v = payload.protocolVersion; + + - const v = err.diagnostic.runtimeVersion; // OS_PROTOCOL_INCOMPATIBLE + + const v = err.diagnostic.protocolVersion; + ``` + + The diagnostic surfaces through every package that re-emits it — `@objectstack/runtime` + spreads it into `ArtifactReferenceError.detail`, `@objectstack/metadata-protocol` throws it + from the package install boundary, and `@objectstack/services-package` reads it during + hydration — so a consumer reading it from any of those reads the new name too. + + `runtimeMajor` on the same diagnostic is deliberately **unchanged**: it is an integer + protocol major, not a semver in a version position, and it does not carry the ambiguity + this rename closes. + + The breaking surface was measured before the rename and is closed inside this repository: + the only reader of the `--json` key was this repo's own e2e pin and the only reader of the + diagnostic member was `metadata-core`'s own unit test, both of which move in this same + change; the published `skills/objectstack-upgrade/SKILL.md` documents `--json` without ever + naming the field. **Zero external consumers were found.** Graded `minor` rather than + `major` for the launch window; the banner above carries the breaking-ness the level cannot. + +### Patch Changes + +- 0283cb9: feat(automation)!: an edge-branched `decision` is exclusive — the first out-edge whose condition holds, in declaration order, wins; `mode: 'inclusive'` takes every one (#15429) + + + + Clause-②: yes + + **BREAKING** — the run-time semantics of a shipped node type change. A `decision` node that + declares no `config.conditions` and branches on its out-edges used to take EVERY out-edge whose + condition held, one after another, while its schema, the docs and the engine's own comment all + called it an exclusive gateway; hotcrm#1555 rendered a refusal screen AND ran the conversion in + one execution. Maintainer ruling on #15429 (2026-09-23, 「跟主流对齐」): the gateway follows + BPMN's exclusive gateway, Salesforce Flow's Decision and n8n's Switch default, and taking every + true branch is a declaration the author writes down. + + | | before | after | + |:--|:--|:--| + | two conditioned out-edges, both hold | both successors run, sequentially, nothing reported | the FIRST declared one runs; the second records a `skipped` step | + | `config: { mode: 'inclusive' }` | accepted, never read | every out-edge whose condition holds runs, sequentially | + | none holds | the `isDefault` edge runs | unchanged | + | `mode` beside a non-empty `conditions` list, or outside `'exclusive' \| 'inclusive'` | refused by a direct parse only | refused at `registerFlow` and by `os validate`, with the schema's own sentence | + + ## Migration: FROM → TO + + `os migrate meta --from 17` lists the mechanical edits for existing sources and applies them + to the migrated stack: the ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit` + writes `mode: 'inclusive'` onto every decision that has no `conditions` list and two or more + conditioned out-edges, inside ADR-0031 regions included, so a migrated flow runs exactly as it + did. + + ```ts + // FROM — every true out-edge ran + { id: 'verdict', type: 'decision', label: 'Verdict?' } + // TO — what the conversion writes; delete the key where the conditions partition + { id: 'verdict', type: 'decision', label: 'Verdict?', config: { mode: 'inclusive' } } + ``` + + Then review each written key (the paired D3 entry `flow-decision-edge-branching-first-match` + carries the acceptance criteria): delete it where the conditions partition (`== 'a'` beside + `!= 'a'`, `>` beside `<=`, a guard beside `isDefault: true`), keep it where the flow relies on + more than one branch running for one record, and where the overlap was accidental narrow the + conditions into a partition and delete the key. `os validate` reports + `flow-decision-inclusive-overlap` on every decision that keeps the key with two or more + conditioned out-edges, so the review list is the lint output. + + ## BREAKING for flows stored in `sys_metadata` — maintainer ruling letter C on #15429 + + A `decision` node stored in `sys_metadata` (a flow built or edited in the Studio designer) with + **no `config.conditions`, no `mode`, and two or more out-edges carrying a `condition`** evaluates + **first-match** after this upgrade: where it took every out-edge whose condition held, it now takes + only the first one that holds, in the order the flow declares its edges. Nothing rewrites that row + — no stored-row migration, no cutoff, no read-path completion — because nothing about a stored row + says it was saved before the flip. The one-line fix, for a node that meant every branch: + + ```ts + { id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } } + ``` + + `os migrate meta --stored` (and `POST /api/v1/meta/_migrate-stored`) lists every such node under + `decisionModeReview` — flow row, node id, label and path — on a preview and an `--apply` run + alike, and writes nothing for it: the list moves no row outcome, no count and no exit code, so an + operator can review the candidates before and after the upgrade. A node leaves the list once it + declares `mode`, either member. Every such node in the measured corpus below is a partition, where + the new meaning runs exactly what the old one did. + + Authored sources and built artifacts keep the old behaviour instead, where the source's age is a + fact: `os migrate meta --from 17` writes `mode: 'inclusive'` (above), while the authoring funnel, + the automation engine's flow rehydration seam and the artifact-ingestion door all refuse the + conversion by id — a default flip replayed there would turn a decision written today against this + contract, where an omitted `mode` means exclusive, into an inclusive gateway. + + ## Reach, measured at landing + + - Release state: the npm registry's `latest` `@objectstack/spec` is `17.4.0` (`npm view`, + 2026-09-27), whose `json-schema/automation/DecisionConfig.json` declares `conditions` only — + `mode` has not shipped; `.changeset/19867-decision-config-mode.md` and + `.changeset/20168-decision-mode-beside-conditions-refused.md` are still unconsumed in this + tree. So `mode` reaches its first release together with the traversal that reads it and the + conversion that writes it; no published accept set narrows, and the registration and + `os validate` refusals narrow nothing that shipped. + - Corpus census (this repository at the branch base and `objectstack-ai/hotcrm` at `2f7b2326`, + read-only): 30 decision nodes across 48 flows; 17 have two or more conditioned out-edges and + no `mode` (the conversion's positives — every one a hand-written partition, including + hotcrm's `lead_conversion.decision_duplicate`, the #1555 node), 13 have one conditioned + out-edge (left alone), and no node of any other type carries a conditioned out-edge, so the + exclusive traversal is scoped to `decision` with nothing else to migrate. + - What the published surface gains: the D2 conversion and its D3 entry in the protocol-18 + chain (`spec-changes.json`, the upgrade guide), `DecisionConfigSchema.mode`'s describe and + docblock now state the run-time semantics, and `@objectstack/lint` gains + `flow-decision-mode-invalid` (gating) and `flow-decision-inclusive-overlap` (advisory). + + The traversal change is scoped to `decision` nodes: conditioned out-edges of any other node + type keep the every-true-edge traversal they had (none was measured to exist). +- 134b410: The artifact-ingestion door no longer replays the **default-flip** class of ADR-0087 conversion, so an artifact carrying `defineApp({ hidden: true })` is registered with `hidden: true` — not as an unpublished app (#17885, #4829). + + `app-hidden-to-unpublished` rewrites `app.hidden: true` into `app._unpublished: true`. Both keys are live and they mean opposite kinds of thing: `hidden` is navigation presentation and *"never an access gate"* (`ui/app.zod.ts`), while `_unpublished` is the machine-managed publish gate `filterAppForUser` drops the app on for every user without `studio.access` / `setup.access`. Measured before the change, on an artifact declaring `engines.protocol: ^17.0.0` — the range `create-objectstack` stamps — against a 17.4.0 runtime: the door emitted the `app-hidden-to-unpublished` notice and the object that reached registration carried `hidden: undefined`, `_unpublished: true`. So an author who asked for "keep this out of the App Switcher" got "nobody but a builder can see this" — the incident the `_unpublished` split was introduced to end, arriving through the conversion layer. + + - **The entry is not withdrawn and no key moves.** It still fires where its precondition is a fact — the stored-row rehydration seams (a pre-split `hidden: true` row can only have come from the materialization path) and `os migrate meta`, where the operator asserts the source's age. What changed is that the artifact door, whose evidence is the artifact's **declared `engines.protocol` floor** rather than its age, no longer treats that guess as sufficient for a rewrite that reinterprets a live authorable key. + - **The retired window stays open.** Closing it wholesale would fix this and re-break #12772: an artifact built by 17.1.0 tooling carrying `allowRestore` / `allowPurge` would again be refused at the tombstone with no operator remedy. The door refuses one named class by id, with its reason written beside it, and the pin drives a retired conversion and a non-retired one through the same window to prove it. + - **New seam option, no new export.** `applyConversions` accepts `excludeConversionIds` — the seat-level spelling of "my evidence cannot carry this entry". `retiredFromLoadPath` cannot express it: that flag's jurisdiction is the authoring funnel and nothing else. + - ⛔ **The consumer is unchanged.** `filterAppForUser` withholding on `_unpublished` is correct; the defect was who writes `_unpublished`. + + Deployments whose apps were being served as unpublished purely because of a permissive `engines.protocol` range will see those apps again, for every user, on the next boot. No artifact file changes and no stored row is rewritten. +- 95fb417: **The declared `zod` floor moves from `^4.4.3` to `^4.6.1`**, because on zod below 4.6.1 the three standard error formatters — `z.treeifyError()`, `error.format()` and `error.flatten()` — cannot render a refusal these packages actually emit (#19581). + + Clause-②: no + + **What breaks below the new floor.** All three formatters walked an issue's `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming a member of `Object.prototype` was answered by the prototype and no node was ever created. Two different failures follow: + + | path shape | what happened on `^4.4.3` | + |:---|:---| + | terminal element (`['assignments','__proto__']`, `['x','toString']`) | the inherited member is adopted as the node, then `node._errors.push(...)` runs on it — `TypeError: Cannot read properties of undefined (reading 'push')` | + | non-terminal element (`['__proto__', …]`) | the walk continues **into** `Object.prototype` and writes the next segment onto it — the message is silently dropped from the returned tree and the process gains a global prototype key | + + **Why it reached this platform's consumers.** `@objectstack/spec` refuses a `__proto__` key on its open-key authoring surfaces, and that refusal's issue path is `['assignments','__proto__']` — precisely the terminal shape. Anything that formatted one of these refusals for display crashed on it, and the crash was in the formatter, not in the guard. The guards themselves are unchanged and still necessary: 4.6.1 still drops a `__proto__` key from `z.record()` and `.catchall()` output, which is what they exist to refuse. + + **What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. + + `@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. + + ## The second half the floor move needs: an unknown key refuses TERMINALLY again + + From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no + longer aborts the shape that raised it. Two things follow, and both were + measured on this package with the same bodies on 4.4.3 and 4.6.1: + + 1. **A closed shape's own refinements now run after the refusal**, adding a + second complaint that contradicts the first. + 2. **A union containing that shape loses its envelope.** zod's + `handleUnionResults` returns a single non-aborted member's issues + *unwrapped* instead of raising `invalid_union`, so the union's message + becomes whichever branch zod judged closest. + + At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong + branch's prescription. Writing `type: 'page'` on a ViewItem answered: + + ``` + Unrecognized key(s) on this view container: `viewKind`, `config`. + • `viewKind` belongs to a single VIEW, not to the container. Wrap it: … + ``` + + — naming neither `page` nor its removal. It now answers, as it did before: + + ``` + config.type: 'page' was removed from the list-view `type` enum in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … + ``` + + **What an upgrading consumer must do.** Nothing. No key or value changed + status: everything this package accepted before it accepts now, and everything + it refused it still refuses. What changed is which of several competing + complaints an author reads, and that a refusal behind a union is again + reported as `invalid_union` with its branches, which is what `z.treeifyError()` + and this package's own `formatZodError` expand. + + ⚠️ A closed shape declared with a bare `z.object(…).strict()` or + `z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT + get this and will still collapse its union. Build closed authoring shapes with + `strictObject`, or re-declare an existing one through `closedObject`. +- 8cbc3c0: docs(metadata-core): the `item-key-discriminators` module docblock quoted a spec sentence that no longer exists and that the contract denies ("best match") (#19592) + + Clause-②: no — no accept set moves, no published payload key changes, no + export is added or removed. The corrected prose ships as TSDoc in + `@objectstack/metadata-core`'s `dist/index.d.ts` and `dist/index.d.cts` (the + package publishes `dist`), which is why this is a changeset rather than + `skip-changeset`. + + The module docblock of `packages/metadata-core/src/item-key-discriminators.ts` + put a sentence inside quotation marks and attributed it to + `EmailTemplateDefinitionSchema` in `packages/spec/src/system/email-template.zod.ts`: + that the service "picks the best match for the recipient's locale". That + sentence occurs nowhere in `packages/spec/src` today, and it states the opposite + of the contract: `SendTemplateInput.template` in + `packages/spec/src/contracts/email-service.ts` says there is no "best match" and + no language-subtag folding. + + The docblock now cites the spec by file and symbol instead of quoting it. It + says the `locale` key is the second half of the bundle key, that resolution is + exact, and that `SendTemplateInput.locale` holds the ladder: the named tag matched + exactly, then the literal `en-US`, then, only for a call that named no locale and + only when the bundle has no `en-US` row, the bundle's lowest locale tag. The one + quotation left in the docblock ("is resolved by `(name, locale)`", from the + schema's header) still exists verbatim in the spec. + + No behaviour changes: the edit is prose. `ITEM_KEY_DISCRIMINATORS`, + `readDiscriminatorValue`, `itemDiscriminator` and the `en-US` canonical are + untouched. +- 7536721: `ENGINE_DELETE_DISPATCH_CASES` and `ENGINE_UPDATE_DISPATCH_CASES` retire their three ARRAY `where.id` rows (#19757) + + The engine-double conformance tables no longer carry these three rows: + + - delete's `array id, no multi` + - update's `array id, no multi` + - update's `a SCALAR data.id beside an ARRAY where.id` + + Each row puts `where: { id: ['a', 'b'] }` in the equality slot. Since this release's `@objectstack/spec` change, the shared comparand-shape face refuses an array in that slot with `INVALID_FILTER` / 400. The face runs at the engine's lowering seam, which every verb crosses before the dispatch runs, so the real engine never reaches the dispatch predicate with such an input. A row claiming a dispatch verdict for it would pin a branch the engine cannot reach. It was measured red against the real engine: `ObjectQL.delete` / `ObjectQL.update` refused the input with the face's words, not the dispatch's. + + The predicates themselves are unchanged. `resolveEngineDeleteDispatch` / `resolveEngineUpdateDispatch` and the `assert*` helpers still answer an array `where.id` with `reject`, and `scalarDeleteId` / `scalarUpdateId` still treat an array as not-an-id. A test double bound to them therefore still refuses such a call, with the dispatch's sentence. No double runs the shared filter face, for this shape or for any other face refusal. The `$in` rows keep the "a non-scalar `where.id` is not an id" coverage, including the #11230 refusal beside a scalar payload id. + + If you run these tables against your own engine double, it has three fewer cases to answer. Nothing else changes. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index 6c88cb5ec0c..d36f07c8b17 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-core", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Metadata Repository contracts: types, canonicalization, errors, interface (ADR-0008).", "type": "module", diff --git a/packages/metadata-fs/CHANGELOG.md b/packages/metadata-fs/CHANGELOG.md index 4a06acf2042..b512eef0e12 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,21 @@ # @objectstack/metadata-fs +## 17.5.0 + +### Patch Changes + +- Updated dependencies [0283cb9] +- Updated dependencies [134b410] +- Updated dependencies [502f179] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [7536721] +- Updated dependencies [e956924] +- Updated dependencies [2bed4c3] +- Updated dependencies [0a56d3b] +- Updated dependencies [cca1dc0] + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index 713ee5dc89d..68ab9709bc8 100644 --- a/packages/metadata-fs/package.json +++ b/packages/metadata-fs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-fs", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "FileSystemRepository: Node-only Repository implementation backed by JSON files and a JSONL change log (ADR-0008).", "type": "module", diff --git a/packages/metadata-protocol/CHANGELOG.md b/packages/metadata-protocol/CHANGELOG.md index b41c75aae57..f2df9aadfb9 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,2103 @@ # @objectstack/metadata-protocol +## 17.5.0 + +### Minor Changes + +- 0283cb9: feat(automation)!: an edge-branched `decision` is exclusive — the first out-edge whose condition holds, in declaration order, wins; `mode: 'inclusive'` takes every one (#15429) + + + + Clause-②: yes + + **BREAKING** — the run-time semantics of a shipped node type change. A `decision` node that + declares no `config.conditions` and branches on its out-edges used to take EVERY out-edge whose + condition held, one after another, while its schema, the docs and the engine's own comment all + called it an exclusive gateway; hotcrm#1555 rendered a refusal screen AND ran the conversion in + one execution. Maintainer ruling on #15429 (2026-09-23, 「跟主流对齐」): the gateway follows + BPMN's exclusive gateway, Salesforce Flow's Decision and n8n's Switch default, and taking every + true branch is a declaration the author writes down. + + | | before | after | + |:--|:--|:--| + | two conditioned out-edges, both hold | both successors run, sequentially, nothing reported | the FIRST declared one runs; the second records a `skipped` step | + | `config: { mode: 'inclusive' }` | accepted, never read | every out-edge whose condition holds runs, sequentially | + | none holds | the `isDefault` edge runs | unchanged | + | `mode` beside a non-empty `conditions` list, or outside `'exclusive' \| 'inclusive'` | refused by a direct parse only | refused at `registerFlow` and by `os validate`, with the schema's own sentence | + + ## Migration: FROM → TO + + `os migrate meta --from 17` lists the mechanical edits for existing sources and applies them + to the migrated stack: the ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit` + writes `mode: 'inclusive'` onto every decision that has no `conditions` list and two or more + conditioned out-edges, inside ADR-0031 regions included, so a migrated flow runs exactly as it + did. + + ```ts + // FROM — every true out-edge ran + { id: 'verdict', type: 'decision', label: 'Verdict?' } + // TO — what the conversion writes; delete the key where the conditions partition + { id: 'verdict', type: 'decision', label: 'Verdict?', config: { mode: 'inclusive' } } + ``` + + Then review each written key (the paired D3 entry `flow-decision-edge-branching-first-match` + carries the acceptance criteria): delete it where the conditions partition (`== 'a'` beside + `!= 'a'`, `>` beside `<=`, a guard beside `isDefault: true`), keep it where the flow relies on + more than one branch running for one record, and where the overlap was accidental narrow the + conditions into a partition and delete the key. `os validate` reports + `flow-decision-inclusive-overlap` on every decision that keeps the key with two or more + conditioned out-edges, so the review list is the lint output. + + ## BREAKING for flows stored in `sys_metadata` — maintainer ruling letter C on #15429 + + A `decision` node stored in `sys_metadata` (a flow built or edited in the Studio designer) with + **no `config.conditions`, no `mode`, and two or more out-edges carrying a `condition`** evaluates + **first-match** after this upgrade: where it took every out-edge whose condition held, it now takes + only the first one that holds, in the order the flow declares its edges. Nothing rewrites that row + — no stored-row migration, no cutoff, no read-path completion — because nothing about a stored row + says it was saved before the flip. The one-line fix, for a node that meant every branch: + + ```ts + { id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } } + ``` + + `os migrate meta --stored` (and `POST /api/v1/meta/_migrate-stored`) lists every such node under + `decisionModeReview` — flow row, node id, label and path — on a preview and an `--apply` run + alike, and writes nothing for it: the list moves no row outcome, no count and no exit code, so an + operator can review the candidates before and after the upgrade. A node leaves the list once it + declares `mode`, either member. Every such node in the measured corpus below is a partition, where + the new meaning runs exactly what the old one did. + + Authored sources and built artifacts keep the old behaviour instead, where the source's age is a + fact: `os migrate meta --from 17` writes `mode: 'inclusive'` (above), while the authoring funnel, + the automation engine's flow rehydration seam and the artifact-ingestion door all refuse the + conversion by id — a default flip replayed there would turn a decision written today against this + contract, where an omitted `mode` means exclusive, into an inclusive gateway. + + ## Reach, measured at landing + + - Release state: the npm registry's `latest` `@objectstack/spec` is `17.4.0` (`npm view`, + 2026-09-27), whose `json-schema/automation/DecisionConfig.json` declares `conditions` only — + `mode` has not shipped; `.changeset/19867-decision-config-mode.md` and + `.changeset/20168-decision-mode-beside-conditions-refused.md` are still unconsumed in this + tree. So `mode` reaches its first release together with the traversal that reads it and the + conversion that writes it; no published accept set narrows, and the registration and + `os validate` refusals narrow nothing that shipped. + - Corpus census (this repository at the branch base and `objectstack-ai/hotcrm` at `2f7b2326`, + read-only): 30 decision nodes across 48 flows; 17 have two or more conditioned out-edges and + no `mode` (the conversion's positives — every one a hand-written partition, including + hotcrm's `lead_conversion.decision_duplicate`, the #1555 node), 13 have one conditioned + out-edge (left alone), and no node of any other type carries a conditioned out-edge, so the + exclusive traversal is scoped to `decision` with nothing else to migrate. + - What the published surface gains: the D2 conversion and its D3 entry in the protocol-18 + chain (`spec-changes.json`, the upgrade guide), `DecisionConfigSchema.mode`'s describe and + docblock now state the run-time semantics, and `@objectstack/lint` gains + `flow-decision-mode-invalid` (gating) and `flow-decision-inclusive-overlap` (advisory). + + The traversal change is scoped to `decision` nodes: conditioned out-edges of any other node + type keep the every-true-edge traversal they had (none was measured to exist). +- 7a25a3e: `ObjectStackProtocolImplementation` and `SysMetadataRepository` no longer open their refusal messages with a bracketed tag restating the `code` the same throw declares — `error` carries the human sentence, `code` carries the machine token, and the token is no longer duplicated onto the prose axis. + + Clause-②: yes + + Every refusal `ObjectStackProtocolImplementation` and `SysMetadataRepository` raised opened with a lowercase `[tag]` that was the restatement of the `code` that very throw declared: `[no_draft]` in front of `NO_DRAFT`, `[item_locked]` in front of `ITEM_LOCKED`, and so on for 38 throw sites across the two producers. They were not invisible. `withoutDeclaredCodePrefix` strips a leading restatement only when the message opens with the declared code followed by a colon (`INVALID_REQUEST: …`); the bracketed lowercase spelling matches neither the casing nor the separator, so it was never stripped and reached the caller in `error.message`. The repo's own de-duplication mechanism existed and did not fire here. + + The maintainer ruling of 2026-08-29 on the `/data` door shipping `FORBIDDEN:` in front of a localized refusal is ONE envelope semantics — `error` is HUMAN LANGUAGE, `code` is the MACHINE TOKEN — and a prefix is removed *because* the same fact already rides the `code` axis. All 38 met that condition by construction. + + ## FROM → TO + + | before | now | + | --- | --- | + | `error: "[no_draft] No pending draft exists for view/task_list."` | `error: "No pending draft exists for view/task_list."` | + | `error: "[item_locked] view/task_list is locked (_lock=…)."` | `error: "view/task_list is locked (_lock=…)."` | + | `error: "[NOT_OVERRIDABLE] 'action' is not allowOrgOverride…"` | `error: "'action' is not allowOrgOverride…"` | + + **`code` is unchanged on every one of them**, and it is where the token always also was — `NO_DRAFT`, `ITEM_LOCKED`, `NOT_OVERRIDABLE`, and the 14 others. A reader matching `error.message` for a bracketed tag reads `error.code` for that tag, upper-cased, instead; a reader already using `code` needs no change. The HTTP `status` is untouched. + + - **Measured, not assumed, before it was removed**: 37 literal openers plus one written as `` `[${code}]` `` from the same variable the throw assigns to `err.code` three lines down — that one spelled by interpolation, so it was invisible to every grep for a literal tag and is absent from the card's own inventory. + - **Nothing consumed the tag.** The only consumers found anywhere are strippers: `@object-ui/react`'s `extractWriteErrorMessage` and two `plugin-detail` call sites each remove a leading bracketed prefix before showing the sentence to a user, next to the `SCREAMING_SNAKE:` strip. They confirm the tag was arriving and they cannot break on its absence — the regex simply matches nothing. + - **Two bracketed vocabularies are deliberately kept**: the `path [zod code]` locators inside a validation headline and the `[rule]` locators the author-time gate composes. Neither restates a declared `code` — they name WHICH finding, a fact the envelope carries nowhere else. + - **The published docs that quoted the openers are corrected in the same change.** `ProtocolSchema`'s promotion `describe()` said the lookup 「answers 404 `[no_draft]`」 and now names `NO_DRAFT`, the axis that still carries it; `content/docs/references/api/protocol.mdx` is regenerated from it, never hand-edited. The error catalog's two documented `INVALID_REQUEST` payloads showed a `message` opening with the tag beside a `code` field already carrying the token, and now show what the platform emits. + - ⛔ **Three carriers in `content/docs/releases/v17/` are deliberately left**: release pages record what shipped and a code change does not rewrite them. + - **Pinned as an absence**, because nothing else would notice one coming back: a re-introduced tag reds exactly one per-door pin and a newly-written refusal reds none. +- 04333d0: The `kernel:ready` migrations ask whether a table exists WITHOUT running a statement that has to be refused, so a normal boot stops printing `[sql-driver] DATABASE_ERROR … no such table` (#17175) + + Two migrations on the boot hook asked "does this table exist?" with a statement + that cannot succeed when the answer is no — `SELECT "tenant_id" FROM + "_objectstack_sequences" WHERE 1 = 0` in `seed-tenancy-backfill.ts`, and `SELECT + 1 FROM sys_setting WHERE 1 = 0` in `sys-setting-identity-index.ts` — and read + the refusal as "no". Both are correct on their own terms. Both make + `SqlDriver.execute()`'s raw terminal write the statement and the dialect's + message to the operator's log on the way out. + + Measured on this tree against real `better-sqlite3`: exactly one line per probe, + on `console.warn` — i.e. **stderr** — carrying both the `DATABASE_ERROR` token + and `no such table`. It fires on **every boot** of every install that has never + allocated an autonumber, and again on every boot of every kernel that does not + register the optional `service-settings`. + + ⭐ The cost is not the line. It is that operators learn this product prints + errors when nothing is wrong, and then miss the one that matters. A consumer told + to read the boot log (`objectstack-ai/hotclm`'s `AGENTS.md` names `no such table` + as a failing boot) must either ignore an unactionable ERROR every boot or chase a + platform-internal probe. + + **The question is now asked of the CATALOG.** A new shared + `migrations/read-probe.ts` compiles one arm per dialect family — `sqlite_master` + for SQLite, `to_regclass` for Postgres, `information_schema.tables` scoped with + `DATABASE()` for MySQL — each of which returns zero rows for a table that is not + there instead of being refused. Both migrations call it; the probe lives once, + not once per site. + + **⛔ Why not in the driver.** Quietening a refusal requires classifying it, this + repo has one predicate for that (`isMissingTableError`), and it needs the name of + the thing the caller was reading — which the raw path structurally does not have + (`rawStatementFaultError` declares no targeted table, and + `driver-error-classification.callers.test.ts` fails any in-repo call that omits + `readObject`). An unclassified demotion of the driver's raw terminal would + quieten real failures too. The caller knows the table; the driver does not. + + **⛔ The fence, and it is the one way this repair can go wrong.** A catalog arm + mis-compiled for some dialect would be refused, caught by the same `catch` the + expected miss uses, and read as "the table is not there" — turning a stored-row + data repair into a silent no-op on whichever dialect nobody exercised. So the + probe answers four verdicts rather than a boolean, and `'unreadable'` is never + folded into `'absent'`: it is returned, and reported at `warn`. An unrecognised + dialect gets no guessed catalog statement at all — it keeps the caller's own + `WHERE 1 = 0` probe, whose refusal is now *classified* with + `isMissingTableError(error, table)` rather than swallowed as absence. + + **Why `minor`.** + + - `SeedTenancyBackfillStatus` gains `'unreadable'`. It is an OUTPUT union, so no + input a caller writes is affected; the one consumer shape that could break is + an exhaustive `switch` with a `never` default, which is why this is not a + `patch`. + - `ensureSysSettingIdentityIndex` gains an optional third parameter + (`{ client? }`). Callers that pass two arguments are unchanged and keep + today's behaviour exactly — without a client there is no catalog arm and the + pre-existing probe runs. + - `buildSequencesPresenceSql` and `buildSysSettingPresenceSql` are unchanged in + text and still exported. They are no longer what the boot path runs first. + - `isResultSet` and `normalizeRows` moved to `migrations/read-probe.ts` and are + re-exported from `seed-tenancy-backfill.ts` unchanged, so the package index and + every importer see no difference. + + **What did NOT change.** #10789's ruling stands: a seam that accepts a statement + and returns no result set still reports `absent` with the `detail` that separates + it. The driver's error channel is untouched — a statement the backend genuinely + refuses is still written to the log in full, asserted against the same driver and + the same sink in the same test as the silence. + + **Dialect coverage, stated rather than implied.** The SQLite arm is pinned end to + end against a real `SqlDriver` (`packages/runtime`'s + `seed-tenancy-autonumber-split.integration.test.ts`); the MySQL arm runs against + the live server in `seed-tenancy-backfill.live-mysql.test.ts`, in both directions + and with the connected-schema scope measured. ⛔ The **Postgres** arm is NOT + MEASURED against a live server: this package has no live-PG harness, no `pg` + dependency, and its CI leg supplies `OS_TEST_MYSQL_URL` only while filtering to + `live-mysql`. Its statement text is pinned; running it is not. +- ada2869: fix(metadata-protocol): `insertManyData` reports the dropped-field union at BATCH level instead of naming rows it cannot identify (#17290) + + + + **BREAKING** — `@objectstack/metadata-protocol`'s `insertManyData` no longer hangs + `droppedFields` on each entry of `outcomes`; the response itself carries it, beside + `outcomes`, exactly as `createManyData` already does. A TypeScript consumer that read + the per-row member stops compiling, and the compiler names the site. The set reported + is the same set — what is gone is a per-row attribution that could not be computed + here and was wrong whenever it mattered. Nothing authored or stored changes shape. + + **What it got wrong.** Every create-side strip is the engine's, and its + `onFieldsDropped` event is the UNION over the batch — the listener signature + carries no row index. This seam reconstructed a row set from that union by + asking which rows SUPPLIED each dropped name + (`[...engineDropped].filter((f) => f in supplied)`), on the stated premise that + "the strip only removes keys the ROW ITSELF supplied, so a dropped name belongs + to exactly the rows whose supplied payload carried it". Maintainer ruling C + falsifies the premise: the static-`readonly` strip runs INSIDE `engine.insert`, + AFTER the `beforeInsert` hooks, and exempts keys a hook itself assigned — + recorded per row (`hookWrittenKeys: rowHookWrittenKeys[i]`). So in a batch where + a hook stamps a protected key on some rows and not others: + + - row A supplied `approval_status`, no hook write ⇒ stripped, enters the union; + - row B supplied `approval_status`, its hook re-assigned it ⇒ **kept and + written**; + - and row B's outcome carried `droppedFields: [{ fields: ['approval_status'] }]` + on a record that still held `approval_status`. + + A row the batch culled before the strip ran (a per-row validation failure) was + named on the same test, having dropped nothing at all. + + ⇒ A wrong attribution costs the reader a wrong investigation, and the import + surface — which prefers this path over `createManyData` — is the consumer most + likely to act on it while reconciling what landed. + + **Why not attribute per row instead.** The honest set is `{rows whose payload + carried N}` minus `{rows whose beforeInsert hook assigned N}`, and the second + half is computed per row upstream but does not cross this seam. The outcome's + own `record` cannot stand in for it: a stripped `readonly` field is RE-DEFAULTED + over exactly the keys the strip took, and a stripped `autonumber` is refilled by + `applyAutonumbers` — so on both, the key is PRESENT on the row that really did + drop it, and a post-hoc "is the key still there?" check would delete true + attributions while leaving the hook-exempt false one standing. Comparing values + fails on the very case `hookWrittenKeys` exists for: the hook assigning the + value the caller also sent. Restoring row precision means giving the engine's + drop report a per-row channel, not a reconstruction at the call site. + + **Prose corrected with it**, by CLAIM rather than by spelling — the docblock + that authorised the inference is the thing that re-authorises the next author: + `insertManyData`'s own docblock and `createManyData`'s parenthetical + (`@objectstack/metadata-protocol`), `mergeDroppedFieldEvents`'s closing + sentence, `engine.insertMany`'s docblock claim that "a caller holding the input + rows can attribute each name back to the rows that carried it" + (`@objectstack/objectql`, TSDoc emitted into its published `.d.ts`), and + `CreateManyDataResponseSchema.droppedFields`'s `.describe()` parenthetical + (`@objectstack/spec`, a string printed AT the customer). + + **Unchanged.** `updateManyData` and `batchData` keep per-row `droppedFields`, + and they always could: each row is its own `engine.update` / `engine.insert` + call, so that call's events are that row's — earned mechanically, not inferred. + `createManyData`'s aggregated shape is untouched. No strip changes, no row + changes, and the same field names are reported. +- 7b1e4a4: feat(spec,metadata-protocol): `os migrate meta --stored` lists every stored page filter the record-filter conversion leaves as stored, as a TODO naming the page, the block and why — the ADR-0087 D3 TODO channel (#17321, ruling B item 2) + + **Clause-②: yes** — `@objectstack/spec` gains public exports (`CONVERSION_TODO_CODE`, + `ConversionTodoDetail`, `ConversionTodoNotice`, and the optional `ApplyConversionsOptions.onTodo` + and `ConversionContext.reportTodo`), and `@objectstack/metadata-protocol` gains + `StoredMigrationTodo` and `StoredMigrationRow.todos`. No door's accept set moves, and nothing + that was left as stored before starts converting: every stored body is rewritten exactly as it + was. + + **What was silent.** The D2 conversion `page-component-filter-record-to-rule-array` leaves a + stored filter as stored wherever no lossless rule-array spelling exists — above all a record + carrying `$and` / `$or` / `$not`, which is never flattened. It emitted nothing for such a site, + and `os migrate meta --stored` reads conversion notices as its change signal, so a page whose + only legacy filter carried a combinator was reported as **already on protocol**. + + **What it says now.** Each such site is a structured TODO (code `OS_METADATA_CONVERSION_TODO`) + carrying its path, the shape left in place, and a reason that names the block (its type, and its + `id` when it has one) and what blocks the rewrite — the combinator by name, the operator + (`$null`, `$exists`, an AST `like`), the null or array value, the rule the door would refuse, or + the inline rows the block renders. The stored pass lists them under their row, whatever the + row's outcome: + + ```text + ⚠ 1 row(s) are outside this pass — each row's reason says why: + • page/pipeline_board [env-wide] — the conversion chain rewrites nothing here: it left 1 site(s) of this row as stored, … + TODO page-component-filter-record-to-rule-array: {"$or":[…]} left as stored at pages[0].regions[0].components[0].properties.filter — On the `object-kanban` block, this filter carries the combinator `$or`: … + ☐ TODO: 1 site(s) in 1 row(s) are left as stored — no conversion can rewrite them without changing what they mean, so no run of this pass will. … + ``` + + The same list is `rows[].todos` in `--json` and in the `POST /api/v1/meta/_migrate-stored` + report. A run with no TODO prints exactly what it printed before. + + **Outcome and exit code.** A row whose only finding is TODOs has nothing to persist and is now + reported `skipped` (it was `canonical`). Like every other skip class it does not change the + run's exit code: no run of this pass can clear it, because the conversion must not flatten a + combinator — it is the hand rewrite's to decide. A row that also converts something keeps the + outcome its conversion gives it, with its TODOs listed beside its notices. Measured through the + write path: on `--apply`, a row whose leftover sits in a block's `properties.filter` or + `properties.defaultFilters` is rewritten (its lossless filters persist; the metadata API's save + does not refuse block props by component type), while a leftover in `dataSource.filter` fails + the save, and the row's TODO says why. + + **For code calling the conversion layer.** `onTodo` and `reportTodo` are optional. Only the + stored-metadata pass passes a sink today; every other seam leaves the site as stored silently, + exactly as before. +- 69b5059: fix(metadata-protocol): `GET /meta/types` stops publishing properties no instance can satisfy (#17502) + + The served JSON Schema advertised the `retiredKey()` tombstones alongside the + live keys. `retiredKey()` keeps a removed authorable key declared on purpose — + the removal has to be audible — and `z.toJSONSchema` renders that tombstone as + a property node, `{ "description": "[REMOVED] ", "not": {} }`. + + `not: {}` is the JSON Schema spelling of "no instance validates", so a consumer + that reads the subschema is told the truth. A consumer that reads the KEY SET is + not: Studio builds a repeater's column headers from + `items.properties[k].title ?? k`, so a tombstone inside a row shape became a + column an author was invited to fill and `saveMetaItem` then refused. + + `toJsonSchemaSafe` now drops every property whose subschema admits no instance + before it serves or caches the document — structurally, by asking the JSON + Schema question, never by matching the `[REMOVED] ` description prefix, which + would put a second hand-written spelling of "this is a tombstone" in a consumer. + A property that admits nothing and is `required` is kept: dropping it would turn + "this object admits nothing" into "this object admits anything". + + Measured over the whole served registry at `74eaab8614`, this change's merge + base (`@objectstack/spec` SOURCE at 17.4.0, plus the retirements unreleased at + that sha — not the published release): 80 such nodes across 16 types — a + reading taken at that tree, not a standing invariant; it moves as retired keys + land or age out. + + **Nothing is un-retired, and no prescription CHANNEL is destroyed.** The removal is a + property of ONE emitter. `tsc` still types the key `never`, the parse still + refuses it with the prescription byte for byte, `packages/spec`'s + `authorable-surface/` ratchet still lists every retired key as `[RETIRED]`, and + the generated reference pages still print the full prescription in the + description column of a `never`-typed row. What this drops is a fourth copy, on + the one surface whose documented job is to describe what an author MAY write. + + **What an author stops being offered, stated as a class.** A tombstone became + visible wherever a renderer derives its field or column list from the served KEY + SET and reads the subschema for nothing but a label — so the retired key arrived + as an editable input, or as a repeater column, that the publish door then + refused. Three mechanisms put one in front of an author, and one retired key can + reach it through more than one of them: + + - **the flat, schema-driven fallback**, for a served type that carries no + `*.form.ts` layout: its field list *is* the served `properties` map, and a + nested object renders recursively, so a tombstone at any depth becomes a field + with the `[REMOVED] ` prescription as its help text; + - **repeater rows**, whose column headers are `items.properties[k].title ?? k` — + the carrier this card was filed on; + - **server-field grafting**, where an inspector merges the server's top-level + properties into a trailing "More fields" section: a key the UI's own bundled + spec predates is offered *because* the served document is the only place it is + known from. + + No count of the affected sites is given, on purpose. Which nodes reach an author + depends on the renderer and on the Console build this repo pins, so any number + written here would be false at the next pin bump. The invariant is the class: the + served document stops offering what the publish door refuses, and every retired + key keeps the full prescription on its generated reference page. A repeater + column loses no text either way — the row-cell renderer has no `description` + branch — so there the removal only withdraws the offer. +- a675ad4: The remaining raw `FieldSchema.reference` readers now **REFUSE** a carrier they cannot read, instead of answering "no target" (#18550). The previous release routed the arbiter (`referenceCarrierOf`) and the lint target readers; these were the measured residue of the same ruling — every reader, not just the arbiter. + + `FieldSchema.reference` is `z.string().optional()`, so `ObjectSchema.safeParse` refuses an object- or array-valued carrier at the contract door. These reads are the other door: the one a value reaches only when it never went through parse — a hand-built fixture, a raw `registerObject`, a stored row rehydrated past its schema. + + **`@objectstack/objectql`** — both of the delete cascade's carrier reads (`planCascadeAtomicity` and `cascadeDeleteRelations`). This is the one with a measurable runtime consequence, and it is why the level is not `patch`: + + ``` + before acct=1 task=1 + delete RESOLVED true <- success reported to the caller + after acct=0 task=1 <- an ORPHANED master_detail row + ``` + + An unreadable carrier made the relation invisible to the cascade, so the parent was deleted, the detail row stayed, and the caller was told the delete succeeded — no `restrict` refusal, no `set_null`, nothing logged. It now refuses before any row is touched. + + **`@objectstack/rest`** — the public-form lookup picker's field-def fallback. The field def is also hoisted out of the metadata fetch's `catch {}`, so an unreadable carrier is no longer reported as `LOOKUP_TARGET_MISSING`: "no target is declared" and "the declared target cannot be read" want different fixes from whoever owns the metadata. + + **`@objectstack/metadata-protocol`** — the seed dependency graph, which also retires an `as string` cast that asserted exactly what its truthiness guard had not checked. + + **`@objectstack/lint`** — the four remaining target readers: `masterDetailCount` (`validate-expressions`), the `displayField` consumer edge (`validate-field-consumers`), the field and action-param targets (`validate-object-references`), and `masterOf` (`validate-sharing-rule-enforceability`). + + **`@objectstack/verify`** — `relationTarget`, which no longer degrades an unreadable carrier to the generic "has no `reference` target" an object with no relationship metadata at all receives. + + `null`, `undefined` and `''` are ABSENCE, not a wrong shape, and still answer `undefined` at every one of these sites — a field is allowed to name no target, and `StrictField` declares `reference` nullable. Each site's absence answer is pinned alongside its refusal. + + Upgrading: nothing conformant changes. A non-string `reference` could not be authored, stored or parsed before this release either; what changes is that one now fails loudly at the read instead of being read as an absent target. If a test asserted the old silence, assert the refusal instead. +- 58644ad: The dataset publish door now judges the dataset. A runtime-created `dataset` reached ZERO author-time rules; it now dispatches the existence rules that were already written for it (#19143). + + `dataset` is a registered metadata type declaring `allowRuntimeCreate: true`, so Studio's designer, REST `/meta` item CRUD and an MCP/AI author may all mint one — and at that door nothing judged it. Measured on `origin/main`: no rule declared `dataset` in `runtimeTypes` (zero, against a lit control returning every other declared type), and `TYPE_TO_STACK_KEY` in `runtime-gate.ts` carried no `dataset` row. The two absences were **consistent rather than contradictory** — the gate filters by `runtimeTypes` before it consults the table — so nothing was mis-wired and CI was green, correctly. What they summed to is that a dataset write built no per-write snapshot and dispatched no rule at all, while the rules that judge a dataset state their own failure mode as a surface that *"renders successfully with empty or wrong numbers"*. An author working only through Studio or MCP has no `os lint` step to fall back on, so for them that door is the only one there is. + + ADR-0049's 「声明即强制」 admits two resolutions and the card chose neither; this takes the first because the measurement says so. The author-time rules for `dataset` **exist**: `validateDatasetReferences` (#14105, `packages/lint/src/validate-dataset-references.ts`), `validateDatasetMeasureAggregates` (#16354) and `validateObjectReferences`' `datasets[].object` rung. The declaration is honoured rather than retired. + + - **`TYPE_TO_STACK_KEY` gains `dataset: 'datasets'`**, and the rules that READ that collection are declared in the same commit — never a mapping ahead of its rules, which is the inert state the table's own `seed: 'data'` note records paying for. Every crossed rule has a door control that fires it through the real gate. + - **`validateDatasetMeasureAggregates` crosses to `CLI_AND_RUNTIME` with `runtimeTypes: ['dataset']`.** Its previous `surfaceReason` named this exact gap as what held it off the door. + - **The reference-integrity suite entry gains `dataset`**, and its per-member axis admits exactly two members — `validateDatasetReferences` and `validateObjectReferences`. Both resolve only against `stack.objects` and `stack.datasets`, the two collections a per-write snapshot carries, so neither opens a missing-collection false-positive channel. They cross together on #7220's reading: an author refused for a dangling dimension field and waved through for a dangling base object cannot predict the door. + - **No new rule and no new finding class.** The rule ids (`dataset-field-unknown`, `dataset-field-not-included`, `dataset-filter-field-unknown`, `dataset-include-unknown`, `measure-aggregate-field-type-refused`, `object-reference-unknown`) and their severities are unchanged — they now reach the door where the author actually is. + - **Findings from a dataset write are name-keyed on the wire** (#10064): `datasets..dimensions[0].field`, never the gate's private snapshot index. `datasets` entered the derived name-keyed set by derivation, with no second edit to remember. + - **Measured before crossing**, at the door's own snapshot shape and differential, over every dataset shipped in this monorepo — **11 datasets** (`platform-objects` 5 over `sys_*`, showcase 4, crm 1, todo 1) judged against 52 platform objects plus each app's own (showcase 22, crm 6, todo 1): **0 findings, 0 advisories, `rulesRun` 2 on every one** — so the zero is a fact about the corpus and not about a door that ran nothing. The same harness's synthetic probe IS refused, with both ids and both name-keyed paths. + + ## Migration + + **A dataset publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id and the offending path, name-keyed on the wire — for example `datasets.invoice_metrics.dimensions[0].field` or `datasets.invoice_metrics.measures[1].aggregate` — plus the string that was written. + + To clear a refusal, do one of: + + - point the `dimensions[].field` / `measures[].field` path at a column the base object actually declares (after a Studio label edit the derived API name is the one to use); or + - add the relationship the path traverses to the dataset's `include[]`, for `dataset-field-not-included`; or + - correct the filter KEY, for `dataset-filter-field-unknown`; or + - for `measure-aggregate-field-type-refused`, either aggregate a field of an accepted type or choose an aggregate the field's type accepts (`count` / `count_distinct` accept every type) — the compile leg already refuses that same pair with `400 DATASET_INVALID` once a query is built, so this is the same fix made earlier; or + - for `object-reference-unknown`, point `object` at an object this stack defines, or at a platform object by its full name. + + `os validate` / `os build` / `os lint` already reported every one of these findings at the same severity, so a code-authored stack can be repaired before it ever reaches a publish. A dataset over an object this stack does not define, one that declares no readable field map, and a registry-injected system column are all skipped exactly as they were on the CLI — the door adds no verdict the commands did not already make. A stored dataset already in violation is never charged to an unrelated publish, and republishing a dataset under its own name with the defect removed is clean (#4463 D4). +- 0870fb5: `GET /meta/types` now marks a member whose AUTHORING arm the output derivation erased, so a consumer can tell an erased authoring type from a member that genuinely admits anything (#19295). + + Every predicate slot the platform serves — `hook.condition`, `field.visibleWhen` / `readonlyWhen` / `requiredWhen`, a flow `edge.condition`, `job.schedule.expression` — composes the expression-input family, a two-arm union whose string arm is a `ZodPipe`. The served derivation is zod's default `io: 'output'`, which describes what comes OUT of the transform, so the arm's own input type is erased and the member is served as + + ```json + { "anyOf": [ {}, { "type": "object", "properties": { "dialect": {}, "source": {} } } ] } + ``` + + On the wire `{}` means "admits everything", so a metadata designer could not tell that husk from a member that really does accept any instance, and a condition builder had to veto both. + + Each such husk arm now carries one vendor-prefixed keyword: + + ```json + { "x-objectstack-erased-authoring-input": { "version": 1, "type": "string" } } + ``` + + `Clause-②: no` + + - **Read the mark, never the key name.** A consumer that enables a builder by matching `hook.condition` / `visibleWhen` / the rest keeps a second, hand-written copy of that list and drifts the moment a new predicate slot lands. The keyword is the whole contract, and `version` travels inside the value so a consumer gates on the shape it understands rather than on mere presence. + - **It constrains nothing.** JSON Schema ignores an unrecognised keyword, so every document accepts exactly what it accepted before — the payload is byte-different and semantically identical. This is deliberately NOT the blanket `io: 'input'` derivation, which was measured across the served surface and refused as a weakening of a published contract (24 of 26 types answer differently; `required` entries 1132 to 867). That refusal and its pin are untouched. + - **The predicate is structural, and declines on absence of evidence.** Three facts must hold: the emitted subschema admits everything, the zod node behind it is a pipe, and the pipe's input side derives a named `type`. `z.unknown()` and `z.any()` emit `{}` too and are not pipes, so they stay bare — including the ADR-0089 envelope's own `ast`, which sits one level below a marked arm. Measured over the served surface: 78 marked arms across eight types, 187 `{}` nodes left unmarked. + - **`action` carries no mark, and that is the honest answer.** It is the one type served from the `io: 'input'` retry, where a pipe derives from its input side, nothing is erased, and the predicate slot already publishes its real string arm. +- 2306a75: fix(metadata-protocol): the protocol install primitive parses the manifest's `id` leg, and the duplicate door parses its target id (#19417) + + Clause-②: no (narrowing) + + **BREAKING for callers of the protocol install and duplicate doors** — + `ObjectStackProtocolImplementation.installPackage` and `duplicatePackage` now + refuse a package id that is not reverse-domain notation, throwing a `400`-tagged + error carrying the declaration's own sentence. Both used to install and report + success. + + The accept set only shrinks back to what the published declaration has always + said. `MANIFEST_ID_PATTERN` is declared once in + `packages/spec/src/kernel/manifest.zod.ts` and referenced by both faces of one + identity — `ManifestSchema.id`, what an author writes, and + `PackageSchema.manifestId`, what the registry stores and publishes by. + `installPackage` parsed nothing at all: it spread the request into `any` and + handed it to `SchemaRegistry.installPackage` with a second `as any`, so + `id: 'pkg-a'` — or `com.example.my_erp` — installed and PERSISTED while + `defineStack()`, `os build`, `os validate` and the publish face all refused the + same id. That is «declared ≠ enforced» on a published contract, and nothing in + `packages/spec` moves for it: the declaration was already right. + + **Why the primitive and not only a door.** #19473 landed the same parse at the + HTTP door (`POST /api/v1/packages`). That door is ONE caller of this primitive — + it routes through `protocol.installPackage` whenever the protocol service + resolves. `duplicatePackage` is a second, and an embedder holding the protocol + object is a third. A gate on one door buys that door; this one is on the method + every caller passes through. + + The gate asks the declaration **by reference** — `ManifestSchema.shape.id` — + rather than keeping a copy of the grammar, so a future move of the + reverse-domain rule reaches this seam with no further edit. The sentence the + caller reads is the declaration's own (`manifestIdRefusal`), **surfaced rather + than reworded**: it names the key, echoes the value, lists the two examples and + carries a suggestion arm that verifies its candidate against the pattern before + offering it. Installing `id: 'com.example.my_erp'` now throws, with: + + ```text + Invalid package id 'com.example.my_erp' on `manifest.id`. Expected + reverse-domain notation ('com.steedos.crm', 'org.apache.superset') — lowercase + dot-separated segments of letters, digits and inner hyphens; a segment may not + open with a hyphen; underscores are not admitted. Did you mean + 'com.example.my-erp'? + ``` + + **The duplicate door refuses BEFORE it mints anything.** `duplicatePackage` + builds its target manifest and writes it through `installPackage` inside a + deliberately best-effort `catch {}` — a refusal raised only there would be + swallowed and the caller would read `success: true` on a package with no + manifest row. So the target id is parsed at the top of the method, ahead of the + row scan and ahead of the copy loop, and the refusal names the key the caller + actually wrote (`targetPackageId`). + + **One assumption, one implementation.** The duplicate door derived both + namespaces with a raw `id.split('.').pop()` while `installPackage` derived the + same default with the spec helper `deriveNamespaceFromPackageId`, which + sanitises to the namespace charset, truncates to 20 and answers `null` when + nothing valid comes out. That mattered: the target namespace is spliced into + every copied object name as `${namespace}_${short}`, and an object name is + `/^[a-z_][a-z0-9_]*$/`. The Studio's own default duplicate id — + `-copy` — therefore minted `leave-copy_ticket`, a name the object + declaration refuses. Both sides now use the helper, so a duplicate of + `com.example.leave` into `com.example.leave-copy` is namespaced `leave_copy`. + An explicitly declared `targetNamespace` still wins untouched; when neither an + explicit nor a derivable namespace exists the door refuses loudly, naming + `targetNamespace` as the remedy, instead of renaming rows with an empty prefix. + + **What is not affected.** Boot-time and in-process installs that reach + `SchemaRegistry.installPackage` / `ObjectQL.registerApp` directly never pass + through this primitive, so nothing about how a package is loaded from disk or + registered by a plugin changes. A conforming manifest installs exactly as + before, versionless and namespace-less manifests included — the version default + and the namespace default still run, now behind the id gate rather than ahead of + it. + + **Scope — the `id` leg alone.** `InstallPackageRequestSchema` / `ManifestSchema` + are still not parsed whole here. The residual classes the HTTP door's own + docblock records are untouched by this change and are each their own narrowing + of a published contract. + + **If you are refused.** Give the package an id in reverse-domain notation — + lowercase dot-separated segments, hyphens allowed inside a segment, underscores + not. The refusal names the key, echoes what you wrote and, where a mechanical + repair exists, offers one it has already checked against the rule, so the + prescription arrives with the failure rather than in a changelog. + + +- 4112752: fix(metadata-protocol): `duplicatePackage` parses an explicit `targetNamespace` through the manifest namespace declaration instead of taking it raw (#19577) + + Clause-②: no (narrowing) + + **BREAKING for callers of `duplicatePackage` / `POST /api/v1/packages/:id/duplicate`** — an explicit `targetNamespace` outside the `manifest.namespace` declaration (`/^[a-z][a-z0-9_]{1,19}$/`) is now refused with a `400` before anything is copied, where it used to be accepted verbatim. Refused now: a hyphen or an uppercase letter (`my-ns`, `MyNs`), a leading digit or underscore (`1leave`, `_leave`), a single character (`l`), more than 20 characters, and surrounding whitespace. Some of these (`l`, `_leave`, a 21-character value) still yield legal object names, so they used to be copied, under a `manifest.namespace` the declaration refuses. Every conforming value — and every call that omits `targetNamespace` — duplicates exactly as before. + + `ObjectStackProtocolImplementation.duplicatePackage` (and so `POST /api/v1/packages/:id/duplicate`, which forwards the body's `targetNamespace` verbatim) resolved its target namespace as `request.targetNamespace ?? deriveNamespaceFromPackageId(request.targetPackageId)`. The derived default already had to satisfy the namespace charset; the explicit value crossed no gate at all. That value is written as the copy's `manifest.namespace` and spliced into every copied object name as `${namespace}_${short}`, so `targetNamespace: 'my-ns'` minted `my-ns_ticket` — a name the object declaration (`/^[a-z_][a-z0-9_]*$/`) refuses — under a manifest namespace the manifest declaration refuses. + + - **One parse for both branches.** Whichever branch answered, the resolved namespace is now parsed by `ManifestSchema.shape.namespace` (`@objectstack/spec/kernel`) — the declaration itself, by reference, not a copied regex — before the source rows are scanned and before the target package record is minted, so a refusal never leaves an empty shell behind. + - **Refused, not sanitised.** An explicit value the declaration refuses is refused; it is never rewritten the way the derivation sanitises an id, because a copy landing under a namespace the caller did not write is a silent rewrite. + - **The sentence is the declaration's.** The refusal names the key and echoes the value, then carries the declaration's own rule text: `Invalid package namespace 'my-ns' on \`targetNamespace\`. Namespace must be 2-20 chars, lowercase alphanumeric + underscore. …`. The derived branch's refusal (an id whose final segment cannot carry the charset) now carries the same declaration sentence after its `Pass \`targetNamespace\` explicitly.` remedy, replacing a reworded one. + - **No new error code.** Both refusals throw with `statusCode: 400` and no `code`, so an HTTP boundary answers `400 VALIDATION_ERROR`, the status-derived code the derived branch already answered. + + **If you are refused:** pass a `targetNamespace` of 2–20 characters that starts with a lowercase letter and continues with lowercase letters, digits or underscores (`leave_copy`, not `leave-copy`), or omit it and let the door derive one from `targetPackageId`. Every conforming value duplicates exactly as before. + + +- cfe2387: fix(metadata-protocol)!: the read door judges the keys inside each per-aggregation `filter` with the gate an explicit `where` meets — an unknown key is `INVALID_FIELD` / 400, not a count of zero (#20148) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what `POST /data/:object/query` (`findData`) accepts in `aggregations[i].filter`. Each entry's filter now goes through the gate the explicit `where` meets, `assertFilterFieldsExist`: the same field set, the same verdicts, `INVALID_FIELD` / 400, with the entry's position as the parameter (`aggregations[1].filter`). The refusal is raised before the engine is called. It ships as `minor` under the launch-window convention for accept-set narrowings. + + Measured on the base through `POST /data/:object/query` on `driver-memory` and `driver-sql`, over six rows in three groups: + + | in `aggregations[i].filter` | before | now | + |:--|:--|:--| + | a key naming no field of the object (`{ nope: 1 }`), also under `$and` | `200`, that count 0 | `INVALID_FIELD` / 400, naming the key | + | the same key under `$ne`, `$not`, or behind a `$or` branch that holds | `200`, every row counted | `INVALID_FIELD` / 400 | + | an unknown key carrying an unknown operator (`{ nope: { $median: 1 } }`) | `INVALID_FILTER` / 400 from the engine | `INVALID_FIELD` / 400: the field gate runs first, as it does for the same key in `where` | + | a dotted key on a scalar head, or a key naming a `formula` field | `INVALID_FIELD` / 400 from the engine's own filter seam | `INVALID_FIELD` / 400 from this gate, in the words `where` gets | + + Run after the entry checks and the aggregated-field check, so an entry the spec cannot read keeps its shape refusal and an unknown aggregated `field` keeps its own. A filter that is not a plain object names no key here and is left to the engine's shape gate. + + Not changed: a filter on declared keys, on `id`, `created_at` or `updated_at`, and a relation head in the nested-object form reach the engine with the filter untouched. +- 1207baf: RLS policies are admitted when they are authored: the engine judges every read-scope `using`, at the save door and at `os validate` / `os build` / `os lint` + + A row-level-security policy (`rowLevelSecurity[]` on a permission set) could carry a `using` predicate that lowers cleanly and that the engine then refuses to run: a text operator (`startsWith` / `endsWith` / `contains`) aimed at a number field, a date field compared against a value its storage cannot read, a filter on a virtual (formula) field, or a `{…}` placeholder string. Nothing refused it when it was written. The first answer was a refused analytics query, long after the author had moved on. And the metadata save door (Studio, REST `/meta`, MCP) did not run the RLS predicate rule at all, so a predicate `os validate` already refused was accepted there. + + - **The engine's own verdict.** `validateRlsPredicateEnforceability` takes the engine's judge-only filter admission (`IObjectQLEngine.judgeFilter`) as an optional input and judges the lowered `using` of every `select` / `all` policy with it. A refusal is reported under the existing id `rls-predicate-unenforceable`, and the message quotes the engine's code, status and sentence verbatim. The rule never models the engine's checks: without the input it answers exactly as before. + - **Both doors hand in a real engine.** The metadata save door probes its host engine for `judgeFilter` and passes the bound method through the publish gate. The CLI commands build an engine with no driver from the stack's own objects and pass its method. + - **The save door now runs the rule for `permission` writes** (`surfaces: ['cli', 'runtime-publish']`, `runtimeTypes: ['permission']`), so every predicate `os validate` refuses is refused there too, as a `422 INVALID_METADATA` whose `issues[]` carries the same sentence. + - **New optional inputs.** `AuthoringRuleContext.judgeFilter` (and so `AuthoringRuleRun.judgeFilter` for `runAuthoringRules`), the `judgeFilter` argument of `runRuntimeAuthoringRules`, and an optional second parameter of `validateRlsPredicateEnforceability`. A caller that passes nothing gets the previous verdicts. + + **BREAKING**: a permission set whose read-scope `using` the engine cannot run now fails `os validate` / `os build` / `os lint`, and a publish of it through the metadata save door is refused with `422`. Stored rows keep being read, and a re-save of one is judged like any other publish. `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns the save-door refusal into a logged warning for a migration window. The refusal's hint names the fix for each class: write the caller's value as a `current_user` key rather than a `{…}` placeholder, point a text operator at a field that holds a string, compare a date field against a value its storage reads, or denormalise a computed value onto a stored field. Every policy authored in this repository, in its examples and in the default permission sets was measured, and none is refused. + + Two edges are not closed here, both deliberately: + + - The judge sees only `using` clauses in the read scope. A `check` is matched in memory against the post-image and never reaches the engine's filter admission. + - At the save door, the judge reads the engine's live registry. An object that exists only in the same publish batch, or only in an organization overlay, is one that registry does not hold, so it gets the engine's unknown-object answer: no field-type verdict, while the placeholder and comparand checks still run. At the CLI door, an object the stack does not define gets the same answer. + + Clause-②: yes (narrowing) + + +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- d4f5232: **BREAKING** — retire the `type: 'page'` list-view mount and its `pageName` binding. + + A list view could declare `type: 'page'` and name a published page in `pageName`, + and the view was to render nothing of its own and delegate to the page renderer. + Only the spec half of that was ever built. **No renderer ever routed the member**: + objectui's list-view switch shares its `default:` arm with `case 'grid'`, so a page + view has always drawn an empty table where the page was supposed to be, and the + three parse refusals that policed the binding policed a mount that never mounted + anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `{ type: 'page', pageName: 'sales_home', columns: [] }` on a list view | nothing on the view. Delete it, and reach the page from the app's `navigation`: `{ id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }` | + | `pageName` beside any other list-view `type` | delete the key — it was refused already, and is now a tombstone | + | a list view that wanted rows | pick a row-drawing `type` — `grid` and its siblings, all unchanged | + + **The one-line fix:** delete `type: 'page'` and `pageName` from the list view; put + the page behind an app navigation item, which is a different key on a different + surface (`PageNavItem.pageName`) and is the page mount that has always rendered. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply + them by hand. + + ## The retirement kit + + - **`pageName`** — a `retiredKey()` tombstone on `ListViewSchema` and + `ObjectListViewSchema`. `tsc` types the key `never`, and a value reaching a parse + raises the prescription rather than a bare unrecognized-key report. + - **`'page'`** — an enum VALUE, so there is no tombstone to hang a prescription on + (the def survives, one value lighter, and the four generated-surface ratchets are + blind to that by construction). The `type` enum's own `error` map carries it, + keyed on `issue.input` so only the value that used to be legal gets the + "was removed" message; every other invalid `type` keeps zod's default text. + - **`checkListViewPageMount`** — the exported object-level refinement existed only + to police this mount, so it is removed with it, along with its three refusal + messages. A downstream mirror that re-attached it (the reason it was exported) + should drop the `.superRefine` line; the compiler delivers this one. It held no + `ERROR_CODE_LEDGER` row — the three refusals were message constants, not codes. + - **`validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`** (`@objectstack/lint`) — the + `os validate` and publish-gate rule that resolved a mount against `stack.pages`. + Removed: there is no reference left to resolve. Its nav twin + (`validateNavTargetRefs`, on the app navigation item) is **untouched**. + - **`RuntimeStackContext.pages`** (`@objectstack/lint`) and the `page` row of + `CLOSURE_CONTEXT_KEY_BY_TYPE` (`@objectstack/metadata-protocol`) — the live page + universe joined the per-write snapshot for that one rule, and leaves with it. A + `PUT /api/v1/meta/view` publish no longer pays a `sys_metadata` round trip for a + collection nothing consults. Hosts calling `runRuntimeAuthoringRules` / + `evaluateRuntimeAuthoringGate` with an explicit `context.pages` drop that key. + - **`defineStack`** — the `validateCrossReferences` branch that resolved a mount's + `pageName` against `stack.pages` is gone. The surviving three page references in + that function (an app nav item's `pageName`, a modal action's `target` at two + rungs) keep their own policy. + - **The metadata form** — `view.form.ts`'s `page` section, whose one input was + `pageName`, is removed. A form input for an unwritable key is the false-compliant + UI half of a retirement. + + ## What an operator with a STORED page view sees + + A `sys_metadata` `view` row written before this release can carry `type: 'page'` and + a `pageName`. Nothing breaks at read: the ADR-0087 conversion + `view-page-mount-removed` (protocol 18) replays on rehydration and strips both keys, + so the row is served canonical. `type` is **stripped, not rewritten** — it defaults + to `grid` in the schema, so the row lands on exactly what it already rendered + without the platform guessing a view type. + + The strip is announced once per row per process, on whichever seam served it. + Grep for `carries a pre-protocol shape` — there are **three** emitters, one per + rehydration seam, and they differ: + + - `[DatabaseLoader] stored view/ carries a pre-protocol shape; ` + - `[ObjectQLPlugin] stored view/ carries a pre-protocol shape; ` + - `[Protocol] stored view/ carries a pre-protocol shape; The row + itself is unchanged — re-save it (Studio edit -> save, or run + "os migrate meta --stored --apply") to persist the canonical shape.` + + `os migrate meta --from 17` lists the same edits for authored sources; + `os migrate meta --stored --apply` rewrites the stored rows so the warn stops, and + the next save through `PUT /api/v1/meta/view` heals one row the way it heals any + pre-protocol shape. + + ⚠️ The conversion walks `stack.views[]` in all three persisted spellings; it does + **not** reach `objects[].listViews.*`, which no conversion in the registry reaches. + An object body still carrying a page mount is refused at its own door with the + prescription rather than converted. Measured population for both at the ruling: + **zero** authored `type: 'page'` list views in this repository or any consuming app + the seats can read — the in-tree `type: 'page'` hits are all app nav items. + + +- 4062aef: fix(metadata-protocol): the runtime authoring gate judges an OVERRIDDEN item from the body the runtime serves (#16224) + + The #4463 runtime authoring gate resolves references against the live metadata universe, and since #15950 it gathers that universe from BOTH homes — the `SchemaRegistry` and `sys_metadata`. That fold was **additive**: a stored row contributed a name the registry did not carry and never displaced a registry entry. Where an org or env-wide overlay REDEFINES an item a code package already declares, the gate therefore judged that item's CONTENT from the registry's copy — a body the runtime had already stopped serving. + + Measured end to end, in one process and one instant. A code package ships `dataset/D` with measure `m`; an env-wide overlay redefines `D` without it: + + - a dashboard widget bound to `values: ['m']` was **accepted**, and the runtime cannot serve it; + - a widget bound to the measure the overlay DOES declare was **refused** `422 widget-measure-unknown`, and the runtime can. + + One cause, both directions: an acceptance that should have been a refusal and a refusal that should have been an acceptance. + + The hand-rolled additive merge is replaced by `mergePackageAwareOverlay` with `foldObjectExtendersFromRegistry` as its transform — the merge, and the transform, that `getMetaItems` (the read API behind `GET /meta/:type`) already runs. The gate's universe is now the universe the platform answers reads from, by construction rather than by agreement, and ADR-0048 package slotting arrives with it: an overlay shadows the entry it actually overrides, and two installed packages shipping one `type/name` remain two entries. + + **#15950's resolved-vs-base distinction is kept by folding, not by declining.** Its argument was never "an overlay must not win" but "an UNRESOLVED body must not win" — the registry's copy of an object is its RESOLVED schema (ADR-0029 D9.2: base layer plus its `extend` contributors), a `sys_metadata` row is the base layer alone — and it names its own remedy, which is what `getMetaItems` does to its winner. Pinned: an `object` overlay wins on its own columns AND keeps the registry's `extend` contributors. For a name the registry does not carry the result is byte-for-byte #15950's additive contribution, pinned in the same process. + + Graded `minor` rather than `patch`: `PUT /meta/:type` is a published verb and this narrows its accept set. An `active` publish that names a reference the overlay removed now answers `422 INVALID_METADATA` where it answered `200` — one legal published answer replaced by another, not the repair of a value the schema already refused. The write it now refuses is one the runtime could never serve; the write it now accepts is one the runtime always could. + +### Patch Changes + +- 0b788da: The query TRANSPORT dialect is declared — keys AND values — and the `findData` fold now derives from that one declaration. + + `FindDataRequestSchema.query` declared `QuerySchema` — the canonical QueryAST — while the shipped `findData` door also accepted a second spelling of the same query through the same slot: `$filter` / `$top` / `$skip` / `$orderby` / `$select` / `$expand` and the plural `filters`. `@objectstack/metadata-protocol` folded them from a module-private table whose own comment called them "the wire-only spellings no schema declares". Two dialects, one slot, one of them declared — so every caller speaking the second was unverifiable at build time and unrejected at runtime. + + **New in `@objectstack/spec/data`** (9 exports, 0 removed): + + - `QueryTransportParamsSchema` / `QueryTransportParams` / `QueryTransportParamsParsed` — the transport parameters, each carrying the value of the canonical slot it folds onto. + - `QUERY_TRANSPORT_ALIAS_SLOTS` — `RPC_QUERY_ALIAS_SLOTS` extended with the transport-only spellings (`filters` / `$filter` onto `where`, `$expand` onto `expand`). + - `QUERY_TRANSPORT_DOLLAR_ALIASES` — the `$`-to-bare pairs that fold in two hops (`$top` onto `top` onto `limit`). + - `QUERY_TRANSPORT_DOLLAR_PARAMS` — the `$` spellings a boundary quotes when it refuses an undeclared one. + - `QueryWithTransportSchema` / `QueryWithTransport` / `QueryWithTransportParsed` — the query slot whose declared input is the AST or its transport spelling and whose parsed output is the AST plus the `count` flag. + + **`FindDataRequestSchema.query` is that slot now.** Its `z.input` admits the canonical AST, the transport spelling, or a bag carrying both. Its `z.output` is `QueryAST & { count?: boolean }` — the canonical AST, plus the response total-count flag, which rides inside this slot on the wire and is read off it by `findData` rather than passed to the engine. The output is CONSTRUCTED: the fold's result is parsed by the AST schema and that parse's result is what leaves the transform, so a transport key or a non-AST value cannot reach a consumer. The transport form is the FLATTENED SPELLING of the canonical AST with a 1:1 alias table — never a second semantics — so `QuerySchema` itself is untouched and still drops a `$` key as unknown. + + **One semantics means one set of VALUES, not only one set of keys, and that is what this declaration now enforces.** Every spelling of a slot accepts the same value shapes; each is lowered to the canonical member's declared shape, or refused. What lowers: a stringly-typed `$top` / `$skip` (`'50'` becomes `50`), a comma list on `$select` / `$searchFields` / `$expand`, a `{field: direction}` sort record, a relation-name list on `populate`, `'true'` / `'false'` on `$count`, and the input-only `FilterArray` sugar (`['status', '=', 'open']`) on every spelling of the filter slot — `where` included — lowered through `parseFilterAST`, the one declared sink (#5158 ruling C; `QuerySchema.where` still refuses the array). + + **What is REFUSED at the parse**, because lowering it would mean parsing the spec must not do, and because emitting it would put a value under the AST type that the AST does not declare: + + - a non-numeric `$top` / `$skip` (`$top: 'abc'`, `$top: ''`) — `400` instead of an engine call with `limit: null`, i.e. an UNBOUNDED read under a `200`, or `limit: 0`; + - a JSON-encoded `$filter` string (`'{"status":"open"}'`); + - an OData sort EXPRESSION on `$orderby` / `sort` (`'name desc'`, `'-created_at'`, `['name']`) — the record and `SortNode[]` forms are unaffected; + - a filter array no lowering can express, such as the INFIX join `[condA, 'and', condB]` — the prefix form `['and', condA, condB]` is the one the platform reads, and the engine already answered `400` for the infix one; + - a `$count` that is neither the boolean nor `'true'` / `'false'`; + - two spellings of one slot carrying different values — reported at the canonical path, quoting the spelling the caller actually wrote (`$orderby`, not `orderBy`). + + These refusals narrow no DECLARED surface: none of these value shapes was ever declared — `FindDataRequestSchema.query` was `QuerySchema`, which STRIPPED every one of these keys rather than declaring it. + + **Five of them were nonetheless SERVED, and now answer `400 VALIDATION_FAILED` at the ingress.** The route forwards the ORIGINAL body, not the parse output, so a key the old schema stripped still reached the door, which read it and answered `200`. A `POST /data/:object/query` body written one of these five ways stops working; each has a declared spelling that means the same thing: + + | body that now answers `400` | what the door served it as | write instead | + |---|---|---| + | `{ $orderby: 'name desc' }` | `orderBy: [{ field: 'name', order: 'desc' }]` | `{ $orderby: { name: 'desc' } }` — or `{ orderBy: [{ field: 'name', order: 'desc' }] }` | + | `{ sort: '-created_at' }` | `orderBy: [{ field: 'created_at', order: 'desc' }]` | `{ sort: { created_at: 'desc' } }` — or `{ sort: [{ field: 'created_at', order: 'desc' }] }` | + | `{ $orderby: ['name'] }` | `orderBy: [{ field: 'name', order: 'asc' }]` | `{ $orderby: { name: 'asc' } }` — or the `SortNode[]` form | + | `{ $filter: '{"status":"open"}' }` | `where: { status: 'open' }` | `{ $filter: { status: 'open' } }` | + | that same JSON string on `filters` or `filter` | `where: { status: 'open' }` | the object form on whichever of the two keys you write | + + **`GET /data/:object` still serves every one of those shapes.** The querystring path does not parse through this schema at all — `FindDataRequestSchema` is parsed at exactly one call site, the POST handler — so `?$orderby=name desc`, `?sort=-created_at` and `?$filter={"status":"open"}` answer exactly as before. What narrowed is the POST body alone — the platform has not stopped accepting these spellings everywhere. + + The remaining refusals in the list narrow nothing that was served correctly; they move an unservable body's refusal earlier — from the engine, or from a wrong answer under a `200`, to the ingress that can name the parameter to fix. + + **`@objectstack/metadata-protocol` folds by the spec export** instead of its own table, and both resolved tables — plus the `$`-parameter list its `UNSUPPORTED_QUERY_PARAM` refusal quotes — are pinned byte-equal to their pre-change values. An undeclared `$` spelling is still refused loudly with the same `400 UNSUPPORTED_QUERY_PARAM`; the sentence now quotes `QUERY_TRANSPORT_DOLLAR_PARAMS` rather than a hand-copied list, so a spelling added to the table cannot leave the refusal naming a set the door no longer has. + + Measured and unchanged: `getData` takes `select` / `expand` directly and carries no `query` slot, and `updateManyData` / `deleteManyData` take `records[]` / `ids[]` — none of the three has a transport-dialect split to declare. + + Clause-②: yes (widening) +- dc709b2: The seed-tenancy backfill's organization probe records the operator channel as is — an empty one included — instead of the placeholder `'unknown error'` (#17167) + + `packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts` had one site left + that did not follow the rule the rest of the file follows. Where the other four + `operatorFacingErrorText` calls record the helper's return value as is, the + `sys_organization` probe spelled `operatorFacingErrorText(e) || 'unknown error'`, so a + backend that failed WITHOUT saying anything was recorded as having said + `'unknown error'` — words no backend produced, in a field an operator reads to find out + which probe failed and why. + + **Measured before and after**, driving `backfillSeedTenancy` at each site in that file + with the same three empty-channel shapes (a thrown `''`, a thrown `[]`, an `Error` whose + `name` and `message` are both empty) and with `new Error('boom')` as the control: + + | site | before | after | + |---|---|---| + | split probe → `result.detail` | `''` | `''` | + | **organization probe** → the warning's `organizationProbeError` | **`'unknown error'`** | **`''`** | + | duplicate-list probe → the warning's `error` | `''` | `''` | + | stamp → the warning's `error` | `''` | `''` | + | counter merge → the warning's `error` | `''` | `''` | + + The control records `'boom'` at every site in both columns. + + **Why this was not a one-line deletion.** The placeholder was carrying two jobs and only + one of them was a record: the site also read `organizationProbeError === ''` as "the probe + did not fail", which is how a failed probe is kept out of the benign `no-organization-yet` + branch (#9261 — unknown is not zero). Deleting the placeholder and putting nothing in its + place was measured: a thrown `''` then reports `no-organization-yet` and warns about + nothing, while the control still reports `skipped-ambiguous-organization`. So the failure + fact moved into the TYPE — `organizationProbeError` is `string | undefined`, `undefined` + means the probe answered, and every string, empty or not, is a failure. The text is then + free to say exactly what the backend said. + + **What does NOT move.** No status value changes for any input: an organization probe that + throws still reports `skipped-ambiguous-organization`, whatever its channel holds, and + `SeedTenancyBackfillStatus`, `SeedTenancyBackfillResult` and every exported signature are + unchanged. This probe's text never reached the returned result in the first place — it is + carried only by the warning this migration logs (measured: the control text appears in + `result.detail` at the split-probe site and appears nowhere in the returned object at this + one). + + **One operator-visible detail beyond the text.** The warning's structured field is now + absent when the probe answered and present-but-empty when it failed silently, so "empty" + and "there was no failure" stay distinguishable in the stored line — the one job the + placeholder was doing that a reader could have depended on. The sentence in the same + warning drops its parenthetical rather than filling it in: `the sys_organization probe + FAILED, so the count above is "unknown"` when the backend said nothing. +- 07f93e0: Seed loader: the pass-2 deferred-reference diagnostics now say which moment they describe + + `SeedLoaderService` runs inside `AppPlugin.start()`, which the kernel completes for every + plugin before it fires `kernel:ready` — where the first-admin handoff + (`claimSeedOwnership`) re-owns every `owner_id IS NULL` row of every user-authored object. + That handoff is the designed completion of a NULL owner column, so two of the loader's + pass-2 lines — `Deferred reference UNRESOLVED after pass 2` and + `Deferred reference back-fill FAILED` — were making a bare present-tense claim + (`x.owner_id stays NULL`) that the same boot then made false, with nothing in either the + log or the table to tell an operator that the other reading existed. + + Both lines now read `is NULL at the end of pass 2` and carry a scope sentence naming the + boot step that can supersede them and stating that a non-NULL value found later is not + evidence the reference resolved. Level, error count and remedy are unchanged — this is a + scope declaration, not a silencing. The two `Deferred reference DROPPED` lines are + deliberately untouched: they report a row that never landed, so no later boot step can + write a column of it and their claim survives to the end of boot as written. + + Nothing an author writes changes. Anything that greps the loader's output for the literal + `stays NULL` on these two lines should grep for `is NULL at the end of pass 2` instead. +- bdb247d: `@objectstack/spec/kernel` exports `SEED_WRITE_EXECUTION_CONTEXT`, the one spelling of the seed-write posture every seeder now reads + + The execution context a seed write must use — `isSystem`, `skipTriggers`, + `seedReplay` — had **no exported form**, so every seeder held a private copy of + it and nothing held the copies equal. There were three on `main`: + `SeedLoaderService.SEED_OPTIONS` (`@objectstack/metadata-protocol`), + `SEED_WRITE_OPTIONS` (`@objectstack/runtime`'s `AppPlugin`, whose own docblock + already recorded that it "mirrors" the first) and `SEED_CONTEXT` + (`@objectstack/verify`'s fixture writer, which spelled it a third time + specifically because the runtime kept its copy module-private). + + **Why a shared constant rather than three accurate copies.** `skipTriggers` is + what suppresses "on create" automation for seed rows, and `isSystem` alone does + **not** suppress dispatch. A seed path that lost that flag once seeded with + automation live while the main path had it suppressed — a self-trigger loop that + wedged first boot (#3760). A constant whose divergence re-opens a boot-wedging + defect is a kernel semantic, not a local detail. + + **What is exported, and what deliberately is not.** The **inner** + `ExecutionContext` value, and nothing wrapped around it: + + ```ts + import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; + + await ql.insert(object, rows, { context: SEED_WRITE_EXECUTION_CONTEXT }); + ``` + + The `{ context: … }` options bag stays at the call site. It is what all three + sites ultimately hand to `insert`, but it is an options envelope rather than the + posture: its type differs per engine method, so freezing one bag onto the + protocol surface would serve `insert` and no other operation, and it is + precisely the convenience bundle this export is not. + + ⛔ **No behaviour change.** The value is byte-identical to all three previous + copies, the three flags keep their existing meanings, and no seed path changes + what it writes or how. The three former copies now read this export, so the two + option bags are `{ context: SEED_WRITE_EXECUTION_CONTEXT }` and the `verify` + context is the export itself. + + **Additive, so `minor` on `@objectstack/spec`**: one new name on the existing + `./kernel` entry point, no existing export removed, renamed or narrowed. The + three consumers take `patch` — their published `dist` changes (an import edge, + and the constant now resolves through `@objectstack/spec/kernel`) while their + own public surfaces do not move. +- e743fb5: fix(metadata-protocol): the `/references` refusal front-loads its ADR-0110 D3 prescription, so the #5423 bound cannot cut the remedy (#17584) + + `GET /api/v1/meta/:type/:name/references` refuses an unanswerable target type + (`field`, addressed by the composite key `.` that no reference + site can hold) with a prescriptive 501: it names the question that IS + answerable, `GET /api/v1/meta/object//references`. That clause is the + half ADR-0110 D3 exists to deliver — the admin "Used by" panel renders an empty + answer as *"Nothing in the metadata graph points at this item. Safe to delete."* + to an operator whose next click is a delete. + + Since #16146 the refusal crosses the REST boundary through the shared #5423 + bound (`CLIENT_MESSAGE_MAX`, 500 characters), which truncates the **tail**. The + sentence back-loaded the prescription and interpolates the object name twice, so + it grew about three characters per character of name and the remedy was the + first thing a long name cost. Measured through the real route on the unrepaired + sentence: a 37-character object name beside a 37-character field name composed + 502 characters and arrived as `…/api/v1/meta/object//referenc…` — the + opener still readable, the URL cut mid-path, an instruction that 404s if + followed. `crm_opportunity_line_item_snapshot_v2` is 37 characters, and nothing + caps a metadata name near that (the ceiling is the storing column's + `maxLength`; the widest is `sys_metadata.name` at 255). + + The clauses are re-ordered so truncation costs the **explanation** instead. No + behaviour moves: the refusal decides exactly what it decided before, the same + `NOT_IMPLEMENTED` / `501` / `refusal` declaration is raised for exactly the same + targets, and the bound is untouched. Callers matching on the message's opening + words will see the new order; matching on `error.code` is unaffected. + + FROM: `References to a 'field' item cannot be computed. … Ask the owning object + instead: GET /api/v1/meta/object//references.` + TO: `Ask the owning object instead: GET /api/v1/meta/object//references. + References to a 'field' item cannot be computed, because …` +- 7e74af3: Execute the read-probe's PostgreSQL catalog arm against a live server, closing the one dialect this package pinned as text and never ran. + + `read-probe.ts` compiles one non-raising table-presence arm per dialect family. Two were executed against something real — SQLite end to end through a real `SqlDriver`, MySQL on the live server the `Temporal Conformance` job provisions. The PostgreSQL arm (`SELECT 1 WHERE to_regclass('"
"') IS NOT NULL`) was pinned character-for-character against all four knex client spellings and run nowhere: this package had no live-PG harness, no `pg` dependency, and its CI step supplied `OS_TEST_MYSQL_URL` alone while filtering vitest to `live-mysql`. + + A text pin cannot close that gap, because the failure this module is fenced against is an arm mis-compiled for one dialect: it raises, the `catch` that exists for the expected miss swallows it, and a stored-row data repair silently becomes a no-op. Whether `to_regclass` answers ZERO ROWS rather than raising is a claim about PostgreSQL, not about this repo's string concatenation. `seed-tenancy-backfill.live-postgres.test.ts` now runs every statement the migration builds, both presence directions with the refusal control beside them, the search-path scoping the arm depends on, and the whole backfill end to end — on a live server, in its own derived schema. Ablated (the Postgres arm re-compiled to MySQL's `DATABASE()` form), six of its seven cases go red, reporting `verdict: 'unreadable'` with `detail: "function database() does not exist"` — the exact shape the fence exists to keep out of `'absent'`. + + Grade: `patch`, measured rather than defaulted. Not `minor` — no new export, no widened accept-set, no runtime behaviour change of any kind. Not `skip-changeset` either, and that is the measurement worth recording: `dist/` is byte-untouched (grepped for this change's markers: zero hits, against a positive control that hits `dist/index.js` and `dist/index.cjs`), but `package.json` is one of the 27 files `npm pack` ships, and it now carries `pg` and `@types/pg` in `devDependencies`. `skip-changeset` is for a diff that publishes nothing from a released package; this one publishes two manifest lines a consumer never installs, which is still publishing. +- fade3da: `installPackage`'s docblock no longer names `marketplace` as the capability that governs package persistence — after #17676 ruling A′ item 1 that half is `package-registry`, and `marketplace` names only the optional catalogue (#17676). + + Clause-②: no + + The shipped note read *"when the `package` service is absent (e.g. the `marketplace` capability is off)"*. That parenthetical was accurate when it was written and stopped being accurate when the carve-out landed: `packages/spec/src/kernel/platform-capabilities.ts` now carries `marketplace` and `package-registry` as two tokens, with the `sys_packages` container and its boot hydration under the second — a core capability mounted always — and browsing left under the first. A consumer reading this docblock in an editor, out of `dist/index.d.ts`, was being pointed at the wrong switch. + + - **⛔ No behaviour moves, and this is not the card's defect being repaired.** The in-memory-only branches in `installPackage` and `updatePackage` are byte-identical. Ruling A′ item 2 keeps them deliberately, as the documented degraded path for reduced hosts — a host that mounts no provider must still be able to install a package for the life of its process — so the note now says that too, rather than leaving the branch reading like an oversight. #17676 stays open. + - **The note also records what is NOT true yet, measured rather than assumed.** `Serve.CAPABILITY_PROVIDERS` (`packages/cli/src/commands/serve.ts`) keys `marketplace` and does not key `package-registry`, so the always-on token is force-appended to every app's `requires` and then resolves to no provider — silently, because the resolver warns only for tokens outside the vocabulary. A stock boot still takes the absent-service branch. That half of the ruling belongs to the capability resolver and is not in this package. + - `updatePackage`'s docblock points at the same note, since the ruling names both primitives. + + Published surface: doc comments only. `dist/index.js`, `dist/index.cjs`, `dist/index.d.ts` and `dist/index.d.cts` all carry the corrected text — tsup keeps JSDoc, which is why this ships at all — and no export, type, signature or runtime string moves. +- e3b3cdd: `/discovery` advertises `capabilities.transactionalBatch` from the predicate the atomic-batch refusal already trusts, so the advertisement and the 501 stop disagreeing (#18997). + + `getDiscovery()` derived the bit from the ENGINE alone — `typeof this.engine?.transaction === 'function'` — while `runAtomicBatch` refuses `batchData({ atomic: true })` with `501 NOT_IMPLEMENTED` on `engineCanRollBack(engine)`, which asks the DEFAULT DRIVER as well. `engine.transaction` is a function on every real engine, so the advertisement answered `true` for compositions that then 501 — and the 501's own remedy text sends the caller to that very bit ("probe `capabilities.transactionalBatch` on /discovery first"). The prescribed remedy routed the caller to a signal that was wrong in exactly the case the remedy exists for. + + **What a consumer sees.** Two compositions, measured separately, stop advertising `true` and now advertise `false`: + + - **(a) a default driver with no `beginTransaction` at all** — pre-existing, not introduced by #18063; + - **(b) a default driver that inherits `beginTransaction` and declares `supports.transactionsUnsupported`** — the population #18063 added; the shipped example is `TursoDriver` on its remote transport. + + Both already answered `501 NOT_IMPLEMENTED` to an atomic batch, so nothing that was accepted becomes refused. A client that read `true` and proceeded was taking the 501; it now reads `false` and takes its non-atomic fallback ahead of the failure — which is what probing the capability was for. A client that hard-asserts `transactionalBatch === true` at startup against such a composition fails at startup instead of at the first atomic batch. + + Unchanged in the other direction, and pinned so that "honest" cannot decay into "always `false`": a composition whose default driver **can** roll back still advertises `true`, and so does a host whose driver registry is not inspectable (test doubles, metadata-only hosts), where the engine-level probe is all there is. Measured over all 16 compositions of the four inputs the two predicates read: 0 go `false` → `true`, 3 go `true` → `false`. +- 482d584: The in-process install primitive honours `enableOnInstall` instead of ignoring it (#19277). + + `InstallPackageRequestSchema.enableOnInstall` (`kernel/package-registry.zod.ts`) is the request contract of `ObjectStackProtocol.installPackage` / `MetadataProtocol.installPackage`. The implementation read `request.manifest` and `request.settings` and nothing else, so a caller that asked for `enableOnInstall: false` got an ENABLED install — no refusal, no warning, no effect. That is a declared option the runtime did not deliver, which ADR-0049 (enforce-or-remove) and Prime Directive #10 refuse outright. Ruling batch #153 item 5 letter 1 (#18605) kept this declaration as a COPY of the HTTP request key with the same meaning, so the disposition is enforce, not retire. + + The primitive now applies the same rule the HTTP door applies (maintainer ruling batch #157 item 5 letter C, 「缺省 = 保持,有旗 = 设置」), through the same registry verbs `PATCH /packages/:id/enable` and `PATCH /packages/:id/disable` use: + + ```text + enableOnInstall: true ⇒ enablePackage — clears a disable, including a boot-seeded one + enableOnInstall: false ⇒ disablePackage — the row and its `status` both move + enableOnInstall absent ⇒ no lifecycle call at all; the row the registry returned stands + ``` + + Absent is a third state, not a synonym for `true`: on a FRESH id the registry still lands the package enabled (the declared default), and on an EXISTING row it preserves whatever that row says (#18877). A non-boolean value is read as absent rather than coerced. + + ⚠️ **What this seam does not write, stated rather than implied.** The runtime's durable disabled-package file is keyed by environment (`setPackageDisabled(environmentId, id, disabled)`, `@objectstack/runtime`), and an `InstallPackageRequest` carries no environment, so that record cannot be written from here — the HTTP door owns that half and writes it from the row it returned. `enableOnInstall` through the in-process primitive therefore moves the registry row, which is what every in-process reader serves from, for the life of the process; a caller that needs the choice replayed after a restart goes through the door that owns the durable record. + + No behaviour changes for any caller on the tree: measured across `packages/**`, `examples/**` and `apps/**`, no existing call site sets the key — the HTTP door deliberately calls `installPackage({ manifest, settings })` and performs the flip itself, and `duplicatePackage` passes `{ manifest }` alone. The change is observable only to a caller that sets the key, which until now got silence. + + Clause-②: no +- 2b321a4: Four consumers of the implicit-reference-target contract resolve a reference field's target through `referenceTargetOf` instead of the materialized `reference` carrier, so a `{ type: 'user' }` field authored without one seeds, serves, and lints as the fully specified metadata the spec says it is (#19289). + + `IMPLICIT_REFERENCE_TARGETS` (`@objectstack/spec/data`) says a `user` field's target is "a CONSTANT OF THE TYPE, so `reference` on a `user` field materializes that constant; it does not supply it. Metadata authored without it (hand-written JSON, an AI author, a Studio form) is **fully specified, not under-specified**." Two arbiters answer two different questions — `referenceCarrierOf` what the carrier says, `referenceTargetOf` what the field points at — and for `user` only the second matches that text. #18550 standardized a population of readers on the first, which is correct wherever a site's own type gate excludes `user` and wrong wherever it does not. This is the census of that population: 17 carrier call sites judged one by one, four repaired. + + Clause-②: no + + Not a widening. It deletes a mistaken refusal of metadata the published contract already declares complete, which the charter files as `no` — 「删已发布契约文本本就否定的误拒本身是 `no`」. No key, alias or spelling is newly accepted anywhere: the target comes from the spec's own constant, never from a second way of writing it. + + - **`@objectstack/rest` — the loud one.** A `publicPicker` on a spec-complete `{ type: 'user' }` field answered `500 LOOKUP_TARGET_MISSING`, so opening a reference picker on a "responsible person" column returned an error page. It now answers `200` over `sys_user`. ⛔ This is not a re-widening of #12920's narrowing: a stored def spelling the target `referenceTo` / `target` / `options.objectName` still resolves nothing and still answers `500`, pinned in both directions. + - **`@objectstack/metadata-protocol` — the silent one, and the one that stored a wrong value.** A seed row's `{ type: 'user' }` field contributed no `dependsOn` edge and never reached `references`, so its natural key was written **verbatim** into a column that holds a record id — the dangling reference `buildDependencyGraph`'s own docblock names as the cause of broken parent joins. ⚠️ Upgrading seed authors: such a field now takes the same path the explicit `reference: 'sys_user'` spelling always took, which includes the failure path — a natural key that resolves to no `sys_user` row now DROPS the whole record, counted, reported and logged at `error`, where it was previously written verbatim. Seed `sys_user` before the referencing object, enable `multiPass`, or fix the key. + - **`@objectstack/lint` — the widest.** `object-graph`'s field slice fed `resolveFieldPath`, whose `RELATIONSHIP_FIELD_TYPES` admits `user`; a carrier-less one answered `hop-untargeted`, which `isUnjudgeable` treats as "the graph could not answer". Every rule in the package that resolves a field path therefore stopped judging any path through such a field, reporting nothing. `validate-field-consumers` separately dropped the `displayField` consumer edge onto `sys_user`, so a field that column displays was reported consumed by nobody. + - **Nothing else widens.** `user` is the only member of `IMPLICIT_REFERENCE_TARGETS`, so a `lookup` / `master_detail` / `tree` whose author-chosen target is absent still names nothing, exactly as before — pinned at every repaired site. + - **The unreadable-carrier behaviour is unchanged.** `referenceTargetOf` reads the carrier through `referenceCarrierOf` **before** it judges the type, so #13053/#18550's `TypeError` on an object- or array-valued `reference` still fires everywhere it fired before. The implicit target is not a fallback that swallows it. + - **No authoring change.** Metadata that already spells `reference: 'sys_user'` resolves to the same target it always did; nobody has to restate the constant, and nobody has to stop restating it. +- cdc1ae0: fix(cli): `objectstack serve` mounts the always-on `package-registry` capability, so a package created through the API survives a restart on a stock boot (#19387) + + Clause-②: no + + `package-registry` has been on the always-on slate (`PLATFORM_ALWAYS_ON_CAPABILITIES`) since the `marketplace` / `package-registry` split, and `serve` appended it to every app's `requires`. But `Serve.CAPABILITY_PROVIDERS` did not key it, and the resolver's no-provider branch says nothing about a token the app did not declare itself. So an app that did not declare `requires: ['marketplace']` got no `package` service. `POST /api/v1/packages` answered `201`, printed `no 'package' service — '…' registered in-memory only (will not survive a restart)`, and `GET /api/v1/packages/:id` answered `404` after a restart. + + - **`package-registry` now mounts `PackageServicePlugin`** from `@objectstack/service-package`, the provider the spec's `PLATFORM_CAPABILITY_PROVIDERS` row declares for it. A stock boot creates `sys_packages` and replays it at start, so installs and manifest edits made through the API persist. + - **Apps that declare `marketplace` boot as before, with one `PackageServicePlugin`.** `marketplace` resolves to the same provider. The capability resolver now remembers the providers it has mounted itself, so the always-on token does not mount a second copy. Without that change, a declarer's boot would print `Plugin superseded: 'package-service'`. + - **A stock database gains one table, `sys_packages`.** `PackageServicePlugin` creates it with raw DDL, as it already did for `marketplace` declarers. On the in-memory driver (`memory://`), which has no raw SQL, the boot now logs that the DDL was not run and that package hydration was skipped. Packages there last only as long as the process, as before. + - `--preset minimal` still opts out of the whole slate. `protocol.installPackage` keeps its in-memory-only branch as the documented degraded path for hosts that mount no provider. + - **`@objectstack/metadata-protocol`: the `installPackage` docblock no longer says the runtime half is missing.** It used to say that a stock boot still took the in-memory-only branch. It now says that `objectstack serve` mounts `PackageServicePlugin` for `package-registry`, so a stock boot persists, and that the in-memory-only branch is for hosts that mount no provider. The docblock ships in `dist`. No behaviour changes. +- a251aaa: fix(metadata-protocol): a stopped or rolled-back bulk batch names the row that actually failed + + `reconcileStoppedBatch` and `buildRolledBackBatchResponse` — the two builders every one of the three bulk-write faces (`batchData`, `updateManyData`, `deleteManyData`) reports through — located the causal row with `findIndex(r => !r.success)`. That encoded an invariant: **`!success` means this row failed, and it carries `errors[0]`.** + + That invariant stopped holding when a matched-but-deliberately-not-removed row started answering `success: false` with no `errors` entry — correctly, because a surviving record is an outcome, not a fault. The locator could then land on that survivor, `errors?.[0]?.message` was `undefined`, and the message named the **wrong index** while calling the real error — sitting in the same array — 「unknown error」. + + Measured on the unfixed tree: + + - non-atomic `deleteMany ['survivor', 'missing', 'other']` — the un-attempted row answered `NOT_ATTEMPTED` *"record 0 failed — unknown error; the batch stopped there. …"* while record **1** is what threw; + - atomic `[t1, survivor, t3]` — the rolled-back rows answered `ROLLED_BACK` *"record 1 failed — unknown error"* for a row that **survived**; + - atomic `[t3, survivor, missing, t2]` — `ROLLED_BACK` said *"record 1 failed — unknown error"* and `NOT_ATTEMPTED` said *"atomic batch aborted by record 1"*, both naming the survivor while record **2** threw. + + Both builders now share one locator, `locateBatchCause`, which finds the row by its recorded **fault** — the row's `errors[]` entry. That is the one per-row value whose declared meaning is a failure: `BatchOperationResultSchema.errors` is documented as *"Array of errors if operation failed"*, and the v17 migration entry publishes `row.errors?.[0]?.message` / `.code` to consumers as exactly that read. Its codes are drawn from the closed `StandardErrorCode ∪ ERROR_CODE_LEDGER` vocabulary, so an unregistered code fails `BatchOperationResultSchema.parse` — giving a non-fault ending an `errors[]` entry is a ledger widening in `packages/spec`, not something a call site can do on its own. `ApiError.message` is required, so a located cause always has text and the 「unknown error」 fallback is **deleted** rather than merely unreached. + + The scan runs from the end of the attempted rows, because a run ends *at* the row it stops on: every stop is a `break` in a loop's `catch`, immediately after that row was pushed. A fault that does not stop the run (the `Unknown operation:` arm records one and keeps going) therefore cannot shadow the row that did. + + One ending has no fault to quote at all — an atomic batch aborted by a lone survivor, where `runAtomicBatch` rolls back on `failed > 0` and nothing ever threw. The rolled-back rows now read *"record 1 did not succeed"*: the row that stopped the batch committing, named as what it is rather than as a failure with an unknown cause. + + No envelope field, per-row code, status or count changes; `succeeded` and `failed` still partition `results`. What changes is which row two message strings name, and both of them stop inventing an error that is not there. Clients branch on `errors[0].code`, which is unchanged — the row classification itself was never wrong. + + `Clause-②: no` — nothing authorable moves: no `packages/spec` key, export, accept set or stored shape changes, and the per-row code vocabulary is untouched. +- 95fb417: **The declared `zod` floor moves from `^4.4.3` to `^4.6.1`**, because on zod below 4.6.1 the three standard error formatters — `z.treeifyError()`, `error.format()` and `error.flatten()` — cannot render a refusal these packages actually emit (#19581). + + Clause-②: no + + **What breaks below the new floor.** All three formatters walked an issue's `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming a member of `Object.prototype` was answered by the prototype and no node was ever created. Two different failures follow: + + | path shape | what happened on `^4.4.3` | + |:---|:---| + | terminal element (`['assignments','__proto__']`, `['x','toString']`) | the inherited member is adopted as the node, then `node._errors.push(...)` runs on it — `TypeError: Cannot read properties of undefined (reading 'push')` | + | non-terminal element (`['__proto__', …]`) | the walk continues **into** `Object.prototype` and writes the next segment onto it — the message is silently dropped from the returned tree and the process gains a global prototype key | + + **Why it reached this platform's consumers.** `@objectstack/spec` refuses a `__proto__` key on its open-key authoring surfaces, and that refusal's issue path is `['assignments','__proto__']` — precisely the terminal shape. Anything that formatted one of these refusals for display crashed on it, and the crash was in the formatter, not in the guard. The guards themselves are unchanged and still necessary: 4.6.1 still drops a `__proto__` key from `z.record()` and `.catchall()` output, which is what they exist to refuse. + + **What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. + + `@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. + + ## The second half the floor move needs: an unknown key refuses TERMINALLY again + + From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no + longer aborts the shape that raised it. Two things follow, and both were + measured on this package with the same bodies on 4.4.3 and 4.6.1: + + 1. **A closed shape's own refinements now run after the refusal**, adding a + second complaint that contradicts the first. + 2. **A union containing that shape loses its envelope.** zod's + `handleUnionResults` returns a single non-aborted member's issues + *unwrapped* instead of raising `invalid_union`, so the union's message + becomes whichever branch zod judged closest. + + At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong + branch's prescription. Writing `type: 'page'` on a ViewItem answered: + + ``` + Unrecognized key(s) on this view container: `viewKind`, `config`. + • `viewKind` belongs to a single VIEW, not to the container. Wrap it: … + ``` + + — naming neither `page` nor its removal. It now answers, as it did before: + + ``` + config.type: 'page' was removed from the list-view `type` enum in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … + ``` + + **What an upgrading consumer must do.** Nothing. No key or value changed + status: everything this package accepted before it accepts now, and everything + it refused it still refuses. What changed is which of several competing + complaints an author reads, and that a refusal behind a union is again + reported as `invalid_union` with its branches, which is what `z.treeifyError()` + and this package's own `formatZodError` expand. + + ⚠️ A closed shape declared with a bare `z.object(…).strict()` or + `z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT + get this and will still collapse its union. Build closed authoring shapes with + `strictObject`, or re-declare an existing one through `closedObject`. +- 71ef221: The runtime authoring gate's `422 INVALID_METADATA` refusal no longer opens its message with a bracketed `[invalid_metadata]` tag restating the `code` the same throw declares. `error` carries the human sentence, `code` carries the machine token, and the token is no longer duplicated onto the prose axis. + + Clause-②: no + + This is the third producer of the family the protocol and the metadata repository already retired. The gate refuses an `active` publish whose body fails an author-time rule, and its message opened with `[invalid_metadata]` in front of its own `code = 'INVALID_METADATA'` / `status = 422`. `withoutDeclaredCodePrefix` strips a leading restatement only when the message opens with the declared code followed by a colon, and a lowercase bracketed tag matches neither the casing nor the separator. So it was never stripped, and it reached every caller in `error.message`. + + ## FROM → TO + + | before | now | + | --- | --- | + | `error: "[invalid_metadata] flow/leave_approval failed author-time validation: 1 issue — flows[0].nodes[1].config.approvers[0].value [approval-expression-invalid]"` | `error: "flow/leave_approval failed author-time validation: 1 issue — flows[0].nodes[1].config.approvers[0].value [approval-expression-invalid]"` | + + **Every accept/reject verdict is unchanged.** The same bodies are refused under the same conditions, with the same `code`, `status`, `issues` and `rulesRun`. A reader matching `error.message` for `invalid_metadata` should read `error.code` (`INVALID_METADATA`) instead. A reader already using `code` needs no change. + + - **The `[rule]` locators stay.** Each one names the rule behind a finding, for example `[approval-expression-invalid]`, and no other field on the message carries that fact. Only the opener that restated `code` is gone. + - **The batch publish response is unaffected on its machine axis.** `publishPackageDrafts` already puts `code: 'INVALID_METADATA'` and the structured `issues` on the causal `failed[]` row beside this message. + - **Pinned as an absence.** The package's bracketed-opener pin now scans this producer too. A re-introduced tag, or a new refusal copied from a neighbour, fails it. +- 586934e: fix(metadata-protocol): a page saved without `type` is stored and served with the `type` default that `PageSchema` declares (#20101) + + Clause-②: no + + `PageSchema` declares `type: PageTypeSchema.default('record')`, so a page authored without `type` is a record page. `saveMetaItem` parsed the body with that default and then stored the body as authored, and the `/meta` reads serve stored rows without parsing them. A record page authored without `type` was therefore served with no `type` at all. A renderer that picks an object's record page by `type === 'record'` never picked it. + + The declared default now reaches the served body at two points: + + - **Save.** When the schema gate accepts a page that omits `type`, the stored body gets the declared default, on draft and publish saves alike. A page with an explicit `type` is stored unchanged. Because the stored row and the served document are the same bytes, a client that re-saves the page it just read writes nothing: the checksum and the version stay the same. + - **Read.** A page row stored before this change is served with the declared default. This covers the list and single `/meta/page` reads, the cached read, draft reads and the `?preview=draft` list, the layered read, boot hydration into the registry, and the `searchAll` page sweep, whose hit now carries `pageType: 'record'` for such a page. The row itself is not rewritten, and `os migrate meta --stored` reports it canonical. + + The value is read from the registered `page` schema, never written out a second time. Only an absent `type` is filled: an explicit value, of any page type, is served as stored. + + What moves for a client: the served body of such a page gains `type: 'record'`, a key `PageSchema` already declares. The set of accepted bodies is unchanged. The ETag of the cached single read for such a page changes once, because the served content changed. Saving that page again after reading it stores `type` once. From then on a read-then-save round-trip writes nothing. +- 8d1f7ab: feat!: retire the saved-report stack — `sys_saved_report` / `sys_report_schedule`, `/api/v1/reports`, `client.reports`, `IReportService`, the `reports` capability and `@objectstack/plugin-reports` (#20102) + + **BREAKING** — the saved-report stack is removed whole, with no deprecation window + (maintainer ruling 2026-09-25, 「A. 退役」). It persisted a raw object query + (`object_name` + `{ filter, fields, orderBy, limit, groupBy }`) with a render format + and an owner, and could e-mail it on a schedule. Measured on the main branch of this + repository, objectui and cloud before removal: zero callers of the routes, the SDK + namespace or the service contract outside their own tests, and no app declaring the + capability. + + **NOT affected: the `report` metadata kind.** `ReportSchema`, `defineReport`, + `/meta/report`, datasets and the analytics service are unchanged. The two shared the + word "report" and nothing else. + + FROM → TO, per surface: + + - `requires: ['reports']` → **refused** by `defineStack` (`STACK_CAPABILITY_UNKNOWN`, + 422) with the prescription "requires: 'reports' was removed in @objectstack/spec + 17.5.0 … Delete the token." Fix: delete the token. `os serve` on an older artifact + that still carries it warns with the same prescription and ignores it; `os validate` + and `os build` over a plain-object config (no `defineStack` call, so no parse-time + vocabulary check) report it as a non-fatal capability advisory carrying the same + prescription, never "check for a typo". The token is + gone from `PLATFORM_CAPABILITY_TOKENS` and `PLATFORM_CAPABILITY_PROVIDERS`; the new + `RETIRED_PLATFORM_CAPABILITY_GUIDANCE` (`@objectstack/spec/kernel`) carries the + prescription. + - `IReportService`, `SavedReport`, `ReportSchedule`, `ReportQuery`, `ReportFormat`, + `ReportRunResult`, `SaveReportInput`, `ScheduleReportInput` + (`@objectstack/spec/contracts`) → removed, no replacement export. Fix: delete the + import. + - `SysSavedReport`, `SysReportSchedule` (`@objectstack/platform-objects/audit`) and + the names `sys_saved_report` / `sys_report_schedule` in + `PLATFORM_PROVIDED_OBJECT_NAMES` → removed. A stack referencing either name is now + flagged as a probable typo instead of resolving. + - `GET|POST /api/v1/reports`, `GET|DELETE /api/v1/reports/:id`, + `POST /api/v1/reports/:id/run`, `POST /api/v1/reports/:id/schedule`, + `GET /api/v1/reports/:id/schedules`, `DELETE /api/v1/reports/schedules/:scheduleId` + → unmounted: each answers the standard unmatched-route `404`, byte-identical to a + path that never existed. Their nine error codes (`REPORTS_LIST_FAILED`, + `REPORT_DELETE_FAILED`, `REPORT_GET_FAILED`, `REPORT_NOT_FOUND`, + `REPORT_RUN_FAILED`, `REPORT_SAVE_FAILED`, `REPORT_SCHEDULE_FAILED`, + `SCHEDULES_LIST_FAILED`, `SCHEDULE_DELETE_FAILED`) leave `ERROR_CODE_LEDGER` with + their only emitter. + - `client.reports.*` (`list`, `save`, `get`, `delete`, `run`, `schedule`, + `listSchedules`, `unschedule`) → removed. Fix: delete the call. A report is `report` + metadata, read through `meta.*` and queried through `analytics.*`; a saved ad-hoc + object query is a ListView on that object. + - `RestServer`'s constructor keeps the position of the retired saved-report provider, + typed `undefined`, so no later positional argument re-binds. Pass `undefined` there; + passing a provider is a compile error. + - `@objectstack/plugin-reports` → no longer built or published from this repository, + and `@objectstack/cli` no longer depends on it or mounts it. Fix: remove the + dependency. There is no successor package and no scheduled-delivery replacement. + + **Existing databases.** `sys_saved_report` / `sys_report_schedule` tables in a deployed + database are left in place, untouched — no backfill, no reaper, no drop — under the + repository's convention for a retired platform object: the platform never drops a + table that metadata stops declaring, and `os migrate plan` lists such a table in its + informational unmanaged-tables section so an operator can decide. + + `@objectstack/metadata-protocol` (patch): the `INVALID_SORT` hint for a sort node + spelled `{ field, direction }` no longer names the retired saved-report contract as + the source of that vocabulary; it names the better-auth adapter's `sortBy`, which + still uses it. Code and status are unchanged. + + Breaking ships as `minor` per the launch-window convention + (`scripts/check-changeset-no-major.mjs`). + + **Clause-②: yes (narrowing)** — a published capability token, a service contract and + its types, two platform objects, eight routes, nine registered error codes and an SDK + namespace are removed; nothing previously refused is now accepted. + + +- 1c1b8c8: A grouped or aggregated query now honours `search`: the groups and every aggregated number are computed over the searched rows, exactly the rows the same query without `groupBy` / `aggregations` returns. + + Clause-②: yes (widening) — `EngineAggregateOptionsSchema` gains two OPTIONAL keys, `search` and `searchFields`, so the accept set of the aggregate options grows. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write them. + + `QuerySchema.search` (ADR-0061) is declared on the query beside `groupBy` and `aggregations`, with no carve-out. Until now, `POST /data/:object/query` accepted a body such as `{ groupBy: ["business_unit"], aggregations: [{ function: "count", alias: "count" }], search: "harbour" }` and answered it with the UNSEARCHED groups — no error and no warning — while the same body without `groupBy` / `aggregations` returned only the searched rows. A grouped list view under a toolbar search would therefore show group headers that ignore what the user typed. + + - **`@objectstack/spec`** — `EngineAggregateOptionsSchema` declares `search` (the bare string, or the structured `FullTextSearchSchema` form) and `searchFields`, identically to `EngineQueryOptionsSchema`. A parse used to strip them. + - **`@objectstack/objectql`** — `engine.aggregate()` (and `ctx.api.object(name).aggregate()`) accepts the two keys it used to refuse as unknown options, and expands them through the same ADR-0061 expansion `find()` uses: the same server-resolved searchable fields, the same `searchFields` narrowing, AND-ed with `where` before the security middlewares run. There is one expander, not two. It applies on both aggregate paths, native `driver.aggregate()` and the in-memory lowering. A key the verb still does not execute, such as `$search`, is refused as before. + - **`@objectstack/metadata-protocol`** — `findData`'s grouped branch passes `search` / `searchFields` to `engine.aggregate()`. `searchFields` is validated on that branch exactly as on the flat one: a column search cannot scan is `400 INVALID_FIELD`. + + Nothing to migrate. A caller that worked around the gap, for example by grouping a page of searched rows on the client, can send the grouped query with its `search` instead. +- 8cdbe0c: fix(metadata-protocol): `diffMetaItem`'s default range labels its to side with the active row's own version, so `GET /meta/:type/:name/diff` with no `from` / `to` names the versions it compares while a draft is pending (#20397) + + With no `toVersion`, the to side is the current active `sys_metadata` row. Its body was compared, but `toVersion` came from the newest `sys_metadata_history` row, which is a draft save whenever a draft is pending: every draft save appends a history row. The labels and the bodies then named different rows. Measured on the real REST stack, an app with one active save and two draft saves answered `fromVersion 2 → toVersion 3` over its version-1 body, and a view with one active save and one draft save answered "no changes" labelled `1 → 2` while version 2 differs. + + - **Now:** `toVersion` is the active row's own `version`, read in the same read as its body. The default `fromVersion` rule is not changed by this entry (#20451, in the same release, then moves it to the nearest earlier version whose body differs from the to side's). An item whose active row is version 2 with a draft pending answers `1 → 2`, the same answer as `?from=1&to=2`. + - **No active row** (a draft-only item, or a deleted one): the to side is absent, and both labels are `null` with empty buckets, as the response schema declares for an absent side. Before, a draft-only item was labelled with its newest draft save, and its from side could be an earlier draft save's body. A deleted item was labelled `N-1 → N` up to its tombstone. That deletion is still read by naming its versions (`?from=N-1&to=N`). + - Unchanged: the response shape, explicit `from` / `to` ranges, and the default range of an item with no draft pending. +- 397572e: fix(metadata-protocol): `GET /meta/:type/:name/diff` with no `from` compares against the nearest earlier version whose body differs, so the default diff right after a publish shows what the publish changed (#20451) + + Clause-②: no — no key, export, route, parameter or response field moves; only which version the default `from` side names. + + Every draft save appends a `sys_metadata_history` row, and publishing the draft appends the same body again as the next row. The default `from` side was the history row immediately before the `to` side, so right after a publish it was the draft save the publish came from, and the default diff answered "no changes". The change the publish carried was reachable only by naming `?from=`. + + - **Now:** with no `from`, `diffMetaItem` walks back from the `to` side over the history rows it already reads and takes the nearest earlier row whose body differs, by the diff's own equality (all three buckets empty means equal). A body-less row, a delete's, compares as an empty body, so the walk stops on it and the answer names the deletion. With no earlier row that differs, the `from` side is absent: `fromVersion: null`, everything added. + - **Measured on the real REST stack**, before → after: + + | history | default range before | default range now | + |:--|:--|:--| + | v1 active, v2 draft save, v3 publish | `2 → 3`, no changes | `1 → 3`, the change the publish carried | + | the same with a v4 draft pending | `2 → 3`, no changes | `1 → 3` | + | create, delete, draft save, publish | `3 → 4`, no changes | `2 → 4`, everything added | + | create, delete, active recreate | `2 → 3`, everything added | unchanged | + | a new item draft-saved, then published | `1 → 2`, no changes | `null → 2`, everything added | + | a single version | `null → 1`, everything added | unchanged | + + - **Unchanged:** an explicit `?from=` / `?to=` names exactly its versions (`?from=2&to=3` over the first row still answers "no changes"); the default `to` side is the active version; the response shape; the one history read, with no cap. The walk compares the stored bodies before redaction, as the diff itself does, so a credential-only change still stops it and its values are still not served. + - `@objectstack/rest`: the route's OpenAPI summary states the new default. +- c3ebe4a: A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. + + `ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. + + The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. + + **What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. + + **What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. + + **For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. +- f9e16d8: `DELETE /api/v1/data/sys_permission_set/{id}` stops reporting a deletion it did not perform. + + A package-declared permission set cannot be deleted from an environment: its delete is an + ADR-0005 RESET — the overlay tombstones and the record re-projects to the declared body. + That behaviour is unchanged and deliberate. What was wrong is the answer: the door replied + `200 {"object":…,"id":…,"success":true}`, byte-identical to a real deletion, so a caller + that meant to revoke a permission set was told it was gone while it was still enforced, and + a UI fired a success toast and showed the row again on refresh. + + The write-through's delete leg now reports how many of the addressed records actually went, + and `deleteData` maps that onto the already declared `success` key instead of hard-coding + `true`. No key is added to `DeleteDataResponseSchema`. + + On the wire: + + - packaged set — `200 {"success":false}`, the record still present with the same id (was + `success: true`); + - environment-authored set — `200 {"success":true}`, the record really gone (unchanged); + - unknown id — `404 RECORD_NOT_FOUND` (unchanged: zero-removed is deliberately not read as + not-found, because the record is still there to GET). + + The read-back that decides this is fail-closed: a read that cannot answer reports the record + as NOT deleted and warns on the durability channel, because "the read failed" and "the row is + gone" are opposite facts and only the second may claim a deletion. + + Clause-②: no +- b110578: fix(metadata): four `isoFromValidDate` call sites collapse onto the shared canonical-ISO spelling; `MetadataHistoryRecord.recordedAt` gets the terminal value it never had (#16422) + + ## What was wrong + + `#14037`/`#14038` landed a narrow per-site helper, `isoFromValidDate`, beside + the shared `canonicalIsoInstant` spelling. It rewrote exactly one shape — a + valid JS `Date` becomes ISO text — and handed **every other input back + untouched**. Four adapter boundaries used it, and each fed a field declared + `z.string()` or `z.string().datetime()`: + + | site | declared as | + |:--|:--| + | `SysMetadataRepository.rowToEvent` → `MetadataEvent.ts` | `z.string()` | + | `DatabaseLoader.rowToRecord` → `MetadataRecord.createdAt` / `.updatedAt` | `z.string().datetime().optional()` | + | `DatabaseLoader.getHistoryRecord` → `MetadataHistoryRecord.recordedAt` | `z.string().datetime()` — **required** | + | `DatabaseLoader.queryHistory` → the same field, the other door | `z.string().datetime()` — **required** | + + So a `null`, a `number`, an opaque column and an Invalid `Date` all arrived at a + field declared `string`, each wearing an `as string` / `as string | undefined` + cast that asserted the opposite. Measured over the seven inputs that + distinguish the two helpers, the declared schemas refused **21 of 35** produced + values. + + `recordedAt` was the sharp end: a REQUIRED `z.string().datetime()` for which + none of the three available answers was legal — the visible text + `"Invalid Date"` fails the refinement, `undefined` fails the required field, and + the pass-through fed it the `Date` object, which fails both. + + ## What it does now + + Those four sites read `canonicalIsoInstant`, whose return type **is** + `string | undefined`, so all four casts are deleted rather than restated. Both + sibling definitions of `isoFromValidDate` are gone. The terminal value is chosen + per site, from the site's own declared schema: + + - `MetadataRecord.createdAt` / `.updatedAt` are `.optional()` → `undefined`, the + branch an absent column already took. ⛔ No default is invented for a field the + schema lets be absent. + - `MetadataHistoryRecord.recordedAt` is required → the **epoch**, via a named + `recordedAtFallback()` shared by both history doors. ⛔ Not `new Date()`: a + `now` stamp is a plausible-looking recording instant nobody measured, and it + sorts a version recorded years ago to the top of a newest-first timeline. The + epoch invents no fact and sorts to the oldest end. It is also the answer the + sibling reader of this same `sys_metadata_history.recorded_at` column already + gives (`rowToEvent` and `history()`, both `?? new Date(0).toISOString()`). + + Schema refusals over the same seven inputs: **21 → 8**. The eight that remain + are a `number` and an opaque object at four sites — shapes no driver is measured + to materialise for these columns. They now arrive as the declared *type* (a + string) that simply is not a valid datetime, so the producer's bug stays visible + instead of being papered over. + + ## One behaviour change worth reading twice — and it is why this is `minor` + + `DatabaseLoader.stat()` computes `record.updatedAt ?? record.createdAt`. An + Invalid `updated_at` used to WIN that `??` — a `Date` is truthy and not nullish — + so a row with an unreadable `updated_at` and a good `created_at` published + `new Date()` as its `mtime`. It now folds to `undefined` one step earlier and + loses the `??`, so the row publishes its `created_at`: a stored instant in place + of a fabricated one, and exactly the "same `?? DEFAULT` chain an absent column + takes" that `#14078`'s own ruling text prescribes for the shape. + + ⚠️ **The old answer was LEGAL.** `new Date().toISOString()` satisfies + `MetadataStats.mtime`'s `z.string().datetime()` perfectly well, and the + pre-existing pin asserted exactly that. So this one site is **not** the repair of + a violation — it is one legal published answer replaced by a different legal + published answer on a published read verb. Nothing was refused before and is + permitted now; a consumer simply receives a different instant. + + ## Why the two levels differ + + - **`@objectstack/metadata` — `minor`.** Its four repaired sites, on their own, + are the "repairing an implementation that silently violated its own already + published declared type" case: the values that changed there are ones + `MetadataRecordSchema` / `MetadataHistoryRecordSchema` already refused, and + nothing a consumer legitimately received has moved. But this package also + carries `stat()`, and that site changes a **legal** published answer, which the + paragraph above measures. The level is per package, so the four repaired sites + ride along at `minor`. + - **`@objectstack/metadata-protocol` — `patch`.** Neither of its two sites moves + a legal published answer. `rowToEvent` only stops emitting values + `MetadataEventSchema` refused (a `Date`, a `number`, an opaque object in a + field declared `z.string()`), and `listCommits` is byte-identical on all seven + probe inputs. + + ⛔ No declared type narrowed, no export was added or removed (neither helper was + ever exported), and no envelope or accept set moved — so this is `minor` by the + changed-answer row, not a breaking change, and it carries no ADR-0087 + disposition. + + ## What deliberately did NOT collapse + + `listCommits` in `@objectstack/metadata-protocol` keeps its copy. Its docblock + promises callers the RAW value back for a non-`Date`, and the shared spelling + rewrites the whole domain: swapping it in would ERASE an Invalid `Date` from the + response (`undefined` — the one answer ADR-0053 D-F3 refuses, because it silently + drops a value that is on disk) and hand a `number` or an opaque object to the + commit-timeline sort as `String(value)` rather than verbatim. Measured, that site + is byte-identical on all seven inputs before and after this change. + + `SqlDriver`'s same-named helper is not part of this family at all: it takes + `Date` (not `unknown`), both its call sites narrow with `instanceof Date` first, + and it is the PRODUCER-side fold ADR-0053 D-F3 governs. It is untouched. +- 29d00cc: Fix `GET /meta/types` serving an empty JSON Schema for `action` + + `ActionSchema` is a `ZodPipe`, and the `output` derivation of a pipe carries no + properties, so `/meta/types` advertised `action` as + `{"$schema": "https://json-schema.org/draft/2020-12/schema"}` — a document that + reads as "this type declares no constraints" for a type that accepts 47 keys. + The hand-crafted fallback declared for this case never fired, because the + conversion did not throw: it succeeded and returned a truthy husk, which + short-circuits the `??` that was supposed to reach the fallback. + + A derivation that comes back with no properties, no union arms, no `$ref` and no + `additionalProperties` object is now treated as a non-answer. It is retried in + the authoring shape (`io: 'input'`), and if that degenerates too the type is + named in a one-shot warning and the hand-crafted fallback decides. + + Only `action` changes. The `output` derivation remains the served default on + purpose: deriving every type with `io: 'input'` was measured across the whole + served surface and would move 24 of the 26 types that carry a Zod schema, in the + direction of a weaker contract (`required` entries 1132 to 867, + `additionalProperties: false` 663 to 637). Gating the retry on degeneracy keeps + the change to the one type that was actually broken. + + Consumers reading `schema` for `action` from `/meta/types` or `/api/v1/meta` now + receive its real 47 properties instead of an empty object. No other type's + served payload moves, and a type that resolves no Zod schema at all continues to + be served with no schema — absence is not the same failure as a derivation that + came back empty. +- b3f7fdc: `POST /api/v1/data/{object}/deleteMany` stops reporting a deletion it did not perform. + + Each row of the batch answered `success: true` for every engine result that was not the + driver contract's `false`. The `false` arm — "no row matched" — has reported honestly since + #4435; the other ending had not: a row that MATCHED and was deliberately NOT removed was + counted in `succeeded` and reported as deleted, byte-identical to a real deletion. + + `sys_permission_set` is the shipped case. Deleting a package-declared set is an ADR-0005 + RESET: the overlay tombstones and the record re-projects to the declared body instead of + vanishing. That behaviour is unchanged and deliberate — what was wrong is the answer, and on + a security-configuration write it told an operator a permission set was gone while it was + still being enforced. + + `IDataEngine.delete` declares `Promise` — the driver boolean for a by-id + write, a count of rows removed otherwise — so a numeric zero is the one value that positively + means the record is still there. The per-row `success` now reads that count instead of being + a literal. + + On the wire, for one row of a `deleteMany`: + + - removed — `success: true`, counted in `succeeded` (unchanged); + - matched but not removed — `success: false`, counted in `failed`, and no `errors[]` entry: + a surviving record is an outcome, not a fault, and this envelope's two per-row codes + (`ROLLED_BACK`, `NOT_ATTEMPTED`) both describe a row that never ran; + - unknown id — `success: false` with `errors[0].code: RECORD_NOT_FOUND` (unchanged). + + Because `succeeded` and `failed` partition `results`, a surviving row also makes the + request-level `success` false, and an `atomic` batch containing one now rolls back rather + than committing under a response that called every row deleted. A non-atomic batch is not + stopped by it: nothing was thrown, so the `continueOnError` stop does not apply and the + remaining ids are still attempted. + + Clause-②: no +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth was + `seed-tenancy-backfill`'s organization probe, which kept a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes recorded `'unknown error'` there rather than `''`; + that fallback was load-bearing — the site read an empty value as "the probe did not fail" — + and #17167 removed it in this same release, so all fourteen sites now record the channel as + is and that site carries its failure fact structurally. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. +- 8c9bd8f: docs(metadata-protocol,objectql,cli): comments describing the standalone stamp now name `env_local`, the value the tree actually produces + + The v5.0 `project` to `environment` rename reached the two remaining stamps in `@objectstack/runtime` and `@objectstack/metadata` in a previous release: `createStandaloneStack` and `MetadataPlugin` both stamp **`env_local`**. Six comments in three other packages still described that stamp as `'proj_local'`, so they named a value nothing in the tree produces any more. + + No behaviour changes. The reason this is a `patch` rather than a no-publish diff is measured, not assumed: two of the six sites are TSDoc on **exported** interface members (`AssembleMetadataProtocolOptions.runPlatformMigrations`, `ObjectQLPluginOptions.runPlatformMigrations`) and land in the shipped `dist/*.d.ts`, and the `@objectstack/cli` site lands in the shipped `dist/utils/schema-migrate.js` because that package builds with `removeComments` unset. All three packages ship `dist` in `files[]`, so the corrected text is what an author reads on hover after upgrading. + + The sites were judged individually rather than search-and-replaced, because they are not all the same edit: + + - Five sites whose verb describing the stamp is present indicative describe today's tree — two of them point the reader at `runtime/src/standalone-stack.ts` to go and look — and take the current spelling. + - `packages/cli/src/utils/schema-migrate.ts` names `'proj_local'` as the value the historical arming deduction consumed. There the literal is preserved as history and its present-tense relative clause moves into the past, with today's spelling named beside it; rewriting it to `env_local` would have falsified the record in the other direction. + + The causal claim at every site is about **presence**, not spelling: the retired gate read `environmentId === undefined`, so it would have misfired identically under either literal. That reading is preserved at all six. +- 7173d7d: Correct the `summary` column representation stated in the sort-hint TSDoc. Since #16318 an engine-maintained `summary` column is an exact `table.decimal` on tables created after that change and a `table.float` on tables created earlier; the shipped doc comment still said flatly that it is a `table.float`. Comment text only — the sort behaviour it describes is unchanged, and the column type was never what makes a `summary` field sortable (having a provisioned column is). +- 5cdb0db: `POST /api/v1/data/{object}/batch` with `operation: "delete"` stops reporting a deletion it + did not perform. + + This is the third and last of the by-id delete doors to read the engine's answer — the + single-record `DELETE` and `deleteMany` already do. Each row of the batch answered + `success: true` for every engine result that was not the driver contract's `false`. The + `false` arm — "no row matched" — has reported honestly since #4435 and #5088; the other + ending had not: a row that MATCHED and was deliberately NOT removed was counted in + `succeeded` and reported as deleted, byte-identical to a real deletion. + + `sys_permission_set` is the shipped shape of it. Deleting a package-declared set is an + ADR-0005 RESET: the overlay tombstones and the record re-projects to the declared body + instead of vanishing. That behaviour is unchanged and deliberate — what was wrong is the + answer, and on a security-configuration write it told an operator a permission set was gone + while it was still being enforced. + + `IDataEngine.delete` declares `Promise` — the driver boolean for a by-id + write, a count of rows removed otherwise — so a numeric zero is the one value that positively + means the record is still there. The per-row `success` now reads that count instead of being + a literal. + + On the wire, for one row of a `delete` batch: + + - removed — `success: true`, counted in `succeeded` (unchanged); + - matched but not removed — `success: false`, counted in `failed`, and no `errors[]` entry: + a surviving record is an outcome, not a fault, and this envelope's two per-row codes + (`ROLLED_BACK`, `NOT_ATTEMPTED`) both describe a row that never ran; + - unknown id — `success: false` with `errors[0].code: RECORD_NOT_FOUND` (unchanged); + - an off-contract `undefined` from a third-party driver keeps its #4435 reading. + + Because `succeeded` and `failed` partition `results`, a surviving row also makes the + request-level `success` false, and an `atomic` batch containing one now rolls back rather + than committing under a response that called every row deleted. A non-atomic batch is not + stopped by it: nothing was thrown, so the `continueOnError` stop does not apply and the + remaining records are still attempted. `returnRecords: false` keeps `success`, so the honest + value survives that projection too. + + Clause-②: no +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [c88fa2c] +- Updated dependencies [1e496f9] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [4f1a56b] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [ca78860] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [c3a95d9] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [0fb6f97] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [6ec467b] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [3da78cc] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [3ab1508] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [f2044ef] +- Updated dependencies [9be2b59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [6f8d751] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [21b7c12] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- Updated dependencies [939f3ea] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [ef256e6] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [a43b9d0] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [2b3eb17] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [58644ad] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [c9b23cd] +- Updated dependencies [2b321a4] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [c3a4c74] +- Updated dependencies [0b4022b] +- Updated dependencies [a227afa] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [1f69917] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [fd920ca] +- Updated dependencies [3875ae6] +- Updated dependencies [f77b806] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [a90272a] +- Updated dependencies [5dba7f3] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [01df025] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [e9eb224] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [d1ca874] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [d498113] +- Updated dependencies [eee0974] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [af32cf9] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [7465eeb] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [a243cfb] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [1207baf] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [5f9d7d7] +- Updated dependencies [0d3ec47] +- Updated dependencies [ae8e3ca] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [d753744] +- Updated dependencies [2304b16] +- Updated dependencies [4b2d904] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [86f4246] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [4bd2c60] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [e958468] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [b110578] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [522f612] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [8fe5cb8] +- Updated dependencies [362dcc3] +- Updated dependencies [31064ca] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [f89dd33] +- Updated dependencies [96451ec] +- Updated dependencies [cca1dc0] +- Updated dependencies [7e05b9d] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [5b5bd36] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [e4fd55d] +- Updated dependencies [7026141] +- Updated dependencies [ba17017] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [131851f] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [4ecfd2b] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/lint@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/metadata@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index 240180e615b..8179854b3bd 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-protocol", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack metadata management protocol: sys_metadata CRUD, draft/publish, locks, package ownership, diagnostics (ADR-0076).", "type": "module", diff --git a/packages/metadata/CHANGELOG.md b/packages/metadata/CHANGELOG.md index 24f3f399080..5cf07565973 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,936 @@ # @objectstack/metadata +## 17.5.0 + +### Minor Changes + +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- b110578: fix(metadata): four `isoFromValidDate` call sites collapse onto the shared canonical-ISO spelling; `MetadataHistoryRecord.recordedAt` gets the terminal value it never had (#16422) + + ## What was wrong + + `#14037`/`#14038` landed a narrow per-site helper, `isoFromValidDate`, beside + the shared `canonicalIsoInstant` spelling. It rewrote exactly one shape — a + valid JS `Date` becomes ISO text — and handed **every other input back + untouched**. Four adapter boundaries used it, and each fed a field declared + `z.string()` or `z.string().datetime()`: + + | site | declared as | + |:--|:--| + | `SysMetadataRepository.rowToEvent` → `MetadataEvent.ts` | `z.string()` | + | `DatabaseLoader.rowToRecord` → `MetadataRecord.createdAt` / `.updatedAt` | `z.string().datetime().optional()` | + | `DatabaseLoader.getHistoryRecord` → `MetadataHistoryRecord.recordedAt` | `z.string().datetime()` — **required** | + | `DatabaseLoader.queryHistory` → the same field, the other door | `z.string().datetime()` — **required** | + + So a `null`, a `number`, an opaque column and an Invalid `Date` all arrived at a + field declared `string`, each wearing an `as string` / `as string | undefined` + cast that asserted the opposite. Measured over the seven inputs that + distinguish the two helpers, the declared schemas refused **21 of 35** produced + values. + + `recordedAt` was the sharp end: a REQUIRED `z.string().datetime()` for which + none of the three available answers was legal — the visible text + `"Invalid Date"` fails the refinement, `undefined` fails the required field, and + the pass-through fed it the `Date` object, which fails both. + + ## What it does now + + Those four sites read `canonicalIsoInstant`, whose return type **is** + `string | undefined`, so all four casts are deleted rather than restated. Both + sibling definitions of `isoFromValidDate` are gone. The terminal value is chosen + per site, from the site's own declared schema: + + - `MetadataRecord.createdAt` / `.updatedAt` are `.optional()` → `undefined`, the + branch an absent column already took. ⛔ No default is invented for a field the + schema lets be absent. + - `MetadataHistoryRecord.recordedAt` is required → the **epoch**, via a named + `recordedAtFallback()` shared by both history doors. ⛔ Not `new Date()`: a + `now` stamp is a plausible-looking recording instant nobody measured, and it + sorts a version recorded years ago to the top of a newest-first timeline. The + epoch invents no fact and sorts to the oldest end. It is also the answer the + sibling reader of this same `sys_metadata_history.recorded_at` column already + gives (`rowToEvent` and `history()`, both `?? new Date(0).toISOString()`). + + Schema refusals over the same seven inputs: **21 → 8**. The eight that remain + are a `number` and an opaque object at four sites — shapes no driver is measured + to materialise for these columns. They now arrive as the declared *type* (a + string) that simply is not a valid datetime, so the producer's bug stays visible + instead of being papered over. + + ## One behaviour change worth reading twice — and it is why this is `minor` + + `DatabaseLoader.stat()` computes `record.updatedAt ?? record.createdAt`. An + Invalid `updated_at` used to WIN that `??` — a `Date` is truthy and not nullish — + so a row with an unreadable `updated_at` and a good `created_at` published + `new Date()` as its `mtime`. It now folds to `undefined` one step earlier and + loses the `??`, so the row publishes its `created_at`: a stored instant in place + of a fabricated one, and exactly the "same `?? DEFAULT` chain an absent column + takes" that `#14078`'s own ruling text prescribes for the shape. + + ⚠️ **The old answer was LEGAL.** `new Date().toISOString()` satisfies + `MetadataStats.mtime`'s `z.string().datetime()` perfectly well, and the + pre-existing pin asserted exactly that. So this one site is **not** the repair of + a violation — it is one legal published answer replaced by a different legal + published answer on a published read verb. Nothing was refused before and is + permitted now; a consumer simply receives a different instant. + + ## Why the two levels differ + + - **`@objectstack/metadata` — `minor`.** Its four repaired sites, on their own, + are the "repairing an implementation that silently violated its own already + published declared type" case: the values that changed there are ones + `MetadataRecordSchema` / `MetadataHistoryRecordSchema` already refused, and + nothing a consumer legitimately received has moved. But this package also + carries `stat()`, and that site changes a **legal** published answer, which the + paragraph above measures. The level is per package, so the four repaired sites + ride along at `minor`. + - **`@objectstack/metadata-protocol` — `patch`.** Neither of its two sites moves + a legal published answer. `rowToEvent` only stops emitting values + `MetadataEventSchema` refused (a `Date`, a `number`, an opaque object in a + field declared `z.string()`), and `listCommits` is byte-identical on all seven + probe inputs. + + ⛔ No declared type narrowed, no export was added or removed (neither helper was + ever exported), and no envelope or accept set moved — so this is `minor` by the + changed-answer row, not a breaking change, and it carries no ADR-0087 + disposition. + + ## What deliberately did NOT collapse + + `listCommits` in `@objectstack/metadata-protocol` keeps its copy. Its docblock + promises callers the RAW value back for a non-`Date`, and the shared spelling + rewrites the whole domain: swapping it in would ERASE an Invalid `Date` from the + response (`undefined` — the one answer ADR-0053 D-F3 refuses, because it silently + drops a value that is on disk) and hand a `number` or an opaque object to the + commit-timeline sort as `String(value)` rather than verbatim. Measured, that site + is byte-identical on all seven inputs before and after this change. + + `SqlDriver`'s same-named helper is not part of this family at all: it takes + `Date` (not `unknown`), both its call sites narrow with `instanceof Date` first, + and it is the PRODUCER-side fold ADR-0053 D-F3 governs. It is untouched. +- d64bcb6: **BREAKING** — retire the `adr-0030-notification-event` data migration. + + `migrateSysNotificationToEvent` had no way to be run: zero production callers + anywhere in the repo, and no `os migrate` sub-command, while the two sibling + members of `CREATION_ATTESTED_MIGRATION_IDS` had both. The runner, its barrel + export, its tests, the ruled `sys_migration` receipt-claim matrix, that matrix's + pin, and the id's membership in `CREATION_ATTESTED_MIGRATION_IDS` are removed + together. Pre-ADR-0030 `sys_notification` rows are not carried by the platform + on this line. + + ## What is gone, and what an upgrader does about it + + ⭐ **Nothing is renamed and nothing replaces it**, so there is no new spelling to + adopt — every item below is a deletion, and the fix is to stop using it. + + - `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) — deleted. + No replacement exists, and none is coming: an `os migrate notification-event` + sub-command was considered and refused. Delete the call. The compiler delivers + this one: the import fails to resolve. + - `SysNotificationMigrationResult`, `SysNotificationMigrationOptions` and + `SysNotificationMigrationReceipt` (same entry point) — deleted with it. They + described that runner's own result, options and receipt and nothing else. + - `CREATION_ATTESTED_MIGRATION_IDS` (`@objectstack/spec/system`) — was a + three-member tuple and is now a two-member one holding + `'adr-0104-file-references'` and `'adr-0104-value-shapes'`. Both ADR-0104 ids + keep their sub-commands, their receipt rows and their birth attestation; only + the notification id left. Code typed against + `(typeof CREATION_ATTESTED_MIGRATION_IDS)[number]` that names the notification + id no longer compiles — delete that arm. + + `NOTIFICATION_EVENT_MIGRATION_ID` (`@objectstack/spec/system`) is **kept**. A + deployment attested at birth, or one that made the operator call while the runner + shipped, still holds a `sys_migration` row keyed `'adr-0030-notification-event'`, + and the constant is that row's name. Nothing writes or reads a row under it any + more — `attestFreshDatastore` no longer includes it — and it is not a + registration: it gates nothing and never did. + + ## Reversal path + + Two answers were considered and both refused: an `os migrate notification-event` + sub-command is a permanent operator surface for a migration with no measured + demand, and a boot-time invoker is an unattended data rewrite nobody asked for. + ⚠️ Nobody has measured whether any live deployment carries pre-ADR-0030 + `sys_notification` rows. If a **named** deployment turns out to hold rows it + needs, the migration returns as an operator-runnable sub-command shaped exactly + like `files-to-references` / `value-shapes` — dry-run default, `--apply` gate, + documented consequence — under its own card. + + + +### Patch Changes + +- b6471ba: `@objectstack/metadata` no longer declares `@objectstack/platform-objects`. + + The dependency was the retired `adr-0030-notification-event` migration runner's, + and that runner was its only consumer. Nothing under `packages/metadata/src` + carries a `@objectstack/platform-objects` specifier any more, so the declaration + described an edge the package no longer has. The two test-tooling entries that + existed only to serve it go with it: the `@objectstack/platform-objects/system` + alias in `vitest.config.ts` (whose comment still cited the retired migration's + receipt cases as its reason) and the matching `paths` mapping in `tsconfig.json`. + + ## What an installing consumer should check + + ⚠️ This is a **published** package dropping a declared dependency, so it changes + what an install tree contains, not just what this repo builds. If you import + `@objectstack/platform-objects` **without declaring it**, and it resolved for you + only because `@objectstack/metadata` hoisted it, that resolution is gone — the + fix is one line, and it is the supported spelling either way: + + ``` + pnpm add @objectstack/platform-objects # or npm/yarn equivalent + ``` + + `@objectstack/platform-objects` is published on its own and is unchanged by this; + nothing is renamed, removed or re-exported. + + ⛔ Nothing `@objectstack/metadata` itself ships is affected. Measured rather than + asserted: its built `dist/` (30 files, 10 declaration files) carries **zero** + occurrences of `platform-objects`, against a positive control in which all nine + of its other declared dependencies appear in four to twelve dist files each. No + runtime import and no type reference reaches it, so no consumer can arrive at it + through anything this package publishes. + + Grade `patch`, measured rather than defaulted: no export moves, no accept-set + widens, no runtime behaviour changes. Not `skip-changeset` either — `package.json` + is shipped by `npm pack`, and a consumer's install tree is what changes. +- 95fb417: **The declared `zod` floor moves from `^4.4.3` to `^4.6.1`**, because on zod below 4.6.1 the three standard error formatters — `z.treeifyError()`, `error.format()` and `error.flatten()` — cannot render a refusal these packages actually emit (#19581). + + Clause-②: no + + **What breaks below the new floor.** All three formatters walked an issue's `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming a member of `Object.prototype` was answered by the prototype and no node was ever created. Two different failures follow: + + | path shape | what happened on `^4.4.3` | + |:---|:---| + | terminal element (`['assignments','__proto__']`, `['x','toString']`) | the inherited member is adopted as the node, then `node._errors.push(...)` runs on it — `TypeError: Cannot read properties of undefined (reading 'push')` | + | non-terminal element (`['__proto__', …]`) | the walk continues **into** `Object.prototype` and writes the next segment onto it — the message is silently dropped from the returned tree and the process gains a global prototype key | + + **Why it reached this platform's consumers.** `@objectstack/spec` refuses a `__proto__` key on its open-key authoring surfaces, and that refusal's issue path is `['assignments','__proto__']` — precisely the terminal shape. Anything that formatted one of these refusals for display crashed on it, and the crash was in the formatter, not in the guard. The guards themselves are unchanged and still necessary: 4.6.1 still drops a `__proto__` key from `z.record()` and `.catchall()` output, which is what they exist to refuse. + + **What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. + + `@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. + + ## The second half the floor move needs: an unknown key refuses TERMINALLY again + + From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no + longer aborts the shape that raised it. Two things follow, and both were + measured on this package with the same bodies on 4.4.3 and 4.6.1: + + 1. **A closed shape's own refinements now run after the refusal**, adding a + second complaint that contradicts the first. + 2. **A union containing that shape loses its envelope.** zod's + `handleUnionResults` returns a single non-aborted member's issues + *unwrapped* instead of raising `invalid_union`, so the union's message + becomes whichever branch zod judged closest. + + At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong + branch's prescription. Writing `type: 'page'` on a ViewItem answered: + + ``` + Unrecognized key(s) on this view container: `viewKind`, `config`. + • `viewKind` belongs to a single VIEW, not to the container. Wrap it: … + ``` + + — naming neither `page` nor its removal. It now answers, as it did before: + + ``` + config.type: 'page' was removed from the list-view `type` enum in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … + ``` + + **What an upgrading consumer must do.** Nothing. No key or value changed + status: everything this package accepted before it accepts now, and everything + it refused it still refuses. What changed is which of several competing + complaints an author reads, and that a refusal behind a union is again + reported as `invalid_union` with its branches, which is what `z.treeifyError()` + and this package's own `formatZodError` expand. + + ⚠️ A closed shape declared with a bare `z.object(…).strict()` or + `z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT + get this and will still collapse its union. Build closed authoring shapes with + `strictObject`, or re-declare an existing one through `closedObject`. +- a90272a: `README.md` — the `TypeScriptSerializer` line now says what the serializer emits and what it is for, instead of claiming it exists "for `ObjectSchema.create()`, `defineView()`, etc." (#19724). + + The serializer has never written a factory call. It writes a JSON document wrapped in a module — `export const metadata = { …JSON… };` then `export default metadata;`, with the `typescript` format adding an `import type { ServiceObject }` and annotating the constant with it — and it reads back only a JSON body (double-quoted keys and strings, no comments, no trailing commas), so an authored `ObjectSchema.create({ … })` file with ordinary unquoted keys is refused with `Failed to parse object literal as JSON`. It is the file format `FilesystemLoader` uses for the `typescript` / `javascript` formats: `MetadataManager.save('object', 'account', data)` routed to the filesystem loader writes `{rootDir}/object/account.ts`, never a `*.object.ts`. + + - **No behaviour moves.** The emitter, the parser and every published export are byte-identical; only the README text shipped in this package's `files[]` changes. + - ⚠️ **Not an authoring shape.** Authored metadata — a `*.object.ts` written `ObjectSchema.create({ … })`, a view written `defineView({ … })` — is not produced by, and in its usual TypeScript spelling not readable by, this serializer; do not point it at authored source files. +- e9eb224: `TypeScriptSerializer` no longer annotates every `typescript`-format file `ServiceObject` (#19852). A saved view, or any other item that is not an object, used to be written as `export const metadata: ServiceObject = { … }`: a false annotation, which `tsc` refused with TS2353 (for a view, `'"type"' does not exist in type …`). + + If you type-check the `.ts` files that `MetadataManager.save()` / `FilesystemLoader.save()` write: + + - An `object` file written by the built-in serializer the package wires in is byte-identical: `import type { ServiceObject } from '@objectstack/spec/data'` and `export const metadata: ServiceObject = …`. + - Every other metadata type whose spec type is exactly the `z.input` type of its schema is now annotated with that type instead: `Flow` (`@objectstack/spec/automation`) for a `flow`, `Page` (`@objectstack/spec/ui`) for a `page`, `PermissionSet` (`@objectstack/spec/security`) for a `permission`, and so on: 28 annotated metadata types, `object` included. `tsc` now checks such a file against its own type instead of `ServiceObject`. + - `view`, `book`, `external_catalog` and any other metadata type (a plugin's own, for example) are written with no annotation and no import: `export const metadata = { … };`. `ViewMetadata` is `unknown` and `Book` is narrower than `BookSchema`, so neither would be a true annotation. + - A serializer you wire into `FilesystemLoader` by hand is called as it always was: a custom one, or a subclass that overrides `serialize()`, writes what its `serialize()` writes, and a `TypeScriptSerializer` taken from the package's other entry point (`.` versus `./node`) writes no annotation. + - If you call `TypeScriptSerializer.serialize()` yourself, it now writes no annotation for any item, an object included. It used to write `ServiceObject` whatever the item was, and it cannot know the item's metadata type, so that annotation could be false. The loader picks the annotation through a package-internal function. The public API is unchanged: `SerializeOptions` and every declaration the package exports are as before. + + Reading is unchanged: `deserialize` still reads the first JSON block, so a file written before this fix, `ServiceObject` annotation and all, still reads back. The `javascript` format is unchanged. +- d1ca874: `sortKeys` — a declared `MetadataSaveOptions` field, already honoured by the `json` and `yaml` + formats — is now honoured by the `typescript` / `javascript` formats too (#19872). + + `TypeScriptSerializer` wraps a JSON body in `export const metadata = { … };`. Both its public + `serialize()` and the package-internal `serializeTypeScriptForMetadataType()` (the one + `FilesystemLoader.save()` calls for the built-in `typescript` serializer, `save()`'s default + format) share one `renderModule()` code path, and neither read `options.sortKeys` — so a caller + saving metadata to the filesystem with `sortKeys: true` got unsorted keys in the default format, + silently. `javascript` shares the same `TypeScriptSerializer` class and the same path, so it was + affected too and is fixed the same way. + + The body is now sorted with the exact recursive (deep) key sort `JSONSerializer` already applies + — extracted into one shared, package-internal helper (`sort-object-keys.ts`, not published from + any `@objectstack/metadata` `exports` entry) so there is one sort implementation, not two. + `sortKeys` absent or `false` is unchanged: byte-identical output to before this fix, for every + format, including through `FilesystemLoader.save()`. + + No public API changes — `SerializeOptions` already declared `sortKeys`; this closes the gap + between the declaration and the `typescript`/`javascript` formats' enforcement of it. +- e956924: feat(spec,metadata-core)!: every retired ADR-0087 conversion carries `retiredAfter`, and the artifact door opens its window per entry (#20390) + + Clause-②: yes + + + + **BREAKING** for code that implements `MetadataConversion` itself — shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above). `MetadataConversion` is now a type alias of a live-or-retired union: an entry with `retiredFromLoadPath: true` must also carry `retiredAfter`, a stable `x.y.z` string, and a live entry carries neither. tsc names the missing member (`Property 'retiredAfter' is missing`). No in-repo conversion is left unstamped, and no metadata an author writes changes. + + **What the field means.** `retiredAfter` is the last published `@objectstack/spec` version whose authoring surface still accepted the entry's old shape. It is a fact when the entry lands: the package's own version label at that moment, because `main` carries the last release's label until the next release is cut. Every published retired entry is stamped from the published tarballs — the stable release just before the first tarball that carries it retired — and each entry not yet in any published tarball carries the current label, `17.4.0`. + + **Why the artifact door needed it.** Between two releases, `main` refuses keys that the next release retires while its label still reads the last release. The artifact-ingestion door (`applyArtifactForwardConversions`) compared an artifact's `engines.protocol` floor with that label alone, so an artifact built by the last published CLI — floor `^17.4.0`, dashboard `chartConfig.type`/`xAxis`/`yAxis` and page `assignedProfiles` — read as "authored current": nothing was converted and the strict parse refused the boot. The door now replays a registry entry when the floor is below the runtime label, **or** at or below that entry's `retiredAfter`. After a release the rule reduces to the old one, and an artifact whose floor is above an entry's `retiredAfter` still meets that entry's tombstone — a floor of `^17.5.0` on a 17.5.0 runtime is refused, not converted. `DEFAULT_FLIPS_NOT_REPLAYED_HERE` is still read first. + + **`@objectstack/metadata-core`.** `ArtifactForwardConversionVerdict` gains `'converted-retired-after'`: the floor is at or above the runtime label, but at or below the `retiredAfter` of at least one retired entry, and only those entries are replayed. `ArtifactForwardConversionResult` gains `replayedRetirements` (exported element type `ArtifactReplayedRetirement`): under that verdict, each retirement this runtime enforces past the artifact's floor, with its `retiredAfter`; empty for every other verdict. A consumer that switches exhaustively over the verdict adds that arm. + + **`@objectstack/metadata`, the artifact door — the arm added.** `MetadataPlugin` now reads which verdicts open the window from one total table over `ArtifactForwardConversionVerdict`, with `'converted-retired-after'` on the open side. The #12915 unbound form-predicate notice rides that same reading, so a 17.4.0-built artifact carrying a bare-root form predicate on `main` is announced now, rather than only once the package label moves past 17.4.0. A verdict added later fails to compile until it is placed on one side of the window. Under the new verdict the conversion summary no longer says the artifact "predates this runtime's spec" beside a runtime version equal to its floor: it names the retirement this runtime enforces past the artifact's floor, with the release that last accepted the shape, and says the artifact converts again on every boot until it is rebuilt with tooling from a release that ships the retirement. Summaries are still one per conversion per artifact, naming the site count. + + **Census.** 94 retired entries when this landed: 73 published (first retired in 15.1.0: 5, 17.0.0: 45, 17.1.0: 5, 17.2.0: 2, 17.3.0: 8, 17.4.0: 8) and 21 unpublished. `packages/spec/src/conversions/retired-after.census.json` holds the raw per-release facts, and `retired-after.census.test.ts` pins every value against it, offline. `packages/spec/scripts/build-retired-after-census.ts` re-derives the census from the npm registry (tarball integrity checked). Run it after each stable publish; `docs/releases-maintenance.md` lists that step in the GA release flow. +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth was + `seed-tenancy-backfill`'s organization probe, which kept a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes recorded `'unknown error'` there rather than `''`; + that fallback was load-bearing — the site read an empty value as "the probe did not fail" — + and #17167 removed it in this same release, so all fourteen sites now record the channel as + is and that site carries its failure fact structurally. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. +- 776d64c: feat(spec)!: the `@objectstack/spec/cloud` subpath is removed — the cloud control plane's contracts leave the open-source spec, and the package & marketplace format moves to `@objectstack/spec/marketplace` (#16325) + + + + **BREAKING** — a published subpath export of `@objectstack/spec` is deleted, with no + alias and no deprecation window (maintainer, 2026-08-27, verbatim: 「项目在创业阶段, + 用户也很少,短期不考虑渐进。」). Shipped as `minor` under the repo's launch-window + convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness + is carried by this banner plus the ADR-0087 disposition; the hand-migration prescription + is registered under protocol major 18 as `cloud-subpath-retired`. + + ## What moved, and why + + Maintainer direction (2026-09-06, verbatim): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」, + ruled option B "cut by owner" on #16325 (director batch #62, 2026-09-07, 「同意」). + `packages/spec/src/cloud/` held two families with different owners: + + - **The cloud control plane's own contracts** — `environment.zod`, `environment-package.zod`, + `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` (62 JSON-Schema + defs, 2087 lines). Their producer and every consumer live in the closed cloud repo; the + open-source tree read exactly one type from them. They are gone from `@objectstack/spec`: + `environment` and `tenant` are re-declared in the cloud repo (objectstack-ai/cloud#2037), and + the other four are deleted outright — zero consumers in any repo (#16526, ruled A). All of it + is recoverable from git history at `d5d8d50db`. + - **The package & marketplace format** — `package.zod`, `package-version.zod`, `marketplace.zod`, + `package-l10n`, `template-manifest.zod` (30 defs, 1400 lines). A package author needs it and the + open-source CLI's `os package publish` speaks it, so it STAYS, relocated to `src/marketplace/` + and published as `@objectstack/spec/marketplace`. Every def, key and JSON Schema is + byte-identical under the new `$id` category (`RENAMED_DEFS`, 32 entries; nothing left the + author-facing contract). + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `import { PackageSchema, CreatePackageRequestSchema, … } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/marketplace'` — same symbols, same shapes | + | `import { EnvironmentArtifactSchema } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/system'` (it was only ever a re-export of that declaration) | + | `import type { EnvironmentType } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/api'` (re-declared beside the discovery fold table that reads it) | + | `import { EnvironmentSchema, TenantPlanSchema, ProvisionEnvironmentRequestSchema, … } from '@objectstack/spec/cloud'` | no open-source replacement — these are the cloud repo's own declarations now | + | `/docs/references/cloud/` | `/docs/references/marketplace/` for the format pages (redirected); the control-plane pages have no successor | + + Why the mis-binding hazard closes with this: `client.environments.*` keeps its erased `any` + deliberately (#11925/#12036), and the camelCase `Environment` row used to be the obvious-looking + binding for it — it compiled and read `undefined` at runtime against the snake_case wire. That + type no longer exists in the open-source package, so the wrong binding is structurally + impossible rather than warned about in a docblock. + + `@objectstack/cli` and `@objectstack/metadata` change only an import path (`marketplace` and + `system` respectively); no behaviour moves. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/metadata-fs@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index c3400c410b6..3e01d13f3d4 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Metadata loading, saving, and persistence for ObjectStack", "type": "module", diff --git a/packages/objectql/CHANGELOG.md b/packages/objectql/CHANGELOG.md index 987279e56b3..acbd9663fe6 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,3264 @@ # @objectstack/objectql +## 17.5.0 + +### Minor Changes + +- fe71032: feat(driver-sql,objectql,cli)!: the ADR-0104 file-family column step, and the kernel→driver supply that arms it (#15989) + + + + **BREAKING** on the published storage behaviour of `@objectstack/driver-sql`. A deployment that runs `os migrate files-to-references --apply` now has its media columns **retyped and their values rewritten** into the bare-`sys_file`-id encoding, and its driver writes bare ids from the next boot. This completes the maintainer ruling on #15041 (「15041 应该改为实际 id 保存。选A,其他同意」) whose encoding half shipped in the previous release. + + Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + ## The column step + + `os migrate files-to-references --apply` gains a further step, run **only after** the backfill and its self-check report zero blocking rows — and it moves nothing at all until three gates pass: + + 1. the migration's own gate (zero blocking rows); + 2. **every** abort pre-check, across **every** planned column, before a single statement runs; + 3. no refusals — a column the driver could not plan stops the columns it could. + + **PostgreSQL** and **SQLite** only. ⛔ MySQL is refused by name and belongs to #17788, where its statement ORDER is settled against a real instance rather than transcribed. + + Per column, the shape is read off the column's **physical type**, not off the dialect: a `json` column is retyped (`ALTER … TYPE varchar(2048) USING (col #>> '{}')`), while a column that is already `varchar` — the population `os generate migration --format sql` creates and a JSON-arm driver fills with quoted ids — has its values unquoted in place. SQLite has only the second shape, since it has no json type. + + ### ⛔ The abort clause is NOT the one the ADR sketched + + The #15041 addendum prescribed the retype with nothing in front of it while *requiring* the step to abort "on the first cell that is not a JSON string". Those two sentences contradict each other, and which was wrong was settled by running it. Measured on live PostgreSQL 16.13, `USING (col #>> '{}')` is **accepted** over a row holding an inline metadata blob, because `#>> '{}'` extracts *any* json type as text: the bytes survive, but the column is no longer `json`, so an object becomes a plain string in a column whose declared contents are ids — silently, in a migration that reports success. The director ruling (decision batch #120 item 1) replaced the clause with the pre-check that implements the requirement: `json_typeof(col) IS DISTINCT FROM 'string'` on PostgreSQL, and `json_valid(col) AND json_type(col) <> 'text'` on SQLite, where excluding invalid JSON is what keeps a re-run idempotent over cells a previous run already moved. + + Both the destructive form and the guarded one are executed side by side, on one fixture, in this release's own test suite — so the difference stays a measurement rather than a comment. + + ## The kernel→driver supply seam + + `SqlDriverConfig.fileColumnsMoved` shipped last release and no host outside the driver supplied it. It is supplied now: `ObjectQL.registerDriver` hands every driver that has the seam a closure over the new `ObjectQL.haveFileColumnsMoved()`, which reads `sys_migration.columns_moved_at` — and requires the `adr-0104-file-references` flag to be verified **as well**, since the stamp alone would attest a column move with nothing attesting the values inside it. + + ⭐ **Every way of not knowing still answers "not moved".** The option omitted, a resolver that throws or rejects or answers a non-`true` value, a resolver that never runs because the host never calls `initObjects`, a driver with no such seam, no `sys_migration` object, no row, an unreadable table, a null or empty stamp — all the JSON arm. That is the encoding every deployment in the world is on, and a driver that guessed the other way would write bare ids into a JSON column. + + ⛔ **A host that names `fileColumnsMoved` in its own config wins**, in either polarity. The engine only ever fills an empty slot, and never contradicts an explicit composition: overruling a declared `false` is precisely the bare-ids-into-a-JSON-column failure this mechanism exists to prevent. + + ## New published surface + + - `@objectstack/spec` — `hasMovedFileColumns(flag)`, the single arbiter of the conjunction above, beside `isDataMigrationFlagVerified` and `authorisesIrreversibleAction`. + - `@objectstack/objectql` — `ObjectQL.haveFileColumnsMoved()`, sharing one memoized read (and one `invalidateDataMigrationFlags()`) with `isFileReferencesMigrationVerified()`, so the two answers can never come out of one another's date. + - `@objectstack/platform-objects` — `recordFileColumnMove(engine, migrationId)`, which refuses to stamp a deployment with no verified flag row. `readDataMigrationFlag` now carries `columns_moved_at`; it previously dropped it, which made a moved deployment indistinguishable from an unmoved one to every caller. + - `@objectstack/driver-sql` — `SqlDriver.setFileColumnsMovedResolver()`, `SqlDriver.planMediaColumnMove()`, and the statement builders `mediaColumnMovePlan` / `mediaColumnMoveDialect` / `isJsonColumnType` with `MEDIA_COLUMN_MOVE_DIALECTS`, `MEDIA_COLUMN_MOVE_ROLLBACK_NOTES` and `MEDIA_ID_MOVE_WIDTH`. The statements live in the package that owns the dialects and measured them; a second copy in the CLI would be a second copy of the clause the ruling got wrong. + + ## What does NOT change + + A deployment that does not run `--apply` is byte-for-byte where it was: the column stays `json`, the write still JSON-encodes, and the read still accepts both encodings. A backfill re-run does not set the stamp and — deliberately — cannot clear it either: `recordDataMigrationRun` omits the key rather than writing a preserved value, so a ledger read that FAILS cannot demote a moved deployment back onto the JSON arm. A partial or failed column step records nothing at all, which leaves such a datastore on the arm that reads both encodings. + + `multiple: true` media is untouched on both arms: its value is a list of ids and a JSON column on every deployment. +- 271d6bb: Record the acting agent on the audit row — ADR-0090 D10 rule 4 dual attribution + + A `sys_audit_log` row written by an MCP OAuth client acting for a human used to + be byte-identical to a row that human wrote in the Console. The envelope carried + the delegation (`principalKind: 'agent'` + `onBehalfOf`), the row did not, and + nothing in between copied it: `assembleExecutionContext` consumed the OAuth + `azp` as a boolean and dropped the value, so the acting client did not exist + downstream of the door at all. + + The delegation now travels the whole way and lands on the row: + + - `ExecutionContext.performedBy` (`{ clientId }`) — decided at the `/mcp` OAuth + door, on the same branch that already decides `principalKind: 'agent'` and + `onBehalfOf`; a member of the closed entry field set like every other. + - `HookContext.provenance.performedByClientId` — the hook-layer carrier, beside + `flowRunId` and `attributedUserId`. Provenance, not `session`: no + caller-gating hook may read the client as the caller. + - `sys_audit_log.metadata` gains `{ performed_by, on_behalf_of }` on a delegated + write, and nothing at all on a personal one — the two shapes are told apart by + absence rather than by guesswork. + + Additive, and attribution only. `user_id` stays the human, so owner-stamping, + `current_user.*` RLS and the `sys_user` join are untouched (ADR-0073 D3 — + attribution is not ownership). `actor` is untouched too: ADR-0118 D1/D5 keeps + that column two-valued — a user id, or `null` for the system — and answers + "which non-user acted" with an added attribution field rather than a second + actor vocabulary. No existing row changes meaning, and no historical row is + rewritten. + + Rule 4's third element, the run id, is NOT delivered here and is not declared + either: nothing on the request path mints one today (`ExecutionContext.traceId` + is declared but resolved by no transport entry point), and declaring a carrier + nothing populates is the defect this change exists to close. +- 5ba2ec3: feat(spec,core,objectql,driver-sql,driver-turso): a transport can declare it has no transactions, and every transaction gate reads the declaration instead of method presence (#18063) + + Maintainer ruling, decision batch #148 item 3, letter B, 「同意」 2026-09-17, verbatim and untranslated: + + > `packages/spec`: the driver contract gains a way for a transport to **declare 「no transactions」** (the dev picks the smallest spelling the existing capability/contract surface already has — a capability bit is preferred over a new key), and the engine's transaction gating reads the declaration instead of method presence. + + **`DriverCapabilities` gains one live bit, `transactionsUnsupported`.** A transport sets it to say that a handle it issued would be a FALSE SUCCESS rather than a missing feature: the caller gets a handle, the writes execute and are already durable, `rollback()` resolves and undoes nothing. Absence means `false`, exactly like `batchSchemaSync`, so a driver that declares nothing keeps the behaviour it has today. + + **⛔ This is not `DriverCapabilities.transactions` un-retired, and the difference is not cosmetic.** That key was tombstoned in 17.0.0 under ADR-0049 enforce-or-remove and STAYS tombstoned — writing it is still a compile error and still a parse refusal carrying its prescription. It claimed "I support transactions" and nothing read it; this one declares "my transport cannot honour one" and the engine dispatches on it. Reviving the name would have inverted the record's own `absence = false` convention into a tri-state, turned a documented refusal into silent acceptance of a value whose meaning had changed underneath it, and made the tombstone's published text ("no code in any repository ever read it") false. A new key costs one bit; the name costs all of that. + + **Adding a bit to a record enforce-or-remove has pruned SATISFIES that ADR rather than reversing it.** The audit removed thirty-one bits for one stated reason — no code anywhere read them — and kept the three where method presence provably cannot carry the signal. This change is the creation of the missing reader: `driverSupportsTransactions()` (exported from `@objectstack/spec`) is the one definition of the gate, and all FOUR places that used to spell `typeof driver.beginTransaction === 'function'` ask it — `ObjectQL.transaction()`, `ScopedContext.transaction`, the `ScopedContext` begin/commit/rollback trio, and `@objectstack/core`'s `engineCanRollBack`. The bit arrives WITH its reader, in the same change, which is the honest order the ADR asks for. + + **Why method presence could not carry it.** `TursoDriver extends SqlDriver`, whose `beginTransaction()` opens a real knex transaction, so the inherited method reported the libSQL REMOTE transport as transactional. It is not — `RemoteTransport`'s data methods take no `options` argument at all, so a handle cannot reach the statement that would have to join it. A subclass cannot opt out of a door it did not open. This is the mirror of `batchSchemaSync`, which exists because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot. + + **What changes for a caller.** On a datasource whose driver declares the bit, `engine.transaction()` now takes the DECLARED non-transactional path (ADR-0119 D1) instead of opening a transaction it cannot honour: the degrade warns once per datasource — naming the declaration, not a missing method — and `{ require: true }` throws `TransactionUnsupportedError` before the callback writes anything. `ScopedContext.transaction` and the discrete begin/commit/rollback trio read the same predicate; the trio's `begin` returns `null`. Both are the answers a driver with no `beginTransaction` already received. + + **`driver-turso`.** The remote face declares `transactionsUnsupported: true`; local and embedded-replica inherit `false` from the base and are untouched. `TursoDriver.beginTransaction()` publishes the inherited declaration instead of `Promise` — the annotation the earlier `any` was masking an LSP violation to avoid, dissolved rather than widened: the remote arm returns `never` (it refuses), so the only arm that still returns is the base's. `SqlDriver.beginTransaction()` keeps its narrow `Promise`; nothing in the base was widened. + + **`@objectstack/core`.** `engineCanRollBack()` — the ADR-0119 D4 gate that `@objectstack/metadata-protocol` uses for `batchData` / `updateManyData` / `deleteManyData` under `options.atomic`, and that `runMigrationJournal()` uses to decide whether to start at all — reads the same predicate. It has to: it does not open the transaction itself, it vouches that `engine.transaction()` will, and on a driver that declares the bit the engine now takes its non-transactional path. A gate still reading method presence would vouch for a runtime that is about to run the callback with no transaction, so the atomic batch would answer `rollback` over writes that stayed on disk and the journal would write `chunk_done` rows its own contract says mean "committed". What a caller sees on such a datasource instead: `batchData({ atomic: true })` refuses with `501 NOT_IMPLEMENTED` — retry without `atomic`, or probe `capabilities.transactionalBatch` on `/discovery` first — and `runMigrationJournal()` refuses with `MigrationJournalRefusal('NOT_IMPLEMENTED')` before writing a single journal row. Both are the answers a driver with no `beginTransaction` already received. + + **`RemoteTransport` loses `beginTransaction()`, `commit()` and `rollback()`.** They are a published surface, and this is **minor** rather than major on the ruling's own stated ground: that transport never honoured a transaction, so no working behaviour is withdrawn. They had already become unreachable from every caller in the repository when the driver started refusing them; they are now gone, and the declaration keeps them gone by design rather than by audit. +- a675ad4: The remaining raw `FieldSchema.reference` readers now **REFUSE** a carrier they cannot read, instead of answering "no target" (#18550). The previous release routed the arbiter (`referenceCarrierOf`) and the lint target readers; these were the measured residue of the same ruling — every reader, not just the arbiter. + + `FieldSchema.reference` is `z.string().optional()`, so `ObjectSchema.safeParse` refuses an object- or array-valued carrier at the contract door. These reads are the other door: the one a value reaches only when it never went through parse — a hand-built fixture, a raw `registerObject`, a stored row rehydrated past its schema. + + **`@objectstack/objectql`** — both of the delete cascade's carrier reads (`planCascadeAtomicity` and `cascadeDeleteRelations`). This is the one with a measurable runtime consequence, and it is why the level is not `patch`: + + ``` + before acct=1 task=1 + delete RESOLVED true <- success reported to the caller + after acct=0 task=1 <- an ORPHANED master_detail row + ``` + + An unreadable carrier made the relation invisible to the cascade, so the parent was deleted, the detail row stayed, and the caller was told the delete succeeded — no `restrict` refusal, no `set_null`, nothing logged. It now refuses before any row is touched. + + **`@objectstack/rest`** — the public-form lookup picker's field-def fallback. The field def is also hoisted out of the metadata fetch's `catch {}`, so an unreadable carrier is no longer reported as `LOOKUP_TARGET_MISSING`: "no target is declared" and "the declared target cannot be read" want different fixes from whoever owns the metadata. + + **`@objectstack/metadata-protocol`** — the seed dependency graph, which also retires an `as string` cast that asserted exactly what its truthiness guard had not checked. + + **`@objectstack/lint`** — the four remaining target readers: `masterDetailCount` (`validate-expressions`), the `displayField` consumer edge (`validate-field-consumers`), the field and action-param targets (`validate-object-references`), and `masterOf` (`validate-sharing-rule-enforceability`). + + **`@objectstack/verify`** — `relationTarget`, which no longer degrades an unreadable carrier to the generic "has no `reference` target" an object with no relationship metadata at all receives. + + `null`, `undefined` and `''` are ABSENCE, not a wrong shape, and still answer `undefined` at every one of these sites — a field is allowed to name no target, and `StrictField` declares `reference` nullable. Each site's absence answer is pinned alongside its refusal. + + Upgrading: nothing conformant changes. A non-string `reference` could not be authored, stored or parsed before this release either; what changes is that one now fails loudly at the read instead of being read as an absent target. If a test asserted the old silence, assert the refusal instead. +- 1f05ea4: A validation rule can read one hop through a lookup — `record.account.type` on an opportunity resolves the owning account's field instead of faulting (#18682) + + Clause-②: yes (widening) + + A validation predicate could only read the record it guards. A `lookup` / + `master_detail` field carries an **id**, so the natural cross-object rule — + "a partner account may not carry an opportunity over 10000" — faulted with + `runtime: No such key: type`, and because a broken validation is fail-closed it + rejected every write on the object. The capability mainstream platforms provide + as a matter of course could not be authored at all. + + ### What you can write now + + ```ts + validations: [{ + name: 'partner_cap', + type: 'script', + message: 'Partner accounts are capped at 10000.', + condition: "record.account.type == 'partner' && record.amount > 10000", + }] + ``` + + One hop, through any reference-typed field (`lookup`, `master_detail`, `user`, + `tree`). The engine reads the related row before evaluating and binds it in + place of the id, so `record..` resolves. + + ### It is data pinned BEFORE evaluation, not a query from inside CEL + + There is no `os.lookup(...)` / `os.exists` / `os.count` — those stay removed. + The engine statically analyses the predicate, learns exactly which reference + fields it reads through and which related fields it names, and loads those + **before** evaluation. Every registered function stays pure once `now` is + pinned, so `objectstack build` artifacts stay byte-stable. + + The cost is bounded by construction: one hop, only the fields a rule actually + names, one batched read per reference field per write, and nothing at all when + no rule traverses. + + ### Read authority — system, bounded by the projection + + The related row is read under **system authority**. A validation rule's output + is a pass/fail the *system* enforces, not data handed to the caller — which is + why RLS predicates are excluded from this capability altogether. Reading as the + acting user instead made the rule unauthorable for exactly the persona it exists + to constrain: a member with CRUD on the child and no read on the parent faulted + on every write. + + What bounds the elevation is the **projection**: only the + columns the predicate names, intersected with the related object's declared + fields. A column the related object does not declare never enters the query, and + is refused as the authoring fault it is — distinct from a column that exists and + is empty, which evaluates as `null`. + + A related object no organization wall scopes — no tenant column (`sys_user` + behind a `user` field), `tenancy.enabled: false`, or `external` — is bounded by + row as well: for any caller that is not system (a user, a public-form + submitter, a caller with no principal), only a row the caller's own read of that + object returns. A reference to any other row refuses the write as not readable, + whatever that row holds. Under a walled posture (`group` or `isolated`), such a + caller with no active organization gets no related read at all: a rule reading + through a stored reference refuses the write as not found. + + ⚠️ **The accepted cost, stated plainly.** A caller can *infer* a related value + they cannot see by observing which writes are refused. The value itself never + appears — the refusal names the field and the rule, never the value — and the + channel is deliberately no wider than "this rule refused this write". + + ### Two shapes are refused, with a prescription + + Both fault at evaluation today, so neither removes anything that works: + + | Shape | Why | Write instead | + | --- | --- | --- | + | `record.account.type == 'x' && record.account == 'acc_1'` | reading through the relationship resolves `record.account` to the related RECORD, so the id comparison would stop matching — silently | `record.account.id == 'acc_1'` for the value comparison | + | `record.account.owner.email` | a second hop is not loaded | denormalise onto `account`'s object, or read it in a hook | + + A field that is **not** reference-typed is untouched: `record.address.city` on + an object-valued field traverses today and keeps traversing. + + ### `@objectstack/plugin-security` gains `canWriteObject` + + The WRITE admission — the sibling of the existing `canReadObject`, running the + middleware's own arms in the middleware's own order: system bypass; then, before + anything resolves, the ADR-0103 engine-owned write guard and the ADR-0090 D12 + delegated-administration gate, each called as the middleware's own primitive; + then no resolved permission sets, unresolvable posture, the ADR-0066 D3 + `requiredPermissions` capability AND-gate for both principals, the CRUD grant, + the ADR-0090 D10 delegator check, and — when the caller's payload is supplied — + the field-level security WRITE gate over it (`getFieldPermissions`, folded + through the D3 field-capability contract, intersected with the delegator's mask + under D10, then the forbidden-write detection); and last, the ADR-0123 D2 + no-active-organization wall, the same verdict the middleware's step 3.7 throws + on. It exists for doors that must ask + "could this caller perform this write" without running the engine middleware — + the write preview is the first. + + ⭐ What it answers, POSITIVELY — by naming what it RUNS, never a category of the + write decision: the ADR-0103 engine-owned affordance gate, the ADR-0090 D12 + delegated-admin gate, the fail-closed postures (#3545's unresolvable posture and + the D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both + principals, the `allowCreate`/`allowEdit` CRUD grant, the D10 delegator's + independent grant, the step 2.5 FLS write gate over the keys the payload + names, and the ADR-0123 D2 organization wall. It says nothing about any refusal + not in that list. `@objectstack/plugin-security`'s + `can-write-object-admission.test.ts` pins the method's answer equal to the + registered middleware's on its equivalence block's cases, and pins one D12 + UPDATE case as a direction: the method `false`, the middleware `true`. + + ⛔ `true` never means the write will succeed, and ⛔ what follows is not an + enumeration of the distance to success: the middleware refuses both before and + after `next()` for reasons this method is never asked. Nearest to hand are the + remaining pre-resolution gates that run beside the two named above — the + package-managed and system-row write gates, which judge a row's PROVENANCE; the + curated-capability-name and audience-anchor binding refusals, which judge a + payload VALUE; and the ADR-0056 public-form grant, which no caller can present + to this method and which has no extracted primitive to call; the row-level and + post-image refusals — the `using` pre-image, the ADR-0055 controlled-by-parent + master edit, the RLS `check` post-image and the Layer 0 tenant post-image, none + of which this method can judge because it is asked about no ROW; the + payload-VALUE refusals the same caller passes by simply not sending the value — + the masked echo and the `owner_id` forge, which therefore widen the caller class + by nothing; the anti-filter-oracle guard on the caller's own predicate, which + this method is handed none of; the post-`next()` assertion that the insert + `check` seam really ran, which judges an executed write; and, outside the + middleware entirely, `readonlyWhen`, the static `readonly` strip and the + validation rules themselves. + + ### Scope + + Object validation rules (`script` / `cross_field`) — and the system-authority + read is confined to that one seam. The field-level + `requiredWhen` / `readonlyWhen` / option `visibleWhen` predicates fail **open** + and are deliberately not covered here; RLS predicates are out too. Depth is one + hop. The cleanup UPDATE a `set_null` delete issues on a referencing record + resolves no relationship, so a rule there is evaluated as before this release — + against the bare id, where reading through it faults and refuses the cleanup, + and with it the delete. +- 0318faf: feat: the server answers `current_user.can(object, verb)` in an option's `visibleWhen` (#18783) + + A `select` / `multiselect` / `radio` / `checkboxes` option can gate itself on the acting subject's grants: + + ```ts + stage: Field.select({ + label: 'Stage', + options: [ + { value: 'open', label: 'Open' }, + { value: 'escalated', label: 'Escalated', visibleWhen: "current_user.can('crm_account', 'edit')" }, + ], + }), + ``` + + `@objectstack/formula` answers `can` from `EvalContext.permissions` and refuses loudly when none is passed — and until now nothing on the write path passed one. Every authenticated write that picked such an option took the evaluator's fail-open branch: the value was admitted, one `warn` said the predicate "failed to evaluate", and the gate was never enforced for anyone. + + **What changes.** The write path now evaluates the predicate with the subject's effective object permissions — on `insert` (single and batch), by-id `update`, bulk `update`, and the `validate()` preview. A subject whose map withholds the verb is refused with `VALIDATION_FAILED` and a field error `invalid_option` on that field; a subject who holds it is admitted. Options whose `visibleWhen` never calls `can` are unaffected. + + **Where the map comes from — one producer.** + + - `@objectstack/plugin-security` implements `ISecurityService.getEffectiveObjectPermissions` (declared optional in `@objectstack/spec`) and registers the same method on the engine. + - `@objectstack/objectql` gains `registerEffectiveObjectPermissionsResolver(fn)`. The engine asks it at most ONCE per write (an N-row bulk update is one resolution), only when a picked option's predicate calls `can`, never for a write with no acting user, and never keeps the answer past the write. The answer goes through formula's `toEvalPermissions`, so a map that is not the published shape is refused rather than answered from. + - `@objectstack/core` exports `buildEffectiveObjectPermissions`: the most-permissive merge plus the super-user seed, wildcard fold, managed-write clamp and `apiOperations` annotation. `/auth/me/permissions` builds its `objects` slot with it and the new security method returns it, so the console and the server's own `can()` read the same map. The four folds (`foldWildcardSuperUser`, `clampManagedObjectWrites`, `seedSuperUserRestrictedObjects`, `annotateEffectiveApiOperations`) and the `ManagedSchemaLike` / `ApiExposureSchemaLike` types moved from `@objectstack/plugin-hono-server` to `@objectstack/core`; `@objectstack/plugin-hono-server` re-exports them under the same names, so no import changes. The `/auth/me/permissions` response is byte-identical for the same resolved sets (measured on five fixtures against the previous build). + + **Failure stance.** + + - If the security service cannot resolve the map, a write that needs it is refused with the resolution's own error — fail closed. It is never read as "no grants". + - With no security plugin, or an engine older than the seam, there is no permission data. The gate stays unevaluable and the value is admitted with the same `warn` as before, which names the missing input. The security plugin logs one `warn` at start when the engine lacks the seam. + + **Plain-wildcard coverage, closed in this release.** `can()` reads only the per-object entries of the map. Before #20083, `/auth/me/permissions` listed an object for a `'*'` wildcard grant only when that grant carried a super-user bit, so a subject whose access to an object came only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — got `false` from `current_user.can()` for that object, although the data plane admits the write, and was refused on a `can`-gated option. That gap is closed in this same release by #20083 (`.changeset/20083-effective-map-plain-wildcard.md`): `buildEffectiveObjectPermissions` now puts each set's plain `'*'` on the registered public objects that set does not name, so that population's map — and any client that answers `can()` from the same `/auth/me/permissions` map — carries an entry for each object the wildcard covers, with the wildcard's grants, narrowed on a guarded managed object by the same managed-write clamp as every other entry. The map also differed from `PermissionEvaluator.checkObjectPermission` for subjects holding a super-user wildcard: an entry the super-user set itself names narrower read as granted, which is closed in this same release (`.changeset/20136-super-user-fold-per-set.md`). Its missing `transfer` is closed in this same release (`.changeset/20134-super-user-entries-every-bit.md`). + + **No spec key, route or config key is added or removed.** +- adbdbc5: `Clause-②: yes (widening)` + + A `percent` field's declared `scale` is the number of decimal places of the **percentage-point** value as displayed and entered; the **stored** allowance now derives from it. For a fraction-stored percent the record validator's `max_scale` branch accepts `scale + 2` decimal places in the stored fraction (#19320). + + Maintainer ruling batch #161 item 3 letter B (2026-09-18) settles what one word means: `scale: 2` on a percent field is two displayed decimals, so the edit widget offers `12.34` and writes the fraction `0.1234`. The branch compared those four places against the raw declaration and refused the write — an author could declare two displayed decimals and then not write two displayed decimals. + + - **Which fields move**: only a **fraction-stored** percent, i.e. one whose `percentScaleOf` is `fraction` — no declared `max`, or a `max` at or below 1. A **whole-percent** field (`max` above 1) stores the displayed number itself and keeps the declared `scale` exactly, as do `number`, `currency`, `slider` and `rating`. The split is read from the spec's `percentScaleOf`, not re-decided at this seam. + - **Direction, measured in both**: over a 1,950-cell corpus of declaration x written value, **36 cells move from refused to accepted and 0 move the other way**. Nothing that writes today stops writing; no stored value is re-read or re-judged; no migration is implied. + - **`FieldSchema.scale`'s describe states both meanings**, which is the half of the ruling that makes the derivation legible to an author: what the number counts (displayed percentage points) and what it permits in storage (`fraction` ⇒ `scale + 2`, `whole` ⇒ `scale`). The generated field reference page carries the same sentence, and `percentScaleOf`'s docblock points at it rather than restating it. + - **The refusal envelope names the allowance that was applied.** On a fraction-stored `scale: 2` field, `0.12345` is still refused and reports `constraint: { scale: 4, actual: 5 }` — previously it would have read `{ scale: 2, actual: 5 }` on a field that accepts four places, a true refusal described by a false constraint. A consumer asserting the raw declaration back out of a percent field's `max_scale` envelope reads the derived number instead. +- 5b9402d: fix(spec,objectql)!: `scale` is retired from the `currency` field type — refused at parse, and no longer enforced on currency writes (#19629) + + Clause-②: no (narrowing) + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. A `currency` field that declares `scale` — any value, `scale: 0` included — no longer parses. The hand-migration prescription is registered under protocol major 18 as `field-currency-scale-refused`. + + A currency's decimal places are the currency's, not a setting. On a currency field the key was three-faced. The metadata-admin field designer offered it as stored metadata; the amount's cell never read it, because a currency amount's fraction digits come from its currency's own ISO 4217 minor unit; and the record validator's `max_scale` branch still refused writes carrying more decimals. An author who set `scale: 3` bought a narrower write contract and no visible change. The maintainer's rulings retire the key from the type rather than aligning the money faces to it. + + **`@objectstack/spec`** — `FieldSchema` refuses `scale` on `type: 'currency'` with a located issue at `scale`. Its remedy: delete the key; the currency's ISO 4217 minor unit decides how the amount displays, and the field's write allowance stays unconstrained. The remedy names no other key to carry the value. No alias and no grace window. `scale` on `number`, `percent`, `rating`, `slider` and `formula` is untouched, and the key's describe now names that set. Studio's object editor no longer offers `scale` on a currency field: the fields grid of the `objectForm` this package registers in `METADATA_FORM_REGISTRY` now shows it only for `number` and `percent`. + + **`@objectstack/objectql`** — the record validator's `max_scale` branch no longer reads `scale` for `currency`, so the type leaves the enforced set. A field definition that reaches the validator without passing `FieldSchema` (stored before this release, or built by hand at runtime) therefore narrows nothing either. `min`, `max` and the finite-number check still apply to `currency`, and `number` / `percent` / `rating` / `slider` still refuse over-scale writes exactly as before. A currency write with more decimals than a former `scale` is now ACCEPTED: the write allowance stays unconstrained, the contract every currency field without `scale` already had. Enforcing a currency width on writes instead was offered to the maintainer and not taken. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `Field.currency({ label: 'Amount', scale: 2 })` | `Field.currency({ label: 'Amount' })` | + | `{ type: 'currency', scale: 2, currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }` | `{ type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }` | + + The one-line fix: delete `scale` from every `currency` field. Nothing replaces it, so ⛔ do not re-declare the value under any other key. The currency's ISO 4217 minor unit decides how the amount displays. + + What an upgrade changes beyond the refusal: + + - **Writes.** A currency value with more decimals than the deleted `scale` is accepted where it used to answer `VALIDATION_FAILED` with field code `max_scale`. + - **Two console faces.** At the console pin measured when this change was written, the grid summary footer and the dashboard metric widget read a currency column's `scale ?? 0`. This change lands only after the console derives both faces from the currency, the way the cell does, and after this repository's console pin has moved past that console change. So in the console bundled with this release, deleting `scale` changes neither face. + + ## Who is affected, measured + + AST sweep on `origin/main` `1f89ba0d70`: 15 `Field.currency` declarations in `examples/` (app-crm 4, app-showcase 11) and 13 documentation code examples carried `scale`, every one `scale: 2`. All were deleted in this change. No platform object, seed or JSON fixture in the tree declares it. Seven test fixtures pinned the old shape and were re-judged. One of them, a flow oracle that needed a live `scale` gate, moved its field from `currency` to `number`. + + +- 5dba7f3: fix(objectql)!: a field-level `requiredWhen` / `readonlyWhen` predicate that cannot be evaluated now REFUSES the write, naming the field and the rule, instead of letting it through (ADR-0137 D2) + + Clause-②: no (narrowing) + + **BREAKING**: shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA). The banner and the ADR-0087 + disposition below carry the breaking change, not the level. + + **Writes that used to save now fail.** ADR-0137 D2 says: "At submit time, a + field-rule predicate that cannot be evaluated refuses the write and names the + field and the rule. Nothing is persisted." The server now enforces that on the + two arms that let such a write through: + + - **`requiredWhen`**: a predicate that faults used to be logged + (`requiredWhen for '' failed to evaluate — skipped`), and the record + saved with the field empty. It now refuses the insert or update. This covers + every fault, including a `parent`-scoped rule whose master-detail header + could not be resolved for the write. + - **`readonlyWhen`**: a predicate that faults used to be logged + (`failed to evaluate — change allowed through`), and the field the author + declared frozen was written. It now refuses the update. On a bulk update, a + fault in any matched row refuses the whole write, and the refusal names that + row. One case is unchanged: a predicate that faults because the header it + reads as `parent` could not be resolved still holds the lock, as before. + + The refusal is the same `ValidationError` a broken validation rule has thrown + since #4649: `VALIDATION_FAILED`, served as `400`. Its entry for the field + carries `code: 'rule_violation'` and + `constraint: { rule: 'requiredWhen' | 'readonlyWhen', reason: 'unevaluable', fault }`, + with `missingKey` or `hint: 'null-comparison'` when the fault is one of those. + The message names the field and the rule. It is refused before anything is + written, on insert, single-id update and bulk update alike. The operator also + gets a `warn` line saying the write was rejected. + + The refusal applies to the whole submit. A `requiredWhen` whose predicate + faults refuses the write even when the write supplies the field, because the + rule has no verdict to judge that value against. + + **What starts refusing.** A stored predicate that faults on the writes it + judges: + + - a key the object does not declare, usually a typo (`record.statsu`); + - an ordering comparison or arithmetic over a `null` (`record.amount > 100` + where `amount` is empty). Guard it with `!= null`. `has(x)` is true for a + declared field holding null, so it does not guard this; + - a column read through a lookup (`record.account.tier`). The field level never + reads the related record, so the reference holds a bare id there. The refusal + says so, and names the reference and its target object; + - an envelope with no evaluable `source`: blank, or `ast`-only. + + Nothing in this repository's own metadata is affected. A census of every + `requiredWhen` / `readonlyWhen` under `packages/`, `examples/` and `apps/` + found none that faults on a write it judges. How many stored predicates in a + deployment fault is unknown, and ADR-0137 names that as the point: the loud + state is what finds them. + + **Fix.** Read the refusal. It names the field, the rule, and the key or + overload that faulted. Then correct the predicate: fix the key's spelling, + guard the null operand with `!= null`, or move a check that reads through a + lookup into a `validations[]` `script` rule, whose condition does read one hop + through a reference. + + Unchanged: a predicate that evaluates is judged exactly as before, in both + directions. So is the ADR-0113 legacy-row rule for an evaluated `requiredWhen`. + Option-level `visibleWhen` is not a field-rule predicate, so D2 does not reach + it, and it stays fail-open. The render side is not touched here (ADR-0137 D3 + keeps its directions for display). + + `@objectstack/lint`: the build-time messages for a field `requiredWhen` no + longer say the server "skips" a faulting predicate. The unbound-root message, + the `parent`-without-a-master message and the null-guard message now say the + server refuses the write. + + +- 4d7e740: A row-level or sharing-rule predicate whose comparison is handed something other than one value is refused at the CEL lowering or at the write-check evaluator, instead of admitting writes and reads it was written to refuse (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing`, `@objectstack/lint` and `@objectstack/objectql` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS write checks and reads.** Each shape below was measured through the real plugin-security on driver-sql and driver-memory: + + - `!(record.status in [['closed', 'archived']])` (a list nested in an `in` list) admitted and stored every write the `check` was written to refuse, and a `using` read returned every row on driver-memory. + - `current_user.org_user_ids != 'x'` and `current_user.org_user_ids > 'a'` (a membership set on a comparison with no field) folded to "no restriction": every write admitted, every row read, on every driver. + - `record.status > ['m']` compared the list as the string `'m'` on the write check, while the analytics read scope bound the whole list as one SQL parameter. `record.reviewer_id > current_user` compared the whole caller object as a string and admitted and stored every write; in this release the RLS compiler's comparand faces (#20212) already drop that policy, and this change refuses it at the lowering for every caller of the compiler. + - `record.status != record.tags`, its negation `!(record.status == record.tags)`, and the mirror `record.tags != record.status`, with `tags` a `json` field or a `multiple` lookup, admitted and stored every write. + + What changes: + + - `@objectstack/formula`: `compileCelToFilter` refuses, with `unsupported`, a list comparand under every comparison (the ordering operators now included, and on the constant-fold branch, whichever side), the `current_user` root or a key resolving to an object under an ordering operator, and an `in` list whose member is itself a list. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports each literal form; a resolved value is refused per request. `matchesFilterCondition` refuses, with `INVALID_FILTER` / 400, an array under `$gt` / `$gte` / `$lt` / `$lte`, an array member of `$in` / `$nin`, and a `{ $field }` comparison (`$eq`, `$ne` or an ordering operator) whose column holds a list or an object on the record being judged, on either side. The message withholds the field, the operator and the value. + - `@objectstack/plugin-security`: the RLS compiler drops a policy the compiler refuses and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, and `getReadFilter` hands the analytics read scope the deny scope). A `check` comparing a field with a list-holding column is refused 400 and stores nothing. + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. + - `@objectstack/lint`: the literal forms are reported as `rls-predicate-unenforceable`, and an ordering comparison against a membership set through the reference pass. + - `@objectstack/objectql`: a `having` comparison against a `{ $field }` column whose aggregated row holds a list is refused 400 where the row carries the list itself (driver-memory); driver-sql rows carry the stored JSON text and compare as before. + - `@objectstack/spec`: the migration registry carries the entry. + + The stage 2a changeset's sentence that `{ $field }` references evaluate as before no longer holds for a column holding a list or an object: that comparison is now refused. + + **What to change.** "One of these values" is `record.status in ['open', 'pending']`, and "none of these values" is `!(record.status in ['closed', 'archived'])`, with the list flat. An ordering takes one bound (`record.status > 'm'`); a range is two comparisons joined by `&&`. Compare against one key of the caller (`record.reviewer_id > current_user.id`). A field compared with a `json` or `multiple` field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook. In a raw filter, use `$in` / `$nin` with flat lists and one bound per ordering operator. + + Not changed: a field compared with a `json` or `multiple` field still lowers and is not reported at authoring time, because the lowering sees the predicate's text and not the object's field types; driver-memory still answers a `{ $field }` comparison on a read without evaluating the reference. + + +- 009da14: fix(plugin-security, objectql)!: a row-level `check` now holds for every row a multi-row write stores — an array insert and a predicate (`multi: true`) update (#19950, #19964) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows the set of writes the write gate accepts. A multi-row write that is admitted today can be refused after this change. It ships as `minor` under the launch-window convention, as the using-defaulted check did (#19942). + + A row-level security `check` (declared on the policy, or defaulted from its `using`) is the write-side half of the policy: a row the check refuses is never stored. The write gate enforced it for a single-row insert and a by-id update, but not for the two multi-row write shapes. An **array insert** (`insert(object, [rows])`, which the create-many data route calls) installed no check, so its rows were stored unjudged. A **predicate update** (`update(object, changes, { where, multi: true })`) never judged its new rows. The gate assumed a `using`-scoped `where` covered the write, but a policy that declares only `check` scopes nothing, and a scoped `where` says nothing about the new row in any case. + + Both shapes are now judged row by row. An array insert judges each row on the image the `beforeInsert` chain produced. A predicate update judges each row the write selects on its new image: the matched row merged with the final payload. The engine (`@objectstack/objectql`) supplies those rows through the seam the insert check already uses (`OperationContext.postHookWriteImageCheck`). It runs the judgement on the predicate path over the rows its composed query selects, reusing the matched-row read that path already makes. + + **Writes that are now refused.** Each refusal is the existing row-level CHECK denial, `403 PERMISSION_DENIED`, and nothing is stored. One failing row refuses the whole write. There is no transition switch. + + - **A predicate update under a policy that declares `check`**, when any matched row's new image fails that check, including when the policy has no `using` at all. + - **A predicate update that moves a matched row out of a policy's `using`**, when no applicable policy declares `check`. The `using` is the defaulted check; a by-id update already gives this answer. + - **An array insert** when any row fails the check. This includes every configuration that already refused each single insert, such as a `using` or `check` that does not compile. + - **A predicate update on a host that installs the judgement and never runs it**, for example a custom write executor in place of the engine. It is refused as an insert already is, with an `error` log saying the check was not evaluated. + + **Remedy.** To let a write store a row outside a policy's scope, declare a `check` on that policy that admits it; otherwise fix the data the write carries. + + **What does not change.** + + - A single-row insert is judged exactly as before. A by-id update is not changed by this entry; its judgement on the row it stores is its own entry (#19989). + - A predicate update still touches only the rows its scoped `where` selects. The check refuses a write; it never changes which rows are selected. + - A predicate update or array insert whose rows all pass is admitted as before. + - A system-context write is not gated. +- aa04ea2: fix(objectql)!: `engine.aggregate({ having })` walks through the shared comparand-shape face, so `having: { total: [5] }` is refused exactly as the same shape in `where` is (#19974) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what `having` accepts on `engine.aggregate` (and on the REST aggregate query that forwards it there). A `having` carrying one of the shapes below used to answer; it is now refused with `INVALID_FILTER` / 400, before any driver is asked for a row. The refusal is the shared face's own message, byte for byte the refusal the same shape gets in `where`, with the path rooted at `having` instead of `where`. It ships as `minor` under the launch-window convention for accept-set narrowings. + + The 2026-09-23 ruling on #19757 refuses an array in the equality slot at the shared comparand-shape face (`assertListComparandShapes` in `@objectstack/spec/data`) "for every driver at once". The face already ran on `where` and on each `aggregations[i].filter`. `having` never reaches a driver: the engine evaluates it itself after aggregation, on both the native `driver.aggregate()` path and the in-memory fallback. That evaluator answered every shape the face refuses. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sqlite-wasm`, over three groups with totals 500, 1250 and 20: + + | you wrote in `having` | what it did before | write instead | + |:--|:--|:--| + | `{ total: [500] }` or `{ total: { $eq: [500] } }`, at any depth under `$and` / `$or` / `$not` | kept the 500 group, because JS `500 == [500]` is true; under `$not` it kept the complement, the 1250 and 20 groups | `{ total: 500 }`, or `{ total: { $in: [500, 1250] } }` for "one of these" | + | `{ total: [] }` | kept no group | drop the condition, or write the value you meant | + | `{ customer_id: { $in: 'c1' } }` / `{ customer_id: { $nin: 'c1' } }` | `$in` kept no group; `$nin` kept every group | `{ customer_id: 'c1' }` / `{ customer_id: { $ne: 'c1' } }`, or wrap the value in a list | + | `{ customer_id: { $in: ['c1', null] } }` (or `$nin`) | the null member was compared as a value | `{ $or: [{ customer_id: { $in: ['c1'] } }, { customer_id: { $null: true } }] }` | + | `{ total: { $gt: null } }` (or `$gte` / `$lt` / `$lte`) | `$gt` / `$gte` kept every group; `$lt` / `$lte` kept none | `{ total: { $eq: null } }` for "has no value", `{ total: { $ne: null } }` for "has a value" | + | `{ total: { $between: 500 } }` or `{ total: { $between: [500] } }` | the scalar kept every group; the one-bound list kept the groups at or above it | `{ total: { $between: [min, max] } }` | + | `{ total: { $between: [null, 1000] } }`, `['', 1000]` or `[undefined, 1000]` | the blank bound compared as a value | `{ total: { $lte: 1000 } }` for a one-sided range, or the bound you meant | + | `{ total: { $between: [{ $field: 'order_count' }, 1000] } }` | the reference compared as a value | literal bounds. ⚠️ The refusal's own text suggests a two-bound `{ $field }` comparison, which `having` does not evaluate. In an operator slot the reference is compared as a value: under `$eq`, `$gt`, `$gte`, `$lt` or `$lte` it keeps no group, and under `$ne` it keeps every group. In the implicit slot (`{ total: { $field: 'order_count' } }`) it is refused as an unsupported operator (`INVALID_FILTER` / 400), though only when a grouped row carries that column: an empty grouped set evaluates nothing and comes back empty. That gap is not changed here | + + The gate is ONE call in `engine.aggregate`, ahead of both `having` evaluations, so the two paths cannot disagree, and the verdict belongs to the filter rather than to the data: an empty grouped set refuses the same `having` a populated one does. Whatever arm the shared face gains later, `having` gains with it. + + Who is affected: `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. Every `having` in this repository's docs and published skills is a scalar comparison (`{ order_count: { $gt: 5 } }` and the like), and none authors a refused shape. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. + + Not changed: scalars, `null` in the equality slot (the has-no-value predicate), `$in` / `$nin` lists including the empty list, a two-bound `$between`, and scalar ordering bounds all answer exactly as before, on both paths. `$ne` with a list is not judged by the face yet, so `having` still answers it. Neither the comparand-TYPE door nor the unknown-field and declared-type gates that `where` also passes are run on `having` by this change, which adds the comparand-shape face only (the comparand-TYPE door, #20099, and the temporal-comparand door, #20263, reach `having` in the same release). +- 4463966: fix(plugin-security, objectql)!: a by-id update's row-level `check` now holds for the row it stores, after the `beforeUpdate` chain (#19989) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows the set of writes the write gate accepts. A by-id update that is admitted today can be refused after this change. It ships as `minor` under the launch-window convention, as the multi-row check did (#19950). + + A row-level security `check` (declared on the policy, or defaulted from its `using`) is the write-side half of the policy: a row the check refuses is never stored. An insert and a predicate update are judged on the row the driver stores. A by-id update was judged only on the change set as the caller sent it, merged with the stored row, before the `beforeUpdate` chain ran. A value a hook wrote into a checked field after that point was never judged, so the row it produced could be stored outside the policy. + + A by-id update is now also judged on the row it stores: the prior row merged with the final payload, after the `beforeUpdate` chain and both readonly strips, before the statement. The engine (`@objectstack/objectql`) runs that judgement through the seam the insert and predicate update already use (`OperationContext.postHookWriteImageCheck`). The existing judgement of the change set as sent stays, so this change only ever refuses more. + + **Writes that are now refused.** Each refusal is the existing row-level CHECK denial, `403 PERMISSION_DENIED`, and nothing is stored. There is no transition switch. + + - **A by-id update whose `beforeUpdate` chain writes a checked field to a value the check refuses**, including a value derived from a field the caller changed. + - **A by-id update on a host that installs the judgement and never runs it**, for example a custom write executor in place of the engine. It is refused as an insert and a predicate update already are, with an `error` log saying the check was not evaluated. + - **An update whose payload `id` addresses no row while `where.id` addresses one**, under a policy with a `check`. The engine writes the `where.id` row while the gate had judged the payload id. It used to be written and then refused; it is now refused before anything runs. + + **Remedy.** A hook that must store a value the caller's `check` refuses does so in a separate write under a system context, or the policy declares a `check` that admits it. Otherwise fix the data the write carries. For the last case, address the row with one id: `update(object, { id, ...fields })` or `update(object, fields, { where: { id } })`. + + **What does not change.** + + - A by-id update whose hooks leave the checked fields inside the check is admitted as before. + - A change set the check refuses as sent is refused as before, even when a hook would have replaced the refused value. + - Inserts and predicate updates are judged exactly as before. + - A system-context write is not gated. +- b98fbc2: feat(objectql,spec)!: a numeric field's declared `precision` ("Total digits") is enforced on writes — a value that needs more digits is refused with field code `max_precision` (#19992) + + Clause-②: yes + + **BREAKING** — a narrowing of the write accept set on `@objectstack/objectql`, shipped as `minor` under the repo's launch-window convention (`check-changeset-no-major` refuses `major` until GA); the breaking-ness is carried by this banner and the ADR-0087 disposition, never by the level. Nothing an author writes changes spelling: `precision` keeps its key, its type and its legality. + + `FieldSchema.precision` was declared ("Total digits") and read by nothing. Every numeric column is the fixed exact decimal of `NUMERIC_COLUMN_REPRESENTATION`, the record validator had no branch for it, and the renderer reads the liveness ledger cited are gone, so `precision: 5` on a `number` stored `123456789` verbatim. The metadata designer writes the key (labelled Precision, beside Scale), so it was a setting an author could make and see nothing come of. It is now enforced at the one place a write is judged. + + **`@objectstack/objectql`** — the record validator refuses, after `min` / `max` and `max_scale`, a `number`, `currency`, `percent`, `rating` or `slider` value whose digit count exceeds a declared `precision`. It refuses with `400 VALIDATION_FAILED` and the field code `max_precision`, and it never rounds. The count is the SQL `DECIMAL(p, s)` one, taken on the stored value: + + - **With a `scale`**, digits are counted at the field's decimal places, so the integer part may carry `precision − scale` digits. `precision: 5, scale: 2` holds up to `999.99` and refuses `1234.5`, which is `1234.50`, six digits. + - **With no `scale`**, the value's own digits count. Leading zeros never count, and trailing zeros of the integer part always do: under `precision: 4`, `0.001` fits and `10000` does not. + - **On `currency`**, where `scale` is refused, an amount counts at its own decimals. The decimals themselves stay unconstrained, and only the total is bounded: `precision: 18` refuses a 19-digit amount. + - **On a fraction-stored `percent`** the count is taken two places further right (`scale + 2`, or 2 with no `scale`). The count is then the percentage-point value's digits as displayed: `precision: 4, scale: 2` holds 99.99% and refuses 100%. + + What an author with an oversize value sees: the write is refused, nothing is stored, and the field error names the declaration and the count. For example, `constraint: { precision: 5, scale: 2, actual: 6 }` renders as "Hourly rate must have at most 5 digits in total, counting 2 decimal places (got 6)" in four locales. The REST create, batch, update and import routes all answer it, and `validate` (the dry run) predicts it. Only NEW writes are judged: a stored value longer than a `precision` declared later is never re-read. Nothing changes in storage or DDL. + + The fix is one of three. Write a value that fits. Raise `precision` to the digits the field really holds. Or delete the key if the number was meant as decimal places: those are `scale`, and a currency's decimal places are its ISO 4217 minor unit. + + **`@objectstack/spec`** — `FieldErrorCode` (the ADR-0114 field-level catalog) gains `max_precision` beside `max_scale`. `BUILTIN_VALIDATION_MESSAGES` gains its two sentences, `max_precision` and `max_precision_scaled`, in `en` / `zh-CN` / `ja-JP` / `es-ES`. `FieldSchema.precision`'s describe now states the counting rule and where it is enforced. The `precision` row of the field liveness ledger is re-evidenced at the write seam. + + **Who is affected, measured** on `origin/main` `df3ba164`: no example app, template, platform object, seed or JSON fixture in the tree declares a field-level `precision`. Two test fixtures do (`precision: 5, scale: 0` on a 1–12 hours field), and every value they write fits. + + +- a08e059: fix(objectql): a `formula` field and a CEL `defaultValue` answer `current_user.can(object, verb)` from the security service (#20082) + + Clause-②: no (narrowing) + + + + **BREAKING**, for one fault path only, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA, so this banner and the ADR-0087 disposition above carry the breaking-ness). An insert row whose CEL `defaultValue` calls `current_user.can(…)` is now refused with the resolution's own error when the registered security service fails to resolve the caller's effective object permissions. Before, that default was left unset with a `warn` and the row was written. + + `@objectstack/formula` answers `can` from `EvalContext.permissions`, and neither engine site passed one. With the security plugin registered: + + - a formula field calling `current_user.can(…)` read `null` on every `find`, `findOne` and write response, and logged nothing; + - a CEL `defaultValue` calling it was left unset with the `warn` "Failed to evaluate default expression". A `required` field defaulted that way therefore refused every insert. + + **What changes.** Both sites now evaluate with the acting subject's effective object permissions. That is the map `ISecurityService.getEffectiveObjectPermissions` returns, which an option's `visibleWhen` already reads. + + - A formula field reads `true` or `false`. + - A CEL default stores `true` or `false`, so a `required` field defaulted by `can` is admitted. + + The engine asks the security service at most once per operation: once per `find` (not per row), and once per write, shared by its defaults, its `can`-gated options and the formula fields on its response. It asks only when a formula, or a default that will be applied, calls `can`, and only when the operation has an acting user. The answer is never kept past the operation. + + **When there is no map.** + + - No security service is registered. No permission data is passed, as before. A formula field still reads `null`, and each operation now logs one `warn` naming the object, the fields and `reason: 'no-permission-source'`. A default is still left unset with its existing `warn`. + - The resolution fails: it throws, or it returns a map that is not the published shape. A formula field reads `null`, and one `warn` carries the error with `reason: 'permission-resolution-failed'`, because a read is not refused over one computed field. An insert row whose `can` default needed the map is refused with the resolution's own error. Under `insertMany` only that row is refused, and the `validate()` preview rejects the same way. Rows that supply the field, and objects whose defaults never call `can`, are unaffected. + + In no case is `can()` answered `true` without a grant, or `false` from an empty map. + + `evaluateFormulaField` (and `resolveRecordTitle`, which uses it) is synchronous and passes no map, so a formula calling `can` still yields `null` there. + + No spec key, export or route is added or removed. +- fc646cf: fix(objectql)!: `engine.aggregate({ having })` takes the rest of the filter doors `where` takes — the comparand-type door, a check that `having` is a filter condition at all, refusals that no longer depend on the rows, and `{ $field }` references resolved against the aggregated row (#20099) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what `having` accepts on `engine.aggregate` (and on the REST aggregate query that forwards it there). Every refusal below is `INVALID_FILTER` / 400, raised once per query before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback. It ships as `minor` under the launch-window convention for accept-set narrowings. + + `having` took one of `where`'s filter doors, the comparand-shape face. The engine evaluates `having` itself, once per aggregated row, and that walker answered the shapes the other doors refuse — usually with no group and no error. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sqlite-wasm`, both paths, over groups with totals 500, 900 and 20 and a `max_cap` of 50, 5000 and 20: + + | you wrote in `having` | what it did before | write instead | + |:--|:--|:--| + | `{ total: { $eq: { v: 1 } } }`, `{ total: undefined }`, a `Map`, a function, a `Symbol`, a bigint beyond 2^53, or `{ $field: 5 }` as a comparand | depended on the operator. On the numeric `total`: in the implicit slot (the non-object values) and under `$eq`, `$gt` and `$gte` it kept no group; under `$ne` it kept every group; under `$lt` / `$lte` it kept no group, except the bigint, which kept every group. A `Symbol` under an ordering operator threw a raw `TypeError` with no `code` and no `status` on a populated grouped set. The same comparand in `where` is refused by the comparand-type door, and `having` now gets that door's refusal, with the path rooted at `having` | a string, number, bigint, boolean, `null` or `Date`; `{ total: { $eq: null } }` for "has no value" | + | `[['total', '>', 100]]`, `['total', '>', 100]` or `['and', …]` | kept no group: the array's index keys were read as column names | `{ total: { $gt: 100 } }`. The array form is input-only sugar declared on `where` alone, and `having` is declared as a filter condition object | + | `[]`, a string such as `'total > 100'`, a number, a boolean, a `Map` or a `Date` | kept every group, as if there were no `having` | the object form, or no `having` | + | an unknown or retired operator (`$median`, `$regex`), or an empty or non-string `$icontains` | refused only when a grouped row reached it: an empty grouped set, a condition on a column the row does not carry, or a `$or` whose earlier branch held all answered without an error | the operator the refusal names | + | `{ total: { $field: 'max_cap' } }` (a reference with no operator) | refused as an unsupported operator, again only when a grouped row carried `total` | `{ total: { $eq: { $field: 'max_cap' } } }`, or `$ne` / `$gt` / `$gte` / `$lt` / `$lte` | + | a `{ $field }` reference as an `$in` / `$nin` member, a `$contains` / `$startsWith` / `$endsWith` / `$notContains` pattern, or an `$exists` / `$null` operand | compared the reference object itself, so the answer never depended on the column it named: no group under `$in` / `$contains`, every group under `$nin` / `$notContains` / `$exists` | a literal there, or the comparison as one of the six scalar operators | + | a `{ $field }` reference naming a column the aggregated row does not have, or carrying an `addDays` that is not an integer or a `{ $field }` | compared the reference object itself, so the answer depended on the operator: no group under `$eq`; every group under `$ne`; under an ordering operator, no group against a number column, and against a text or date column an answer that follows each value's string order against the text `[object Object]` | a groupBy projection or an aggregation alias of the same query (the refusal lists them); a whole-day `addDays` | + + Not refused, but answering differently: + + - **A `{ $field }` reference as the whole comparand of `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` is now resolved against the aggregated row.** Before, it was compared as an object: `{ total: { $gt: { $field: 'max_cap' } } }` kept no group, and the same reference under `$ne` kept every group. It now keeps the groups whose `total` exceeds their own `max_cap`. The two-bound spelling the `{ $field }` `$between` refusal prescribes, `{ total: { $gte: { $field: 'max_cap' }, $lte: 1000 } }`, now works on `having`. The reference names another column of the same aggregated row: a groupBy projection or an aggregation alias. The comparison is the one the platform's in-memory filter evaluator makes and the SQL cross-field compiler matches row for row: an ordering against a missing value is false, `$eq` holds when both sides have no value, and `addDays` adds whole days to a date column. + - **The same resolution applies to a per-aggregation `filter`** (`aggregations[i].filter`), which the engine evaluates with the same walker against the source rows. `{ function: 'count', filter: { amount: { $gt: { $field: 'cap' } } } }` used to count no row. It now counts the rows whose `amount` exceeds their `cap`. + - **An exact-range bigint comparand is narrowed to a number, as it is in `where`.** `{ total: { $in: [500n, 20n] } }` kept no group, because `[500n].includes(500)` is false. It now keeps the 500 and 20 groups. The caller's `having` object is not edited. + + Who is affected: `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. Every `having` in this repository's docs and published skills is a scalar comparison against an aggregation alias (`{ order_count: { $gt: 5 } }` and the like), which answers exactly as before. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. + + Not changed: scalars, `null` in the equality slot, `$in` / `$nin` lists, a two-bound `$between`, scalar ordering bounds, `{}`, `null` and an omitted `having`, on both paths. The `$like` / `$ilike` operators are still refused on `having` (they are staged out of `FILTER_OPERATORS`), and `$ne` with a list is still answered until the shared face judges it. A `having` key that names no column is not judged by this change; since #20123 it is refused (`INVALID_FILTER` / 400) rather than keeping no group. +- 949e99b: fix(objectql)!: an engine `where` that is not a filter — a string, a number, a `Map`, a boolean, a `Date` — is refused with `INVALID_FILTER` / 400 before any driver call, and a `multi: true` update or delete no longer rewrites or removes every row for it (#20121) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on the engine's `where`, shipped as `minor` + under the launch-window convention (`check-changeset-no-major` refuses `major` + until GA; breaking-ness is carried by this banner and the ADR-0087 disposition + above, not by the level). + + **What changed.** `find`, `findOne`, `count`, `aggregate`, `update` and `delete` + on the engine now refuse a `where` that is neither absent, a filter object nor a + filter array. The refusal is thrown before a driver is asked for anything, as + `INVALID_FILTER` with `status` and `httpStatus` 400, and its message reads + "`('')`: 'where' must be a filter object or condition array, + received …. It was not applied, …" — the REST normalizer's words for the same + input. The refusal for an array that is not a filter (`[1, 2, 3]`, an infix + join) keeps its message and now carries the same `INVALID_FILTER` / 400 + envelope; it used to have no `code` and no `status`. + + **What it replaces.** A value with no filter keys fell through every check on + the seam and the driver ignored it. Measured on `driver-memory` and + `SqlDriver` (better-sqlite3) with four rows: + + - `find` / `count` / `aggregate` answered for every row, as if no `where` had + been given; `findOne` answered the first row (for a `Map`, its no-predicate + guard refused the call, with no `code`). + - `update(…, { where, multi: true })` rewrote all four rows, and + `delete({ where, multi: true })` deleted all four. That held without + `SecurityPlugin`, and with it under a system context. + - For a caller scoped by row-level security, it depended on the value. + - A string, a number, a `Map`, a `Date`, a `Set` or `true` was wrapped by the + security middleware into its `$and`, where the driver refused it + (`INVALID_FILTER`) and nothing was written. + - The empty string was not refused. The middleware's composition reads a + falsy `where` as absent and dropped it, so `find` answered all of the + member's rows, and `update(multi)` / `delete(multi)` rewrote or deleted + every row the member could reach (2 of 4 on both drivers). + - Such a `where` also stepped past the unscoped-write guard that a hook opts + into with `dispatchUnscopedMultiWrite`, because that guard treats only an + absent or `null` `where` as unscoped. + + **Unchanged.** An absent `where`, `null`, `{}` and `[]` still mean "no + filter". A filter object is accepted when it is a non-array object whose + built-in tag (`Object.prototype.toString`) is `[object Object]`. That covers a + plain object, an `Object.create(null)` object, an instance of your own class + carrying the filter on its own keys, a `Proxy` of one, and an object from + another realm, and each filters exactly as before. A well-formed filter array is + lowered as before. + + **One accepted shape is now refused.** An object that overrides + `Symbol.toStringTag`, as its own key or through its prototype chain, has a + different built-in tag. It is refused and named by that tag (for example + `received Criteria`). Before this change such an object filtered correctly on + its own keys. No producer in this repository creates one: the wire is JSON, and + the SDK builds arrays or plain objects. If yours does, pass its filter keys in a + plain object instead. The REST door already answered a non-filter `?filter=` with + `INVALID_FILTER` / 400 and is not touched. + + **Fix.** Pass the predicate you meant as a filter object, for example + `{ amount: { $gt: 100 } }`, or as a filter array (`[['amount', '>', 100]]`), and + leave `where` out when you mean every row. If the value came from somewhere + untyped, the refusal names what arrived (`received string "amount > 100"`, + `received Map`), and an un-awaited promise shows up as `received Promise`. +- 16c5a33: fix(objectql)!: a per-aggregation `filter` (`aggregations[i].filter`) on `engine.aggregate` is judged once, before any row is read — its refusals no longer depend on whether the table has rows, and it takes the shape gate and the comparand-type door `where` takes (#20122) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what a per-aggregation `filter` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query` with `aggregations`) that forwards it there. Every refusal below is `INVALID_FILTER` / 400, raised in the engine's per-aggregation loop, before any driver is asked for a row, identically on an empty and on a populated table. The refusal names the aggregation that carries the filter (`aggregations[1].filter`). It ships as `minor` under the launch-window convention for accept-set narrowings. + + The engine evaluates a per-aggregation filter itself, in the in-memory fallback, once per source row of each bucket, with the same walker `having` uses. So the walker's refusals were reached only when a row was. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sql`, and through `POST /data/:object/query` on both, over six rows in three groups, with `{ function: 'count', alias: 'n', filter }`: + + | you wrote in `aggregations[i].filter` | what it did before | write instead | + |:--|:--|:--| + | an unknown or retired operator (`{ amount: { $median: 1 } }`, `$nand`, `$regex`, `$regex` with `$options`), an operator of `where` the walker does not evaluate (`$like`, `$ilike`), a non-`$` key beside an operator, or an empty or non-string `$icontains` | refused on a populated table only. An empty table answered `200 []` with a `groupBy`, and `[{ n: 0 }]` without one. (At the REST door an empty or non-string `$icontains` was already refused at ingress, whatever the rows.) | the operator the refusal names | + | an unknown operator on a column the source row does not carry (`{ nope: { $median: 1 } }`) | counted no row, with no error, on a populated table too | the operator the refusal names | + | an unknown operator in a `$or` branch after one that held (`{ $or: [{ amount: { $gt: 0 } }, { amount: { $median: 1 } }] }`) | counted EVERY row on a populated table: the walk stopped at the branch that held | the operator the refusal names | + | `{ amount: { $field: 'cap' } }` (a reference with no operator) | refused as an unsupported `$field` operator on a populated table only | `{ amount: { $eq: { $field: 'cap' } } }`, or `$ne` / `$gt` / `$gte` / `$lt` / `$lte` | + | a `{ $field }` reference as an `$in` / `$nin` member, a `$contains` pattern, or an `$exists` operand | compared the reference object itself: no row under `$in` / `$contains`, every row under `$nin` / `$exists` | a literal there, or the comparison as one of the six scalar operators | + | a `{ $field }` reference whose `addDays` is not an integer (`1.5`, `'7'`) | counted rows by the in-memory evaluator's own reading of that offset, which `FieldReferenceSchema` refuses | a whole-day `addDays` | + | `{ amount: { $eq: { v: 1 } } }`, `{ amount: undefined }`, `{ $eq: new Map() }`, a function, an `undefined` `$in` member, a bigint beyond 2^53, or `{ $gt: { $field: 5 } }` | counted no row (each measured under the operator shown, or in the implicit slot). The same comparand in `where` is refused by the comparand-type door; the per-aggregation filter now gets that door's refusal, rooted at its own position | a string, number, bigint, boolean, `null` or `Date` | + | a `Symbol` comparand | under `$ne`, counted every row; under `$gt`, threw a raw `TypeError` with no `code` and no `status` on a populated table | a literal of one of the types above | + | a `filter` that is not a filter object: a string (`"stage = 'won'"`), a number, `true` / `false`, `''`, a `Map`, a `Date` or a `Set` | dropped: the aggregation read every row of its group, with no error. `driver-sql`'s native aggregate answered a non-empty string with `NOT_IMPLEMENTED` / 501. (The REST door already refused the JSON-expressible ones, through `AggregationNodeSchema`.) Now refused by the shape gate `where` takes, with the aggregation named (`'aggregations[1].filter' must be a filter object, received …`) | a filter object, `{ stage: 'won' }` | + | an array, `[]` included: a condition array (`[['amount', '>', 100]]`, `['and', …]`) or an empty one | a condition array counted NO row: the walker read its index positions as column names. `[]` was read as no filter. The REST door already refused every array here with `VALIDATION_FAILED` / 400. Now refused in-process too (`'aggregations[1].filter' must be a filter object, received an array (…)`), because `AggregationNodeSchema.filter` is declared `FilterConditionSchema`, which admits no array form: the condition-array sugar is lowered on `where` alone | the object form, `{ amount: { $gt: 100 } }`; omit `filter` for no filter | + + Not refused, but answering differently: + + - **An exact-range bigint comparand is narrowed to a number, as it is in `where`.** `{ amount: { $in: [400n, 20n] } }` counted no row, because `[400n].includes(400)` is false. It now counts the rows it names. The caller's aggregation entry is not edited. + + Who is affected: a per-aggregation filter reaches `engine.aggregate` from a direct engine call, from the REST aggregate query, and from the analytics service, which lowers a dataset measure's own `filter` onto it. On a populated table the refusals in the first and fourth rows above were already raised; what changes there is that an empty table refuses them too. The two shape rows are reachable in-process only: the REST door already refuses a non-object `filter` and every array. Callers in a deployment were NOT measured. + + Not changed: implicit equality, scalar ordering bounds, `$in` / `$nin` lists, a two-bound `$between`, `$icontains` / `$startsWith` with a non-empty string, `$ne: null`, `$exists`, `$null`, `$or` / `$not` composition, a `{ $field }` reference as the whole comparand of a scalar comparison (with or without a whole-day `addDays`), an exact bigint in the implicit slot, and `{}`, on both drivers, measured identical before and after. The zero-row filter `{ $not: {} }`, which the analytics service lowers FALSE to, still counts no row (a unit pin, green against the base code too). `null`, an absent `filter` and a null-prototype filter object are not refused by the shape gate, as they are not on `where`, and answer as before. +- 16c5a33: fix(objectql)!: a `having` key that names no column of the aggregated row is refused on `engine.aggregate`, instead of answering as if that column had no value in every group (#20123) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what `having` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query`) that forwards it there. A `having` key — at any depth under `$and` / `$or` / `$not` — must name a column of the aggregated row: a groupBy projection (the field name, or a structured item's `alias`) or an aggregation alias. Any other key is refused with `INVALID_FILTER` / 400, once per query, before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback, whether or not any group exists. It ships as `minor` under the launch-window convention for accept-set narrowings. + + The engine evaluates `having` itself, per aggregated row, and read a key the row does not carry as a column with no value. So a typo for an alias answered like a real query. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sql`, both paths, and through `POST /data/:object/query` on both, over three groups by `customer_id`, each with a positive `total` (a `sum` alias) beside a `count` alias `n`: + + | `having` | before | now | + |:--|:--|:--| + | `{ totl: { $gt: 100 } }`, `{ totl: 500 }`, or `{ amount: { $gt: 100 } }` (a source column the aggregated row does not project) | no group, no error | refused, naming the key, its position and the query's columns | + | `{ totl: { $ne: 1 } }`, `{ totl: { $exists: false } }`, or `{ $not: { totl: { $gt: 100 } } }` | EVERY group, no error | refused | + | `{ $or: [{ total: { $gt: 0 } }, { totl: { $gt: 100 } }] }` | every group whose `total` is positive: the walk stopped at the branch that held | refused | + | `{ $and: [{ total: { $gt: 0 } }, { totl: { $gt: 100 } }] }` | no group | refused | + | `{ 'customer_id.name': 'c1' }` (a dotted path) | no group | refused | + | `{ customer_id: 'c1' }` when the groupBy item is `{ field: 'customer_id', alias: 'cust' }` | no group: the row projects `cust` | refused; `{ cust: 'c1' }` answers | + + The refusal opens the way the REST ingress's refusal of an unknown `where` field does ("filters on 'totl' … which is not a column of the aggregated row"), names every unknown key, and lists the aggregated row's columns. Its code is `INVALID_FILTER`, the code of every other `having` refusal: the name is a column of the query's own projection, not a field of the object. It is judged after the rest of the clause: a condition on an unknown column that also carries an unknown operator (`{ nope: { $median: 1 } }`) is still refused for its operator first, as before. + + Who is affected: `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. Every `having` in this repository's docs and published skills names an aggregation alias of its own query (`{ order_count: { $gt: 5 } }` and the like), which answers exactly as before. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. + + Not changed: a key naming a groupBy column, a structured item's alias, a `count` / `sum` / `max` alias, or any of those under `$and` / `$or` / `$not`, answers exactly as before on both paths, measured identical before and after. +- 16c5a33: fix(objectql)!: in `engine.aggregate({ having })`, a `{ $field, addDays }` reference is evaluated only between two temporal columns of one class, with a numeric offset column, as `FieldReferenceSchema.addDays` declares, instead of answering by epoch-millisecond coercion (#20127) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what `having` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query`) that forwards it there. A `{ $field }` reference that carries `addDays` in one of the six scalar comparisons is now refused with `INVALID_FILTER` / 400 unless the column it filters and the column it references are both `date` or both `datetime`, and an `addDays` offset read from a column reads a numeric one. The refusal is raised once per query, before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback, whether or not any group exists. It ships as `minor` under the launch-window convention for accept-set narrowings. + + An aggregated row has no declared field types, so each column's class is now read off the query and the object's declaration, before any row exists: + + - a groupBy projection takes its field's declared type. A `day` date bucket is a `date`, because its label is `YYYY-MM-DD` on every face. A `week`, `month`, `quarter` or `year` bucket is a text label; + - `count`, `count_distinct`, `sum` and `avg` are numeric; + - `min` and `max` take the type of the field they read. + + A column whose class the declaration cannot tell is not judged: an object with no field map, a field it does not declare, or a `formula` field. + + `having` resolved every pair through `@objectstack/formula`'s evaluator, which reads a number as epoch milliseconds, while `driver-sql` refuses the same pair on `where`. The refusal reuses `driver-sql`'s sentences for the pair, naming each aggregated column's class where `driver-sql` names a stored type ("is numeric" for "is stored as numeric"), because an aggregated column is computed rather than stored. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sql`, both paths, and through `POST /data/:object/query` on both, over three groups with a `sum` alias `total`, a `max` of a number `max_cap`, `max` / `min` of two `date` fields, `max` / `min` of two `datetime` fields and a `count` `n`: + + | `having` | before | now | + |:--|:--|:--| + | `{ total: { $gt: { $field: 'max_cap', addDays: 1 } } }` (two numeric columns) | no group, no error | refused: "addDays adds whole days to a date or datetime column, and "max_cap" is numeric — an offset has no meaning on it." | + | `{ n: { $gte: { $field: 'n', addDays: 0 } } }` (a count against itself) | every group | refused, in the same words | + | a `date` column against a numeric column, a numeric column against a `date` one, or the `customer_id` groupBy text column against a `date` one | no group | refused, naming both columns and their classes: "… and a cross-class comparison answers differently in SQL (storage-class ordering) than in memory (JS coercion) — compare same-class columns." | + | a `date` column against a `datetime` one | one group | refused, in the cross-class words | + | a `datetime` column against a `date` one | two groups | refused, in the cross-class words | + | a `date` pair whose `addDays` reads a text column or a `date` column | no group | refused: "the addDays offset … is not a numeric column, and a day offset must be a number of days." | + + Who is affected: `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. No `having` in this repository's docs and published skills carries a `{ $field }` reference. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. + + Not changed, measured identical before and after on both paths: a `date` / `date` pair and a `datetime` / `datetime` pair, with a positive or negative whole-day literal or with an offset read from a numeric column (`max` of a number, or a `count`); a `day` date bucket against a `date` column; and any `{ $field }` comparison WITHOUT `addDays`, including a numeric pair and a numeric column against a `date` one. A per-aggregation `filter` (`aggregations[i].filter`) is not judged by this change: it reads the object's raw columns, and this change classifies only the aggregated row's. Since #20148 it is judged by the same class rule, against the object's declared fields. +- cfe2387: fix(objectql)!: a per-aggregation `filter` (`aggregations[i].filter`) on `engine.aggregate` takes the doors `where` takes — the temporal-comparand door, a `{ $field }` referent that must be a declared field, and the `addDays` class rule — and a `Date` bound is compared as an instant, as it is in `where` (#20148) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what a per-aggregation `filter` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query` with `aggregations`) that forwards it there. Every refusal below is `INVALID_FILTER` / 400, raised in the engine's per-aggregation loop before any driver is asked for a row, identically on an empty and on a populated table. It ships as `minor` under the launch-window convention for accept-set narrowings. + + Measured on the base through `engine.aggregate` and through `POST /data/:object/query`, on `driver-memory` and `driver-sql`, over six rows in three groups, with the same condition written as the call's `where` beside each: + + | in `aggregations[i].filter` | before | now | + |:--|:--|:--| + | a comparand a declared temporal field cannot read: `{ placed_on: { $gt: 'not-a-date' } }` on a `date`, the same as an `$in` member, a `$between` endpoint, in the implicit slot, behind a `$or` branch that holds or under `$not`; a preset name (`'last_30_days'`) on a `datetime`; `'noon'` on a `time` | counted as written: no row for the bad bound, only the readable members of the `$in`, every row for the `$between`, held-`$or` and `$not` shapes. The same condition as a `where` was refused | refused by the temporal-comparand door `where` takes, run unchanged on this position, in its words. A `{placeholder}` is stepped around and resolved, as there | + | a `{ $field }` naming no declared field of the object (`{ amount: { $gt: { $field: 'nope' } } }`), a dotted referent, or an `addDays` offset column the object does not declare | no row counted; every row under `$ne`, `$not` or a held `$or`. `driver-sql` refused the same comparison in a `where` | refused | + | `addDays` on a pair `FieldReferenceSchema.addDays` does not declare it for: two numeric fields (`{ amount: { $gt: { $field: 'cap', addDays: 1 } } }`), a numeric referent, a text or a time pair, a `date` against a `datetime`, or an offset read from a column that is not numeric | answered by the evaluator's epoch-millisecond coercion: no row on the fixture, 4 of 6 for the `date` / `datetime` pair. `driver-sql` refused the same pair in a `where` | refused | + + The two `{ $field }` rows are refused in the withholding posture `driver-sql` applies to the same comparison in a `where`: the message names the aggregation that carries the reference (`aggregations[1].filter`) and the rule, and withholds the fields, the operator and the specific reason. The engine logs the withheld diagnostic at `warn`, once per refusal. A referent is judged against the object's declared field map plus `id`, `created_at` and `updated_at`, the set the REST field gate reads; on a host whose registry holds no field map for the object, nothing is judged. + + These refusals run after the walker's own (an unknown operator, a malformed reference), so a filter carrying both gets the walker's refusal first. + + Not refused, but answering differently: + + - **A `Date` bound is compared as an instant.** `{ opened_at: { $gt: new Date('2026-02-01') } }` against the ISO text a `datetime` column holds counted no row: JS compared the `Date` with the string by coercion. `$ne` and `$nin` counted every row, and a `$between` of two `Date`s counted every row. The same bound in a `where` counted 4 of 6 on both drivers. The comparison now reads the pair through `@objectstack/spec/data`'s `utcInstantMs`, the lift `@objectstack/formula`'s evaluator applies, whenever one side is a `Date` and both sides denote an instant. Every `datetime` row and a UTC-midnight `Date` on a `date` field now count what the same bound counts in a `where`. `having` shares this comparison, so a `Date` bound in `having` keeps the groups its ISO spelling keeps on a `datetime` column; on a `date`-class column (`min` / `max` of a `date` field) a UTC-midnight `Date` follows the calendar-day reading a `where` gives (`{ $gte: new Date('2026-02-01') }` keeps the group whose max is `2026-02-01`, and so does `$eq`), which the ISO-instant text, compared as text, did not. A `Date` is not JSON, so this reaches in-process callers only. Two readings still differed from a `where` in this change alone, because the per-row comparison held no declaration (#20176 closes both in the same release, reading every temporal comparand at this position by the column's storage rule): a `Date` carrying a time of day against a `date` field, which a `where` reads as that UTC calendar day, and a `Date` against a `time` field, which is not an instant and is left as before. + + Not changed, measured identical before and after on both drivers: every `where`, `groupBy` and `having` answer outside the `Date` shapes above, and a per-aggregation filter that uses implicit equality, ordering bounds, `$in`, `$between`, `$or`, `{}`, a `{ $field }` between two declared numeric fields, `addDays` between two `date` or two `datetime` fields (literal or read from a numeric column), and a reference to `created_at` or `id`. +- 4df101c: `IObjectQLEngine` gains an optional judge-only member, `judgeFilter(objectName, where, { operation?, context? })`, and `ObjectQL` implements it (#20157, #19995 ruling C). It answers "can this filter run against this object?" without running anything: `{ ok: true }`, or `{ ok: false, code, status, message }` with the same diagnostic execution would raise. + + - **The engine's own admission, not a copy.** The judge calls the two stage functions every verb that takes a `where` already runs, in their order. First the lowering doors: the shape gate, the list-comparand shape, the virtual-field and dotted-path refusals, the text operator over a non-text field, the uninterpretable temporal comparand and the comparand-type door. Then the filter-placeholder resolver. A new door on that pipeline is judged the day it lands. + - **Nothing executes.** No driver is resolved or called, and no hook or middleware runs. The member is synchronous, so a door that needs I/O cannot join it without a contract change. Driver-level refusals and the predicates middleware composes later (RLS, sharing, tenant scope) are not judged. + - **Placeholders resolve against `context`**, exactly as execution resolves them. A context placeholder the context cannot answer (`{current_user_id}` with no user) is refused with `FILTER_TOKEN_UNRESOLVED`, never resolved to `null`. + - **`operation`** (default `'find'`) names the verb the caller will run, so the message carries that verb's prefix. The verdict is the same on every verb. + - **The message is not redacted.** It names fields, operators and comparands. A caller judging a filter it must not disclose, such as a read-scope policy, withholds the message itself. + + Optional by the ruling. A caller probes for it (`typeof ql.judgeFilter === 'function'`) and keeps its current behaviour on an engine without it. Existing engine doubles and foreign engines need no change. Execution is unchanged for every CRUD caller: same diagnostics, same order. + + Clause-②: yes +- e5cf27d: fix(objectql,core): a per-aggregation `filter` and `having` on `engine.aggregate` read a temporal comparand by the column's storage rule, the rule `where` already applies — one function, `temporalStorageForm`, now exported by `@objectstack/core` and shared by both drivers (#20176) + + A per-aggregation `filter` (`aggregations[i].filter`) and `having` are evaluated by the engine itself, over the rows (or aggregated rows) a driver returns. Both compared a temporal comparand exactly as written, while the same condition as a `where` is put into the column's storage form by the driver first. So they counted differently. Measured through `engine.aggregate` and through `POST /data/:object/query`, on `driver-memory` and `driver-sql`, over six rows: + + | in `aggregations[i].filter` (or `having`) | before | now, and the `where` twin | + |:--|:--|:--| + | an ISO instant on a `date` field, `{ placed_on: { $gte: '2026-02-01T00:00:00.000Z' } }` | 1 | 3 | + | the same instant under `$eq` | 0 | 2 | + | a bare day as the upper bound of a `datetime`, `{ opened_at: { $lte: '2026-02-01' } }`, or as a `$between` max | 2 | 3 | + | an epoch-millisecond bound on a `datetime` | 0 | 3 | + | a `Date` carrying a time of day on a `date` field, `$gte` / `$lt` / `$eq` (in-process only) | 1 / 5 / 0 | 3 / 3 / 2 | + | a `Date` on a `time` field (in-process only) | 0 | 3 | + | `having` on `max` of a `date` field with an ISO-instant bound | kept one group | keeps the two groups whose day is on or after it | + + The same holds for `$ne`, `$in` / `$nin` members, `$between` endpoints, implicit equality, an offset instant (`'…T18:00:00+08:00'`), an epoch-millisecond string, a zone-naive `'2026-02-01T10:00'`, and a short wall clock (`'11:00'`) or an ISO instant on a `time` field. On a `having` column, the class comes from the query, as the `addDays` rule already reads it: `min` / `max` take the class of the field they read, a `groupBy` projection takes its field's, and a `day` date bucket is a `date`. + + What the rule does, now in one place: + + - A comparand, and the row's value, are put into the column's storage form: canonical UTC ISO text for `datetime`, `YYYY-MM-DD` for `date`, and `HH:MM:SS` (`.fff` only when non-zero) for `time`. + - A bare `YYYY-MM-DD` used as the upper bound of a `datetime` (`$lte`, a `$between` max) means that whole day, as it does in a `where` (ADR-0053 D-D). On a `date` or `time` column it is not widened. + - A value the rule cannot read is compared as written, and so is every non-temporal column, presence tests (`$exists`, `$null`), the text operators and a `{ $field }` reference. + - An object whose declared fields the engine cannot see keeps the previous comparison. + + `@objectstack/core` exports the rule as `temporalStorageForm(value, kind)`, `kind` being `'datetime' | 'date' | 'time'`. `driver-sql` (`canonicalUtcDatetime`, `toDateOnly`, `canonicalTimeOfDay`) and `driver-memory` (`coerceTemporalValue`) each carried a copy of it; both now call it. The copies agreed on every shape measured when they were lifted, so the lift itself changes no `where`, write or read answer of either driver (#20203, in the same release, then reads an epoch-millisecond number on a `date` field as its UTC calendar day). MySQL still binds a `datetime` in its own literal spelling. + + `@objectstack/objectql`'s `applyInMemoryAggregation(rows, ast, timezone?, fields?)` takes the object's declared field map as an optional fourth argument, and a per-aggregation `filter` reads a temporal comparand by the rule only when it is given. Called without it, the function answers as before. + + Not changed, measured identical before and after on both drivers: every `where` answer, every refusal a per-aggregation `filter` or `having` gives, and every per-aggregation `filter` and `having` cell whose column is not temporal. +- 89f87f2: fix(core,objectql)!: a number or `Date` compared against a `date` field spells its year with four digits, and one whose UTC year falls outside 0..9999 is refused `INVALID_FILTER` / 400, as its ISO string already was (#20240) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what a filter on a `date` field accepts. A number or `Date` whose UTC calendar day falls in a year below 0 or above 9999 used to answer 200 with the wrong rows, or a 500 on PostgreSQL; it now answers `INVALID_FILTER` / 400. It ships as `minor` under the launch-window convention for accept-set narrowings. + + `temporalStorageForm(value, 'date')` in `@objectstack/core` spelled the year of a `Date` or an epoch-millisecond number unpadded: `999-06-15`, `10000-01-01`, `-1-01-01`. The ISO string and the bare day of the same instant spelled `0999-06-15`, and as text an unpadded year sorts as no day does. Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query` (the two doors agree), on a `date` field holding six 2026 days and 0999-06-15, `$gt` / `$lt` / `$eq`: + + | position | comparand | before: memory · SQLite · PostgreSQL | now, on all three | + |:--|:--|:--|:--| + | `where` | a number (or, in-process, a `Date`) for 0999-06-15 | 0/7/0 · 0/7/0 · 6/0/1 | 6/0/1 | + | per-aggregation `filter` | the same | 0/7/0 on all three | 6/0/1 | + | `having` on `max(date)` | the same | no group / every group / no group | the three 2026 groups / none / the 0999 group | + | `where` | a number (or `Date`) for 10000-01-01 | 6/1/0 · 6/1/0 · 0/7/0 | `INVALID_FILTER` / 400 | + | `where` | a number (or `Date`) for -1-01-01 | 7/0/0 · 7/0/0 · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | + | per-aggregation `filter` | either of those two | 6/1/0 and 7/0/0 on all three | `INVALID_FILTER` / 400 | + | `where`, per-aggregation `filter` | the ISO string of either | `INVALID_FILTER` / 400 | unchanged | + + What changes: + + - The rule pads a year from 0 to 999 to four digits, for a `Date` and a number alike, so a number, its `Date` and its ISO string spell one day. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call it. + - `isUninterpretableTemporalComparand('date', value)` is now also true for a finite number or a valid `Date` whose UTC year is below 0 or above 9999, a finite number past the `Date` range (±8.64e15) included. The engine's temporal-comparand door refuses such a comparand on `where` for every verb (`find`, `findOne`, `count`, `aggregate`, `update`, `delete`), in both the object and the array spelling, and in a per-aggregation `filter`, before any driver read. `IObjectQLEngine.judgeFilter` runs the same door. + - The write path: `create()` / `update()` on `driver-memory` or SQLite, given a year-0..999 number or `Date` for a `date` field, now stores `0999-06-15` where it stored `999-06-15`; `engine.insert` of such a `Date` does the same. PostgreSQL and MySQL already stored a three-digit year's day, but not a shorter one: under its default `DateStyle` (`ISO, MDY`) PostgreSQL stored the unpadded `9-03-04` as 2004-09-03 and refused `99-03-04` (`22008`), and MySQL 8.0 stored `99-03-04` as 1999-03-04. All three dialects now store the day. A year outside 0..9999 keeps the spelling it had on the write and read paths; no ordered form is invented for it. + + **Who is affected.** A caller that compares a `date` field with an epoch-millisecond number or a `Date` in a year below 0 or above 9999. No writer that stores or queries such a day has been measured; the reach is the public query door. + + **Fix.** Compare against a `YYYY-MM-DD` day, or a number or `Date` whose UTC calendar day falls in a four-digit year. + + **Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included (#20264, in the same release, then narrows the range to 0001..9999 on `date` and `datetime` alike: year 0 is refused too, and so is a `datetime` number, `Date` or string outside it, and the padding covers 0001..0999); every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 read back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch and #20280, in the same release, corrects by reading a MySQL `DATE` as its text. `having` reaches the same door in the same release (#20263), so a number or `Date` outside 0..9999 is refused there too. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. +- a78f731: fix(objectql)!: `having` on `engine.aggregate` takes the temporal-comparand door `where` and the per-aggregation `filter` take, so a comparand its aggregated column cannot read is refused `INVALID_FILTER` / 400 instead of keeping no group or every group (#20263) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what `having` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query`) that forwards it there. A comparand the aggregated column's storage rule cannot read used to answer 200; it is now refused with `INVALID_FILTER` / 400, once per query, before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback, on an empty and on a populated object. It ships as `minor` under the launch-window convention for accept-set narrowings. + + Measured through `engine.aggregate` and `POST /data/:object/query`, on `driver-memory`, `driver-sql` on SQLite and `driver-sql` on PostgreSQL 16, on both paths, with `groupBy` customer over four groups. The three drivers gave the same answer in every cell: + + | `having` | before | now | its `where` twin | + |:--|:--|:--|:--| + | `{ last_placed: { $lt: 'not-a-date' } }`, `last_placed` = `max(placed_on)` of a `date` field | 200, every group | 400 | 400 | + | the same under `$gt` | 200, no group | 400 | 400 | + | `'+010000-01-01T00:00:00.000Z'` on the same column, `$gt` | 200, every group | 400 | 400 | + | the number for 10000-01-01 on the same column, `$gt` (and its `Date`, in-process) | 200, every group | 400 | 400 | + | `'not-a-date'` on `min` of a `datetime` field or `max` of a `time` field, `$lt` | 200, every group | 400 | 400 | + | `'not-a-date'` on a groupBy key that is a `date` or `datetime` field or on a `day` bucket, `'noon'` on one that is a `time` field, `$gt` | 200, no group | 400 | 400 | + | the number for 10000-01-01 on a `day` bucket, `$gt` | 200, every group | 400 | 400 | + + Each one read the object once before; each is now refused with no read. + + What is judged: + + - The same walk and the same predicate, `isUninterpretableTemporalComparand` in `@objectstack/core`, that the door runs on `where` and on each per-aggregation `filter`. A change to that rule reaches `having` with it. + - The kind is the aggregated column's class, the one the `addDays` rule already reads: `min` / `max` of a `date`, `datetime` or `time` field keeps that kind, a groupBy projection of such a field takes its kind, and a `day` bucket is a `date`. `count`, `count_distinct`, `sum` and `avg`, a `week` / `month` / `quarter` / `year` bucket, and every other column are not temporal, so they are not judged. + - Every comparison and set operator's comparand, each `$in` / `$nin` member and `$between` endpoint, and the implicit-equality slot, under `$and`, `$or` and `$not`. As on `where`, a `{placeholder}` string, the empty string, `null` and a `{ $field }` reference are not judged. `having` resolves placeholders from the same release (#20334), after this door, so the refusal's remedy on a `date` or `datetime` column is the `where` refusal's and names them, e.g. `{30_days_ago}` / `{current_month_start}`. + - The text operators (`$contains`, `$notContains`, `$startsWith`, `$endsWith`, `$icontains`) are not judged. On `where` the text-operator declared-type door answers them first, and that door does not front `having`. + - The door runs after every other `having` door, so a clause one of them refuses (an unknown operator, a key naming no column, a comparand of no comparable type, an `addDays` pair, an array in the equality slot) keeps that refusal and its words. + + The refusal follows the `where` door's words. It names the `having` path, the column, what the column aggregates and its kind, and the comparand, for example: `` `having` on 'last_placed' (max(placed_on), a date column) compares against "not-a-date" at having.last_placed.$lt ``. Like the `where` refusal, it names the column's kind, and otherwise only what the query carries. + + **Who is affected.** `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. Every `having` in this repository's docs and published skills compares a numeric aggregation alias, which is not judged. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. + + **Fix.** Compare a `date` column with a `YYYY-MM-DD` day, a `datetime` column with an ISO-8601 instant, a bare day or epoch milliseconds, either one with a relative-date placeholder the resolver knows (`{30_days_ago}`, `{current_month_start}`; `having` resolves them from the same release, #20334), and a `time` column with an `HH:MM` or `HH:MM:SS` wall clock. + + **Unchanged**, measured identical before and after on the three drivers, both paths and both doors: every `where` and per-aggregation `filter` answer; every `having` on a temporal column whose comparand the rule reads (a `YYYY-MM-DD` day, an ISO instant, an epoch-millisecond number or string, an in-range `Date`, a zone-naive instant, a wall clock, an extended-year instant on a `datetime` column, which that rule reads, until #20264, in the same release, refuses a `datetime` year outside 0001..9999 through the same predicate); `{today}`-style placeholders, known or not; the empty and the whitespace-only string; `null`, `$exists`, `$in` / `$nin`, `$between`, `$not` / `$or` / `$and` and `{ $field }` references; `$contains` and `$startsWith`; every `count` / `sum` / `avg` column, a string comparand included; a `month` bucket; and every existing `having` refusal, in its words. +- 3062e50: fix(core,objectql)!: a `date` or `datetime` value names a year from 0001 to 9999, or it is refused: `INVALID_FILTER` / 400 as a comparand on `where`, a per-aggregation `filter` and `having`, and `VALIDATION_FAILED` / 400 as a written value (#20264) + + Clause-②: yes (narrowing) + + + + **BREAKING**: this narrows what a `date` or `datetime` field accepts, as a filter comparand and as a written value. A value whose year falls outside 0001..9999 used to answer 200 with the wrong rows, 201 with a non-day stored, or a 500 on PostgreSQL; it now answers 400. It ships as `minor` under the launch-window convention for accept-set narrowings. + + FROM a `date` or `datetime` value in year 0, before it, or after 9999 (`"+010000-01-01T00:00:00.000Z"`, `"-000001-…"`, `"0000-06-15"`, or the epoch-millisecond number or `Date` of such an instant) → TO `INVALID_FILTER` / 400 as a comparand and `VALIDATION_FAILED` / 400 (`invalid_date`) as a written value. The fix is one line: write a year from 0001 to 9999. + + Measured through `engine.find` / `engine.aggregate` / `engine.insert` and `POST /data/:object/query` / `POST /data/:object` (the two doors agree), over seven 2026 rows, `$gt` / `$lt` / `$eq`: + + | position | value | before: memory · SQLite · PostgreSQL 16 | now, on all three | + |:--|:--|:--|:--| + | `where` on a `datetime` | year 10000 or −1: a number, `Date` or ISO string | 7/0/0 · 7/0/0 · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | + | per-aggregation `filter`, `having` on `min` of a `datetime` | the same, `$gt` | 7 rows, every group, on all three | `INVALID_FILTER` / 400 | + | `where` on a `datetime` | year 0, every spelling | 7/0/0 · 7/0/0 · 500 | `INVALID_FILTER` / 400 | + | `where` on a `date` | year 0: a number, `Date`, ISO string or bare `0000-06-15` | 7/0/0 · 7/0/0 · 500 | `INVALID_FILTER` / 400 | + | create a `date` | `"+010000-01-01T00:00:00.000Z"` | 201, read back verbatim (not a day) · the same · 500 | `VALIDATION_FAILED` / 400 | + | create a `date` or a `datetime` | year 0, year −1, year 10000 | 201 · 201 · 500 | `VALIDATION_FAILED` / 400 | + + MySQL 8.0 answered the year-10000 and year-−1 cells with a 500 and the year-0 cells like SQLite. A `datetime` in year 10000 spells `+010000-…`, which sorts below every four-digit year as text (its `where` answer was 7/0/0 for `$gt` / `$lt` / `$eq`, where the right answer is 0/7/0); PostgreSQL's `DATE` and `timestamptz` have no year 0 (`22008`). Year 0 was answered right on memory and SQLite and a 500 on PostgreSQL; it is refused everywhere now, one answer on every driver. Each refused query or write now reaches no driver. + + What changes: + + - `@objectstack/core` exports `isOutsideTemporalYearRange(value, kind)`, the one range both doors ask. The year is the one the kind's storage rule reads: a `datetime`'s UTC year, a `date` string's leading `YYYY-MM-DD` year (otherwise the UTC year of the instant it names), never a `time`'s. + - `isUninterpretableTemporalComparand` is true for a `date` or `datetime` number, `Date` or readable string whose year falls outside 0001..9999; before, it judged only a `date` number or `Date`, against 0..9999. The engine's temporal-comparand door refuses such a comparand on `where` (every verb, both spellings), in a per-aggregation `filter` and on `having`, before any read, in words that name the year range. `IObjectQLEngine.judgeFilter` and `service-analytics`' raw-SQL decline read the same predicate. + - The record validator's `date` / `datetime` arm refuses a value outside the range on insert, update, a multi-row update and `engine.validate`, with the field's `invalid_date` code and its existing message. + - `temporalStorageForm(value, 'date')` pads a `Date`'s or a number's year to four digits for 0001..0999 only; year 0 keeps its unpadded spelling (`0-06-15`) like every other year outside the range. Only a direct driver write, which bypasses both doors, reaches that arm with year 0. + + **Who is affected.** A caller that filters on or writes a `date` or `datetime` in year 0, before it, or after 9999. No writer that stores or queries such a year has been measured; the reach is the public query and write doors. + + **Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every year from 0001 to 9999 (the edges 0001-01-01 and 9999-12-31T23:59:59.999Z included) and every 2026 control; every `time` cell; every string the rules could not read before, refused in its existing words, except a `date`-column string whose instant names a year outside 0001..9999 (`+010000-01-01T00:00:00.000Z`, `-000001-…`, an out-of-range epoch-millisecond string), refused with the same code and status on `where`, the per-aggregation `filter` and `having` but now in the year-class words; `NaN`, ±Infinity and an Invalid Date, which name no year; the `datetime` storage rule's own spelling of any instant on the write and read paths. On MySQL 8.0, a `datetime` in years 0001..0099 is still stored right and read back a century late through mysql2's instant parser (`0009-03-04T10:00Z` as `2004-09-03T10:00Z`), which ADR-0053 D-F2 keeps and this change does not touch; from year 0100 up it reads back as written. `driver-mongodb` keeps its own copy of the storage rule and is not changed; both doors sit in the engine, in front of it. +- c74de10: fix(objectql)!: a cleared number, boolean, date, datetime or time field stores `null` on every backend, and a `progress` field refuses a non-numeric value (#20308) + + Clause-②: no (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner and the ADR-0087 disposition below, never by the level). The one + narrowing: a non-numeric string written to a `progress` field is now refused + with `invalid_number` on memory and SQLite, where it used to be stored. + + ## What was wrong + + The record validator reads a blank string — `''`, or whitespace only — as + "missing", and returns before any type check. Nothing rewrote the value, so the + driver received the blank exactly as sent. The same clear of one field therefore + had three outcomes: + + - **memory and SQLite** stored `''` in a number, currency, percent, rating, + slider, progress, summary, boolean, toggle, date, datetime or time column, on + every write door (create, update, batch, `createMany`, `updateMany`). On SQLite + a stored `''` on a boolean then read back as `false`. + - **PostgreSQL** refused the statement (`invalid input syntax for type + numeric` / `boolean` / `date`), which REST answered as `500 DATABASE_ERROR` — + or as a failed row with `INTERNAL_ERROR` on the batch doors. + + objectui's edit form sends a cleared date, datetime or time box as `''`, so this + is the ordinary "clear the field and save" gesture. + + Separately, `progress` had no type check at all: a non-numeric string such as + `'abc'` was stored verbatim on memory and SQLite, and failed at the driver as a + `500` on PostgreSQL. + + ## What changes + + - **The write door reads a blank on a non-string-typed column as `null`.** Every + field whose declared type is in the spec's `NON_TEXT_STORED_VALUE_TYPES` (the + numeric types including `progress` and `summary`, `boolean`, `toggle`, + `date`, `datetime`, `time`) has a blank string replaced by `null`. It happens + at the start of `ObjectQL.insert()` and `ObjectQL.update()`, before the + middleware, the hooks, the defaults and validation read the payload, and at + the same point in `ObjectQL.validate()` (the dry run). Every REST, batch and + import door writes through those methods. The caller's own objects are never + mutated. + - **What that means for a write:** the column stores `null` on every backend, + and PostgreSQL no longer refuses the request. A blank on a `required` field is + refused with `required`, exactly as `null` is. On create, a blank takes the + field's `defaultValue` exactly as `null` does, so a blank on a required field + that declares a `defaultValue` is now accepted with the default. + - **String-stored columns are untouched.** A text, lookup or select `''` is still + stored as `''`. + - **`progress` joins the numeric type check.** A non-numeric string on it is + refused with `invalid_number`, as on `number`. No `min`, `max` or `scale` is + newly enforced on it. This is the narrowing above. + - **`summary` is exempt from that type check.** It is in the spec's + `COMPUTED_VALUE_TYPES` ("never client-written; shape is producer-owned"), so + the roll-up producer decides its value's shape. A `max` or `min` roll-up over a + date, datetime or time child field keeps recomputing on memory and SQLite as + before, and a non-numeric value written to a `summary` is not judged by this + check. A blank on a `summary` still becomes `null`. + + ## Rows already stored + + This fixes new writes only. Rows written earlier on SQLite (and on memory, + MongoDB or libSQL) may still hold `''` in such a column; on SQLite a boolean + holding `''` reads back as `false`, and one holding whitespace as `true`. + PostgreSQL never stored one. To repair a SQLite table, run this once per + non-string-typed column (a field of one of the types listed above): + + ```sql + UPDATE "" SET "" = NULL + WHERE typeof("") = 'text' AND trim("", ' ' || char(9) || char(10) || char(13)) = ''; + ``` + + +- db74b16: fix(objectql)!: a number, currency, percent, rating, slider or progress field refuses an array, a boolean or an object with `invalid_number` (#20309) + + Clause-②: no (narrowing) + + **BREAKING**: shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner and the ADR-0087 disposition below, never by the level). The + narrowing: an array, a boolean or an object whose `Number()` is finite, such as + `[500]`, `[]`, `true` or `false`, written to one of those fields is now refused + with `400 VALIDATION_FAILED` / `invalid_number`. It used to be accepted and + stored as sent. + + ## What was wrong + + The record validator judged `Number(value)` on a number-typed field, but the + write carried `value` itself. Every value that JavaScript coerces to a finite + number therefore passed the check and reached the driver unchanged: + + - **SQLite** stored `[500]` as the TEXT `'[500]'`, which a read returned as the + string `"[500]"`; `[]` as the TEXT `'[]'`; and `true` / `false` as `1` / `0`. + - **memory** stored the array or the boolean itself. + + `[5, 7]` and `{}` were already refused, because `Number()` of each is `NaN`. + + ## What changes + + - On `number`, `currency`, `percent`, `rating`, `slider` and `progress`, a value + that is neither a number nor a string is refused with `invalid_number`: an + array, a boolean, a plain object, a `Date`. This holds on every engine, REST, + batch and import write door, because they all write through the same + validator. + - A number is judged and stored exactly as before, and so are the `min`, `max` + and `scale` checks and their messages. + - A string is also unchanged. It is still judged by `Number()` and stored as + sent. Which strings a number field accepts is a separate change. + - `summary` is still not judged by this check (it is in the spec's + `COMPUTED_VALUE_TYPES`). A blank still becomes `null` before the check runs. + + ## Rows already stored + + This refuses new writes only; a stored value is never re-read by the check. + Rows written earlier on SQLite may hold such a value as TEXT in a numeric + column. To find them, run this once per number-typed column: + + ```sql + SELECT id, "FIELD" FROM "OBJECT" WHERE typeof("FIELD") = 'text'; + ``` + + OBJECT is the object name and FIELD is the field name. A match is a cell that + SQLite could not store as a number: an array written as TEXT, or a string such + as `'0x10'`. Decide its number by hand; nothing here rewrites it. + + +- 2b24b8b: fix(objectql)!: a number, currency, percent, rating, slider or progress field reads a string by the platform's numeric grammar and stores the number it denotes (#20309) + + Clause-②: no (narrowing) + + **BREAKING**: shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner and the ADR-0087 disposition below, never by the level). The + narrowing: a string that `Number()` reads as a finite number but the platform's + numeric grammar does not is now refused with `400 VALIDATION_FAILED` / + `invalid_number`. It used to be accepted. + + **What a caller sees, before → after.** One of these strings written to one of + those fields: `201`, stored as sent (memory kept the string; SQLite kept + `'0x10'` as TEXT and the others as numbers by column affinity) → `400 + VALIDATION_FAILED` with the field code `invalid_number`, nothing stored. The + REST create, batch, update and updateMany routes all answer it, and `validate` + (the dry run) predicts it. The forms: + + - a radix literal: `'0x10'`, `'0X1A'`, `'0o17'`, `'0b101'`; + - a whitespace-padded number: `' 12 '`, `'12\n'`, `'\t-3'`; + - a spelling that is not a JSON number: `'+5'`, `'.5'`, `'5.'`, `'007'`. + + The fix, when a write is refused: send a JS number, or the number's plain JSON + spelling — `'16'`, `'12'`, `'-3'`, `'5'`, `'0.5'`, `'7'`. `String(n)` of any + finite number always qualifies, exponent forms included (`'1e-7'`, `'1e+21'`). + + ## What was wrong + + This is the separate change the earlier #20309 note (arrays, booleans and + objects refused) left open. The record validator judged a string by `Number()` + while the write carried the string itself, so an accepted string reached the + driver as sent: + + - **memory** stored `'12'` as the string `'12'` and read it back as a string; + - **SQLite** stored `'0x10'` as the TEXT `'0x10'` (read back as `16`), and the + other accepted strings as numbers through the column's affinity. + + One write, two stored shapes, depending on the backend. + + ## What changes + + - A string is judged by `parseNumericString` from `@objectstack/spec/data`, the + one numeric grammar the filter door also reads: the whole string is a JSON + number literal naming a finite double. Its case table, + `NUMERIC_STRING_GRAMMAR_CASES`, decides every form. No second grammar lives in + the engine. + - An admitted string is stored as the number it denotes, on every backend: + `'12'` is written as `12`, `'1e3'` as `1000`. The rewrite runs at the write + door, before the middleware, the hooks, the `readonlyWhen` locks and + validation read the payload, so a `before*` hook now sees the number. The + caller's own object is not mutated. + - `min`, `max`, `scale` and `precision` read that number, exactly as they read + a number: `'12.50'` passes `scale: 1` (it is `12.5`), and `'150'` over + `max: 100` is `max_value`. + - This holds on every engine, REST, batch and updateMany door, and in + `validate` (the dry run). The server `/import` route is unchanged: its own + cell reader turns a numeric cell into a number before the write, so the + grammar never sees a string from it. + - A blank is still `null` before the check (#20308). `summary` is still not + judged. A number, and an array, boolean or object, are answered as before. + + ## Who sends numeric strings + + objectui's CSV import wizard, on its legacy per-row fallback (`legacyImport`, + used only when the connected client cannot reach the server `/import` route), + posts each raw cell to `create` after a client check of + `!isNaN(Number(value))`. Its parser trims cells, so of the refused forms it can + send the radix literals and the non-JSON spellings. Those rows now fail with + `invalid_number` instead of storing a string. The fix there is the wizard's + default path: import through the server `/import` route, whose cell reader + converts the number before the write. Every interactive form widget sends a JS + number or `null`, and is unaffected. + + ## Rows already stored + + This judges new writes only; a stored value is never re-read by the check. On + memory, an accepted string stayed a string until the record is next written. + On SQLite, the earlier #20309 note's query finds a numeric column holding TEXT + (such as `'0x10'`): + + ```sql + SELECT id, "FIELD" FROM "OBJECT" WHERE typeof("FIELD") = 'text'; + ``` + + OBJECT is the object name and FIELD is the field name. Nothing here rewrites + such a cell; decide its number by hand. + + +- c5d6b2b: fix(cli): `os validate` refuses a `views:` container whose own `name` disagrees with the object it binds to, the stack the server refuses at boot (#20331) + + Clause-②: yes + + A view container is registered under the object it binds to. When its own `name` + is set to something else, for example `{ name: 'order_line', object: 'my_app_order_line', list: { … } }`, + the server refuses the whole stack at boot. `os validate` used to pass that stack + at exit 0, so the first sign of the mistake was a server that would not start. + + `os validate` now runs the same check the server runs at boot and prints the same + message. The text form and `--json` both exit `1`. The `--json` failure payload lists + one `errors` entry per refused container, with `path` (for example `views[0]`, or + `packages[1].manifest.views[0]` in a multi-package stack), `code: 'VALIDATION_ERROR'`, + `httpStatus: 400` and `message`. Every other exit, and the success payload, are + unchanged. + + **Fix:** remove the container's `name`, or set it to the object name the message names. + + **New in `@objectstack/objectql` (the widening):** two new exports on the package's + root entry, `viewContainerNameRefusal(container, sourceLabel, ownerId)` and its + return type `ViewContainerNameRefusal`. The function returns the refusal the boot + registrar throws, or `undefined`. It returns `undefined` for a container whose + derived object key is empty, because the boot registrar skips that entry with a + warning and never refuses it. The boot registrar now calls this function. What it + refuses, its message and its `VALIDATION_ERROR` / `400` envelope are unchanged. + + `os build` runs the same check as well (#20393, its own entry), so it no longer + writes an artifact carrying such a container. +- 2f122b6: fix(objectql)!: `having` on `engine.aggregate` resolves `{placeholder}` tokens through the resolver `where` uses, so an unknown one is refused `FILTER_TOKEN_UNKNOWN` / 400 instead of keeping no group with a 200; and the per-aggregation `filter`'s temporal and text-operator refusals name `aggregations[i].filter` instead of `where` (#20334) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what `having` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query`) that forwards it there. A `{placeholder}` in `having` is now resolved by the same resolver, with the same refusals, as one in `where`, once per query, before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback. It ships as `minor` under the launch-window convention for accept-set narrowings. + + Measured before and after through `engine.aggregate` and `POST /data/:object/query`, on InMemoryDriver, SqlDriver on SQLite and SqlDriver on PostgreSQL 16, on both `having` paths, four groups whose `max(placed_on)` falls between 2026-01-02 and 2026-03-01: + + | `having` | before | now | the same token in `where` | + |:--|:--|:--|:--| + | an unknown token: `{ last_placed: { $gte: '{not_a_token}' } }`, or a near miss such as `'{TODAY}'`, on any column, under `$and` / `$or` / `$not` or as an `$in` member | 200, keeps no group | `FILTER_TOKEN_UNKNOWN` / 400, no read | the same refusal, in the same words | + | `'{current_user_id}'` with no user on the request, `'{current_org_id}'` with no active organization, `'{record_id}'` always | 200, keeps no group | `FILTER_TOKEN_UNRESOLVED` / 400, no read (a REST request with no user is answered 401 before it reaches the engine, as before) | the same refusal | + | a known token: `{ last_placed: { $gt: '{current_year_start}' } }` on `max(placed_on)` | compared as its own text, which sorts after every digit: keeps no group | compares as `'2026-01-01'`: keeps all four | resolved | + | any known token, as a comparand, an `$in` member or a `$between` endpoint, under `$and` / `$or` / `$not` | compared as its own text (on a date or datetime column, `$gt` / `$gte` kept no group and `$lt` / `$lte` every group) | the groups the resolved value keeps, the same as that value written out | resolved | + | `{ customer_id: '{current_user_id}' }` on a groupBy key, as user `c2` | keeps no group | keeps `c2` | resolved | + + Tokens resolve the way they do in `where`: a date macro to a `YYYY-MM-DD` day (a sub-day macro to an ISO instant) in the request's timezone, `{current_user_id}` and `{current_org_id}` from the request. A string that only contains braces (`'a{b}c'`) is not a placeholder and compares as written, as in `where`. The `having` doors run first, as `where`'s do: a clause an earlier `having` door refuses (an unknown key, an unknown operator, a comparand its temporal column cannot read) keeps that refusal, in that door's words. + + **The per-aggregation `filter`'s refusals name their position.** A comparand a declared temporal field cannot read, and a text operator aimed at a field that never holds a string, in `aggregations[1].filter` said `at where.placed_on.$gt` / `at where.amount.$contains`, a `where` the author did not write. They now say `at aggregations[1].filter.placed_on.$gt` / `at aggregations[1].filter.amount.$contains`, as that filter's list-shape and comparand-type refusals already did. The code (`INVALID_FILTER`), the status (400) and every other word are unchanged at the engine. Over REST, the message keeps its existing 500-character bound: a `date` field's refusal of a string such as `'not-a-date'`, which fitted within it, now loses the end of its remedy (`"{current_month_s…` at the shortest path), and the refusals that already exceeded the bound still do. + + **The `having` temporal refusal's remedy is `where`'s.** A string a `date` or `datetime` aggregated column cannot read (`{ last_placed: { $lt: 'last_30_days' } }` on `max(placed_on)`) was refused with a remedy that named the literal forms only (`Write a "YYYY-MM-DD" calendar day.` on a `date` column), written when `having` resolved no placeholder. Now that it resolves them, the refusal ends in the remedy the same comparand gets in `where`, which names the placeholder: `Write a "YYYY-MM-DD" calendar day, or a relative-date placeholder the resolver knows, e.g. "{30_days_ago}" / "{current_month_start}".` on a `date` column, and the `where` remedy for a `datetime` field on a `datetime` column. The code (`INVALID_FILTER`), the status (400), what is refused and every word before the remedy are unchanged, and so are a `time` column's refusal and the refusal of a number or `Date` whose year falls outside 0000 to 9999. Over REST the message keeps its 500-character bound, which the longer remedy now reaches, measured with an object named `ledger_having`. A `date` column's refusal still arrives whole in every cell measured, the longest at 498 characters (`'+010000-01-01T00:00:00.000Z'` on `max(placed_on)`), and it stays whole while the object name, the column, what it aggregates, the comparand as quoted and its path take at most 90 characters together. A `datetime` column's refusal no longer fits whatever those are, because its fixed words and remedy alone take 502 characters: over REST it now ends inside the remedy, before the placeholder it names (`…epoch milliseconds, or a relative-date pl…` on `min(opened_at)`), as the `where` refusal for a `datetime` field already did. A preset name such as `'last_30_days'` does not reach this refusal over REST: the query schema refuses it first (`VALIDATION_FAILED`), before and after. + + **Who is affected.** `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. The five `having` clauses in this repository's docs and published skills compare a numeric aggregation alias with a number, and none carries a placeholder; no runtime code or example app in this repository composes a `having`. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured. + + **Fix.** For an unknown token, write one the resolver knows (the refusal lists them: `{today}`, `{current_quarter_start}`, `{30_days_ago}`, `{current_user_id}`, …) or the literal value. For `{current_user_id}` / `{current_org_id}`, send the request with a user or an active organization. + + **Unchanged**, measured identical before and after on the three drivers, both paths and both doors: every `where` answer, placeholders and refusals included; every `having` that carries no placeholder, and its refusals in their words other than the `date` / `datetime` temporal refusal's remedy above; every per-aggregation `filter` answer other than the two refusals' paths above, placeholders included. +- 4a1df19: fix(objectql)!: a string compared against a number field must be a number: a non-numeric one is refused with `INVALID_FILTER` / 400 on `where`, a per-aggregation `filter` and `having`, and a numeric one is narrowed to its number (#20351) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what a filter may compare a number field with. A string the platform's numeric grammar does not read as a number used to answer 200 with no rows (every row under `$ne`) on memory and SQLite and a 500 on PostgreSQL; it now answers 400, before any read, on every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. `@objectstack/objectql`'s root exports are unchanged. + + FROM a string that is not a JSON number spelling of a finite number (`"abc"`, `""`, `" 12 "`, `"0x10"`, `"1,000"`, `"+5"`, `"007"`, `"Infinity"`, a `{placeholder}`), compared against a `number`, `currency`, `percent`, `rating`, `slider`, `progress` or `summary` field (or a `count` / `sum` / `avg`, or a numeric `min` / `max` / groupBy column in `having`) at the implicit comparand, `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`, or a member of `$in` / `$nin` / `$between` → TO `INVALID_FILTER` / 400, naming the field, its declared type, the comparand, its position and what is wrong with it. The fix is one line: send the number (`12`, `-3.5`, `1e3`) or a string of exactly that spelling (`"12"`). + + Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query` (the two doors agree), three rows (5, 12, 30): + + | position | comparand on a `number` field | before: memory · SQLite · PostgreSQL 16 | now, on all three | + |:--|:--|:--|:--| + | `where` | `$gt` / `$eq` / implicit / a `$in` member `"abc"`; `$eq ""` | no rows · no rows · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | + | `where` | `$ne "abc"` | every row · every row · 500 | `INVALID_FILTER` / 400 | + | `where`, over REST | `$gt "{current_user_id}"` (resolved to the user's id) | no rows · no rows · 500 | `INVALID_FILTER` / 400 | + | per-aggregation `filter` | `$gt "abc"` (`$ne "abc"`) | count 0 (3), on all three | `INVALID_FILTER` / 400 | + | `having` on `sum(amount)` | `$gt "abc"` (`$ne "abc"`) | no group (every group), on all three | `INVALID_FILTER` / 400 | + | `where` | `$gt "12"` / `$eq "12"` | **no rows** · 1 row · 1 row | 1 row, the number's answer | + + What changes: + + - A new door at the engine's single filter collection point, after the temporal-comparand door. It reads `@objectstack/spec/data`'s published contract (`numberComparandDoorVerdict` over `NUMERIC_VALUE_TYPES`, the numeric grammar, and `numberComparandRefusalMessage` for the words); the engine carries no numeric grammar of its own. + - It runs on `where` in both spellings (the filter object and the `FilterArray` sugar) on `find`, `findOne`, `count`, `aggregate`, `update` and `delete`, and on `IObjectQLEngine.judgeFilter`; on each per-aggregation `filter`, against the object's declared fields; and on `having`, over the columns the engine classes numeric. + - A numeric string is rewritten to its number, copy-on-write, before any driver or in-memory evaluator reads it. InMemoryDriver used to compare `"12"` as a string and match nothing; it now matches what `12` matches, as SQLite and PostgreSQL already did. + - A `{placeholder}` compared against a number field is refused unresolved: every filter token resolves to an id or a date, never a number. + + **Who is affected.** A caller that compares a number field with a string that is not a plain number, through any door that reaches the engine: a REST query parameter (`?amount=abc`), a `where` / `$filter` / `filter` body of `POST /data/:object/query`, or an in-process engine call. A caller that sends a number, or a numeric string such as `"12"` or `"1e3"`, is unaffected, except that memory now answers it as the other drivers do. + + **Unchanged.** A numeric comparand: the `$gt 10` controls at `where`, the per-aggregation `filter` and `having` answered identically before and after on memory, SQLite and PostgreSQL, through the engine and REST. Not judged by this door, so the filter reaches the driver as written (pinned per case in the engine suite): a string compared against a non-numeric field; `$null` / `$exists` / `$empty`; the text operators (a text operator over a number field keeps its own refusal); a `{ $field }` reference; a dotted key; a key that names no declared field. A `formula` field is still refused one door earlier with `INVALID_FIELD`. RLS, sharing and tenant predicates the security layer composes onto a query are not judged by this door; a policy predicate is judged at authoring where the host hands the rule the engine's `judgeFilter`. A boolean or a `Date` compared against a number field is outside this contract (it judges strings) and keeps its old answer, measured: `$gt true` no rows on memory, every row on SQLite and a 500 on PostgreSQL; a `Date` no rows on memory and SQLite and a 500 on PostgreSQL. +- 1c1b8c8: A grouped or aggregated query now honours `search`: the groups and every aggregated number are computed over the searched rows, exactly the rows the same query without `groupBy` / `aggregations` returns. + + Clause-②: yes (widening) — `EngineAggregateOptionsSchema` gains two OPTIONAL keys, `search` and `searchFields`, so the accept set of the aggregate options grows. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write them. + + `QuerySchema.search` (ADR-0061) is declared on the query beside `groupBy` and `aggregations`, with no carve-out. Until now, `POST /data/:object/query` accepted a body such as `{ groupBy: ["business_unit"], aggregations: [{ function: "count", alias: "count" }], search: "harbour" }` and answered it with the UNSEARCHED groups — no error and no warning — while the same body without `groupBy` / `aggregations` returned only the searched rows. A grouped list view under a toolbar search would therefore show group headers that ignore what the user typed. + + - **`@objectstack/spec`** — `EngineAggregateOptionsSchema` declares `search` (the bare string, or the structured `FullTextSearchSchema` form) and `searchFields`, identically to `EngineQueryOptionsSchema`. A parse used to strip them. + - **`@objectstack/objectql`** — `engine.aggregate()` (and `ctx.api.object(name).aggregate()`) accepts the two keys it used to refuse as unknown options, and expands them through the same ADR-0061 expansion `find()` uses: the same server-resolved searchable fields, the same `searchFields` narrowing, AND-ed with `where` before the security middlewares run. There is one expander, not two. It applies on both aggregate paths, native `driver.aggregate()` and the in-memory lowering. A key the verb still does not execute, such as `$search`, is refused as before. + - **`@objectstack/metadata-protocol`** — `findData`'s grouped branch passes `search` / `searchFields` to `engine.aggregate()`. `searchFields` is validated on that branch exactly as on the flat one: a column search cannot scan is `400 INVALID_FIELD`. + + Nothing to migrate. A caller that worked around the gap, for example by grouping a page of searched rows on the client, can send the grouped query with its `search` instead. +- 9801da1: fix(objectql)!: a `progress` field's declared `min` / `max` are enforced on writes — a value outside them is refused with `min_value` / `max_value`, exactly as on `number` (#20386) + + Clause-②: no (narrowing) + + **BREAKING** — a narrowing of the write accept set on `@objectstack/objectql`, shipped as `minor` under the repo's launch-window convention (`check-changeset-no-major` refuses `major` until GA); the breaking-ness is carried by this banner and the ADR-0087 disposition, never by the level. Nothing an author writes changes spelling: `min` and `max` keep their keys, their type and their legality on every field type. + + `FieldSchema.min` / `max` declare a check ("Checked on the WRITTEN value only") with no type exclusion, but the record validator returned for a `progress` field right after its finite-number check, above the bounds. So a `progress` field declaring `max: 100` stored `150`, and one declaring `min: 0` stored `-5`, with `201` on memory and SQLite, while a `number` field with the same bounds refused both. The bounds now bind on `progress` at the one place a write is judged. + + **What a caller sees, before → after.** A `progress` write outside a declared bound: `201`, stored as sent → `400 VALIDATION_FAILED` with field code `max_value` (`constraint: { max }`) or `min_value` (`constraint: { min }`), nothing stored. That is the `number` field's answer, envelope for envelope, in all four locales. The REST create, batch, update and updateMany routes all answer it, and `validate` (the dry run) predicts it. A value inside the bounds, or on either bound (both are inclusive), writes exactly as before. Only a write that CARRIES the field is judged: a stored value outside a bound is never re-read and survives an update that does not send it. + + The fix, when a write is refused: send a value inside the bounds, or widen or delete the field's `min` / `max` to match what it really holds. + + ⛔ Only the bounds. `scale` and `precision` stay unread on `progress`: each key's own contract names the types it binds on, and `progress` is in neither set, so `33.5` still writes into a `progress` field that declares `scale: 0`. + + **Who is affected, measured** on `origin/main` `dc0ab6a2e`: the two example-app `progress` fields (`examples/app-showcase` `showcase_task.progress` and the field zoo's `f_progress`) both declare `min: 0, max: 100`, and every value their seeds and actions write (12 seed rows, one `progress: 100` action) is inside. The console's `progress` editor, objectui's `SliderField`, drives a Radix slider bounded by the field's declared `min` / `max`, so it cannot emit a value outside them. + + +- fb38607: feat(drivers,formula,objectql): the engine's filter faces answer the staged `$empty` operator (#20444) + + Clause-②: yes (widening) + + `$empty: true | false` is declared by `@objectstack/spec` (`FieldOperatorsSchema`) with a per-type meaning: a text-like field is empty when it is null or `''`, a multi-value field (multiselect, checkboxes, tags, or a select / radio / lookup / user / file / image with `multiple: true`) when it is null or `[]`, and every other type only when it is null. `$empty: false` is the exact complement. Until now every face in this list refused it (`INVALID_FILTER` / 400), except `matchesFilterCondition`, which answered `false` for every record. **A driver or evaluator called directly now answers it:** + + - **By the field's declared type**, through the spec's one expansion (`expandEmptyOperator`): `driver-sql`'s filter compiler (and so `driver-sqlite-wasm` and `driver-turso`'s local transport, which inherit it), `driver-turso`'s remote transport, `driver-memory`'s query path (`find` / `count` / `update` / `delete`) and `driver-mongodb`'s `translateFilter` (its `find`, its aggregate `$match`). The declaration is the one each driver already receives — `initObjects` / `registerObjectMetadata` / `registerExternalObject` on the SQL family, `syncSchema` on the others. On SQL a multi-value field's empty list is tested as stored JSON per dialect (SQLite `json_array_length` behind a `json_valid` guard, PostgreSQL a `jsonb` comparison, MySQL `JSON_LENGTH`), never as an equality comparand. + - **By value** — null, a missing value, `''` and `[]` are empty (`isEmptyFilterValue`) — on the faces that read no field declaration: `@objectstack/formula`'s `matchesFilterCondition` (the RLS write-side `check`), `driver-memory`'s reference matcher, and `@objectstack/objectql`'s `having` and per-aggregation `filter`. In `having`, a `count` or `sum` holding `0` is not empty. + + **Refused, never guessed** (`INVALID_FILTER` / 400): `$empty` on a field whose declaration the driver does not hold (a table built outside its registration, a builtin column such as `id`, a field with no `type`, or `translateFilter` / `RemoteTransport` used standalone without a declaration), a multi-value field on a SQL dialect the driver does not model, and a flag that is not a boolean. `driver-memory`'s analytics (cube) face refuses `$empty` as an operator it cannot compile, as it does `$null`. + + New optional API: `translateFilter(where, temporalKind?, valueShape?)` in `@objectstack/driver-mongodb` takes a declared-value-shape resolver (type `ValueShapeResolver`), and `buildAggregationPipeline` a `valueShape` option; `RemoteTransport.setDeclaredValueShapeResolver` in `@objectstack/driver-turso`, which `TursoDriver` wires. `@objectstack/spec`'s shared `FILTER_LOGIC_CASES` table gains seven `$empty` cases: a backend that runs it answers `$empty` or goes red, and its harness must declare the fixture's columns. + + `$empty` stays staged: it is not in `FILTER_OPERATORS`, so the engine's front door still refuses it until the flip card adds it, and the view operators `is_empty` / `is_not_empty` still lower to `$null`. +- b2b6a06: fix(objectql)!: a `date` string is written in its `YYYY-MM-DD` form, or it is refused with `VALIDATION_FAILED` / 400 (`invalid_date`) — `"2026/07/15"` is no longer stored verbatim as a non-day (#20481) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what a `date` field accepts as a written value. It ships as `minor` under the launch-window convention for accept-set narrowings (`check-changeset-no-major` refuses `major` until GA; the breaking-ness is carried by this banner and the ADR-0087 disposition above). + + FROM a `date` field written as a string with no leading `YYYY-MM-DD` that `Date.parse` still reads — `"2026/07/15"`, `"07/15/2026"`, `"07/08/2026"`, `"15 July 2026"`, `"July 15, 2026"`, `"2026-7-15"`, `"2026.07.15"`, `"+002026-07-15"` → TO `VALIDATION_FAILED` / 400 with the field code `invalid_date` and its existing message, nothing written. The fix is one line: send `YYYY-MM-DD` (`"2026-07-15"`), or a JS `Date`. + + No other spelling is read for you, on purpose: `07/08/2026` is July 8 in one locale and August 7 in another, and a guess stores the wrong day silently. + + Measured through `POST /api/v1/data/:object` and a read-back, before this change, the process in America/New_York and PostgreSQL 16 at `DateStyle` `ISO, MDY`: + + | written to a `date` | memory | SQLite | PostgreSQL | now, on all three | + |:--|:--|:--|:--|:--| + | `"2026/07/15"`, `"07/15/2026"`, `"15 July 2026"`, `"2026-7-15"`, `"2026.07.15"`, `"July 15, 2026"` | 201, read back verbatim | 201, read back verbatim | 201, `"2026-07-15"` | 400 `invalid_date` | + | `"07/08/2026"` | 201, verbatim | 201, verbatim | 201, `"2026-07-08"` (a `DMY` server reads August 7) | 400 `invalid_date` | + | `"+002026-07-15"` | 201, verbatim | 201, verbatim | 500 | 400 `invalid_date` | + + A verbatim `"2026/07/15"` is not a day: it sorts and compares as text beside real days, so it falls out of every date range and every date filter. PostgreSQL's reading was its server's `DateStyle`, not the writer's. + + What changes: + + - The record validator's `date` arm asks one more question of a string: does the `date` storage rule read it? That rule (`@objectstack/core`'s `temporalStorageForm`) collapses a string with a leading `YYYY-MM-DD` to that day and hands every other string back unchanged. The question is asked through `isUninterpretableTemporalComparand`, the predicate the engine's temporal-comparand door already refuses such a `date` comparand with, so a `date` string refused on `where` is refused as a written value too. It applies on insert, update, a multi-row update and `engine.validate` (the dry run), before any driver write. + + **Who is affected.** A caller that writes a `date` field as a locale or free-form string: a REST or SDK client, a flow, an MCP `create_record` / `update_record` call written by a model. The server import (`POST /api/v1/data/:object/import`) is not affected: it already turns a date cell into `YYYY-MM-DD` before the write. A row that already holds such a string keeps it, since nothing re-reads stored rows. An update that omits the field is not affected; one that sends the old string back is refused, so re-write the field as `YYYY-MM-DD`. + + **Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through REST: + + - a string with a leading `YYYY-MM-DD`, still stored as that day: `"2026-07-15"`, `"2026-07-15T10:00:00Z"`, `"2026-07-15 10:00"`, `" 2026-07-15"`; + - a `Date`, still stored as its UTC calendar day; + - an epoch-millisecond number, still refused with `invalid_date`; + - a string `Date.parse` cannot read, still refused: `"20260715"`, `"15/07/2026"`; + - the year range 0001..9999; + - every `datetime` and `time` value. +- b057434: fix(spec)!: a boolean, a `Date` or an array compared against a number field is refused with `INVALID_FILTER` / 400 at `where`, a per-aggregation `filter` and `having`, the same as a non-numeric string + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what a filter may compare a number field with. `numberComparandDoorVerdict`, the published verdict the engine's number-comparand door consumes, judged strings only; it now also answers `door-refusal` for a boolean, a `Date` and an array, so the engine refuses them before any read, on every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. The string rule is unchanged, and so are both packages' root exports except three additions to `@objectstack/spec/data`: `NON_NUMERIC_VALUE_FORMS` and the types `NonNumericValueForm` and `NonNumericComparandForm` (the refusal's `form` and the refusal site's `value` widen to carry a non-string). + + FROM `true` / `false`, a `Date`, or an array where one value belongs (a scalar operator's comparand, or a member of `$in` / `$nin` / `$between`), compared against a `number`, `currency`, `percent`, `rating`, `slider`, `progress` or `summary` field (or a `count` / `sum` / `avg`, or a numeric `min` / `max` / groupBy column in `having`) → TO `INVALID_FILTER` / 400, naming the field, its declared type, the comparand, its position and what is wrong with it. The fix is one line: send the number the filter means (`12`, `-3.5`, `1e3`), or compare a `Date` with a date or datetime field. + + Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query`, three rows (5, 12, 30) of a `number` field: + + | position | comparand | before: memory · SQLite · PostgreSQL 16 | now, on all three | + |:--|:--|:--|:--| + | `where` | `$gt true` | no rows · every row · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | + | `where` | `$ne true` | every row · every row · 500 | `INVALID_FILTER` / 400 | + | `where` | `$between [true, 20]` | no rows · two rows · 500 | `INVALID_FILTER` / 400 | + | `where` | `$gt` a `Date` (in-process callers) | no rows · no rows · 500 | `INVALID_FILTER` / 400 | + | `where` | `$gt [1]` | a driver's own 400, in each driver's words | `INVALID_FILTER` / 400, in one set of words | + | `where` | a `$in` member `[1]` | no rows · a driver 400 · a driver 400 | `INVALID_FILTER` / 400 | + | per-aggregation `filter` | `$gt true` / `$gt [1]` (`$gt` a `Date`) | count 3 (count 0), on all three | `INVALID_FILTER` / 400 | + | `having` on `sum(amount)` | `$gt true` / `$gt [1]` (`$gt` a `Date`) | every group (no group), on all three | `INVALID_FILTER` / 400 | + | all three positions | `$gt 10` (the numeric control) | 2 rows / count 2 / both groups | the same | + + **Who is affected.** A caller that compares a number field with a boolean or an array through any door that reaches the engine (a `where`, `$filter` or `filter` body of `POST /data/:object/query`, or an in-process engine call), or with a `Date` in-process (a flow, a hook, server code). No example app, platform object or other in-repo producer compares a number field that way. + + **Unchanged.** A number, a `bigint` (narrowed or refused by the comparand-type door, as before) and `null` (the null test) are answered as before. A value outside the accepted comparand types (`undefined`, a plain object, a `Map`) keeps the comparand-type door's own refusal and words. An array at an equality slot (implicit, `$eq`, `$ne`) keeps the comparand-shape door's refusal. A boolean or a `Date` compared against a boolean, date or datetime field is not this door's subject. Driver-direct callers that never pass through the engine keep each driver's native binding. +- 92ea760: fix(objectql)!: a `date` / `datetime` string is written on a calendar day that exists, and a `datetime` string in an ISO 8601 spelling, or it is refused with `VALIDATION_FAILED` / 400 (`invalid_date`) — `"2026-02-30T10:00:00Z"` is no longer stored as March 2, and `"07/15/2026 10:00"` is no longer read in the server's zone (#20525) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what a `date` and a `datetime` field accept as a written value. It ships as `minor` under the launch-window convention for accept-set narrowings (`check-changeset-no-major` refuses `major` until GA; the breaking-ness is carried by this banner and the ADR-0087 disposition above). + + Two kinds of string are now refused with `VALIDATION_FAILED` / 400, the field code `invalid_date` and its existing message ("must be a valid date (ISO-8601)" / "must be a valid datetime (ISO-8601)"), naming the field, before anything is written: + + - **A day that does not exist**, as a `date` or as the day part of a `datetime`: `"2026-02-30"`, `"2026-02-29"` (2026 is not a leap year), `"2026-04-31"`, `"2026-02-30T10:00:00Z"`. `"2028-02-29"` is a real day and is accepted. + - **A `datetime` string in any spelling but these ISO 8601 ones**, after trimming: `YYYY-MM-DD` (midnight UTC); `YYYY-MM-DDTHH:MM[:SS[.fraction]]` followed by `Z`, a `±HH:MM` or `±HHMM` offset, or nothing (a zone-naive wall clock is UTC, ADR-0074); and `YYYY-MM-DD HH:MM[:SS[.fraction]]` with no zone (UTC the same way). Refused now, for example: `"2026/07/15 10:00"`, `"07/15/2026 10:00"`, `"15 July 2026 10:00"`, `"07/08/2026"`, `"2026-07-15 10:00 PM"`, `"Wed, 15 Jul 2026 10:00:00 GMT"`, `"2026"`, `"2026-07"`, `"2026-07-15t10:00:00z"` (lower case), `"+002026-07-15T10:00:00Z"`, and a space-separated time carrying a zone, `"2026-07-15 10:00:00+08:00"` (write it with a `T`). + + The fix is to send the value in one of those spellings — `"2026-07-15T10:00:00Z"`, `"2026-07-15T10:00:00+08:00"` or `"2026-07-15 10:00"` — or a JS `Date`. No other spelling is read for you, on purpose: `07/08/2026` is July 8 in one locale and August 7 in another, and a wall clock with no zone was read in whatever zone the server process ran in. + + What a caller sees, before and after, through `POST /api/v1/data/:object` and a read-back, the process in America/New_York, PostgreSQL 16 at `Asia/Shanghai`: + + | written | memory | SQLite | PostgreSQL | now, on all three | + |:--|:--|:--|:--|:--| + | `date` `"2026-02-30"` | 201, read back `"2026-02-30"`, a day that does not exist | the same | 500 `DATABASE_ERROR` | 400 `invalid_date` | + | `datetime` `"2026-02-30T10:00:00Z"` | 201, read back `"2026-03-02T10:00:00.000Z"` | the same | the same | 400 `invalid_date` | + | `datetime` `"2026/07/15 10:00"`, `"07/15/2026 10:00"`, `"15 July 2026 10:00"` | 201, `"2026-07-15T14:00:00.000Z"`, the server process's zone | the same | the same | 400 `invalid_date` | + | `datetime` `"07/08/2026"` | 201, `"2026-07-08T04:00:00.000Z"`, month-first in the process zone | the same | the same | 400 `invalid_date` | + | `datetime` `"2026"` | 201, `"1970-01-01T00:00:02.026Z"` | the same | the same | 400 `invalid_date` | + + The stored instant of a non-ISO `datetime` was a property of the deployment host: the same request landed hours apart on two servers. + + What changes: the record validator's `date` / `datetime` arm asks two more questions of a string, on insert, update, a multi-row update and `engine.validate` (the dry run), before any driver write. Does its leading `YYYY-MM-DD` name a day that exists (month 01..12, day up to that month's length, February 29 only in a leap year)? And, for a `datetime`, is it one of the ISO spellings above? A `Date` names a real instant and keeps its answer. + + **Who is affected.** A caller that writes a `date` or `datetime` field as a string: a REST or SDK client, a flow, an MCP `create_record` / `update_record` call written by a model. A row that already holds such a value keeps it, since nothing re-reads stored rows. An update that omits the field is not affected; one that sends the old string back is refused, so re-write it in an ISO spelling. The server import (`POST /api/v1/data/:object/import`) turns a `datetime` cell into ISO text itself before the write, so its `datetime` cells reach this check already converted; a `date` cell naming a day that does not exist (`2026-02-30`) is now a per-row `invalid_date`, where memory and SQLite stored it and PostgreSQL failed the row. + + **Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through REST: + + - a real leap day: `date` `"2028-02-29"`, `datetime` `"2028-02-29T10:00:00Z"`; + - each ISO spelling above, stored as the same instant: `"2026-07-15T10:00:00Z"`, `"2026-07-15T10:00:00+08:00"` (`"2026-07-15T02:00:00.000Z"`), `"2026-07-15 10:00"` and `"2026-07-15T10:00"` (`"2026-07-15T10:00:00.000Z"`, UTC, not the host zone), `"2026-07-15"` (`"2026-07-15T00:00:00.000Z"`); + - a `date` string with a leading real `YYYY-MM-DD`, still stored as that day; + - a `Date`, still accepted; an epoch-millisecond number, still refused with `invalid_date`; + - the year range 0001..9999; + - every `time` value, and every filter comparand (`where`, a per-aggregation `filter`, `having`). +- f03f6c7: fix(driver-memory)!: an analytics time dimension buckets by its declared `granularity`, and refuses a sub-day one instead of ignoring it (#16178) + + + + **BREAKING** in three senses, all on `driver-memory`'s analytics face, landing in + the launch window as `minor` under the lockstep convention this cluster's + siblings already use: + + - an accepted request now answers **differently**: a time dimension carrying a + `granularity` folds its rows into calendar buckets instead of returning one + group per distinct timestamp. Every affected answer was wrong before; + - a **trend query answers rows where it used to answer one total**: a + `granularity` on a member `dimensions` does not also list is now a group + column of its own, so `{measures, timeDimensions: [{dimension, granularity}]}` + — the canonical trend shape — comes back one row per bucket, carrying the + member and a `fields` entry for it, instead of a single ungrouped total with + no such column; + - an accepted request is now **refused**: `granularity: 'second' | 'minute' | + 'hour'` answers `NOT_IMPLEMENTED` / 501 instead of being silently dropped. + + ## What was wrong + + `AnalyticsQuery.timeDimensions[].granularity` is declared by the spec and a cube + dimension enumerates the granularities it offers (`granularities: ['day']`). + `memory-analytics.ts` read neither. The `$group` stage keyed on the raw field + path, so a time dimension bucketed **one group per distinct timestamp** — one bar + per row in a "new accounts by month" chart, which is the symptom #3588 + catalogued and repaired for `service-analytics`. + + Measured through the public entry against the built package, two rows on one UTC + calendar day (`2026-09-06T01:00:00Z` and `2026-09-06T23:00:00Z`) under + `granularity: 'day'`: + + | | before | after | + |:--|--:|--:| + | `granularity: 'day'` | **2 groups**, keyed on the raw instants | 1 group, `2026-09-06` | + | no granularity (control) | 2 groups | 2 groups, unchanged | + | `granularity: 'hour'` | **2 groups**, silently | `NOT_IMPLEMENTED` / 501 | + | same, but with no `dimensions` | **`{count: 2}`** — one total, no time column, and no `fields` entry naming it | `{'events.createdAt': '2026-09-06', count: 2}`, `fields` naming both | + | `granularity: 'fortnight'` past the schema door | — | `INVALID_QUERY` / 400 | + + The emitted pipeline was byte-identical across all three, which is the whole + finding: the request was accepted, no warning was emitted, and the key was inert. + + ## What it does now + + - **One forward labeller, in `@objectstack/core`.** `bucketDateKey(value, + granularity, timezone)` sits beside the inverse `bucketKeyToCalendarRange` and + the `calendarPartsInTzOrUtc` primitive it builds on, and it is now the only + statement of the rule. `BUCKET_GRANULARITIES` and `isBucketGranularity` name + the five granularities that HAVE a canonical key, so a face that must refuse + the other three quotes the accepted set instead of hand-listing it. + - **`@objectstack/objectql`'s `bucketDateValue` is a delegate**, export name and + signature unchanged, answers unchanged — pinned across granularity, timezone + and input form rather than asserted. A driver that pushes the bucket down into + SQL and this in-memory path must label one instant identically or a drill-down + breaks at the seam, and that is now one function rather than an agreement + between two. + - **A granular time dimension is a group column, listed or not.** `dimensions` + no longer decides alone what `$group` keys on: every `timeDimensions` entry + carrying a `granularity` is grouped, projected and named in `fields`, deduped + against `dimensions` on the resolved member so two spellings of one member + stay one column. This is the rule the SQL/ObjectQL face already records + (`projectedDimensions`, #4033/#5688) — one set feeding grouping, row mapping + and field metadata, because rows carrying a bucket under a `fields` list that + never mentions it is a trend chart with no x-axis. ⛔ An entry carrying only a + `dateRange` is a predicate and is still **not** projected. + - **`driver-memory` folds by granularity before its `$group`.** The pipeline is + cut at that stage: the `$match` half still runs in the driver, the bucket keys + are written onto the selected rows, and the grouping half runs over those. The + key travels under a synthetic field rather than overwriting the row's own, so a + member that is both a group key and a measure's aggregand still ranks instants + in `max()` while grouping on the label. + - **The output vocabulary is the published one** — `2026`, `2026-Q3`, `2026-09`, + `2026-09-06`, `2026-W36`. The week label is `YYYY-Www`, never the Monday's + `YYYY-MM-DD`: `DriverCapabilitiesSchema.queryDateGranularity` calls this an + output contract, and a second spelling is what breaks a drill-down across a + backend seam. + - **Bucketing honours `AnalyticsQuery.timezone`** — the same reference zone + #16042 threaded through the `dateRange` window resolver, so the window that + selects the rows and the bucket that folds them agree on where a calendar day + starts. The same two rows answer one group in UTC, two in `America/New_York` + and two in `Asia/Tokyo`. An absent zone buckets in UTC, the resolver's default. + + ⚠️ That agreement is about the PRESET arm of `dateRange`, which the resolver + reads in the reference zone. An explicit `[start, end]` array is the caller's + own **instant** window and keeps its published reading (#16179), while the + bucket beside it is always a **calendar** label (ADR-0053) — so an array + window and a bucket can still disagree about where a day starts. That + combination is legitimate and is not refused; it is stated here rather than + left to be discovered. + - **`second` / `minute` / `hour` are refused at compile**, in the ADR-0112 + envelope this driver's other capability gaps speak (`NOT_IMPLEMENTED` / 501, + the class `refusePerAggregationFilter` uses for the same reason: the query is + spelled correctly, the spec declares the value, and it is this backend that + compiles nothing for it). The canonical key vocabulary defines no label for a + sub-day bucket, so there is no string another backend's pushed-down SQL would + agree with. Passing it through unbucketed is this card's own defect wearing a + new name. + - **An undeclared granularity is a 400, not a 501.** A 501 says "this backend + cannot", which is only honest about a value the contract declares. + `TimeUpdateInterval` is checked first, so a spelling it never declared — + reachable past the schema door, where `POST /analytics/dataset/query` types + `selection.timeDimensions` without Zod-parsing them — answers `INVALID_QUERY` + / 400 rather than a 501 asserting the spec declared it. The same separation + the `dateRange` half of this face already draws (#16322 / #16041). + + ## If a caller is refused + + A stored widget or a request asking for a sub-day granularity was never bucketed + by this backend — it received one group per distinct timestamp under an ordinary + 200. Nothing that worked stops working. Ask for `day` or coarser and the answer + is a real bucket; keep the raw timestamps deliberately by dropping the key, which + is the behaviour that key used to produce by accident. +- a54ecaa: feat(objectql)!: refuse a text operator aimed at a field whose DECLARED type can never store a string — `INVALID_FILTER` 400 at the engine's field-aware door (#15773) + + + + **BREAKING** for a caller that aims `$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike` at a numeric, boolean, temporal or structured-JSON field: the call used to be answered (with `[]`, with every row for `$notContains`, or with a dialect accident) and is now refused with `400 INVALID_FILTER`. Shipped as `minor` under the repo's launch-window convention. Execution lane (2) of the maintainer ruling on #15661 (decision batch #43, option C-deny); lane (1) is the contract it consults, `@objectstack/spec/data`'s `filter-text-operator-declared-type.ts` (#15804). + + ## What was wrong + + Measured on `origin/main` `59db8a02cb` with a real `ObjectQL`, the lane-1 fixture registered and a recording driver beneath — the filter reached the driver verbatim every time: + + | filter | before | after | + |:--|:--|:--| + | `{ f_number: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_summary: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_json: { $contains: 'a' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_date: { $startsWith: '2026' } }` | `400 INVALID_FILTER` — from the #8690 TEMPORAL door, about the COMPARAND | `400 INVALID_FILTER`, naming the field's declared type | + | `{ f_text: { $contains: 'a' } }` | driver read | unchanged — driver read | + + What the driver then answered is #14079's option-A row: no row for a positive operator, EVERY row for `$notContains`. Neither answer is wrong beneath the door — it is the declared answer — and neither carries any signal that the field can never hold a string, which is the cell this closes. + + ## What it does now + + - **One door, at the engine's single filter collection point** (`lowerWhereFilterArray`), third in the ladder: comparand shape (#5869) → materializable field (#8296 / #8371) → **declared type (this)** → temporal comparand (#8690). It runs before the temporal gate deliberately: a text operator over a `date` field was already refused there, with the same wire envelope but a message about the comparand, which sends the author to fix a value that could never have made the filter runnable. + - **The refused classes are DERIVED, never re-listed**: the verdict is `@objectstack/spec/data`'s `textOperatorDoorVerdict`, over `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES`. A type added to any of those sets is refused with no change in this package. String-valued classes pass unchanged — `STRING_VALUE_TYPES`, `autonumber`, option codes (single AND multi, so `tags` keeps its substring filter), reference ids and the file classes. + - **No vocabulary is minted.** `INVALID_FILTER` already exists (`StandardErrorCode`) and is this package's filter envelope; the refusal carries `code`, `status` and `httpStatus` per ADR-0112 D5, and names the field, its declared type and the operator. + - **Both filter forms and every verb**: the object form and the `FilterArray` sugar, on `find` / `findOne` / `count` / `aggregate` / `update` / `delete`, plus the per-aggregation `filter` position (#10576's second filter slot on `aggregate`) — a door that spoke on `where` alone would answer one mistake two ways within one verb. + - **Beneath the door nothing moves.** A direct driver call never passes this seam and keeps answering `FILTER_TEXT_CASES`' option-A row (#14079), as does `having` — both pinned. + + ## Deliberately unjudged + + - **A dotted key** (`f_address.city`) — `filter-dotted-head`'s subject, whose structured-JSON heads are deliberately unjudged there (#8371). The door steps over it rather than re-closing that carve-out. + - **An unknown filter field** — the engine keeps its registry-less tolerance; this door adds no second opinion about a name. + - **A registry-less host** (`schema.fields` absent) — a door that cannot see the field map invents no verdict, the same early return both neighbours make. + - **`formula`** — judged one door earlier. `assertFilterIsMaterializable` (#8296) refuses every filter over a `formula` field with `INVALID_FIELD` 400, for the broader reason that no driver materialises a column for it, so a formula's declared `returnType` is never the deciding fact at this seam. Not reordered around: that would answer ONE condition with TWO wire codes chosen by `returnType`. The divergence from lane (1)'s formula rows is pinned by name in `engine-text-operator-declared-type-door.test.ts` rather than dropped. + + ## The ADR-0087 ledger entry, and why this is `registered` rather than `not-required` + + `@objectstack/spec` carries one new semantic migration entry, `filter-text-operator-declared-type-refused` (protocol 18) — the `patch` bump above is that entry and nothing else; no schema, no export and no published set moved. + + It is a real registration because the refused shape has an AUTHORED, STORED surface, measured on the tree rather than assumed. Nothing rejects a stored filter at load — `FilterConditionSchema` constrains no field type, and `ViewFilterRuleSchema` takes `field: z.string()` with `contains` in its operator enum — so a filter body written before this change still parses, still loads, and answers `400` the next time it is executed. Carriers measured to reach this seam: + + | stored surface | how it reaches the door | + |:--|:--| + | `sys_saved_report.query_json.filter` | `report-service.ts` runs `engine.find(report.object_name, { where: q.filter })` verbatim; every `sys_report_schedule` row reaches the same body through `report_id` | + | `FieldSchema.summaryOperations[].filter` | `summary-aggregate.ts` ANDs it with the parent-FK match and calls `engine.aggregate` | + | `ListView.filter`, tab filters (`ViewFilterRuleSchema`) | `contains` / `not_contains` / `icontains` / `starts_with` / `ends_with` lower to the same operators through `AST_OPERATOR_MAP` | + | dashboard widget / `GlobalFilter`, dataset `filter`, report `runtimeFilter`, `FieldSchema.relatedListFilter` | `FilterConditionSchema` carriers, executed through the same engine seam | + + **Not** on that list, deliberately: an RLS / sharing / tenant predicate. Those are composed onto the AST by the middleware chain AFTER this door, so the door never judges one — a policy filter cannot become a 400 nobody can act on. + + No mechanical rewrite exists, which is exactly what a `semantic` entry is for: `{ amount: { $contains: '5' } }` may have meant `$eq: 5`, a range, or a different column, and `objectstack migrate meta` must not choose. The entry ships the repair procedure and its acceptance criteria instead. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `where: { amount: { $contains: '500' } }` | `where: { amount: { $eq: 500 } }` (or `$gte` / `$lte` for a range) | + | `where: { created_at: { $startsWith: '2026' } }` | `where: { created_at: { $gte: '2026-01-01', $lt: '2027-01-01' } }` | + | `where: { is_open: { $contains: 'true' } }` | `where: { is_open: true }` | + | `where: { address: { $contains: 'Berlin' } }` | filter a stored text field, or `where: { 'address.city': { $contains: 'Berlin' } }` (a dotted path stays unjudged) | + | `where: { tags: { $contains: 'urgent' } }` | unchanged — option codes are strings and still pass | +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- d2c1d19: fix(objectql)!: `beforeUpdate` receives the record the engine intends to persist, and the caller's submission travels on `ctx.submitted` (#16344) + + + + **BREAKING** — what a `beforeUpdate` handler reads on `ctx.input.data` changes. A `readonly` field the caller supplied a value for is no longer there. The hidden set is the update strip's own subject set: author-declared `readonly: true` **and** the types whose value the runtime owns end to end (`autonumber`, implicitly read-only since #5503). `readonlyWhen` locks are deliberately not hidden. + + ## The defect + + On update, a value sent for a field declared `readonly: true` was correctly **not persisted** — and was still handed to the object's `beforeUpdate` hook. A hook deriving columns from the incoming record therefore derived them from a value the row would never contain, and **those derived writes persisted**, because they are the hook's own. + + Measured on a real app (17.2.0, sqlite, dev runtime) and reproduced in `packages/objectql/src/engine-readonly-hook-input.test.ts`. One `PATCH { actual_value: 380, target_value: 1, weight: 1 }` against a `readonly` `target_value`: + + ``` + read back: target_value 400 weight 10 ← the strip worked + score 1.2 calc_trace "实际 380 / 目标 1 … 权重 1%" + ``` + + The row's own audit trail cites values the row does not hold. No error, no warning, 200, and `droppedFields` correctly reporting the strip the whole time — every channel said the write was fine, because by every channel's own lights it was. The only way for an application to be safe was for every hook to re-read its read-only columns and ignore the incoming record, which defeats declaring them read-only at all. + + ## What changed + + **`ctx.input.data` on `beforeUpdate` is now the record the engine intends to persist.** Caller-supplied values for `readonly` fields are taken out of the hooks' view before the before phase is dispatched, and handed back at the engine's post-hook confluence — so the payload every engine-owned consumer below reads is byte-for-byte what it read before. `onFieldsDropped` reports the same fields with the same `readonly` reason, the read-only WARN says the same sentence, and `strictReadonlyWrites` refuses exactly the same writes. + + **The caller's submission travels on a new `HookContext` member, `ctx.submitted`** (`@objectstack/spec`, `HookContextSchema`) — the payload as sent, snapshotted at engine entry before any middleware or hook stamp, frozen, and documented as *diagnostics only, never the persist image*. It is bound on the update verb, both phases, and every per-row dispatch of one caller write. + + Two things deliberately did **not** move: + + - **The enforcement pass is still after the hooks.** It is the only point that can tell a hook's stamp from a caller's forgery (`hookWrittenKeys`), so a `beforeUpdate` that stamps a read-only column still lands — including when the caller echoed the same key back, which is the whole subject of #5591 / #14088. + - **`beforeInsert` is untouched.** The create side's strip position is settled post-hook by ruling C (#14147, "one semantics, one enforcement point"), and `readonlyWhen`-locked fields stay hook-writable per #9107. + + `@objectstack/plugin-auth`'s ADR-0092 identity write guard is migrated onto the new member in the same change, which is why nothing degrades: its 403 and its security warn still name the non-whitelisted field the caller sent. Without that migration the identical request answers `None of the submitted fields (—) are editable` — as strong a refusal, saying nothing about what was refused. Both readings are pinned side by side in `identity-write-guard.test.ts`. + + Ruled 2026-09-08 (maintainer, verbatim 「批 #87 同意」, director seat, decision batch #87). The refused primary was the same strip move **without** the new member: the ADR-0092 diagnostic degrades and every third-party `beforeUpdate` guard reading `ctx.input.data` degrades with it, silently. The refused alternative on the other side was documenting that hooks must read read-only columns from `ctx.previous` — which outsources the invariant to every application, the exact shape triage had already rejected. + + ## Who is affected + + A `beforeUpdate` handler that **reads a `readonly` field (declared, or runtime-owned) out of `ctx.input.data`**, on a non-`isSystem` write. Three shapes, and the fix is one line each: + + - **deriving a value from it** — this is the defect; the handler now derives from `ctx.previous`, or from `ctx.input.data` with the payload's absence meaning "unchanged", which is what it always meant for a field the caller never sent. + - **reporting on what the caller sent** (a guard naming the offending key) — read `ctx.submitted`. + - **a self-assignment** (`data.x = data.x`) on such a field — this used to promote the caller's forged value to hook-owned and commit it. It is now a **no-op**: the key the hook reads is gone, so the line re-creates it holding `undefined`, and the engine treats set-to-undefined of a hidden read-only key as the no-op it is — deleting the key, dropping it from the hook-write record, and letting the ordinary hand-back put the caller's value back for the strip to judge. **The stored value stands**, and the write reports exactly as it would with no hook at all (stripped, `onFieldsDropped`, the WARN, `strictReadonlyWrites` refusing). Persisting the `undefined` instead would erase the stored value on the memory driver and hand knex an undefined binding on a SQL one — neither is the record the engine intends to persist. That laundering route closing is intended, and it is re-pinned in both directions rather than removed. + + ⚠️ **The sharpest edge is a sandboxed `body` hook, and it is a refusal rather than a quiet change.** A body that reaches *through* such a key — `ctx.input.locked_meta.who = 'hook'` — now dereferences `undefined` and throws, and a `body`'s default `onError` is `abort`, so the caller's **whole write is rejected** where it used to succeed. What that body used to do was persist a value derived from the caller's forgery, so refusing is the correct direction; but the message the author sees is a raw `TypeError` from their own dereference and names nothing actionable. Measured end to end through a real QuickJS sandbox and pinned in `packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts`. + + A body hook cannot read `ctx.submitted`: it is deliberately not marshalled onto the sandbox face, for the reason `dispatch.scope` is not — that face is assembled key by key, and a key added there is a second published contract with its own compatibility story. A body deriving a column from a read-only field reads **`ctx.previous`**, the stored row, which is the correct source either way. + + ⚠️ **One ADR-0092 boundary changes a status code, and no in-repo object hits it today.** On an object whose UPDATE whitelist admits a field that is ALSO declared `readonly`, a whitelist-only payload now answers **403** where it used to answer **200 having written nothing**. The identity write guard composes its refused list from what the engine left it, and a whitelisted key is excluded from that list by design, so the refusal reads `None of the submitted fields (—) are editable` — naming nothing. The write was already being dropped by the read-only strip before this change; what moves is that the caller is now told, and told imprecisely. `sys_user`'s three writable fields are not read-only, so nothing in this repository is on that boundary; an application that puts a `readonly` field in an UPDATE whitelist should take it out, which is what the whitelist meant either way. + + An `isSystem` caller sees no change at all: the strip has never applied to one, and neither does the hide. +- 54e8234: **BREAKING** `engine.registerHook` refuses an engine lifecycle event the engine never dispatches (#17713) + + `registerHook(event, handler)` took `event: string`. For a name outside the dispatched set it logged a warning and then **registered the handler anyway**, so the declaration succeeded and the handler never ran — ADR-0078's prohibited fourth state (parsed, unmarked, silently inert) on an authorable seam. + + The measured cost is a data-visibility one. A consumer registered **read filters** on `beforeFindOne` and `beforeCount`, expecting them to scope single-record reads and list totals. They sat inert through every boot behind ~40 warning lines: `findOne` was still filtered (`beforeFind` covers it, so the mistake gave no signal), `count` was not — a `limit`ed list answered a `total` counting rows the caller could not see — and `aggregate` was not either, so a `groupBy` was not narrowed at all. + + Six event names now throw at registration instead of registering inert. They are the engine's own lifecycle namespace — `before`/`after` × `OperationContext['operation']` — minus the eight the engine dispatches, derived in code rather than typed out. + + FROM → TO: + + | was | now | fix | + | --- | --- | --- | + | `registerHook('beforeFindOne', h)` | throws | register on `'beforeFind'` — it already fires for `findOne` | + | `registerHook('afterFindOne', h)` | throws | register on `'afterFind'` — same reason | + | `registerHook('beforeCount', h)` | throws | `count()` dispatches no hook; use `engine.registerMiddleware(fn)` and read `ctx.operation === 'count'` | + | `registerHook('afterCount', h)` | throws | same as `beforeCount` | + | `registerHook('beforeAggregate', h)` | throws | `aggregate()` dispatches no hook; use `engine.registerMiddleware(fn)` and read `ctx.operation === 'aggregate'` | + | `registerHook('afterAggregate', h)` | throws | same as `beforeAggregate` | + + One-line fix for a read filter that was on `beforeCount` or `beforeAggregate`: move it into `engine.registerMiddleware(async (ctx, next) => { if (ctx.operation === 'count' || ctx.operation === 'aggregate') ctx.ast.where = ctx.ast.where ? { $and: [ctx.ast.where, scope] } : scope; await next(); })` — the same seam RLS and sharing already use, so the predicate reaches the driver call. + + What is **not** affected: an event name outside the engine's lifecycle namespace (`'myPlugin:flush'`) still warns and still registers, so a plugin that dispatches its own events through `triggerHooks` keeps working. Metadata-authored hooks were never exposed — `HookSchema.events` is `z.array(HookEvent)` and `HookEvent` enumerates exactly the eight dispatched names, so the gap only ever existed on the code door. + + +- a016f08: fix(plugin-security)!: the insert-side RLS `check` is evaluated on the row that will be STORED — after `beforeInsert` — instead of on the caller's raw payload (#16608) + + + + **BREAKING** — an accept-set narrowing on the write gate's refusal behaviour. An insert that is admitted today can be refused after this change. + + `check` validates the row a write produces — the PostgreSQL `WITH CHECK` analog. `update` reached that row by merging the caller's pre-image with the change set. `insert` could not: it has no pre-image, and the security middleware runs BEFORE the engine's operation, so its post-image was `opCtx.data` — the caller's payload as it arrived, ahead of `applyFieldDefaults` and ahead of every `beforeInsert` hook. + + A denormalised scoping field is exactly what an RLS predicate compares (ADR-0055: a predicate cannot traverse a lookup) and exactly what an app stamps server-side so a caller cannot choose it. Judging the raw payload therefore inverted the policy in both directions, measured on 17.3.0 with a real engine, a real `SecurityPlugin` and both drivers: + + - **the derived value was not on the image**, so the only way to pass a `check` over it was for the caller to SEND the value the hook exists to make un-sendable. Same identity, same object, same second: the payload carrying the stamped field returned 201, the identical payload leaving it to the hook returned 403 — and the stored row was identical either way. + - **the sent value WAS on the image and was then overwritten**, so an insert naming an in-scope organization while pointing at a parent in ANOTHER organization PASSED the check and stored the parent's organization. That is a row whose stored scope the caller does not hold, and it is why this is a narrowing rather than a widening: today it is admitted, after this change it is refused with nothing stored. + + Ruled 2026-09-07 (maintainer, verbatim 「同意」, director seat, summon #17, decision batch #3). The refused alternative — keep the order and write the contract that a checked field must arrive from the caller, plus an `os validate` rule to police it — institutionalises the contradiction and needs a permanent lint to hold it in place. + + **What changed, mechanically.** `OperationContext` gains `postHookWriteImageCheck` (`@objectstack/objectql`), an optional judgement an enforcement layer installs and `ObjectQL.insert` runs once the `beforeInsert` chain has produced the row — after the post-hook declared-field door, after the two value-changing strips (`stripRuntimeOwnedFields` and the static-`readonly` strip with its `defaultValue` re-default, both moved ahead of it), and before every producer with a side effect (the secret channel, the autonumber, validation, the statement), so a refusal still costs nothing. `@objectstack/plugin-security` installs its compiled `check` filter there for `insert` instead of matching it against `opCtx.data`; this entry leaves `update` unchanged, and the predicate and by-id updates move onto the same seam in their own entries (#19950, #19989). The compiled filter is still built in the middleware, where the caller's permission sets, the ADR-0090 D10 delegator's, the staged membership and the request context are all resolved — only the IMAGE is deferred. A middleware that installed the judgement and finds the seam was never run refuses the write and logs at ERROR: an unjudged write is not an allowed one. + + **Who is affected.** Only objects governed by a permission set that EXPLICITLY declares `check`, on single-row inserts by a non-system caller — the gate's existing scope, unchanged. Two behaviour changes to expect, and they are the two halves of the same correction: an insert that left a hook-stamped field off the payload now succeeds where it used to be refused, and an insert whose hook-stamped field lands outside the caller's scope is now refused where it used to be admitted. Callers that were duplicating the stamp to get past the gate keep working and may stop. + + **Two further behaviour changes the reorder produces, measured on both legs** (the reviewed order and this one), because moving the strips ahead of the seam also moves them ahead of the credential channel: + + - a caller-forged value on an author-declared `readonly` **`secret`** field is now stripped. Before, `encryptSecretFields` ran first and replaced the row's value with a `sys_secret` reference, so the strip's `Object.is` value test compared that reference against the caller's plaintext, read the difference as a hook's write, and KEPT the forgery — measured on 17.3.0's order as stored `token: "secret:sec_1"` with a `sys_secret` row minted. This is a narrowing, and it closes a hole that predates this card. + - an empty string on a `readonly` **`password`** field is stripped instead of answering `VALIDATION_ERROR`. `""` reaches the store on neither order, so the 2026-08-13 empty-credential ruling's guarantee is unchanged; only which refusal a caller sees moves, on a payload a caller was never allowed to send. ⚠️ This is the one direction of the reorder that is not a narrowing, and it is recorded rather than left to be discovered. + + **The invariant this buys, stated to its real edge.** A stored row satisfies the insert `check` on every field the CALLER can steer, whatever the caller sent. Nothing offered any such guarantee before: the check read the payload, and the payload was entirely the caller's. + + ⚠️ It is deliberately not "on every field", and the difference is a boundary rather than a hedge. Four engine-owned passes still run between the judgement and the driver, and each substitutes a platform value for whatever stands on the row: the tenant fill of an ABSENT organization column (`resolveSystemInsertOrganization` plus the driver's `injectTenantOnInsert`), `encryptSecretFields` replacing a `secret` field's plaintext with a `sys_secret` reference, `applyAutonumbers` issuing a record number, and `normalizeMultiValueFields` coercing a declared multi-value field to its stored shape. A policy whose `check` names an autonumber, a `secret` or the tenant column is therefore judging a value the platform is about to replace. None of those four is caller-steerable — which is exactly why the two passes that WERE (`stripRuntimeOwnedFields` and the static-`readonly` strip) moved above the seam instead of being explained away. +- 5c8f5af: feat(engine): `ObjectRepository.findOne` / `.update` publish their honest types — the contract's shapes, not `any` (#16786) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #15280 used for `SqlDriver.update()` and the `TursoDriver.update()` override, and PR #14434 before it on `@objectstack/driver-memory`). + + `ObjectRepository.findOne()` and `.update()` were written out with an explicit `Promise` while they have always answered what the contract declares — each one forwards, one line down, to an `IDataEngine` door that already declares the shape: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + + `IScopedObjectRepository` — the contract this class carries an `implements` clause for — declares both, and has since ruling A on #16231 landed (PR #16783). An explicit `any` satisfies that structurally, because a **wider** declared return always satisfies a narrower one: `class ObjectRepository implements IScopedObjectRepository` compiled green the whole time while the emitted `.d.ts` read `Promise`, so no caller holding an `ObjectRepository` — or reaching one through `ScopedContext` or `ObjectQL.createContext()`, both exported from this package's index — was ever asked to narrow. They are now declared as the contract declares them. No runtime behaviour changes. + + A caller that read fields off `findOne()`'s result through the `any` now narrows the `null` arm first; a caller that read `update()`'s result now separates the by-id record from the predicate-form count. The in-repo census for this change was one file, repaired alongside. + + `updateById` is deliberately untouched: `IScopedObjectRepository.updateById` itself declares `Promise`, so the class already matches its contract and there is no drift to repair on this side. That half stays open on #16786. + + +- 5b5bd36: fix(objectql)!: the create-side static-`readonly` strip judges the user-writable `managedBy` buckets, as update already did (#15719) + + + + **BREAKING** for a non-system caller that CREATES a static `readonly` column on an + object declaring `managedBy: 'platform'`, `'config'` or `'system-data'` under a name + outside the reserved `sys_` namespace: the forged value used to be persisted and is + now stripped, with the field's own `defaultValue` re-derived (#3043) and the drop + reported on the usual channels (`readonlyStripWarning` at `warn`, `onFieldsDropped` + under reason `readonly`, `strictReadonlyWrites` refusing before any driver dispatch). + That is exactly what the same caller's UPDATE of the same column already did. Shipped + as `minor` under the repo's launch-window convention. + + ## The census, both halves — neither one is the whole reading + + **(b) is greater than zero, so the affected objects are named.** 20 shipped objects sit + in the three now-judged buckets and carry a static `readonly` column between them — 64 + columns in all: + + - `platform` (6 objects, 14 columns): `sys_attachment`, `sys_business_unit`, + `sys_business_unit_member`, `sys_comment`, `sys_report_schedule`, `sys_saved_report` + - `config` (6 objects, 29 columns): `sys_capability`, `sys_email_template`, + `sys_permission_set`, `sys_position`, `sys_sharing_rule`, `sys_webhook` + - `system-data` (8 objects, 21 columns): `sys_approval_delegation`, + `sys_notification_preference`, `sys_notification_subscription`, + `sys_notification_template`, `sys_position_permission_set`, + `sys_user_permission_set`, `sys_user_position`, `sys_user_preference` + + **And the shipped behaviour delta is ZERO.** Of the 81 object declarations in this tree + carrying `managedBy`, **none** is named outside `sys_` — every one of the 20 above + included — so the namespace test, which this change does not touch, keeps all of them + exempt exactly as before. `sys_metadata_history.recorded_by`, seeded by a direct + non-system `engine.insert` from the metadata repository, is doubly exempt + (`engine-owned` bucket **and** `sys_`) and is pinned as such. + + ⚠️ **Read both halves together.** "Behaviour-free" on its own overstates it — the + population the narrowing reaches is real and named above, and an app that declares one + of those buckets on its own object gets the strip. The population on its own + understates it — not one shipped object changes behaviour on this release. What moves + is the contract for **app-authored** objects, which is the population the ruling is + about. + + ## What was wrong + + `staticReadonlyInsertSubject` returned `null` for `managedBy` set to **anything**, + carried over byte-for-byte from the deleted DataProtocol ingress copy on ADR-0086 / + #3004 grounds: those columns have their own 403 guards, and a silent strip must not + swallow the payload the guard exists to reject. The argument is sound and the bucket + list was not. `managedBy: 'system-data'` means "platform-defined schema, + **admin/user-writable data**" by its own definition, and `object.zod.ts` says in the + same breath that it "carries no such guard; its writes are adjudicated by the + delegated-admin gate / RLS / permission sets". So the create side skipped the strip on + objects whose data is the user's, while the update side stripped them — and #14147's + "one semantics, one enforcement point" was not literally true on that population. + + ## What it does now + + The exclusion follows its reason. `null` is returned for the `sys_` namespace, and for + the three buckets whose columns really do carry a fail-closed refusal: + + | bucket | its own refusal | the create-side strip | + |:--|:--|:--| + | `engine-owned` | ADR-0103 engine-owned write guard | steps around it | + | `append-only` | ADR-0103, same guard (locked default) | steps around it | + | `better-auth` | ADR-0092 identity write guard | steps around it | + | `platform` | none — full user CRUD by default | judges it | + | `config` | none — admin-authored, writable by default | judges it | + | `system-data` | none — "admin/user-writable DATA" | judges it | + + An **unrecognised** bucket value is deliberately not read as platform-internal: the one + legacy value that can still arrive is `'system'`, retired in protocol 17 (#3355) and + converted to `'system-data'` — a judging bucket — so exempting unknowns would exempt + precisely the rows that conversion targets. The partition is pinned against + `@objectstack/spec`'s own enum, so a seventh bucket fails a test instead of landing + silently on one side. + + The ruling's fallback ("leave it, if those buckets' readonly columns already carry + their own 403") does not apply: of the 64 columns above, 14 are the ADR-0086 + package-provenance family (`package_id`, `managed_by`, `customized`, `drift_status`, + `drift_detail`, `is_system`, all on `config` objects) and the other 50 are `id` / + `created_at` / `updated_at` stamps, which that guard does not reach. + + `@objectstack/lint` mirrors this predicate to decide which objects its create-verb + `flow-update-readonly-field` / `hook-api-update-readonly-field` findings may describe, + and is narrowed in the same stroke — a lint that kept the wider exemption would go on + suppressing findings for a strip that now really happens. + + ⛔ The UPDATE path is untouched, and so is `beforeInsert`'s post-hook strip position. + The asymmetry is closed by moving CREATE toward UPDATE. + +### Patch Changes + +- 63b6818: fix(objectql): a failed `find` reports at `warn`, not `error` — the caller was already told (#17212) + + `find` ends its `catch` with `throw e`, and one frame down `reportFindFailure` + logged every failure it did not classify as a missing table at ERROR. AGENTS.md + → *Degradation log levels* names that exact shape and forbids it: "a failure + handed to the CALLER is not a degradation at all … Do not bolt a `logger.error` + onto such a site." It is the read-door twin of the write doors' move to `warn` + (#17052). + + **Nothing else about the entry moved.** Same message (`Find operation failed`), + same `object` meta, and the message and stack still travel with it: the `Logger` + contract gives an `Error` slot to `error`/`fatal` only, so the engine builds the + `{ error: { message, stack } }` meta that slot used to build — handing the Error + to `warn` as meta would have serialised `{}`, because those two fields are + non-enumerable. The throw is unchanged, and so is the missing-table branch, + which stays at `debug` without a stack. On the SQL read path the fault is also + reported one frame down on the driver's own `warn` line, as before. + + If you grep your logs for this message, keep the message and drop the level + from the pattern. If you alert on error-level lines from `@objectstack/objectql`, + a failed read no longer raises one — the read's exception still does. +- ada2869: fix(metadata-protocol): `insertManyData` reports the dropped-field union at BATCH level instead of naming rows it cannot identify (#17290) + + + + **BREAKING** — `@objectstack/metadata-protocol`'s `insertManyData` no longer hangs + `droppedFields` on each entry of `outcomes`; the response itself carries it, beside + `outcomes`, exactly as `createManyData` already does. A TypeScript consumer that read + the per-row member stops compiling, and the compiler names the site. The set reported + is the same set — what is gone is a per-row attribution that could not be computed + here and was wrong whenever it mattered. Nothing authored or stored changes shape. + + **What it got wrong.** Every create-side strip is the engine's, and its + `onFieldsDropped` event is the UNION over the batch — the listener signature + carries no row index. This seam reconstructed a row set from that union by + asking which rows SUPPLIED each dropped name + (`[...engineDropped].filter((f) => f in supplied)`), on the stated premise that + "the strip only removes keys the ROW ITSELF supplied, so a dropped name belongs + to exactly the rows whose supplied payload carried it". Maintainer ruling C + falsifies the premise: the static-`readonly` strip runs INSIDE `engine.insert`, + AFTER the `beforeInsert` hooks, and exempts keys a hook itself assigned — + recorded per row (`hookWrittenKeys: rowHookWrittenKeys[i]`). So in a batch where + a hook stamps a protected key on some rows and not others: + + - row A supplied `approval_status`, no hook write ⇒ stripped, enters the union; + - row B supplied `approval_status`, its hook re-assigned it ⇒ **kept and + written**; + - and row B's outcome carried `droppedFields: [{ fields: ['approval_status'] }]` + on a record that still held `approval_status`. + + A row the batch culled before the strip ran (a per-row validation failure) was + named on the same test, having dropped nothing at all. + + ⇒ A wrong attribution costs the reader a wrong investigation, and the import + surface — which prefers this path over `createManyData` — is the consumer most + likely to act on it while reconciling what landed. + + **Why not attribute per row instead.** The honest set is `{rows whose payload + carried N}` minus `{rows whose beforeInsert hook assigned N}`, and the second + half is computed per row upstream but does not cross this seam. The outcome's + own `record` cannot stand in for it: a stripped `readonly` field is RE-DEFAULTED + over exactly the keys the strip took, and a stripped `autonumber` is refilled by + `applyAutonumbers` — so on both, the key is PRESENT on the row that really did + drop it, and a post-hoc "is the key still there?" check would delete true + attributions while leaving the hook-exempt false one standing. Comparing values + fails on the very case `hookWrittenKeys` exists for: the hook assigning the + value the caller also sent. Restoring row precision means giving the engine's + drop report a per-row channel, not a reconstruction at the call site. + + **Prose corrected with it**, by CLAIM rather than by spelling — the docblock + that authorised the inference is the thing that re-authorises the next author: + `insertManyData`'s own docblock and `createManyData`'s parenthetical + (`@objectstack/metadata-protocol`), `mergeDroppedFieldEvents`'s closing + sentence, `engine.insertMany`'s docblock claim that "a caller holding the input + rows can attribute each name back to the rows that carried it" + (`@objectstack/objectql`, TSDoc emitted into its published `.d.ts`), and + `CreateManyDataResponseSchema.droppedFields`'s `.describe()` parenthetical + (`@objectstack/spec`, a string printed AT the customer). + + **Unchanged.** `updateManyData` and `batchData` keep per-row `droppedFields`, + and they always could: each row is its own `engine.update` / `engine.insert` + call, so that call's events are that row's — earned mechanically, not inferred. + `createManyData`'s aggregated shape is untouched. No strip changes, no row + changes, and the same field names are reported. +- eea7ccc: `GET /packages` reports every function a package declares. A bare callable `functions` entry is normalised to the declared form at the assembly boundary, so the registry record no longer drops it (#17518). + + `SchemaRegistry.installPackage` stores `toRecordManifest(manifest)`, a structural JSON projection whose rule is "a live object reached the record" and deliberately ⛔ not a key denylist. That rule treated the two authored `functions` spellings unequally through no fault of its own: a DECLARED entry (`{ handler, effect: 'writes' }`) is a plain object, so it survived with its callable dropped, while a BARE callable entry IS the callable, so the whole key vanished. `examples/app-showcase` ships one of each, so a package declaring two functions was reported as declaring one — a machine-readable read door under-reporting by construction. + + - **The repair is at the assembly boundary, ⛔ not in the projection.** `installPackage` makes the two spellings structurally equal before projecting, so the structural rule is untouched and no key name is special-cased. The projection then leaves `{ effect }` for both. + - **⛔ No ref is minted.** `objectstack build` mints refs with `uniqueName(base, taken)` and dedupes by function identity, so a ref minted in the registry is not guaranteed to be the one `build` mints — a record could assert a handler that resolves in no sibling module. An absent `handler` is the honest statement "declared here, not serialisable", which is exactly what `@objectstack/spec`'s new `RecordStagePackageBodySchema` declares. + - **⛔ No entry is dropped**, either: under-reporting by design was the other arm, and it also throws away the `effect` declaration, the one half that survived. + - The caller's manifest is never mutated — `ObjectQL.registerApp` and the hook binder read the live callables off that object — and a copy is made only when an entry really needed rewriting. The ARRAY form is untouched: its entries are objects carrying their own `name`, so the projection already kept them. +- 758ac40: refactor(types): one `isNativeErrorName` reader, so three doors cannot disagree about what a crash is (#17681) + + The predicate that decides whether a sandboxed body's `throw` is a business + REFUSAL (4xx, the author's words relayed) or a CRASH (5xx, the words withheld) + had **three byte-identical copies** — measured, one distinct 74-character regex + literal across three packages: + + | copy | package | its stated reason for being a copy | + |:--|:--|:--| + | `isScriptFaultMessage` | `@objectstack/rest` (`error-response.ts`, #7543) | the original | + | `isScriptCrash` | `@objectstack/objectql` (`hook-withheld-readonly-fault.ts`) | this package must not depend on `@objectstack/rest` for a regex | + | `sandboxRefusalMessage` | `@objectstack/runtime` (`sandbox/quickjs-runner.ts`, #17265) | rest declares one export subpath and re-exports nothing from `error-response` | + + ⭐ **Every reason is a statement about reaching `@objectstack/rest`, and none of + them survives moving the rule.** `@objectstack/types` now owns + `isNativeErrorName` — the name list, the `^` anchor, and the deliberate absence + of a bare `Error:`. All three packages already depend on it and it depends on + none of them, so this fold **adds zero dependency edges** and cannot cycle. + + ⚠️ The hazard was never style. One copy learning a new native error name and the + others not means the same throw is a refusal at one door and a crash at the + next — a crash message **leaked** at one boundary and **withheld** at another. + #16013's argument for extracting exactly this class applies verbatim: the + classification is the part nobody may get wrong, so one *tested* helper is worth + more than N correct copies that must each stay correct forever. + + ⛔ **No behaviour changes at any door, per case.** This is a pure refactor and + the three WRAPPERS are deliberately NOT folded, because they are not the same + shape and merging them would move a door's answer: + + - rest asks a trimmed message and answers a boolean; + - objectql asks **two** slots — `err.name` **or** `err.innerMessage.trim()` — + because a code hook and a sandboxed body carry the native name in different + places; + - runtime asks the trimmed inner message and answers the **message**, not a + boolean. + + What the three share is the predicate, so the predicate is what moved. Each call + site keeps its own slot choice and its own trimming, and `isNativeErrorName` + deliberately does **not** trim for its callers — a contract pinned in its test. + + **Shipped rather than `skip-changeset`**, measured on a real build: all four + packages publish `files[]: ["dist", …]`, and the built `dist` of each carries + the new call — `@objectstack/types` 4 files, `@objectstack/objectql` 4, + `@objectstack/rest` 3, `@objectstack/runtime` 2 — with `looksLikeInternalErrorLeak` + scoring 4 in `types/dist` as the lit control and a nonexistent symbol scoring 0. + The retired copies are gone from the artifacts too: the regex literal scores + **0** in `rest/dist`, `objectql/dist` and `runtime/dist`, and **2** in + `types/dist` (the ESM and CJS bundles). + + `@objectstack/types` takes **minor**: a new export is a purely additive widening + of a published surface, which is at least minor whatever the commit type says. + The three consumers take `patch` — their artifacts change, their behaviour does + not. +- 17005cc: docs(objectql): the per-row `before*` docblock states the #16074 rule — a row-invariant-in-effect rewrite is ADMITTED (#17975) + + `dispatchPerRowBeforeHooks`'s docblock (ADR-0058 Addendum II, clause D3) still + said per-row `previous` was supplied *"so a guard can REFUSE the write (throw), + not so a rewrite can be aimed"*, and a test comment in + `bulk-write-per-row-hooks.test.ts` said the same. Ruling #16074, landed in + `@objectstack/spec` by PR #17249, retired that: a per-row `previous`-conditioned + rewrite is admitted when its written KEY SET is the same on every matched row + and is assigned IN PLACE, kept safe by the engine's + `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099). Key-set divergence, a + per-row VALUE and a row-conditioned REPLACEMENT of `ctx.input.data` all stay + outside the contract. + + This is published text, not an internal comment: JSDoc on a `private` member + survives `.d.ts` emit. Measured in the shipped `@objectstack/objectql@17.4.0` + tarball — the retired sentence is present in six published files, including + `dist/util-Dw5ZTIII.d.ts:3554`, on a member of the `ObjectQL` class that both + the `.` and `./core` entrypoints export. Every consumer's editor surfaces it on + hover, so as soon as spec's changeset is consumed the two packages would state + opposite contracts. + + No behaviour change: the engine already follows the new rule, and the three + shipped provenance stamps (`email-template-provenance.ts`, + `sharing-rule-provenance.ts`, `webhook-provenance.ts`) all assign in place. The + admitted shape's coverage already exists in + `multi-update-hook-key-divergence.test.ts`; the test comment now points at it. + + Graded `patch`: the act moves published PROSE. It adds no exported symbol, no + key and no accepted value — the accept set was widened by PR #17249 in + `@objectstack/spec`, not here — so this PR declares no clause ②. +- 922c755: docs(objectql): the hook-wrapper docblocks state the per-row `before*` contract (#18331) + + Two docblocks in `hook-wrappers.ts` stated the RETIRED batch model in the + present tense: `pickRecordPayload`'s said a predicate (`multi: true`) bulk + update's `before*` dispatch "still fires once for the batch with no prior row", + and `pickPreviousPayload`'s "when `previous` is ABSENT" list named that same + dispatch as an absence case because "it fires ONCE for N matched rows". + + Ruling #16074 / ADR-0058 Addendum II (clauses D1/D2) retired that model, and the + engine already implements the replacement: `dispatchPerRowBeforeHooks` dispatches + `before*` once per matched row on the single-record shape and binds that row's + pre-image (`previous: coerceBooleanFields(schema, row)`). So both phases of a + predicate write now merge, materialise and bind `previous` exactly as a + single-record write does; what remains unbound is any update-shaped context + whose prior row is not in hand, which is what the second docblock now says. + + This is published text, not an internal comment. Measured against the shipped + `@objectstack/objectql@17.4.0` tarball: the first docblock is emitted verbatim + onto the exported `hookRecordState` declaration (`dist/util-Dw5ZTIII.d.ts:8039`, + and the matching `.d.mts`), reachable from both the `.` and `./core` + entrypoints, so every consumer's editor surfaces the retired sentence on hover. + The second docblock does NOT ship — `pickPreviousPayload` is module-private and + appears in `dist/` only as an `{@link}` reference — but it is the source a + maintainer reads, and two docblocks one screen apart stating opposite contracts + is the drift this repairs. + + No behaviour change and no assertion change: prose only. + + Graded `patch`: the act moves published PROSE. It adds no exported symbol, no + key and no accepted value — the accept set was widened by PR #17249 in + `@objectstack/spec`, not here — so this PR declares no clause ② (`Clause-②: no`). +- ef67b47: fix(objectql,driver-turso): "is this field multi-valued" is `isMultiValueField` here too — the `domain:engine` half of the one-definition ruling (#18408) + + Maintainer ruling, 2026-09-13 (decision batch #128 item 5, option 1′): there is + ONE definition of 「is this field multi-valued」, `@objectstack/spec`'s + `isMultiValueField`, and storage follows it. `driver-sql` was aligned by #17469 + and `os generate migration` by #18199. These four sites were the remainder: they + read `field.multiple` raw, which answers `true` on types the predicate calls + single-valued (`text`, `master_detail`, `tree`, `number`, …) and `false` on the + inherently-multi option types (`multiselect` / `checkboxes` / `tags`) that carry + no flag at all. + + **`@objectstack/driver-turso`** — `RemoteTransport.mapFieldTypeToSQL` short- + circuited its whole type switch on the raw flag, so a `{ type: 'number', + multiple: true }` field was declared `TEXT` in remote mode while the SAME + driver's local transport (`SqlDriver`, aligned since #17469) declared `float`: + one declaration, two storage classes, chosen by which URL the deployment + happens to hold. New columns for such a field are now declared by the field's + own type. Genuinely multi-valued fields (`lookup` / `select` / `file` / `image` + / `user` flagged `multiple`, and the inherently-multi option types with or + without it) are unchanged — still the JSON-array `TEXT` column. + + **`@objectstack/objectql`** — three sites, all deciding the SHAPE of a stored + value: + + - the option-derived insert default (`resolveOptionDefault`) assembles an array + for a multi-valued field. A `multiselect` / `checkboxes` / `tags` field with an + option marked `default: true` and no `multiple` flag was defaulted to a bare + scalar, which this engine's own validator then refused as + `invalid_type_array` on the insert the default was resolved for; + - the referential-integrity dependents probe (`referenceProbeFilter`) composes + `$contains` for a multi-valued reference and bare equality for a scalar one. A + `master_detail` flagged `multiple` is outside `MULTI_CAPABLE_TYPES`, so every + aligned storage side builds it a scalar column — the probe now asks that column + the question it can answer, instead of a substring match repaired afterwards by + a second narrowing pass; + - the cascade-delete `multiValued` verdict, which that probe, the `set_null` + write shape and the required-FK escalation all read. + + **What a deployment feels.** Only declarations that are already off-spec move: + `FieldSchema` has refused `multiple` on a non-capable type since #17469 (ADR-0087 + semantic entry 18), so these shapes now reach the engine and the driver only + through doors that never run it — `registerExternalObject` / `initObjects` and a + driver's own unvalidated input. Existing columns are untouched: the remote + transport only ever declares types for columns it is creating. A deployment + holding one of these shapes should re-declare the field — drop the flag if the + value really is single, or move the field to a multi-capable type if it is not — + which is the same prescription entry 18 already carries. + + No export is added, removed or renamed in either package, and no authorable key + changes its name, type or optionality. +- 4fef271: Installing a package no longer reverts an operator's most recent enable/disable. The install contract is now 「缺省 = 保持,有旗 = 设置」: an install that was not asked to move the lifecycle state does not move it (#18877). + + `SchemaRegistry.initialDisabledPackageIds` is a boot hydration input — filled once, before any registration, from the durable disable file, and never updated by `enablePackage` / `disablePackage`. It was nevertheless consulted by every `installPackage` call, so once an id was in the boot seed set, every re-install within that boot re-landed it DISABLED whatever the operator had most recently done. Since the durable write started following the row the door returns (#18752), that stopped being memory-only: + + ```text + boot 1 operator disables the package → disk lists the id + boot 2 seeded from disk; the package installs disabled + PATCH /packages/:id/enable → 200, registry true, disk CLEARED + install(m, { overwrite: true }) (no flag) → the seed still listed the id + → row disabled, disk written DISABLED + boot 3 the operator's enable is gone, with no error anywhere + ``` + + Reachable with nothing exotic: disable → restart → enable in Studio → an SDK upgrade with `overwrite`. + + - **`installPackage` reads the ROW first.** An existing row keeps its own `enabled`, `status` and `statusChangedAt`; the boot seed decides only for an id that has no row yet (boot hydration and a genuinely fresh install). A fresh id the seed never named still lands enabled, the declared default. + - **`enableOnInstall` now sets the state in BOTH directions.** `true` ⇒ `enablePackage`, `false` ⇒ `disablePackage`, and an ABSENT flag makes no lifecycle call at all — previously only `false` was read, and the `true` case was carried by the re-install restamping every row enabled. The bare (unwrapped) body form still honours nothing: no schema declares the key there. + - **`DELETE /packages/:id` clears both records.** The id leaves the boot seed set with its row, and its durable disable entry is cleared, so the next install of that id is a fresh install. Previously the durable record was immortal — a delete left a disable behind that named a package that no longer existed. + - ⚠️ **Behaviour change for a flag-absent re-install of an EXISTING row.** It used to return the package to the declared default (enabled); it now preserves what the row says. An upgrade flow that relied on a re-install to clear a disable must now send `enableOnInstall: true` — the same key, the same door, now honoured in that direction. A fresh install is unaffected. + + Maintainer decision batch #157 item 5, letter C. Item 4 of that ruling re-rules the #18058 F1 pin 「flag-absent re-install clears the durable disable」 to 「preserves」; F1b stands unchanged. + + Clause-②: no +- 875e9ad: `buildSummaryIndex` no longer drops a declared `summary` field silently when the roll-up's `reference` carrier cannot be read — the skip now reports itself at `error`, naming the field, the consequence and the fix (#19082). + + The child→parent foreign key is resolved by scanning the child object's `master_detail` / `lookup` fields for one whose `reference` names the parent. That comparison read the carrier raw (`cd.reference === parent.name`), so a carrier **no reader can read** — a non-string, where `FieldSchema.reference` declares an optional string — compared `false` against every name, `fkField` stayed unset, and + + ```ts + if (!fkField) continue; // can't resolve the relationship — skip + ``` + + removed the roll-up from **both** summary indexes. `recomputeSummaries()` then had nothing to do after every insert / update / delete of the child, so the parent's stored summary value kept whatever it held while each of those writes reported success, and nothing anywhere said so. It is the second way this one function invents *"nothing to recompute"*; the first, its registry read, was closed as #9154. + + - **⛔ The resolution rule is deliberately unchanged.** Loosening the comparison would trade a silent stall for a **mis-matched foreign key**, which is more expensive: a roll-up quietly aggregating the wrong children reads exactly like a correct one. PR #18503 recorded this site in its C2 list and the #18550 round left it there on purpose; that boundary still stands. What ends is only the silence. + - **The carrier is read through the one arbiter**, `referenceCarrierOf` — the same accessor #19080 routed the two delete-cascade seams through. Its refusal is **caught** here rather than propagated, because this is a *scan* looking for the foreign key across every relation field: a propagating refusal on one unreadable field would hide a readable sibling that really is the FK, turning a roll-up that works today into a hard failure of every write to that child. + - **`error`, not `warn`**, and said once per index build rather than once per write. A persisted summary that silently stops tracking its children while every write keeps reporting success is the durability class, and the line it prints carries both halves an operator needs: what is not being maintained and will not recompute, and the two ways to fix it — spell the carrier as the target object's name, or name the FK explicitly with `summaryOperations.relationshipField`. + - **Absence is untouched.** `undefined`, `null` and `''` mean "this field names no target", which is a legal thing to declare; they skip silently exactly as before. Every readable carrier resolves exactly as before. + + No schema changed, no key was added or removed, and nothing that resolved before resolves differently now. `engine-summary-index-unreadable-carrier.test.ts` pins both directions — the unreadable carrier reporting its skip, and a normal `reference` still resolving `fkField` — because without the second one, a change that simply stopped resolving anything would look identical to a fix. +- 95fb417: **The declared `zod` floor moves from `^4.4.3` to `^4.6.1`**, because on zod below 4.6.1 the three standard error formatters — `z.treeifyError()`, `error.format()` and `error.flatten()` — cannot render a refusal these packages actually emit (#19581). + + Clause-②: no + + **What breaks below the new floor.** All three formatters walked an issue's `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming a member of `Object.prototype` was answered by the prototype and no node was ever created. Two different failures follow: + + | path shape | what happened on `^4.4.3` | + |:---|:---| + | terminal element (`['assignments','__proto__']`, `['x','toString']`) | the inherited member is adopted as the node, then `node._errors.push(...)` runs on it — `TypeError: Cannot read properties of undefined (reading 'push')` | + | non-terminal element (`['__proto__', …]`) | the walk continues **into** `Object.prototype` and writes the next segment onto it — the message is silently dropped from the returned tree and the process gains a global prototype key | + + **Why it reached this platform's consumers.** `@objectstack/spec` refuses a `__proto__` key on its open-key authoring surfaces, and that refusal's issue path is `['assignments','__proto__']` — precisely the terminal shape. Anything that formatted one of these refusals for display crashed on it, and the crash was in the formatter, not in the guard. The guards themselves are unchanged and still necessary: 4.6.1 still drops a `__proto__` key from `z.record()` and `.catchall()` output, which is what they exist to refuse. + + **What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. + + `@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. + + ## The second half the floor move needs: an unknown key refuses TERMINALLY again + + From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no + longer aborts the shape that raised it. Two things follow, and both were + measured on this package with the same bodies on 4.4.3 and 4.6.1: + + 1. **A closed shape's own refinements now run after the refusal**, adding a + second complaint that contradicts the first. + 2. **A union containing that shape loses its envelope.** zod's + `handleUnionResults` returns a single non-aborted member's issues + *unwrapped* instead of raising `invalid_union`, so the union's message + becomes whichever branch zod judged closest. + + At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong + branch's prescription. Writing `type: 'page'` on a ViewItem answered: + + ``` + Unrecognized key(s) on this view container: `viewKind`, `config`. + • `viewKind` belongs to a single VIEW, not to the container. Wrap it: … + ``` + + — naming neither `page` nor its removal. It now answers, as it did before: + + ``` + config.type: 'page' was removed from the list-view `type` enum in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … + ``` + + **What an upgrading consumer must do.** Nothing. No key or value changed + status: everything this package accepted before it accepts now, and everything + it refused it still refuses. What changed is which of several competing + complaints an author reads, and that a refusal behind a union is again + reported as `invalid_union` with its branches, which is what `z.treeifyError()` + and this package's own `formatZodError` expand. + + ⚠️ A closed shape declared with a bare `z.object(…).strict()` or + `z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT + get this and will still collapse its union. Build closed authoring shapes with + `strictObject`, or re-declare an existing one through `closedObject`. +- afc3b64: fix(objectql): a lookup can no longer point at a record in another organization + + When a user in one organization saved a `lookup` (or any other reference field) whose id named a record that exists only in a **different** organization, the write was accepted and the cross-organization link was stored. An id that exists nowhere was refused. So a caller could tell "this id belongs to another organization" apart from "this id does not exist", without being able to read that record. + + The reference check now looks only where the caller's organization can see. A record in another organization is treated exactly like a record that does not exist: the write is refused with the existing `VALIDATION_FAILED` error, and the field error code is `reference_not_found`. This applies on create, on update by id and on bulk update. The two cases now give the same response. + + What does not change: + + - References inside the caller's own organization resolve as before. + - References to platform-global objects (`tenancy: { enabled: false }`) and to federated (`external`) objects still resolve from any organization. The engine already sends no tenant to the driver for those objects. + - The check still ignores row-level security. A user can still link to a record they are not allowed to read, as long as it is in their organization (or in their membership set under the `group` tenancy posture). Whether they may create that link at all is still decided by the permission layer. + - System-context writes (seed replay, package install, provisioning) are still not checked. + - The dangling-reference audit (`inspectDanglingReferences`) still checks existence across all organizations. + + One case to check if your deployment uses it: an object made global only by the deployment's `platformGlobalObjects` setting (not by its own `tenancy: { enabled: false }`) is still scoped by organization when the database is read. So a reference to a record of that object that another organization created is now refused. This matches what the caller already gets when reading that object directly. Records with no organization still resolve. +- 2bbb462: fix(objectql): `parent.*` validation predicates no longer read another organization's header, and, outside the `group` posture, the dangling-reference audit reports cross-organization references + + **Master-detail `parent.*` predicates.** A detail object's `requiredWhen` and `readonlyWhen` can read the master-detail header as `parent` (for example `requiredWhen: "parent.status == 'locked'"`). The engine read that header without the caller's organization, so it found the header in any organization. A user in one organization who put another organization's header id on a detail record got an answer that depended on that header's fields: on create, a `locked` header answered "`note` is required" while an `open` one answered `reference_not_found`; on update, a `readonlyWhen` field was dropped or kept, and a strict write was refused or not. That leaked one bit of another organization's record per write. + + The header is now read only where the caller's organization can see, like the reference check. A header outside the caller's tenant scope (another organization; under the `group` posture, an organization outside the caller's membership set) is treated exactly like a header that does not exist: `parent` is left unbound. On create and on repoint, the write gets the same `VALIDATION_FAILED` / `reference_not_found` answer whatever the header's state. A `readonlyWhen` that needs `parent` stays locked, as it already did for a header that cannot be read. A `requiredWhen` that needs `parent` is skipped, as it already was in that case. + + What does not change: + + - Headers in the caller's own organization bind as before, so their `requiredWhen` and `readonlyWhen` rules apply as before. + - Headers of platform-global masters (`tenancy: { enabled: false }`), federated masters, and headers with no organization still bind from any organization. + - The header read still ignores row-level security. + - System-context writes with no organization (seed replay, provisioning) still read the header from any organization. + + One case to check: a detail record that already points at a header in an organization the editor cannot see (another organization; under the `group` posture, one outside the editor's membership set), written before this fix or by a system-context write, now edits as if its header were missing. Its `parent`-scoped `readonlyWhen` fields stay locked, and its `parent`-scoped `requiredWhen` rules are not enforced. Outside the `group` posture, the dangling-reference audit below now reports such records, so you can find and fix them. Under `group` it does not; see the audit paragraph below. + + **Dangling-reference audit.** `inspectDanglingReferences` (the read-only audit that runs with the lifecycle sweep) checked each stored reference across all organizations. A reference to a record in another organization therefore looked fine, even though the write path now refuses it. Outside the `group` tenancy posture, the audit now checks each record's references in that record's own organization. A cross-organization reference is reported in `dangling`, and records with no organization are still checked across all organizations. Under the `group` posture the audit still checks across all organizations, as before. There, a member of several organizations may legitimately link records across them, and the stored record does not say which organizations its writer could see. So under `group`, a reference into another organization is reported only when the target does not exist anywhere. Custom `DanglingReferenceAuditPort` implementations get the record's organization, or `null`, as a new third argument to `probe`. Implementations that ignore it keep working. +- 3bd221d: fix(objectql): a parent-scoped `readonlyWhen` lock is judged against the invoice a line STAYS under, not the one the update names (#19853) + + **What a caller could do before.** On a master-detail child whose parent field + is read-only, a caller with edit rights could change a field locked by a + `parent`-scoped `readonlyWhen` by naming a different, unlocked parent in the + same update. With `amount: { readonlyWhen: "parent.status == 'paid'" }` and a + `readonly: true` `invoice` field, `update(line, { amount: 999, invoice: + 'open_invoice' })` on a line of a PAID invoice committed `amount = 999`: the + lock was judged against the open invoice the payload named, then the read-only + `invoice` was stripped, so the line stayed under the paid invoice with its + frozen amount rewritten. The same happened when the parent field carried a + `readonlyWhen` lock of its own that kept the line where it was, and on bulk + (`multi: true`) updates for every matched row. + + **What happens now.** The engine settles whether the update really moves the + line BEFORE it judges any `parent`-scoped lock, and judges every lock against + the parent the row is stored under afterwards. In the example above `amount` is + dropped as locked, exactly as `update(line, { amount: 999 })` on its own always + was. A legitimate move — the parent field writable, or an `isSystem` / + `preserveAudit` write the read-only strip exempts — is still judged against the + parent it moves to, unchanged. + + **What else you may see move, all in the same direction (the parent the row is + stored under decides):** + + - Naming a LOCKED parent beside a read-only parent field no longer locks a line + that stays under an open one — its field now commits. + - `requiredWhen` reads the same parent binding, so a `parent`-scoped requirement + is also judged against the parent the row stays under: clearing a required + field on a paid line by naming an open parent is now refused + (`VALIDATION_FAILED`), and naming a paid parent beside a read-only parent + field no longer refuses an open line. + - `onFieldsDropped` now reports the locked field (`readonly_when`) beside the + parent field (`readonly`), and a `strictReadonlyWrites` refusal names both. + - When the parent field carries its own `readonlyWhen`, that lock is still + judged against the parent the update names, as before. +- 8490127: fix(objectql): a record-scoped `readonlyWhen` lock is judged against the values the update STORES, not a read-only value the caller forged (#19887) + + **What a caller could do before.** A caller with edit rights could change a + field locked by a `record`-scoped `readonlyWhen` by putting a forged value for + a statically `readonly` field in the same update. With `amount: { readonlyWhen: + "record.status == 'closed'" }` and a `readonly: true` `status`, + `update(ticket, { status: 'open', amount: 999 })` on a CLOSED ticket committed + `amount = 999`: the lock was judged against the forged `status: 'open'`, then + the read-only `status` was stripped, so the ticket stayed closed with its frozen + amount rewritten. The same happened through a read-only master-detail field + read as `record.invoice`, on bulk (`multi: true`) updates for every matched + row, and for a parent field's own `readonlyWhen` lock that reads a read-only + field. + + **What happens now.** The lock reads the update as it will be stored: a value + the read-only strip removes is replaced by the row's stored value before any + `readonlyWhen` predicate is evaluated. In the example above `amount` is dropped + as locked, exactly as `update(ticket, { amount: 999 })` on its own always was. + Where the read-only strip keeps the value (an `isSystem` caller, a + `preserveAudit` write of a preservable field, a value a `beforeUpdate` hook + wrote), the value is stored, and the lock reads it as before. + + **What else you may see move, all in the same direction (the stored values + decide):** + + - Forging a LOCKING value for a read-only field (`status: 'closed'` on an open + ticket) no longer locks the other fields — they now commit. + - `onFieldsDropped` now reports the locked field (`readonly_when`) before the + read-only one (`readonly`), and a `strictReadonlyWrites` refusal names both; + it was a refusal before and still is. + - A field that is both `readonly: true` and `readonlyWhen`-locked may now be + reported as `readonly_when` instead of `readonly` when its own predicate reads + a forged value; it is dropped either way. + - `requiredWhen` and validation rules run on the stripped update, so they now + see the amount the row keeps: a requirement only the forged-through amount + raised no longer refuses the write, and clearing a field the kept amount + requires is now refused (`VALIDATION_FAILED`). +- ae0c90c: fix(objectql): a value one `readonlyWhen` lock drops can no longer unlock another `readonlyWhen` lock (#19911) + + **What a caller could do before.** When one field's `readonlyWhen` read a field + that carries its own `readonlyWhen`, a caller with edit rights could change a + locked field by sending a new value for the other one in the same update. With + `status: { readonlyWhen: "previous.status == 'closed'" }` and `amount: { + readonlyWhen: "record.status == 'closed'" }`, `update(c1, { status: 'open', + amount: 999 })` on a CLOSED row committed `amount = 999`: `status` was dropped + by its own lock, but `amount` was judged against the dropped `'open'`, so the + row stayed closed with its frozen amount rewritten. The same happened on bulk + (`multi: true`) updates for every matched row, and for a master-detail field's + own `readonlyWhen` lock that reads such a field — the row moved to another + header although its lock held on the row it kept. `isSystem` callers were + affected too (a `readonlyWhen` lock binds them). + + **What happens now.** A value one `readonlyWhen` lock drops can no longer + unlock another: no field is written while its `readonlyWhen` is TRUE on the row + the update stores. A lock is judged after the locks whose fields it reads, with + each value they drop put back to the row's stored one. Locks that read each + other in a cycle are judged together, again with each dropped value put back, + until no further field locks. Then the fields held only by a value that was + later put back are released, and every lock in the cycle is judged again + against what that release stores, round after round, until the dropped fields + are exactly the ones locked on the row the update stores; after one round more + than the cycle has fields, the first, larger set of drops stands for the + cycle's fields instead. In the example + above `amount` is dropped as locked, exactly as `update(c1, { amount: 999 })` + on its own always was. Values a `beforeUpdate` hook wrote are still stored and + read as before. + + **What else you may see move:** + + - The reverse: a value that WOULD lock another field no longer locks it when + its own lock drops it. With `status` frozen by `previous.frozen == true`, + `update(r, { status: 'closed', amount: 999 })` now stores the amount (the row + stays open, so its amount is unlocked); before, the amount was dropped too. + - `onFieldsDropped` reports every dropped field in the one `readonly_when` + event, and a `strictReadonlyWrites` refusal names the fields the update would + have dropped; it was a refusal before and still is. + - `requiredWhen` and validation rules run on the stripped update, so they see + the amount the row keeps: a requirement only the let-through amount raised + no longer refuses the write, and clearing a field the kept amount requires is + now refused (`VALIDATION_FAILED`). + - A field whose own lock is FALSE on the stored row can still be dropped, but + only when its lock is in a cycle of locks that read each other's fields (a + `parent`-scoped lock counts as reading the master-detail field, which picks + the header, and a lock that reads `record` other than as `record.` + counts as reading every field). A field whose lock is in no such cycle is + dropped exactly when its lock is TRUE on the row the update stores (on a + bulk update, on at least one matched row). In a cycle, + no set of drops may agree with the stored row: with `a` locked by `record.b + == 'x'` and `b` by `record.a == 'old_a'`, `update(r, { a: 'new_a', b: 'x' + })` on a row `{ a: 'old_a', b: 'y' }` drops both, although `a` is unlocked + on the row it stores. A cycle can also have more than one set that agrees: + with `a` locked by `record.b == 'new_b'` and `b` by `record.a == 'new_a'`, + `update(r, { a: 'new_a', b: 'new_b' })` on a row holding neither new value + would agree with the row by dropping either one, and it drops both. Some + other updates with two such sets store one of them. Where a cycle's drops do + not settle, the first, larger set stands for that cycle's fields: a lock the + update cannot settle is not waived. + - A master-detail repoint that the field's own `record`-scoped lock used to + hold can now land. Its lock is judged together with the other locks on the + header the update names, so when the value that lock reads is itself locked + under that header, the value is dropped, the repoint lands, and the edit is + dropped under the header the row lands on. With `invoice: { readonlyWhen: + "record.amount == 'big'" }` and `amount: { readonlyWhen: "parent.status == + 'paid'" }`, `update(line, { invoice: 'inv_a', amount: 'big' })` on a line + under an open invoice, naming a paid one, used to keep the line where it was + and store `amount: 'big'` (`onFieldsDropped` reported `invoice`); it now + moves the line onto the paid invoice and keeps its old amount + (`onFieldsDropped` reports `amount`), by id and on bulk updates. A + `strictReadonlyWrites` refusal of that write now names `amount` instead of + `invoice`. Both outcomes agree with the locks on the row they store. + - A master-detail field whose own lock reads `record`, on an object where + another field in the update has a `parent`-scoped lock, now reads the named + header before deciding whether the row moves: one more header read when it + does not. +- 0b866bf: fix(objectql): a chain of `readonlyWhen` locks no longer ignores an edit whose own lock is FALSE on the row the update stores (#19927) + + **What happened before.** When `readonlyWhen` locks read each other in a chain, + an update could ignore an edit whose own lock was FALSE on the row it stored. + With `c: { readonlyWhen: "previous.c == 'L'" }`, `x: { readonlyWhen: "record.c + == 'open'" }` and `y: { readonlyWhen: "record.x == 'xv'" }`, `update(r, { c: + 'open', x: 'xv', y: 'yv' })` on a row with `c: 'L'` ignored all three fields. + The row keeps `c: 'L'`, so `x`'s lock is FALSE there, yet its edit was lost + with no refusal, and a `strictReadonlyWrites` refusal named `x` as read-only. + + **What happens now.** That update stores `x: 'xv'` and ignores `c` and `y`: + `c` is locked, and `y`'s lock reads the `x` the row now holds. This holds by + id and on bulk (`multi: true`) updates, and for `isSystem` callers. + + **What else you may see move:** + + - `onFieldsDropped` reports only the fields the update ignored (`['c', 'y']` + above), and a `strictReadonlyWrites` refusal names only those. Whether a + write is refused under that option does not change. + - An edit that used to be stored can now be ignored. When a field that used + to be ignored now lands, a lock that reads it can be TRUE on the row the + update stores, and that lock's field is then ignored instead of written. + With `p` locked by `previous.p == 'L'`, `m` by `record.p == 'L'`, `j` by + `record.m == 'new'` and `k` by `record.p == 'L' && record.j == 'new'`, + `update(r, { p: 'new', m: 'new', j: 'new', k: 'new' })` on a row `{ p: 'L', + m: 'old', j: 'old', k: 'old' }` used to ignore `j` and store `k: 'new'`; it + now stores `j: 'new'` and ignores `k`, whose lock reads that `j`. A + `strictReadonlyWrites` refusal of that update now names `k` instead of `j`. + - A master-detail repoint that such a chain used to hold can now land. With + `c` locked by `previous.c == 'L'`, the master-detail `invoice` by `record.c == + 'open'`, `y` by `record.invoice == 'h_open'` and `amt` by `parent.status == + 'paid'`, an update setting all four on a line with `c: 'L'` under a paid + invoice used to keep the line there, store `y` and ignore `amt`. It now moves + the line to `h_open`, stores `amt` (unlocked under the open invoice) and + ignores `y`, by id and on bulk updates. A `strictReadonlyWrites` refusal of + that update now names `c` and `y` instead of `c`, `invoice` and `amt`. +- c839986: fix(objectql): a `readonlyWhen` cycle no longer makes an update ignore edits to fields outside it (#19929) + + **What happened before.** When an update wrote fields whose `readonlyWhen` + locks read each other in a cycle whose drops did not settle, it gave up + settling the drops for the whole update and kept the first, larger set of + drops for every field. It could then ignore an edit to a field in no cycle + whose own lock was FALSE on the row it stored. With `c: { readonlyWhen: + "previous.c == 'L'" }`, `x: { readonlyWhen: "record.c == 'open'" }`, `y: { + readonlyWhen: "record.x == 'xv'" }`, `a: { readonlyWhen: "record.b == 'x'" }` + and `b: { readonlyWhen: "record.a == 'old_a'" }`, `update(r, { c: 'open', x: + 'xv', y: 'yv', a: 'new_a', b: 'x' })` on a row `{ c: 'L', a: 'old_a', b: 'y' }` + ignored all five fields. The row keeps `c: 'L'`, so `x`'s lock is FALSE there, + yet its edit was lost, and a `strictReadonlyWrites` refusal named `x`. The same + happened beside a cycle that two sets of drops agree with, such as `a: { + readonlyWhen: "record.b == 'new_b'" }` and `b: { readonlyWhen: "record.a == + 'new_a'" }` written with `a: 'new_a', b: 'new_b'` on a row holding neither new + value. + + **What happens now.** A lock is judged after the locks whose fields it reads, + locks that read each other in a cycle are judged together, and the fallback to + the first, larger set of drops applies only to the fields of the cycle whose + drops do not settle. The update above stores `x: 'xv'` and ignores `c`, `y`, + `a` and `b`. A field whose lock is in no such cycle is ignored exactly when its + lock is TRUE on the row the update stores (on a bulk update, on at least one + matched row); a `parent`-scoped lock counts as reading the master-detail field, + which picks the header. This holds by id and on bulk (`multi: true`) updates, + and for `isSystem` callers. It holds for a master-detail field's own lock too: + with `c` locked by `previous.c == 'L'`, the master-detail `invoice` by + `record.c == 'open'`, `y` by `record.invoice == 'h_open'` and `amt` by + `parent.status == 'paid'`, an update setting all four and `a`/`b` above, on a + line with `c: 'L'` under a paid invoice, used to keep the line there, store `y` + and ignore the other five fields; it now moves the line to `h_open`, stores + `amt`, and ignores `c`, `y`, `a` and `b`. + + **What else you may see move:** + + - A lock that reads a cycle's field is judged against the value the cycle's + drops leave on the row. With `z: { readonlyWhen: "record.a == 'new_a'" }` + beside the cycle `a`/`b` above, `update(r, { a: 'new_a', b: 'x', z: 'zv' })` + used to ignore `z` too; it now stores `z: 'zv'`, because the row keeps `a: + 'old_a'`. A lock reading `record.a == 'old_a'` there is still ignored. + - A cycle that more than one set of drops agrees with can now reach a + different one of those sets than before, or keep its first, larger set of + drops where it used to reach one. Which it reaches now depends only on the + cycle's own locks and the fields they read, never on the other locks in the + update. + - A lock whose predicate reads `record` other than as `record.` (for + example `record['b']` or `size(record)`) counts as reading every field the + update writes. + - `onFieldsDropped` reports only the fields the update ignored, and a + `strictReadonlyWrites` refusal names only those. Whether a write is refused + under that option does not change. +- b373596: fix(objectql): a delete refused because its reference cleanup trips a traversing validation rule now says so, naming the delete, the cleared reference and the repair (#20006) + + Clause-②: no + + Deleting a record clears each `set_null` reference to it (the default for an optional `lookup`) with an UPDATE of every record that references it. That cleanup resolves no related record for a validation rule, so a `script` / `cross_field` rule on the referencing object that reads through a reference (`record.account.status`) cannot be evaluated there. It refuses the cleanup, and the delete with it. That refusal is unchanged: same `VALIDATION_FAILED` error, same `rule_violation` field error, same `constraint` (`reason: 'unevaluable'`, the fault, the missing key), and the same set of deletes refused. + + What changes is the message. It used to be the generic one about the rule's own object: `The predicate reads 'status', which this object does not declare — fix the rule's condition, or declare the field.` Whoever deleted the record did not write that rule, and following the advice adds a bogus column to the wrong object. The message now names the blocked delete, the reference being cleared, the rule and its object, and the repairs: + + ```text + Cannot delete crm_account (acc_1): the delete clears `account` on the crm_deal records that reference it, + and validation rule 'closed_account_frozen' on crm_deal could not be evaluated on that write — it reads + 'status' through `account`, and a rule is given no related record while a delete clears references. + Guard the rule on `account` being set: make it the `then` of a `conditional` rule whose `when` is + `record.account != null`. Or change `deleteBehavior` on crm_deal.account: 'cascade' deletes those records + with the crm_account, 'restrict' refuses the delete while they exist. + ``` + + - **The guard** is offered only to a rule that reads through the reference the cleanup empties. Such a rule already refuses every write that leaves that reference empty (`no single related record`), so the guard only lets those writes through. The guarded rule is still judged on every write where the reference is set. + - **A rule that reads only through another reference** is offered only `deleteBehavior`. A guard on the cleared reference would stop judging that rule on every record whose cleared reference is empty, on every insert and update. + - **On a multi-value reference** the cleanup removes the deleted record and keeps the other members, so the guard would still run the rule. There, only `deleteBehavior` is offered. + - **Unchanged:** a rule that reads a key where it is not held keeps today's text byte for byte, whichever key the fault reports. Those reads are `record.KEY`, `record.FIELD.KEY` through a field that is not a reference, `previous.KEY`, and `previous.FIELD.KEY` through any field, when the record, the previous row or the field's value lacks `KEY`. A declared column always reads, as `null` when empty, so it never counts. A rule that reads through no reference keeps today's text too, and so does every write that is not a delete's reference cleanup. +- 7465eeb: fix(formula,objectql): the two refusals a traversing validation rule on an optional lookup meets now name the repairs that work — a `conditional` wrapper or `required: true` (#20007) + + Clause-②: no + + An author who wants to refuse a write when an OPTIONAL lookup is set and its related record is secret writes `record.line != null && record.line.kind == 'secret'`. Two refusals then sent them in a circle: + + 1. That expression reads `line` both through the relationship and as a plain value, which cannot be served, and is refused. The refusal said to "compare the id explicitly" and write `record.line.id` for the value comparison. + 2. `record.line.id != null && record.line.kind == 'secret'` reads through `line` too, so an order with no line is refused before the rule is evaluated, as "no single related record". That refusal said to "guard the rule on the reference being set" and named no spelling for the guard. + + Which writes are refused is unchanged, and so are the error, the `rule_violation` field error and its `constraint` (`reason: 'unevaluable'` and the fault). `@objectstack/lint` passes the formula refusal through unchanged, so it shows the new text too. Only the prescriptions change. Both now name the two spellings measured to work for an optional reference, and the guard is worded exactly as in the delete-cleanup refusal: + + ```text + … To compare the id, write `record.line.id` for the value comparison, and keep + `record.line.` for the traversal. `record.line.id` is not a null guard: it + reads through `line` too, and a rule that reads through an empty `line` rejects the write + instead of being skipped. If the plain value tests for empty, take that test out of this + expression. To skip the rule while `line` is empty, guard it on `line` being set: make it + the `then` of a `conditional` rule whose `when` is `record.line != null`. To refuse an + empty `line`, make `line` required (`required: true`). + ``` + + ```text + … A predicate resolves ONE hop through a single reference. To skip the rule while `line` + is empty, guard it on `line` being set: make it the `then` of a `conditional` rule whose + `when` is `record.line != null` — `record.line.id != null` inside the rule is no guard, as + it reads through `line` too. To refuse an empty `line`, make `line` required + (`required: true`). For a multi-value reference, test it with a macro (`exists`, `size`) + instead of reading through it. + ``` + + The repair as an author writes it, measured end to end on insert and update. It accepts an order with no line or a public line, and refuses a secret line with the rule's own message: + + ```ts + validations: [{ + name: 'no_secret_line_when_set', type: 'conditional', + message: 'Only checked while the order names a line.', + when: 'record.line != null', + then: { name: 'no_secret_line', type: 'script', message: 'An order may not carry a secret line.', + condition: "record.line.kind == 'secret'" }, + }] + ``` + + With `required: true` on `line` instead, an order with no line is refused at the field (`required`), and the rule still judges one with a line. +- 08c8484: `@objectstack/spec/data` declares which field types are masked on read — `MASKED_ON_READ_FIELD_TYPES` and `isMaskedOnReadFieldType(fieldType, managedBy)` (#20141) + + Clause-②: yes + + The protocol used to state this only in prose (the `FieldType` comments), so + two consumers each carried their own hand-written copy: objectql's + `collectMaskedReadFields` (the generic read mask and the echoed-mask write + guard) and the renderer's masked-type set. The fact is now declared once: + + - `MASKED_ON_READ_FIELD_TYPES` — a deep-frozen per-type rule table: + `secret` is masked on every object; `password` is masked on every object + except `managedBy: 'better-auth'` ones. Each masked type carries its own + `exemptManagedBy` list, typed against `ObjectSchema.managedBy`'s enum. + - `isMaskedOnReadFieldType(fieldType, managedBy)` — the one reading of that + table. `managedBy` is a required argument (pass `undefined` when the object + has none), and exemptions fail closed: an absent or unlisted `managedBy` + never unmasks a masked type. + + `@objectstack/objectql`: `collectMaskedReadFields` and + `collectMaskedPasswordFields` now ask `isMaskedOnReadFieldType` instead of + carrying their own `type === 'secret'` / `'password'` arms. No behaviour + change: the masked-on-read answer is identical for every `FieldType` × + `managedBy` cell, pinned by a table test. + + `@objectstack/spec`: `ObjectSchema.create()`'s author-time warning for a `password` field on a non-auth object now reads its `managedBy` exemption from `isMaskedOnReadFieldType` instead of hard-coding `'better-auth'`, so it follows the declaration; which objects and fields warn is unchanged. + + A client that renders credential fields (show the mask, offer no copy) should + derive its set from `isMaskedOnReadFieldType` rather than keep its own list, + so the server's mask and the client's cannot drift apart. +- 615c468: fix(core): an epoch-millisecond number compared against a `date` field is read as the UTC calendar day of its instant, by every driver and at every position that compares it (#20203) + + Clause-②: no — no key, export or operator moves, and no comparand that was accepted is now refused: a number was already an accepted comparand on every `date` position, and its answer moves to the storage rule's reading. + + `temporalStorageForm(value, 'date')` in `@objectstack/core` returned a finite number unchanged, so each face compared it by its own type rules and they disagreed. Over six rows, with `1769940000000` (2026-02-01T10:00:00.000Z) against a `date` field: + + | face | `$gt` | `$lt` | `$eq` | `$in` (with a Jan 10 member) | `$between` (from Jan 2) | + |:--|:--|:--|:--|:--|:--| + | `where` on `driver-memory` | 0 | 0 | 0 | 0 | 0 | + | `where` on `driver-sql`, SQLite | 6 | 0 | 0 | 0 | 0 | + | `where` on `driver-sql`, PostgreSQL | `DATABASE_ERROR`, a 500 at REST | the same | the same | the same | the same | + | a per-aggregation `filter` on `engine.aggregate` | 0 | 0 | 0 | 0 | 6 | + | **now, on every face above** | **1** | **3** | **2** | **3** | **5** | + + The same holds through `engine.find` and `POST /data/:object/query`, and for `$gte`, `$lte`, `$ne`, `$nin` and implicit equality. PostgreSQL's server refused the bound number itself (`22008`, date/time field value out of range), on an empty table too. `having` over `max` of a `date` field kept no group for `$gt`, `$eq` or `$in`; it now keeps the groups whose day compares. + + A finite number is now read as the `datetime` rule already reads it, as epoch milliseconds. It takes the UTC calendar day of that instant: the day `new Date(value)` names, through the same conversion a `Date` takes. So a number and its `Date` always answer alike. A time of day is dropped, never rounded, a negative number is a day before 1970, and a fraction truncates toward zero as the `Date` constructor does. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call this rule, so they now agree. + + The rule is shared by the drivers' write and read paths too: + + - `create()` / `update()` on either driver, given a number for a `date` field, stores its UTC day. Before, `driver-memory` and SQLite stored the number, and PostgreSQL refused the statement. The engine and REST write doors refuse a number on a `date` field before a driver sees it (`VALIDATION_FAILED`), as before. + - A number already stored in a SQLite `date` column is read back as its UTC day by `find()`, a `groupBy` key and `distinct()`. Only a direct driver write could have put one there. + + Not changed, measured identical before and after: `NaN`, ±Infinity, a number outside the `Date` range (past ±8.64e15), a bigint, an epoch-millisecond string, every `Date` and every string on a `date` field (#20240, in the same release, then pads a `Date`'s or a number's year 0..999 to four digits and refuses one whose year falls outside 0..9999, a number past the `Date` range included; #20264, in the same release, narrows that to 0001..9999, so year 0 is refused rather than padded), and every `datetime` and `time` reading. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. +- 8538edf: fix(objectql): the engine's rows path adds `sum` / `avg` with compensated summation, as SQLite does + + Clause-②: no + + `engine.aggregate` answers a `sum` / `avg` on one of two paths: the driver's own aggregate, or + the rows path (`applyInMemoryAggregation`), which aggregates `find()` rows in JavaScript and is + taken for a per-aggregation `filter`, a non-UTC date bucket, or a driver without native + aggregation. SQLite 3.43 and later adds with Kahan-Babuska-Neumaier compensation; the rows path + added naively. So on SQLite one query answered two doubles depending on the path. A `number` + column holding `0.1`, `0.2` and `0.3` in one group: + + | | `sum` | `avg` | `having { s: { $eq: 0.6 } }` | + |:--|:--|:--|:--| + | SQLite native | `0.6` | `0.19999999999999998` | keeps the group | + | rows path, before | `0.6000000000000001` | `0.20000000000000004` | keeps no group | + | rows path, after | `0.6` | `0.19999999999999998` | keeps the group | + + The rows path now adds with the same compensation, transcribed from SQLite's own, so on SQLite + both paths answer the same double, through `engine.aggregate` and + `POST /api/v1/data/:object/query` alike. It is also the more accurate sum: `1e16 + 1 - 1e16` is + `1`, where the naive fold answered `0`. + + Unchanged: two addends (the compensated `a + b` is the naive one, so `0.1 + 0.2` is still + `0.30000000000000004`), integers whose running total stays within 2^53, a non-finite total, + `null` and non-numeric cells, and the empty group (`sum` `0`, `avg` `null`). The answer is still a + JS number. + + **Residual, stated.** PostgreSQL and MySQL add `sum` / `avg` natively in double without + compensation, and that arithmetic is the database's own. So over three or more fractions their + native path can still differ from the rows path in the last place (`0.1 + 0.2 + 0.3`: native + `0.6000000000000001`, rows path `0.6`). The `@objectstack/driver-sql` entry for the double + accumulation states the same residual: the difference is no longer SQLite's native path against + every other face; it is PostgreSQL / MySQL native against SQLite and the rows path. The + in-memory driver (`@objectstack/driver-memory`) still adds naively in its own `aggregate`, so on + that driver the two paths can now differ in the same last place. An exact `$eq` on a fractional + sum compares doubles: compare with a range. +- f26fb8e: Correct six `edit distance cannot reach` citations that are measurably false, and pin the role each alias entry actually plays. + + `aliases` has two jobs, not one: filling a gap the distance fallback leaves empty, and overruling a hit the fallback reaches and gets wrong. The lookup is `aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, budget, 1)[0]` — the table is consulted first and wins outright — and the budget is `Math.max(2, Math.floor(key.length / 3))`. A sentence saying distance "cannot reach" the cited case denies the second job, and in three places the cited case is itself an example of it. + + - **`latitude` → `lat` is an OVERRULE, not a gap** (`data/field-value.zod.ts`, `data/default-value-shape.ts`, `data/field-value.test.ts`, `data/default-value-shape.test.ts`, objectql `validation/record-validator.ts`). `latitude` is 8 characters, so the budget is 2; `lat` is 5 edits away and out of reach, but the declared `altitude` is exactly 2 — so without the curated entry the bare fallback answers `latitude` → `altitude` and points an author who wrote a GPS latitude at the elevation member. Four docblocks cited this pair as proof that aliases exist only where distance reaches nothing. + - **`postal_code` → `postalCode` never involved an alias at all** (`data/default-value-shape.ts`). Scoring folds case and separators on both sides, so it is 1 edit against a budget of 3 — the worked example rendered in that docblock is the fallback's own answer, not the `AddressValueSchema` table's. + - **`uri` → `url` is reachable and agreeing** (`data/driver/turso.zod.ts`). The block was headed "the spellings edit distance cannot reach"; that is true of five of its six rows and false of `uri`, which is 1 edit from `url` against a budget of 2. The row is a pin on an answer the fallback already gets right, not a gap-filler. + + Prose plus new pins. No alias is added or removed, no schema, key list, strictness, suggestion or error message changes: `Clause-②: no`. The three roles are now asserted — `longitude` (gap), `latitude` (overrule, with the negative half), `altitud` (a plain typo still riding the fallback) in `data/field-value.test.ts`, and `dsn` (gap) beside `uri` (reachable) in `data/driver/turso.test.ts`. +- 0780e88: fix(objectql): a refused write reports at `warn`, not `error` — the caller was already told (#17052) + + `insert`, `update` and `delete` each end their `catch` with `throw e`, then + logged the failure at ERROR one statement earlier. AGENTS.md → *Degradation log + levels* names that exact shape and forbids it: "a failure handed to the CALLER + is not a degradation at all … Do not bolt a `logger.error` onto such a site." + + **This moves published behaviour**, which is why it is a changeset rather than a + `skip-changeset`: the level is what an operator greps, and at least one consumer + reads it structurally. `scripts/publish-smoke.sh` fails a boot on any + error-level line (`SMOKE_ERROR_LOG_PATTERN`), and that is how the defect was + found — `@better-auth/oauth-provider` seeds `sys_oauth_resource` in `insertOnly` + mode and documents its `identifier` UNIQUE constraint AS its race-safety + mechanism, catching the collision and continuing at `debug`. Our line was + emitted before that catch ever ran, so a healthy first boot of every fresh + `create-objectstack` project printed `ERROR Insert operation failed` and red-lit + `publish-smoke / packed-tarballs` for six consecutive runs on a candidate whose + auth and CRUD probes were all green. + + **Nothing else about the entry moved.** Same message, same `object` meta, same + redaction (#8682: the bound statement and its values stay cut from `message` + and `stack`), same subject (#14095: the entry carries the driver's own error — + a `DuplicateRecordError`'s `cause` — never the envelope, so the failing column, + MySQL's index name and the driver's frames survive). The `Logger` contract gives + an `Error` slot to `error`/`fatal` only, so the engine now builds the + `{ error: { message, stack } }` bag that slot used to build; handing the Error + to `warn` as meta would have serialised `{}`, because those two fields are + non-enumerable. The rendered line is byte-identical apart from the level word, + and that equivalence is pinned rather than asserted. + + If you grep your logs for these three messages, keep the message and drop the + level from the pattern. If you alert on error-level lines from `@objectstack/objectql`, + a refused write no longer raises one — the write's exception still does. +- 706ad0f: fix(objectql): a hook that faults reaching through a withheld read-only key now names the key, says the platform withheld it, and points at `ctx.previous` (#17219) + + Since #16344 the update path hides a caller-supplied static `readonly` value from `before*` hooks. A hook body that reaches **through** such a key — `ctx.input.locked_meta.who = 'hook'`, where `locked_meta` is a caller-supplied read-only `json` column — therefore dereferences `undefined` and throws, and a `body` hook's default `onError: abort` refuses the caller's whole write. + + **The refusal is correct and is unchanged.** What it replaced is a write that succeeded while persisting a value derived from the caller's forgery, and #16344 exists to close exactly that route. What this fixes is the diagnostic. Measured before this change, at both doors: + + ``` + direct SandboxError: hook 'guard_task_body' threw: + TypeError: cannot set property 'who' of undefined + REST 500 {"error":"Internal server error","code":"INTERNAL_ERROR"} + ``` + + The REST reading is the one that matters, and it is the worse of the two: a leading `TypeError:` is correctly classified as a script fault and sanitised (#7543), so an author was told nothing at all — not which key, not that the platform had taken it away, not what to read instead. + + ### Who is affected + + Anyone whose `beforeUpdate` hook reads a read-only field that the caller may also send. The write was already being refused; only the message changes. A hook that needs the stored value reads it from **`ctx.previous.`** — the same remedy PR #17195's changeset documents. + + ### What the message says now + + ``` + A `beforeUpdate` hook faulted while `locked_meta` was withheld from it. That field is + `readonly: true`, and the engine withholds a caller-supplied value for a read-only field + from `beforeUpdate` hooks, so `ctx.input.locked_meta` reads `undefined` — withheld by the + platform, not missing by accident. Read the stored value from `ctx.previous.locked_meta` + instead. Original fault: TypeError: cannot set property 'who' of undefined + ``` + + The error declares **HTTP 400**, which is what carries it past the script-fault sanitiser onto the same "message verbatim" channel a body's own authored refusal already rides; REST callers who previously saw `500 INTERNAL_ERROR` for this case now see 400 with the text above. The original fault is carried inside the message rather than replaced. + + ### Deliberate limits + + No new error code is registered and no key is added to any published payload — a dedicated `ERROR_CODE_LEDGER` entry for this refusal is a separate decision. The explanation claims only what is knowable at the seam: *faulted while these keys were withheld*, never a proven cause. An **authored** refusal (`throw new Error('…')`) is never rewritten, and a crash on an operation where nothing was withheld passes through untouched. +- 0f38ab0: fix(driver-memory,driver-sql): an explicit `tenancy.enabled: false` opt-out is sticky, so a partial `syncSchema` re-registration no longer flips a platform-global object's UNIQUE partition (#16729) + + ## What was wrong + + `InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever + schema THAT call happened to carry. A second registration without a `tenancy` + block — the `{ name, fields }` shape — fell through to the implicit + `organization_id` heuristic, so a `unique` field moved from **one row per + install** (`scopeField: null`, which is what `tenancy.enabled: false` declares) + to **one row per organization**. A duplicate the declaration refuses then + landed. Measured at the driver door on `origin/main` `d61139f1ba`: + + | sequence | second `key: 'K'`, different organization | + |:--|:--| + | register with `tenancy.enabled: false` | `REFUSED` — `UNIQUE_VIOLATION` / 409 | + | …then re-register with `{ name, fields }` | **`LANDED`** | + + `SqlDriver` running the same sequence refuses in **both** cases: it has kept a + sticky `tenantOptOutByTable` since #3249. `driver-memory` had mirrored the inner + `computeTenantField` and not the wrapper that consults the record, so "mirrors + `computeTenantField` arm for arm" stayed literally true while the pair diverged. + + It is silent in both directions — nothing logs the flip, and the refusal names + the field, never the partition. That is the declared-vs-enforced shape Prime + Directive #10 forbids, reached by a state change rather than by a missing check. + + ## What it does now + + - **`@objectstack/driver-memory`** gains `computeAndRecordTenantField`, the + sticky resolver, and the `TenantOptOutRecord` type for the per-instance record + a driver owns. `InMemoryDriver` holds one and resolves through it, handing + BOTH declaration surfaces — field-level `unique` and declared `indexes[]` — + the same resolved column. `uniqueConstraintsFromFields` and + `uniqueConstraintsFromDeclaredIndexes` accept that column as an optional + second argument; called with one argument they answer exactly as before. + `tenantFieldOf` is unchanged and still a pure function of its argument. + - **`@objectstack/driver-sql`**: the shard leaf resolved its tenant column with + the BARE `computeTenantField`, so a `rotateShards` sweep carrying no `tenancy` + block gave a shard an organization key part the base table's index does not + have — one object, two partitions, decided by which physical table a row + landed in. It now resolves through the record, keyed by the base table. + - **`@objectstack/objectql`**: `LifecycleObjectLike` declares `tenancy`. The + Archiver hands that object straight to `cold.syncSchema`, and the published + type refused the key while the driver below read it — so an author writing a + fresh literal was pushed into producing exactly the partial re-registration + above. Same correction #16711 made where the shard leaf narrowed the key off + the object it was handed. + + The record is deliberately narrow. Only the explicit OPT-OUT is sticky: a + declared `tenancy.tenantField` is not recorded, matching `SqlDriver`. An object + that never declared the opt-out never enters the record, so a genuinely + org-scoped object keeps its `organization_id` partition across a partial + re-registration — an implementation answering `null` more often would not be + stickier, it would be tenant isolation switched off. A carried `tenancy` block + stays authoritative in both directions and CLEARS a recorded opt-out. + + `@objectstack/driver-memory` is `minor` for the two new public-entry exports. + The behaviour repairs themselves are `patch`: each restores an implementation to + the `tenancy.enabled: false` contract (`isTenancyDisabled`, ADR-0066) it was + already declaring, rather than replacing one legal published answer with + another. The `objectql` entry is a published type WIDENING — a key the interface + refused is now accepted, and nothing that compiled before stops compiling. +- 980dc78: fix(objectql): `engine.aggregate`'s in-memory lowering asks the driver for ROWS, so a per-aggregation `filter` stops being refused by the driver it was lowered for (#16642) + + `engine.aggregate` forks: a driver with a native `aggregate()` gets the pushdown, and anything the pushdown cannot express — a per-aggregation `filter` (#10576), a date granularity the driver does not advertise, a non-UTC reference timezone — falls back to `driver.find()` plus `applyInMemoryAggregation`. That fallback handed `find()` the whole aggregate AST, **aggregation keys included**. + + `find()`'s contract says nothing about `groupBy` / `aggregations`, and the drivers disagree about them. `driver-sql` and `driver-rest` ignore both and return rows — which is the only reason this path ever worked. `driver-memory` **honours** them (`find()` → `performAggregation`, the same method its `aggregate(AST)` door funnels through), which is the shape measured here; `driver-mongodb` and `driver-turso` carry the same refusal on their own aggregation faces, so a driver that ever routes `find()` into one lands in the same place. Against a driver of the second kind the one seam answered two different wrong things: + + - the per-aggregation `filter` that **routed the call here** was refused `NOT_IMPLEMENTED`/501 by the driver's own #10413 guard — a guard aimed at a caller reaching the driver's aggregation face directly, whose remedy text is *"route the query through the engine"*. The engine's own lowering was being told to use the engine. Downstream, `service-analytics`'s ObjectQL strategy lowers a dataset measure `filter` into exactly this key, so on the memory driver a measure `filter` (and the `derived: { op: 'ratio' }` that needs two differently-filtered counts) answered **501** while sqlite answered the number; + - a date-bucketed `groupBy` came back **already grouped**, on the raw timestamp — `dateGranularity` is an engine concept no driver face reads — and `applyInMemoryAggregation` then aggregated those group rows a second time. That half does not refuse: it reports a count of *buckets* under the author's own measure name. + + The fix is one seam: on the in-memory path the AST sent to `find()` carries no `groupBy`, no `aggregations` and no `having` — the three things this path is about to evaluate itself. `where` is untouched, so the middleware-injected read scope (RLS / tenancy) still travels with the call. + + `patch`: no signature moves and no key is added or retired. The pushdown fork is unchanged (an aggregation with no filter still goes to `drv.aggregate`), and on `driver-sql` — which ignored the stripped keys — the emitted statement and every number are unchanged. What changes is that two shapes that used to answer a refusal or a wrong number now answer the number the contract already promised: `driver-memory`'s `refusePerAggregationFilter` and `driver-sql`'s `unsupportedAggregationFilterError` both document themselves as *unreachable through `engine.aggregate`, which lowers in memory for every driver* — this is the line that makes that true. +- 2e8e118: Documentation only: seven in-source prose sites that still stated the superseded readonly-on-INSERT contract as live now state the ruled one. + + The 2026-09-03 maintainer ruling (option C, #14147) put the static `readonly` strip inside `engine.insert` under the same `isSystem` gate as `engine.update`, and deleted the metadata-protocol create-ingress copy. Comments and test headers written before that ruling still said, in the present tense, that a non-system INSERT is exempt from the static strip, or that the strip lives at the DataProtocol create ingress. Each now states the ruled contract, and the superseded sentence is kept only as history, marked as superseded. + + No behaviour changes and no test was deleted, skipped or re-scoped — the diff is comments only. It is a `patch` rather than `skip-changeset` because it was measured to publish: `@objectstack/objectql`'s comment edit moves source line numbers, so `dist/{index,core}.{js,mjs}.map` change, and `@objectstack/rest` inlines that same objectql source into its bundle, so `dist/index.{js,cjs}.map` change with it. Every emitted `.js` / `.mjs` / `.cjs` and every `.d.ts` / `.d.mts` / `.d.cts` is byte-identical before and after, and all six maps ship inside the published tarballs. +- 8c9bd8f: docs(metadata-protocol,objectql,cli): comments describing the standalone stamp now name `env_local`, the value the tree actually produces + + The v5.0 `project` to `environment` rename reached the two remaining stamps in `@objectstack/runtime` and `@objectstack/metadata` in a previous release: `createStandaloneStack` and `MetadataPlugin` both stamp **`env_local`**. Six comments in three other packages still described that stamp as `'proj_local'`, so they named a value nothing in the tree produces any more. + + No behaviour changes. The reason this is a `patch` rather than a no-publish diff is measured, not assumed: two of the six sites are TSDoc on **exported** interface members (`AssembleMetadataProtocolOptions.runPlatformMigrations`, `ObjectQLPluginOptions.runPlatformMigrations`) and land in the shipped `dist/*.d.ts`, and the `@objectstack/cli` site lands in the shipped `dist/utils/schema-migrate.js` because that package builds with `removeComments` unset. All three packages ship `dist` in `files[]`, so the corrected text is what an author reads on hover after upgrading. + + The sites were judged individually rather than search-and-replaced, because they are not all the same edit: + + - Five sites whose verb describing the stamp is present indicative describe today's tree — two of them point the reader at `runtime/src/standalone-stack.ts` to go and look — and take the current spelling. + - `packages/cli/src/utils/schema-migrate.ts` names `'proj_local'` as the value the historical arming deduction consumed. There the literal is preserved as history and its present-tense relative clause moves into the past, with today's spelling named beside it; rewriting it to `env_local` would have falsified the record in the other direction. + + The causal claim at every site is about **presence**, not spelling: the retired gate read `environmentId === undefined`, so it would have misfired identically under either literal. That reading is preserved at all six. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [dc709b2] +- Updated dependencies [04333d0] +- Updated dependencies [07f93e0] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [69b5059] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e743fb5] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [7e74af3] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [fade3da] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [e3b3cdd] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [58644ad] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [482d584] +- Updated dependencies [2b321a4] +- Updated dependencies [0870fb5] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [cdc1ae0] +- Updated dependencies [5c5b67f] +- Updated dependencies [2306a75] +- Updated dependencies [3f9e2ea] +- Updated dependencies [a251aaa] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [4112752] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [71ef221] +- Updated dependencies [a90272a] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [e9eb224] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [d1ca874] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [7465eeb] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [586934e] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [cfe2387] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [1207baf] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [8cdbe0c] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [397572e] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [f9e16d8] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [b110578] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [29d00cc] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [b3f7fdc] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [4062aef] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [7173d7d] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [5cdb0db] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-protocol@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/metadata@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 1e11b2e67b1..7e68bc57c71 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Isomorphic ObjectQL Engine for ObjectStack", "main": "dist/index.js", diff --git a/packages/observability/CHANGELOG.md b/packages/observability/CHANGELOG.md index c4ffbf7402a..b51062e21d4 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,454 @@ # @objectstack/observability +## 17.5.0 + +### Patch Changes + +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index 18dee54e616..2416adf31d7 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Observability contracts and exporters for ObjectStack — MetricsRegistry, ErrorReporter, Logger plus noop/console/OTLP-HTTP exporters. Deployment-target neutral; runtime and services depend on this so the same instrumentation works on Cloudflare Workers, Node, and self-hosted Kubernetes.", "type": "module", diff --git a/packages/platform-objects/CHANGELOG.md b/packages/platform-objects/CHANGELOG.md index 329fc745526..4f38e4191d0 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,1650 @@ # @objectstack/platform-objects +## 17.5.0 + +### Minor Changes + +- fe71032: feat(driver-sql,objectql,cli)!: the ADR-0104 file-family column step, and the kernel→driver supply that arms it (#15989) + + + + **BREAKING** on the published storage behaviour of `@objectstack/driver-sql`. A deployment that runs `os migrate files-to-references --apply` now has its media columns **retyped and their values rewritten** into the bare-`sys_file`-id encoding, and its driver writes bare ids from the next boot. This completes the maintainer ruling on #15041 (「15041 应该改为实际 id 保存。选A,其他同意」) whose encoding half shipped in the previous release. + + Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + ## The column step + + `os migrate files-to-references --apply` gains a further step, run **only after** the backfill and its self-check report zero blocking rows — and it moves nothing at all until three gates pass: + + 1. the migration's own gate (zero blocking rows); + 2. **every** abort pre-check, across **every** planned column, before a single statement runs; + 3. no refusals — a column the driver could not plan stops the columns it could. + + **PostgreSQL** and **SQLite** only. ⛔ MySQL is refused by name and belongs to #17788, where its statement ORDER is settled against a real instance rather than transcribed. + + Per column, the shape is read off the column's **physical type**, not off the dialect: a `json` column is retyped (`ALTER … TYPE varchar(2048) USING (col #>> '{}')`), while a column that is already `varchar` — the population `os generate migration --format sql` creates and a JSON-arm driver fills with quoted ids — has its values unquoted in place. SQLite has only the second shape, since it has no json type. + + ### ⛔ The abort clause is NOT the one the ADR sketched + + The #15041 addendum prescribed the retype with nothing in front of it while *requiring* the step to abort "on the first cell that is not a JSON string". Those two sentences contradict each other, and which was wrong was settled by running it. Measured on live PostgreSQL 16.13, `USING (col #>> '{}')` is **accepted** over a row holding an inline metadata blob, because `#>> '{}'` extracts *any* json type as text: the bytes survive, but the column is no longer `json`, so an object becomes a plain string in a column whose declared contents are ids — silently, in a migration that reports success. The director ruling (decision batch #120 item 1) replaced the clause with the pre-check that implements the requirement: `json_typeof(col) IS DISTINCT FROM 'string'` on PostgreSQL, and `json_valid(col) AND json_type(col) <> 'text'` on SQLite, where excluding invalid JSON is what keeps a re-run idempotent over cells a previous run already moved. + + Both the destructive form and the guarded one are executed side by side, on one fixture, in this release's own test suite — so the difference stays a measurement rather than a comment. + + ## The kernel→driver supply seam + + `SqlDriverConfig.fileColumnsMoved` shipped last release and no host outside the driver supplied it. It is supplied now: `ObjectQL.registerDriver` hands every driver that has the seam a closure over the new `ObjectQL.haveFileColumnsMoved()`, which reads `sys_migration.columns_moved_at` — and requires the `adr-0104-file-references` flag to be verified **as well**, since the stamp alone would attest a column move with nothing attesting the values inside it. + + ⭐ **Every way of not knowing still answers "not moved".** The option omitted, a resolver that throws or rejects or answers a non-`true` value, a resolver that never runs because the host never calls `initObjects`, a driver with no such seam, no `sys_migration` object, no row, an unreadable table, a null or empty stamp — all the JSON arm. That is the encoding every deployment in the world is on, and a driver that guessed the other way would write bare ids into a JSON column. + + ⛔ **A host that names `fileColumnsMoved` in its own config wins**, in either polarity. The engine only ever fills an empty slot, and never contradicts an explicit composition: overruling a declared `false` is precisely the bare-ids-into-a-JSON-column failure this mechanism exists to prevent. + + ## New published surface + + - `@objectstack/spec` — `hasMovedFileColumns(flag)`, the single arbiter of the conjunction above, beside `isDataMigrationFlagVerified` and `authorisesIrreversibleAction`. + - `@objectstack/objectql` — `ObjectQL.haveFileColumnsMoved()`, sharing one memoized read (and one `invalidateDataMigrationFlags()`) with `isFileReferencesMigrationVerified()`, so the two answers can never come out of one another's date. + - `@objectstack/platform-objects` — `recordFileColumnMove(engine, migrationId)`, which refuses to stamp a deployment with no verified flag row. `readDataMigrationFlag` now carries `columns_moved_at`; it previously dropped it, which made a moved deployment indistinguishable from an unmoved one to every caller. + - `@objectstack/driver-sql` — `SqlDriver.setFileColumnsMovedResolver()`, `SqlDriver.planMediaColumnMove()`, and the statement builders `mediaColumnMovePlan` / `mediaColumnMoveDialect` / `isJsonColumnType` with `MEDIA_COLUMN_MOVE_DIALECTS`, `MEDIA_COLUMN_MOVE_ROLLBACK_NOTES` and `MEDIA_ID_MOVE_WIDTH`. The statements live in the package that owns the dialects and measured them; a second copy in the CLI would be a second copy of the clause the ruling got wrong. + + ## What does NOT change + + A deployment that does not run `--apply` is byte-for-byte where it was: the column stays `json`, the write still JSON-encodes, and the read still accepts both encodings. A backfill re-run does not set the stamp and — deliberately — cannot clear it either: `recordDataMigrationRun` omits the key rather than writing a preserved value, so a ledger read that FAILS cannot demote a moved deployment back onto the JSON arm. A partial or failed column step records nothing at all, which leaves such a datastore on the arm that reads both encodings. + + `multiple: true` media is untouched on both arms: its value is a list of ids and a JSON column on every deployment. +- 9c577c1: fix(platform-objects,cli): the generated i18n staleness predicate judges every section a run generated, not two fixed names + + `os i18n extract --no-objects-only --fill=default --source-hashes` emits + `apps` / `dashboards` / `pages` leaves and fills them from the source locale — + leaves carrying exactly the property the GENERATED staleness predicate exists to + judge — but the population that predicate walked was the fixed + `GENERATED_SECTIONS` list (`['objects', 'metadataForms']`). So no provenance + record was written for such a leaf, none was read back, and a `--fill=default` + copy left behind by a revised source kept being served as a superseded draft + with every i18n gate green. The hand-authored predicate does reach those paths, + but it judges against `LOCALE.source-hashes.ts`, which by construction carries + no entry for a leaf a generator produced. Neither mechanism covered them. + + The population now follows the RUN, at both ends: + + - **write** — `collectFilledFromHashes` takes a new **optional** fourth + parameter, `sections?: readonly string[]`, defaulting to `GENERATED_SECTIONS`. + `collectGeneratedLeaves` takes the same optional second parameter. Every + existing call site compiles and behaves exactly as before; `os i18n extract` + passes the sections it actually built. + - **read** — `findStaleFills` walks the sections the recorded table itself + names. One run wrote that table, so the table is the record of what that run + emitted, and the two ends cannot disagree about it. For every table committed + today this resolves to `['objects', 'metadataForms']`, so no served byte moves. + + Adding `'apps'` to `GENERATED_SECTIONS` was the other available shape and is + deliberately not taken: it would make `collectSourceLeaves` and + `collectGeneratedLeaves` walk one section — two predicates permanently on one + path — and it would assert `apps` is always generated, which is false for every + bundle set that ships. Both constants are unchanged and pinned unchanged. + + Widening the generated population is safe in a way widening the hand-authored + one would not be, because the rule is self-discriminating per leaf: a record is + written only when `value === currentSource` or `previous[path] === hash(value)`, + so a leaf someone actually translated satisfies neither and stays + legacy-trusted however wide the walk. The section list was the only part of the + mechanism that could not tell a fill from a translation. + + No committed bundle or companion byte moves in this repository. All nine + `--source-hashes` configs run the default `--objects-only`, whose commit layer + already narrows the run's table to the sections it emits a bundle for. The 387 + hand-recorded digests across `zh-CN` / `ja-JP` / `es-ES` are neither read, + written, shadowed nor lost — `apps` stays in `HAND_AUTHORED_SECTIONS`, + `collectSourceHashes` still walks it, and the extractor still never writes that + file. Its header now states which table a maintainer keeps for a path that can + appear in both, and why the overlap cannot serve wrong text. +- b6471ba: A datastore created from empty now attests **two** creation-attested migration ids, not + three. + + `attestFreshDatastore` (`@objectstack/platform-objects/system`) writes one `sys_migration` + row per id in `CREATION_ATTESTED_MIGRATION_IDS` (`@objectstack/spec/system`) at the moment + a store is created from empty. That tuple lost `'adr-0030-notification-event'` when the + ADR-0030 notification cut-over was retired, so a store born on this version is attested for + `'adr-0104-file-references'` and `'adr-0104-value-shapes'` alone. + + ## What an operator sees + + - A fresh deployment's `sys_migration` table holds **two** creation-attested rows where it + held three. Nothing else about them moves: both carry the same + `attested: 'datastore-created-empty'` marker in `details`, and both ADR-0104 gates are + enabled from birth exactly as before. + - **No row is written under `'adr-0030-notification-event'` any more, and nothing reads + one.** A deployment that already holds such a row keeps it, untouched — + `NOTIFICATION_EVENT_MIGRATION_ID` (`@objectstack/spec/system`) survives as that row's + name so the table stays readable by an operator. The id gates nothing, and never did. + - Nothing this package exports is renamed, removed or re-signed. `attestFreshDatastore` + takes the same arguments and answers the same shape; a caller passing its own + `migrationIds` is unaffected, because only the default moved. + + There is nothing to adopt and no command to run. Pre-ADR-0030 `sys_notification` rows are + not carried by the platform on this line, so a store created from empty has nothing the + retired id could have attested. +- 1a2bb9e: Studio's property-panel repeater tables name their columns in the author's own language: every repeater enumerates its row properties in the owning `*.form.ts`, and all four platform catalogs carry a translated name for each one + + Clause-②: no + + A `type: 'repeater'` renders as a table whose column heads come from the form's declared row children when it declares any, and from the served JSON Schema `items.properties[k].title` when it does not. `os i18n extract` only emits a `metadataForms..fields['.']` key for a **declared** child, so a repeater that enumerated none had no localisation channel at all — #17232 (PR #17500) authored English titles on thirteen item schemas, #17505 and #17506 on four more, and every one of those column heads reached a Chinese, Japanese or Spanish author in English. + + Both halves land together, because either alone is a half-state: 112 row properties across fifteen repeaters are now enumerated, each with a `label` equal to the item schema's own `.meta({ title })`, and the `en` / `zh-CN` / `ja-JP` / `es-ES` catalogs gain a leaf for each. Nothing in the accept set moves — the same author input parses identically before and after, and no row child declares a `type`, so the row widgets stay schema-derived. + + Terms reuse the word each catalog already uses for the concept (`Label` → 显示名称 / 表示名 / Etiqueta, `Filter` → 筛选 / フィルター / Filtro, `Timeout (ms)` → 超时(毫秒)/ タイムアウト(ms)/ Tiempo de espera (ms)), and `field.options.*` mirrors its `object.fields.options.*` twin verbatim. + + `page.variables.source` is the one existing string that moves. Its children were enumerated without labels, so the extractor emitted the humanized path `"Source"` as the English source and the bundle overlay then wrote that over the schema's authored `"Written By"`. The form now declares the label, the `en` leaf becomes `Written By`, and its three translations are re-authored with it (写入组件 / 書き込み元 / Escrito por). + + `view.columns` / `view.sort` / `view.tabs` are untitled and enumerate no children — they are #17507's, and are untouched here. `object.fields.options` stays the curated four-key subset its reconciliation-ledger entry declares. +- a2c2852: Notification fan-out asks a channel whether the tenant can send on it before writing anything, so a channel with no transport no longer produces `sys_notification_delivery` rows that exist only to dead-letter (#17732). + + `MessagingChannel` gains one **optional** member, `isAvailable(ctx, { organizationId })`, answering `{ available: true }` or `{ available: false, reason }` from the closed vocabulary `CHANNEL_UNAVAILABLE_REASONS` (today: `transport_not_configured`). `emit()` consults it once per channel per emit — availability is a property of `(tenant × channel)`, not of a recipient — and a channel that answers unavailable gets no delivery row and no `send()` call on either the outbox (P1) or the inline (P0) path. + + - **Optional means available.** A channel that does not implement the member is treated exactly as before. Every existing implementation, in this repo and in yours, keeps working unchanged with no edit; the same is true of a channel that is registered but unknown to this version. ⛔ There is no way to configure the opposite default. + - **The suppression is recorded, not swallowed.** `sys_notification` gains one key, `suppressed_channels` — `[{ channel, reason }]`, `NULL` when nothing was suppressed — written in the *same* insert that creates the event row, so the feature costs no additional write. `EmitResult` gains the matching `suppressed` array, so a caller is never handed a delivery count that silently omits a channel it asked for. + - **The `email` channel answers from the transport it was handed** — a service-registry lookup, no I/O, nothing cached. Mail configuration in this tree is the `mail` settings namespace at `scope: 'global'`, materialised into a single in-memory transport that the settings change bus hot-swaps, so there is no per-tenant row to read and a memoized answer would survive the settings save that fixed it. The query still takes the tenant context so a future tenant-scoped transport needs no interface change. + - **A probe that throws is treated as available** and logged at `warn`: a broken availability check degrades into today's behaviour, never into a silent notification outage. + - ⚠️ **Unchanged on purpose**: a channel named in `channels` that is not *registered* at all keeps its existing path — the inline fan-out reports it as a failed delivery, the outbox enqueues a row the dispatcher dead-letters. It has no implementation to ask, and widening this ruling to cover it is filed separately. +- c744c0a: `apps.account.navigation.nav_connect_agent` is translated in all four locales, so the Connect an Agent page renders the same string behind both doors + + `@objectstack/mcp` contributes the Connect an Agent page into **two** apps — `setup` (admins) and, since #17646, `account` → Developer (every authenticated user). The translation bundles are keyed `apps..navigation.`, one namespace per app, and only the `setup` key existed. So `apps.setup.navigation.nav_connect_agent` never answered for the Account door, and one destination rendered two different strings for the same signed-in user: + + | door | before | + |:--|:--| + | Setup → Integrations | 「连接智能体」 / 「エージェントを接続」 / "Conectar un agente" | + | Account → Developer | `Connect an Agent`, the English literal, in every locale | + + The population that got the untranslated one is precisely the non-admin on a non-English locale: Account is the only one of the two doors they can open. + + Adds the key to `en` / `zh-CN` / `ja-JP` / `es-ES`, mirroring the Setup twin's strings verbatim, plus the `#8765` provenance row in each of the three hand-maintained `.source-hashes.ts` tables (`en` is the source, not a copy of one, so it has no table and gets no row). The recorded digest is `collectSourceHashes(en)['apps.account.navigation.nav_connect_agent.label']` — the repo's own `hashSource`, not a hand-written value. + + ⛔ No behaviour outside the bundle moves. No nav item, permission, route or page is added: the contribution and the destination already existed and are untouched, and the Setup key is byte-unchanged. This is an additive key on a published payload, which is why it ships `minor` rather than `patch`. + + Neither gate over this surface could see the gap, and neither is changed here: `pnpm check:app-nav-i18n` scopes itself to `APP_NAME = 'setup'` and skips every contribution targeting another app, and `app-nav-translation-parity.test.ts` walks statically declared nav — the Account entry is contributed at runtime, so no static walk reaches it. Extending the gate is the next step in the standing repair order and lands in `packages/cli/scripts/**` under its own card, deliberately not folded in here. +- 74fb2f7: feat(platform-objects): declare the `set_user_manager` row action on `sys_user` (#19249) + + `sys_user.manager_id` drives the approvals `{ type: 'manager' }` rung and the ADR-0057 `own_and_reports` read scope, and `POST /api/v1/auth/admin/set-user-manager` (#16678 Phase 3) has been its only product write surface since it landed — with nothing in the Console reaching it. This declares that affordance: a `set_user_manager` row action on `sys_user`, offered from the Users list row menu and the record-detail header, collecting the new manager through an inline `sys_user` lookup and POSTing `{ userId, managerId }` to the admin endpoint. + + Three properties of the declaration are decisions rather than detail: + + - **It posts the admin endpoint, never the generic data API.** `sys_user` is `managedBy: 'better-auth'` and the ADR-0092 D2 managed-update whitelist is `{name, image, locale}`, so a picker writing `manager_id` through `/api/v1/data` would be refused by the identity write guard — correctly — and would read as a Console bug. The field keeps `readonly: true`; the endpoint reaches the column by system context. + - **Its `visible` predicate carries the directory-sync term and not the self-service one.** A directory-owned identity (`source: 'idp_provisioned'`) is refused by the endpoint, so the button is hidden for one — the same term the three self-service identity actions on this object already spell. Their `record.id == ctx.user.id` half is deliberately not carried over: this is an admin action on someone else's row. + - **No second copy of the server's refusals.** Self-assignment, cycle, depth, cross-organization and directory-owned identity are enforced at the write, in one derivation, and surface from there. Nothing is re-derived client-side. + + Additive: no existing action, field or predicate changed. The `manager_id` field and its read-only rendering are untouched, and `sys_business_unit.manager_user_id` (Business Unit Head) is a separate, independent relation that this does not read or write. +- 8d1f7ab: feat!: retire the saved-report stack — `sys_saved_report` / `sys_report_schedule`, `/api/v1/reports`, `client.reports`, `IReportService`, the `reports` capability and `@objectstack/plugin-reports` (#20102) + + **BREAKING** — the saved-report stack is removed whole, with no deprecation window + (maintainer ruling 2026-09-25, 「A. 退役」). It persisted a raw object query + (`object_name` + `{ filter, fields, orderBy, limit, groupBy }`) with a render format + and an owner, and could e-mail it on a schedule. Measured on the main branch of this + repository, objectui and cloud before removal: zero callers of the routes, the SDK + namespace or the service contract outside their own tests, and no app declaring the + capability. + + **NOT affected: the `report` metadata kind.** `ReportSchema`, `defineReport`, + `/meta/report`, datasets and the analytics service are unchanged. The two shared the + word "report" and nothing else. + + FROM → TO, per surface: + + - `requires: ['reports']` → **refused** by `defineStack` (`STACK_CAPABILITY_UNKNOWN`, + 422) with the prescription "requires: 'reports' was removed in @objectstack/spec + 17.5.0 … Delete the token." Fix: delete the token. `os serve` on an older artifact + that still carries it warns with the same prescription and ignores it; `os validate` + and `os build` over a plain-object config (no `defineStack` call, so no parse-time + vocabulary check) report it as a non-fatal capability advisory carrying the same + prescription, never "check for a typo". The token is + gone from `PLATFORM_CAPABILITY_TOKENS` and `PLATFORM_CAPABILITY_PROVIDERS`; the new + `RETIRED_PLATFORM_CAPABILITY_GUIDANCE` (`@objectstack/spec/kernel`) carries the + prescription. + - `IReportService`, `SavedReport`, `ReportSchedule`, `ReportQuery`, `ReportFormat`, + `ReportRunResult`, `SaveReportInput`, `ScheduleReportInput` + (`@objectstack/spec/contracts`) → removed, no replacement export. Fix: delete the + import. + - `SysSavedReport`, `SysReportSchedule` (`@objectstack/platform-objects/audit`) and + the names `sys_saved_report` / `sys_report_schedule` in + `PLATFORM_PROVIDED_OBJECT_NAMES` → removed. A stack referencing either name is now + flagged as a probable typo instead of resolving. + - `GET|POST /api/v1/reports`, `GET|DELETE /api/v1/reports/:id`, + `POST /api/v1/reports/:id/run`, `POST /api/v1/reports/:id/schedule`, + `GET /api/v1/reports/:id/schedules`, `DELETE /api/v1/reports/schedules/:scheduleId` + → unmounted: each answers the standard unmatched-route `404`, byte-identical to a + path that never existed. Their nine error codes (`REPORTS_LIST_FAILED`, + `REPORT_DELETE_FAILED`, `REPORT_GET_FAILED`, `REPORT_NOT_FOUND`, + `REPORT_RUN_FAILED`, `REPORT_SAVE_FAILED`, `REPORT_SCHEDULE_FAILED`, + `SCHEDULES_LIST_FAILED`, `SCHEDULE_DELETE_FAILED`) leave `ERROR_CODE_LEDGER` with + their only emitter. + - `client.reports.*` (`list`, `save`, `get`, `delete`, `run`, `schedule`, + `listSchedules`, `unschedule`) → removed. Fix: delete the call. A report is `report` + metadata, read through `meta.*` and queried through `analytics.*`; a saved ad-hoc + object query is a ListView on that object. + - `RestServer`'s constructor keeps the position of the retired saved-report provider, + typed `undefined`, so no later positional argument re-binds. Pass `undefined` there; + passing a provider is a compile error. + - `@objectstack/plugin-reports` → no longer built or published from this repository, + and `@objectstack/cli` no longer depends on it or mounts it. Fix: remove the + dependency. There is no successor package and no scheduled-delivery replacement. + + **Existing databases.** `sys_saved_report` / `sys_report_schedule` tables in a deployed + database are left in place, untouched — no backfill, no reaper, no drop — under the + repository's convention for a retired platform object: the platform never drops a + table that metadata stops declaring, and `os migrate plan` lists such a table in its + informational unmanaged-tables section so an operator can decide. + + `@objectstack/metadata-protocol` (patch): the `INVALID_SORT` hint for a sort node + spelled `{ field, direction }` no longer names the retired saved-report contract as + the source of that vocabulary; it names the better-auth adapter's `sortBy`, which + still uses it. Code and status are unchanged. + + Breaking ships as `minor` per the launch-window convention + (`scripts/check-changeset-no-major.mjs`). + + **Clause-②: yes (narrowing)** — a published capability token, a service contract and + its types, two platform objects, eight routes, nine registered error codes and an SDK + namespace are removed; nothing previously refused is now accepted. + + +- 23aa83c: `DataMigrationFlagSchema` gains `columns_moved_at`, and the `sys_migration` platform object gains the matching column: the deployment-level attestation that a migration's COLUMN MOVE ran here — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. + + **What it attests** is a fact the ledger could not previously express. `applied_at` says the backfill ran in apply mode; `verified_at` says the self-check passed. Neither says anything about the physical columns, because the backfill and the column move are separate acts and only the first of them had somewhere to be recorded. A deployment can therefore have applied AND verified a migration and still store the legacy encoding. `columns_moved_at` is that second fact, carried as its own member rather than as a widening of either existing one: folding it into `verified_at` would change what an already-verified row authorises on every deployment that has never heard of a column move. + + **Absence is the contract, not a default.** The member is optional and nullable, and nothing in this change writes it. Null or absent means the columns still hold the legacy encoding — a real, expected steady state on any deployment that has run the backfill but not the move, and never an error state — so every row that exists in the world today, and any consumer that cannot read the member at all, lands on the legacy encoding with no extra logic. A required member, or a default value, would destroy the exact property the mechanism was chosen for. + + **Nothing reads it yet, and the arbiter is untouched.** `isDataMigrationFlagVerified` — documented as the ONE arbiter for the existing consumers (reap gating, the strict value-shape flip) — is unchanged in this diff, and is now pinned to return the same verdict for a row that omits the new member as it returned before the member existed; `authorisesIrreversibleAction`, which composes it, is pinned the same way. The predicate that will require `columns_moved_at` non-null belongs to the driver work this change unblocks, and reads it in addition to the arbiter, never inside it. + + This is an additive widening: `DataMigrationFlag` (`z.input` of the schema) gains one optional member, no existing member changes or moves, and no export is added or removed. +- 4bbf766: Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). + + **BREAKING** (return shape) — `walkAddressedPageComponents` is a published export of `@objectstack/spec` and its return value is now the rebuilt roots pair `{ regions?, slots? }` where it used to be the regions array alone. A caller that only enumerates components through the visitor and ignores the return value is unaffected. A caller that reads the return value binds `const { regions } = walkAddressedPageComponents(doc, visit)` and reads `regions` exactly as it did before; `slots` is the other half of the same rebuild and is present exactly when the input page authors slots. The bump stays `minor` because the launch-window convention `scripts/check-changeset-no-major.mjs` enforces refuses a `major` while the fixed group is in lockstep — during that window the version number carries nothing about breaking-ness, so this banner and the disposition below are the carriers. + + **`walkAddressedPageComponents` widens in both dimensions.** The shared page walk behind `translatePage` and the CLI extractor (`os i18n extract` / `os i18n coverage`) rooted at `regions[].components[]` only and descended `properties.children` only. A slotted record page authors `regions: []` and puts everything under `slots.`, so the walk visited nothing on it and `pages.` carried exactly two addressable keys however many components the page authored; a `page:tabs` / `page:accordion` keeps its panels' components under `properties.items[].children`, one level deeper than the descended slot, so a related list inside a tab was unreachable on any page kind. The walk now roots at `regions[].components[]` **and** `slots.` (one component or an array per slot, regions first, then slots in authored order — both root level for the collision arbitration and for the page-name `page:header` route, so a slotted page's `slots.header` is translated as the page's header), and descends `properties.children` **and** `properties.items[].children` (matched by shape, so a custom container speaking the same vocabulary is walked too; `body` / `footer` remain undescended — a renderer back-compat fallback, not an authorable spelling). The depth cap, the cycle guard and the ruled id arbitration are unchanged. + + - Signature: the parameter is `AddressedPageRoots` (= `Pick`) instead of `Pick`, and the walk returns the rebuilt roots pair `{ regions?, slots? }` (each key present exactly when present on the input) instead of the regions array alone. `PageLike` gains `slots`. An enumeration-only consumer that ignores the return value needs no change; a consumer reading the returned regions destructures `{ regions }`. + - `translatePage` carries the rebuilt `slots` back onto the document. + + **`dashboards..globalFilters.` is a new bundle group.** A dashboard's filter bar draws directly above the widget titles the bundle has always translated, and neither a filter's label nor its static option labels had a key. The group is keyed by the filter's `name` (`GlobalFilterSchema.name`, declared as defaulting to `field` — a filter that authors no `name` is keyed by its `field`) and carries `label` and an `options.` map keyed by the option `value` spelled as a string. `translateDashboard` overlays it on the served document, which is what objectui's filter bar already reads; the exported `globalFilterKey()` is the one key derivation both the resolver and the extractor use. `optionsFrom` options are fetched rows and are deliberately not addressable. + + **`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. + + **`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards..globalFilters.created_at.label` entry for the system-overview dashboard's date-range filter, which authors no `name` and is therefore keyed by its `field`. + + **Why no ADR-0087 ledger entry.** Nothing an author writes moves. The authorable side is purely additive — `dashboards..globalFilters.` is a new optional group and every bundle that was valid before is valid unchanged — no spec key is retired, no stored `sys_metadata` shape changes, and no conversion or migration id is touched, so `objectstack migrate meta` has nothing to act on. The one incompatible surface is a published function's TypeScript return type, which reaches every affected consumer through the compiler. + + +- 9bd4344: feat(auth)!: adopt better-auth's account-issuer rollback — drop `sys_account.issuer`, retire the backfill, lift the `@better-auth/*` family to an exact `1.7.3` (#17440) + + + + **BREAKING** — a platform object drops a declared field and `@objectstack/plugin-auth` + drops six published symbols. Shipped as `minor` under the launch-window convention + (`major` is refused by `check-changeset-no-major`; breaking-ness is carried by this + banner plus the ADR-0087 disposition above). The hand-migration prescription is + registered under protocol major 18 as `sys-account-issuer-retired`. + + better-auth `1.7.3` removed the issuer-scoped account identity outright + (`better-auth/better-auth#10909`): `createLocalAccountIssuer` is deleted, + `accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the + `account.issuer` column and its unique index are gone from `get-tables`. There is no + drop-in replacement. `#16186` pinned the family at an exact `1.7.2` as a stopgap; this is + the durable half, per the maintainer ruling of 2026-09-10 on `#16629`. + + ## 迁移:FROM → TO + + | FROM | TO | the one-line fix | + |:--|:--|:--| + | `sys_account.issuer` (column + `{ fields: ['issuer','account_id'], unique: true }`) | — | nothing replaces it; identity is `(provider_id, account_id)`, declared UNIQUE on `sys_account` since the object was created | + | reading `account.issuer` off a row or off `client.accounts.list()` | `sys_sso_provider.issuer`, resolved through the account's `provider_id` | `provider_id` is unique per environment, so it names the authority on its own | + | `backfillAccountIssuer(ql, …)` | — | delete the call; there is no successor pass | + | `CREDENTIAL_ISSUER` / `oauthIssuerFor(id)` | — | drop the argument; `internalAdapter.createAccount({ userId, providerId, accountId, password })` takes no `issuer` | + | `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult` | — | delete the import; the compiler names every site | + | `@better-auth/*` at an exact `1.7.2` (eleven members) | an exact `1.7.3` (eleven members) | the family moves as ONE line — `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions | + + ## ⭐ Existing deployments: run the pre-flight BEFORE the column is dropped + + Uniqueness moves from `(issuer, account_id)` to `(provider_id, account_id)` — a + **narrower** key. Two rows sharing `provider_id` + `account_id` and differing only in + `issuer` are legal under the old key and are ONE account under the new one. + + ``` + os migrate account-issuer # read-only; exits non-zero when the drop must not proceed + # … take a backup (the operator's act, and the apply step's precondition) … + os migrate apply --allow-destructive + os migrate account-issuer # post-check: reads zero + ``` + + The pre-flight reads **rows**, never the index declaration. `syncDeclaredIndexes` logs a + plain UNIQUE whose CREATE failed on existing duplicates onto the durability channel and + lets the boot continue (`#14902` / `#15479`), so a database can carry the declaration + without the constraint — and on such a database the drop does not fail loudly, it + degrades silently: the rows become indistinguishable and a sign-in can resolve onto the + wrong user's account. `os migrate apply --allow-destructive` re-runs the same pre-flight + and refuses the drop before writing any DDL. A read that throws, or a scan that + truncates, refuses too — an unread table is not a clean one. + + ⛔ Colliding rows are never merged or dropped for you: which row survives is application + knowledge, and two different people can be behind one colliding key. Keep the row whose + provider account is live, delete the rest so a fresh sign-in re-links, and re-run. + + The boot refusal is unchanged and needs no new machinery: a runtime already refuses to + start against unapplied destructive drift, naming the command to run, and never + auto-migrates. + + ## ⚠️ A `provider_id` re-pointed at a different IdP must have its bindings REBUILT + + This is the one case `issuer` still discriminated. After the drop no column records which + IdP vouched for a row, so if a re-pointed provider's new IdP mints a subject the old one + had already issued to somebody else, the key resolves that sign-in onto the other + person's account. Under the old key that failed loudly (`unable_to_link_account`); under + the new one it is silent. + + ⇒ `sys_sso_provider` now **refuses an `issuer` change while `sys_account` rows are still + bound to that `provider_id`** (`RESOURCE_CONFLICT` / 409). Delete the provider's account + bindings first; each user re-links on their next sign-in. + + ## Why the column was a liability, not an asset + + A credential row whose `issuer` was not the local credential issuer was invisible to + `findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not + found" warn pointing at the `sys_user` row rather than at the account. **Four checklist + items had that recorded as a knownGap, each rediscovering it.** Its discriminating power + here was near zero anyway: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: + true }`, so `provider_id → issuer` is a function within an environment. + + ## Also in this change + + `pnpm check:vendor-export-contract` (from `#16186`) keeps its exactness requirement and + still resolves every named symbol — its self-test re-anchors from the now-retired + `@better-auth/core/db` specimen onto a live edge, and gains a case asserting the two + deleted names are imported nowhere. `#11627`'s hash-shadow-key machinery is untouched: it + is a generic driver capability serving five UNIQUE members of the >768-char class. + +### Patch Changes + +- 0f95f43: docs(identity): re-point the cloud-identity `ADR-0024` citations at the records that decide them (#14361) + + From this repository's point of view `ADR-0024` names two unrelated decisions. + `docs/adr/0024-mcp-connectors.md` is *MCP Servers as Connectors* — an open, + vendor-neutral tool protocol, with a Decision section numbered §1–§5 and no + D-lettered clauses at all. The identity surface's citations mean something else + entirely: the identity-and-access decision taken in `objectstack-ai/cloud` as + its own ADR-0024, whose open mechanism half has been mirrored into this repo + since 2026-09-07 as + [ADR-0135](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0135-identity-and-access-architecture.md). + A reader following one of those citations landed on a real page about the wrong + subject, which is worse than a dangling id: a plausible-looking record invites + belief rather than a second question. + + 79 citation lines were read one at a time and re-pointed. 73 mean a clause + ADR-0135 restates and now name it with its letter — D4 (source-of-truth marking, + managed vs env-native), D5.2 (the break-glass last-administrator invariant), D6 + (SSO per production environment, including the opt-in DNS domain-verification + clause this tree spelled `ADR-0024 ②`) and D9 (environment users and + organization membership). 6 mean a clause ADR-0135 deliberately leaves in the + cloud record and now carry the anchors gate's cross-repo qualifier + `cloud ADR-0024`: `V1` (the SSO default-role provisioning, the roadmap and + commercial framing) and `§7` (the `ai_seat` synthesis, which ADR-0135 does not + restate). + + What actually reaches a consumer of these packages: + + - `@objectstack/plugin-auth` — the **operator-facing break-glass refusal + detail** now reads `break-glass invariant, ADR-0135 D5.2 — an environment must + always keep at least one administrator who can sign in`. The condition that + raises it, its status, its error code and the rest of its wording are + unchanged; only the ADR number moves. ⚠️ A deployment that greps that message + for the literal `ADR-0024` should grep for `ADR-0135`. The guard's + registration log line moves the same way. + - `@objectstack/platform-objects` — `sys_sso_provider`'s `domain_verified` field + help text, its `protection.reason`, and the matching leaf in all four shipped + locale bundles (`en`, `es-ES`, `ja-JP`, `zh-CN`). + - `@objectstack/spec` — the doc comment above `AuthConfigSchema`'s + `ssoDomainVerification`, published both in `dist/` and as + `src/system/auth-config.zod.ts`. + - `@objectstack/core`, `@objectstack/cli` — doc comments only, published in + `dist/`; no runtime string and no behaviour. + + No behaviour moves. No schema accepts or refuses anything it did not accept or + refuse before, no security or permission semantics are touched, and no ADR + record is written or edited. Bare `ADR-0024` still resolves exactly as it did: + the 15 citations that mean the local MCP-connectors record are byte-identical to + `main`, and `check:adr-anchors` reports the same resolving-citation totals before + and after. Historical archives are deliberately untouched — 36 CHANGELOG lines + across seven packages, and the 22 lines under `docs/adr/`, which is a governed + surface this change does not enter. +- 825d70f: docs(identity): re-point the SCIM/identity `ADR-0071` citations at the records that mean them (#14361) + + From this repository's point of view `ADR-0071` named two unrelated decisions, + and only one of them had a record here. `docs/adr/0071-dataset-semantic-layer-depth.md` + is *Dataset semantic-layer depth — multi-hop joins*. The identity and SCIM + citations mean something else entirely: the enterprise-identity decision taken in + `objectstack-ai/cloud`, whose open mechanism half has been mirrored into this + repo since 2026-09-07 as + [ADR-0134](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0134-env-side-scim-provisioning.md). + So a reader following one of those citations landed on a real page about the + wrong subject — worse than a dangling id, because a plausible-looking record + invites belief rather than a second question. + + 44 identity-meaning citations now name the record that holds the decision they + describe. 43 of them read `ADR-0134` (the open mechanism half: effective SCIM + forces the better-auth `admin` plugin on, `active:false` lands as a ban plus + session revocation, the SCIM 2.0 Service Provider mounts in the environment, and + the seven stable `sys_scim_*` models). One reads `cloud ADR-0071` — the + "paid Identity lifecycle" note in `auth-manager.ts`, which names the commercial + half that deliberately stays in the cloud record. + + What actually reaches a consumer of these packages: + + - `@objectstack/plugin-auth` — the **operator-facing construction-time refusal** + raised when SCIM is effective beside an explicit `plugins.admin: false` now + cites ADR-0134 instead of ADR-0071. The condition that triggers the refusal, + its wording otherwise, and the two documented ways out are unchanged; only the + ADR number in the sentence moves. ⚠️ A deployment that greps that message for + the literal `ADR-0071` should grep for `ADR-0134`. + - `@objectstack/spec` — the `admin` flag's `.describe()` text (shipped both as + `src/system/auth-config.zod.ts` and in the generated `json-schema/` bundle), + and therefore the generated `content/docs/references/system/auth-config.mdx` + reference page app authors read. + - `@objectstack/platform-objects` — the `protection.reason` strings on the eight + `sys_scim_*` identity objects and on `sys_user`. + + No behaviour moves. No schema accepts or refuses anything it did not accept or + refuse before, no security or permission semantics are touched, and no ADR + record is written or edited. Bare `ADR-0071` still resolves exactly as it did: + the 22 dataset-meaning citations are byte-identical to `main` and + `check:adr-anchors` reports the same 35477 resolving citations before and after. + Historical archives — the six package CHANGELOGs — are deliberately untouched. +- 305e7fc: Name where the organization record page's Members / Invitations / Teams tab strip is declared, at the three places that assert it (#16270) + + #16270 measured that no object under `packages/platform-objects/src/identity/` declares + the `Field.relatedList` prominence key, and inferred from that a two-way disjunction: + either the metadata is short three `relatedList: 'primary'` declarations, or the three + documents that describe the page as opening on tab-0 **Members** have gone stale. + + **Neither. The premise is false.** The tab strip is declared metadata — + `SysOrganizationDetailPage` in `packages/platform-objects/src/pages/sys-organization.page.ts`, + a `kind: 'slotted'` record page for `sys_organization`, `isDefault: true`, handed to the + runtime by plugin-auth's `pages: [SysOrganizationDetailPage, SysUserDetailPage]`. Its + `slots.tabs` override carries exactly three `record:related_list` tabs — Members, + Invitations, Teams, in that order — and objectui's synthesizer pushes that authored node + and never calls `buildDefaultTabs`, so the strip replaces the synthesized + Details + stacked `Related` one outright and Members really is at index 0. That file was + already in the tree at the commit the card measured. + + `relatedList: 'primary'` is a different mechanism (prominence on a child's lookup field, + promoting one derived list to its own tab). The card looked for that key, correctly found + none, and read the zero as "declared by no metadata". While the `tabs` slot is present, + adding the key would not move this page at all. + + **What changes here is prose only — no metadata, no behaviour.** The two source comments + that assert the tab order and the QA checklist item that grades it now name the page that + declares it, so the next reader does not repeat the measurement: + + - `packages/platform-objects/src/identity/sys-member.object.ts` — the `invite_user` + mirror's rationale + - `packages/platform-objects/src/identity/invite-entry-toolbar.test.ts` — the file header + that states the whole pin's premise + - `docs/qa/platform-checklist/areas/identity-auth.json` — + `identity-auth.org-membership-team-management`, a new `source` entry plus the revision + and history bump its ledger requires. Steps, acceptance clauses, oracles and negatives + are unchanged: a grader grades exactly what it graded before, and now knows that a + Details + stacked `Related` strip means this page failed to load rather than that the + clause was wrong. + + This package ships its `src` comments in `dist` (measured: the new comment text appears + 4 times under `packages/platform-objects/dist`, with an exported symbol as the positive + control and the test-file header absent at 0), which is why a comment-only diff here + takes a changeset rather than the publishes-nothing exemption. +- 8a017af: `sys_job_queue`'s claim path no longer sorts the whole queue on every poll, and a job's due time is now a SQL predicate instead of a filter applied after `LIMIT` (#17612). + + `DbQueueAdapter.claimBatch` — the 1s poll every `DbQueueAdapter` runs — read the queue as `WHERE queue = ? AND status = 'pending' ORDER BY priority ASC, scheduled_for ASC`, while `sys_job_queue` declared `['queue','status','scheduled_for']`. The sort's **first** key, `priority`, was in no declared index at all, so the equality prefix seeked and the planner then built a sorter over every pending row in the queue, every tick. Measured on both Turso faces: + + ``` + SEARCH sys_job_queue USING INDEX idx_sys_job_queue_queue_status_scheduled_for (queue=? AND status=?) + USE TEMP B-TREE FOR ORDER BY + ``` + + - **The declared index becomes `['queue','status','priority','scheduled_for']`**, replacing `['queue','status','scheduled_for']` — the table still declares three. The full-queue sort is gone on both faces; what remains is a sorter bounded to rows tying on the whole indexed prefix, because a paged read carries one ORDER BY term the caller never writes — the unique tie-breaker of the deterministic-paging contract (ADR-0053 D-A1), here `id`. ⛔ That last term is deliberately **not** closed by appending `id` to the index: `id` is an unbounded `Field.text`, and a text column a declared index keys on without a `maxLength` makes MySQL reject the index DDL outright (`check:keyed-text-bounds`, ER_BLOB_KEY_WITHOUT_LENGTH). + - **Due-ness moved into `where`** as `$or: [{ scheduled_for: null }, { scheduled_for: { $lte: now } }]`, the same shape `SqlOutboxStore.claim` uses. It had been a JS filter applied to rows `LIMIT` had already chosen, so a window full of not-yet-due high-priority jobs hid already-due work behind it indefinitely: at the default `batchSize: 10` (candidate window 30), 30 future-dated `priority: 1` rows plus one due `priority: 100` row claimed **0** per poll, forever. It now claims 1. + - **`priority` still decides claim order.** The alternative — dropping it from the sort — would have left a declared, documented field (`Lower = higher priority`) with no runtime effect at all. + - ⚠️ **On an existing database the superseded index is not dropped.** The retrofit adds `idx_sys_job_queue_queue_status_priority_scheduled_for` and leaves `idx_sys_job_queue_queue_status_scheduled_for` in place (measured: 3 indexes before, 4 after, no row touched), so a provisioned table carries one redundant index until an operator drops it through the migrate-plan path. A freshly created table gets three. +- cd5fdaa: docs(email): the shipped carriers said "best-matching locale"; the resolver matches `(name, locale)` exactly (#18499) + + Clause-②: no — no accept set moves and no published payload key changes; the + corrected prose ships as JSDoc in each package's `dist/*.d.ts` (and, for + `@objectstack/service-messaging`, inside the bundled `dist/index.js`), which is + why this is a changeset rather than `skip-changeset`. + + `packages/plugins/plugin-email/src/template-loader.ts` already enumerates + "the EmailService picks the best-matching locale" as a FALSE declaration, and + three shipped carriers still stated it. Measured against the code at this + branch's base rather than against the card's transcription: + + - `createSysEmailTemplateLoader.load` — `locale` given ⇒ exact `{ name, locale }` + match ordered by `id`, or `null`; `locale` absent ⇒ `{ name, locale: 'en-US' }` + first, and only if that misses `{ name }` ordered by `locale` ascending; + - `EmailService.resolveAndRenderTemplate` — `wanted = input.locale?.trim() || + 'en-US'`, then exactly one retry at the literal `'en-US'` when the call NAMED a + locale, then `TEMPLATE_NOT_FOUND`; the unpinned rung is reachable only for a + call that named no locale. + + No language-subtag folding anywhere on that path, and nothing that could be + called a "best match". Corrected: + + - `sys_email_template`'s object doc (`@objectstack/platform-objects`) now states + the exact match, the single `en-US` rung and the no-locale last resort; + - `sys_notification_template.locale`'s sibling-declaration comment + (`@objectstack/service-messaging`) said "both resolve a template by + best-matching locale", which was false in a second way: the two resolvers do + not agree. `NotificationTemplateStore.load` walks `(topic, channel, locale)` + through a candidate list — the named tag, its primary subtag, then + `DEFAULT_LOCALE` (`'en'`) — so it DOES fold a subtag, where + `sys_email_template` does not. Only the shared 16-char BCP-47 bound is shared; + the resolution is not, and the comment now says so; + - `template-loader.ts`'s own "What was wrong" block quoted two sentences it can + no longer quote — one was already stale at this base (the + `EmailTemplateDefinitionSchema.locale` text it reproduces has zero occurrences + in `packages/spec` today) and the other is corrected above. Both bullets are + now cited rather than quoted, so a later rewording cannot strand them again. + + No resolution behaviour changes: every edit in this changeset is prose. +- 502f179: **BREAKING** — retire `object.tenancy.organizationField`, the stamp-only column + declaration the whole protocol declared exactly once, on a table this platform ships. + + The key answered "which column says who this platform row is ABOUT", where + `tenancy.tenantField` answers "what is this object WALLED by". The spec's own docblock + stated the consequence: *"For ordinary objects the two coincide and `organizationField` + is never needed."* Measured on `main` before this change, the entire repository declared + it **once** — `packages/platform-objects/src/identity/sys-api-key.object.ts`, the + better-auth credential table — and zero business objects declared it anywhere. Its + readers were three platform-row writers, scope-pinned **by name** (audit stamping, the + approval-row writer, the automation-run recorder), so an application declaration was + inert by construction while still being authorable on every object, which made every + future piece of organization logic owe the question "what if somebody set this?". + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18, verbatim and untranslated: + 「organizationField 撤出可授权面 同意你的建议」. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `tenancy: { enabled: false, organizationField: 'active_organization_id' }` | `tenancy: { enabled: false }` — delete the key. Nothing read it on an application object | + | `tenancy: { enabled: true, organizationField: 'about_org_id' }` on an object whose tenant column really is `about_org_id` | `tenancy: { enabled: true, tenantField: 'about_org_id' }` — the surviving key both walls the object and stamps its platform rows | + | you declared it to make one platform table's rows stamp differently | nothing to write. That divergence is a platform fact now, not a knob | + + The `tenancy` block is `.strict()`, so the key is **refused** with its prescription + rather than stripped, and `os migrate meta --from 17` lists the mechanical edits for + existing sources. + + ## What does NOT change + + The `sys_api_key` divergence is intact, and that is the point of the shape this takes. + The credential table is `managedBy: 'better-auth'`, so `resolveInjectedSystemColumns` + bails before tenancy is consulted and no `organization_id` is ever injected; the column + it really carries is better-auth's `active_organization_id`. Its audit, approval and + automation-run rows still stamp that column. What moved is only where the fact is + written: `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, one row, + keyed by object name and read by the STAMP face alone. The WALL face + (`resolveRecordWallOrganizationField`) never read the key and is untouched, so the + stamp/wall divergence pin stands unchanged. + + ⛔ The column is **not** renamed to `organization_id` and must never be: in this platform + "has an `organization_id` column" IS the wall, so the rename would wall the credential + table on an equality that excludes NULL and every pre-existing key would vanish from its + own owner's key list. + + ## For `@objectstack/metadata-core` consumers + + `resolveRecordOrganizationField` and `createRecordOrganizationResolver` keep their + signatures and their four-limb precedence. Limb 0 is now keyed by the object's + registered NAME against the platform table instead of by a declaration on the definition: + the engine-bound resolver passes the name it was asked about, and the two-argument + function reads `objectDef.name` when the definition carries one. A caller that fed it a + hand-built definition carrying `tenancy.organizationField` — only reachable by + reimplementing a platform writer — now gets limbs 1 to 4. + + The retirement kit, in the shape the playbook prescribes: + + - the key is DELETED from `TenancyConfigSchema` (the block is a `strictObject`), and a + `TENANCY_RETIRED_KEY_GUIDANCE` row carries the prescription beside the two v15.0 + precedents (`tenancy.strategy`, `tenancy.crossTenantAccess`) + - D2 conversion `object-tenancy-organization-field-removed` (`toMajor: 18`, + `retiredFromLoadPath: true`) strips the key from authored sources and stored + `sys_metadata` rows; D3 wires it into the protocol-18 chain step, and + `RETIRED_KEYS_BY_MAJOR[18]` declares `data/TenancyConfig:organizationField` + - the `authorable-surface/data.json` row is deleted in this same commit — the strict + route's tripwire — with the build computing the guidance-route proof for itself + - the liveness ledger row is deleted, since the key leaves the walked shape entirely + - pin tests: the authored shape is refused with its prescription, and the `sys_api_key` + stamp is pinned end to end beside the closed-set control (the same shape under any + other object name takes the ordinary limbs) + + Clause-②: no + + +- 74554a3: `field.relatedListFilter` and `object.validations` are authorable in the metadata form. Both keys were **declared** by the served schema and offered by **no** form in `METADATA_FORM_REGISTRY`, so the generic metadata form never rendered a row for either and an author's only door was the Source tab — free-text JSON, where a mis-spelled sibling key is written, stored, and refused by the runtime later. + + Measured on the tree before the change: zero rows for either key across every `*.form.ts` in `packages/spec/src`, with a lit control (`maskingRule`, offered twice) and a dark control (a name no form carries) in the same read — so the zero is a reading, not a dead probe. + + **The face each row gets is a measurement, not a preference.** Both keys serve as JSON-Schema **pointer rows**, which is the shape a generic renderer cannot be assumed to resolve: + + - **`field.relatedListFilter` → `widget: 'filter-condition'`.** The served node is `{ $ref: '#/$defs/…' }` onto the recursive Query-DSL `FilterCondition`, whose derivation is `allOf: [open record, { $and/$or/$not }]` with **no top-level `type`** — there is nothing for the generic renderer to derive a control from. `filter-condition` names the FilterCondition wire, and this file already uses it one section down for `summaryOperations.filter`, the sibling `FilterConditionSchema` key. What the hint renders as **today**, measured at the pinned `.objectui-sha`, is the announced **raw-JSON editor carrying the hint** — not a criteria builder: the renderer that consumes this registry is the metadata-admin `SchemaForm`, whose own `WIDGETS` map registers no `filter-condition` (the `FilterConditionField` of that name lives in `@object-ui/fields`, on the ComponentRegistry path `ObjectForm` uses), and with the pointer unresolved neither structural fallback applies, so `resolveFieldFace` lands on `{ kind: 'raw-json', hint }` — the same face `summaryOperations.filter` gets. That editor hands `JSON.parse` output through verbatim and the save door judges it, so the wire is exact either way; the hint is the forward-looking half. ⛔ Deliberately **not** `filter-builder`: that widget consumes a rule **ARRAY** (what `view.filter`, `dataset.filter` and `page.filterBy` store), so routing this key there would write metadata the runtime refuses — the authoring trap this row exists to close, re-created one layer up. `visibleWhen` mirrors the key's own contract text (`lookup` / `master_detail`), a meaningfulness gate rather than a parse gate: `FieldSchema` accepts the key on every type, but the related-list derivation only ever reads it on the child-side FK. + - **`object.validations` → `widget: 'json'`.** The served node is an array whose items are a **double-hop** pointer (`items.$ref` → `$defs/__schema1` → `$defs/__schema2`) landing on a `oneOf` over the six `ValidationRule` members. A repeater would have to resolve both hops **and** pick a union branch before it could render a row; neither half is measured for this node, and a repeater that resolves neither renders an empty row whose values never land — the offer-vs-door defect the reconciliation gate beside it exists to catch. The Zod parse still refuses a malformed rule loudly at publish. Precisely: `json` is in that renderer's passthrough set, but the set is consulted **after** the structural fallbacks, not instead of them — so this row reaches the raw-JSON editor because the unresolved double-hop pointer derives nothing, not because the hint suppresses derivation. Once the pin moves past objectui's pointer resolution the same hint derives an `object-rows` repeater over the first `oneOf` branch; that is the renderer's precedence, not this repo's contract. Same treatment as the sibling structured-array rows `permission.rowLevelSecurity` and `email_template.variables`. Upgrading it to a structured control is a form-face addition, ⛔ not a reconciliation. + + A new pin (`metadata-form-declared-rows.pin.test.ts`) keeps both rows and both faces, and adds a registry-wide assertion — every row of every form, at every depth — that **no** form routes a `FilterCondition`-typed key to the rule-array builder, with a lit control proving the walk reaches both keys before it reports an empty misrouted set. + + ⛔ **No wire byte moves and no export changes.** `check:api-surface` is green with no regeneration: `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so the row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles (44 additive lines; the new `en` entries are source text, the translated locales still need translating). +- 408ca2e: 45 declared-but-unoffered scalar metadata keys are authorable in the metadata form. Each was **declared** by an object-rooted metadata schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY` — so the generic metadata form rendered no row for any of them and an author's only door was the Source tab: free-text JSON, where a mis-spelled sibling key is written, stored, and refused by the runtime later. + + **The population was re-derived, not inherited.** The reconciliation gate's own helper block (`packages/spec/src/system/metadata-form-zod-reconciliation.test.ts`, lines 104-469 verbatim) was run over the live registry with a lit control (`name`, offered by 17 of 17 forms) and a dark control (a fabricated key, 0 forms and 0 schemas) asserted in the same probe. Readings on the tree this change starts from: **142** top-level zod-only keys across the 17 forms once the ADR-0010 provenance overlay is skipped, **94** of them on the 11 object-rooted types (`view` is union-rooted and contributes the other 48), **87** of those graded `live`, and **49** of those resolving to a scalar schema node. After the change the same probe reads 4, which are the four rows deliberately not landed. + + **Four keys are deliberately still unoffered**, each because a control for it would be an authoring trap rather than an offer: + + - `object.displayNameField` — `[DEPRECATED → nameField]`. Its canonical replacement `nameField` lands here; offering the alias beside it would teach an author the retired spelling. + - `app._unpublished` — the schema's own text says `Never authored`: a machine-managed publish gate written by the AI materialization path and cleared by publish-drafts. + - `field.system` — the auto-injected/system-column marker the platform stamps (`applySystemFields`, the search companion). It is read widely on the write path — the record validator skips required and multi-value checks for a flagged column — so a control for it lets an author assert a false provenance that silently disables validation for that field. + - `field.format` — **one `z.string()` key carrying three vocabularies**, so no help text can be written for it until someone rules which one it has. The engine reads it as an **autonumber pattern**: `resolveAutonumberFormat` (`packages/spec/src/data/autonumber-format.ts:196-202`) falls back from `autonumberFormat` to `format`, and `packages/objectql/src/engine.ts:5043-5051` calls it for every `autonumber` field — as does the SQL driver. objectui reads it as a **date display style**, `short` / `relative`, pinned at the `.objectui-sha` this repo builds against by `packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx` and `packages/plugin-detail/src/__tests__/DetailSection.dueLikeReachesTheCell-9729.test.tsx`. The published `describe` names a third — `email`, `phone` — that **nothing measured honours**: an author who follows it on an autonumber field gets the literal string `email` rendered as their number. + + **The control follows the scalar type and the copy states what the runtime enforces**, including what ABSENCE resolves to, which is the half an author cannot read off an enum: `object.sharingModel` says a custom object that omits it resolves to `private`; `field.step` says the write path does not reject a value off the step grid; `action.undoable` says an action with no `operation` has no write set to capture. Nineteen rows carry a `visibleWhen` MEANINGFULNESS gate mirrored from the same key's row in the object designer's quick-add grid — the schema accepts each key whatever the sibling value is, but only some field types, page kinds or action operations ever read it. + + Three enums (`object.managedBy`, `action.execution`, `action.openIn`) deliberately carry **no** inline `options` list: `FormSelectOptionSchema.value` is a system identifier (`^[a-z][a-z0-9_.]*$`), so members such as `system-data`, `engine-owned` or `perRecord` cannot be spelled as option values at all. Those rows derive their enum from the served JSON Schema, which carries every member verbatim, and the meanings ride the help text. + + ⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles, whose 90 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills, because those three catalogs are ratcheted against undecided echoes. + + ⛔ **The gate that would notice a missing row is NOT landed here.** The top-level `zodOnly` direction of the reconciliation gate stays unwired: turning it on today would turn the remaining absences into red lines with no offers behind them, which is the shape the census round explicitly refused. This change lands offers; the assertion is a separate card. +- ec292cf: Clause-②: no + + Sixteen live structured metadata keys are authorable in the metadata form: eleven on the field form — `accept`, `currencyConfig`, `dependsOn`, `lookupColumns`, `lookupFilters`, `readonlyWhen`, `relatedListColumns`, `requiredPermissions`, `requiredWhen`, `storage`, `visibleWhen` — and five on the action form — `bodyExtra`, `description`, `errorMessage`, `patch`, `requiredPermissions`. Each was **declared** by its schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each now has exactly one row, whose control copies a row a registered form already carries for the same node shape: + + - `visibleWhen`, `readonlyWhen`, `requiredWhen` — `type: 'code'`, `language: 'expression'`, the object designer's per-field rows for the same three keys. + - `lookupFilters` — `widget: 'json'`, the object designer's per-field row for the same key; no inline operator list, because `notIn` is not a spellable option value. + - `lookupColumns`, `dependsOn` — `widget: 'json'`, **never** `string-tags`: each is an array of a union (a field name, or an object entry), and the tag widget is a chip input for strings only, which cannot show or edit a stored object entry. + - `accept`, `relatedListColumns`, and both `requiredPermissions` — `widget: 'string-tags'`, the app form's `requiredPermissions` row: a chip input over a plain `string[]`. + - `currencyConfig`, `storage` — a `composite` with declared sub-rows (`currencyMode` as a `dynamic` / `fixed` select, `defaultCurrency`; `notNull`), the shape of the object form's `access` row. + - `patch`, `bodyExtra` — `widget: 'json'` over a string-keyed record. + - `description` — `widget: 'textarea'`, the page form's `description` row, over the same `I18nLabel` node; `errorMessage` — a plain row, the twin of `successMessage`. + + Each type-specific row is gated to the types its runtime reader serves: the media types for `accept`, `currency` for `currencyConfig`, `lookup` / `master_detail` for the picker and related-list rows, those two plus the four option types for `dependsOn`, `operation: 'update'` for `patch` (the parse refuses it anywhere else), and `type: 'api'` for `bodyExtra`. The help text states what the runtime does with each value, including what absence resolves to. The four field-name lists (`relatedListColumns`, `lookupColumns`, `lookupFilters[].field`, `dependsOn`) are free text: no authoring door judges their names today — not the schema parse, not the publish door and not `os validate` — so the help text claims no such refusal. + + ⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles, whose 38 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. + + ⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers only. +- dc0ab6a: Clause-②: no + + Two live structured object keys are authorable in the metadata form: `fieldGroups` and `indexes`. Each was **declared** by `ObjectSchema`, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each is now a `type: 'repeater'` row on the object form whose sub-rows are declared by hand rather than derived from the schema: + + - `fieldGroups` (Basics, beside `highlightFields`) — six sub-rows, one per canonical group key: `key` and `label` (required text), `icon` (text), `description` (textarea), `collapse` (a `none` / `expanded` / `collapsed` select) and `visibleWhen` (`type: 'code'`, `language: 'expression'`, the `fields` grid's predicate rows). The three `[DEPRECATED → collapse]` aliases (`defaultExpanded`, `collapsible`, `collapsed`) are **not** offered; the metadata-form reconciliation ledger records a nested `omit` row for each. The parse still accepts them and derives `collapse` from one only when `collapse` is absent, so a stored entry keeps its meaning, and a `collapse` set in the form outranks any alias it carries. + - `indexes` (Advanced, beside `datasource`) — three sub-rows over the keys the SQL driver reads: `name` (text), `fields` (`widget: 'string-tags'`, required) and `unique`, a select offering **only** `global` and `organization`. The deprecated bare `unique: true` is never offered: a schema-derived control would take the union's first arm and render a switch that writes it. An edit merges into the stored entry, so an index that already carries `true` or `false` keeps it until the author picks a scope, and the select can write only the two values the parse accepts. `type` and `partial` are tombstones and have no row. + + The help text states what the runtime does with each value. `indexes[].fields` is free text, and the schema parse, so a draft save, does not judge its names; `os validate`, `os build`, `os lint` and the publish door refuse a name that is not a field of the object (`object-field-ref-unknown`, #20479, in the same release). A name that is not a stored column, a `formula` field say, makes the SQL driver skip the whole index at sync with an error in the server log, and the help text says exactly that; `os migrate plan` reports the skipped index too (#20432, in the same release). A field group has no field-name list: a field joins a group through its own `group` key. + + The two row schemas also carry a JSON Schema `title` on every property, as every repeater row schema must: `IndexSchema` on `name`, `fields` and `unique`, and `ObjectFieldGroupSchema` on its nine keys, the three deprecated aliases included. A property panel that reads the served schema's titles therefore shows a named column instead of a raw key. Each title is a `.meta({ title })` call and nothing more. + + ⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves (its rows, and the titles above in its JSON Schema) and the translation keys `os i18n extract` walks, hence the regenerated `platform-objects` metadata-form bundles. Their 22 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. + + ⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers and three nested ledger rows only. +- 19e58e2: Clause-②: no + + Four more live structured keys are authorable in the metadata forms: `activityMilestones`, `publicSharing` and `userActions` on the object form, and `inlineColumns` on the field form. Each was **declared** by its schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each is now a row whose sub-rows are declared by hand rather than derived from the schema: + + - `activityMilestones` (object form, Advanced, beside `validations`) — a `type: 'repeater'` over the milestone's four keys: `field` (`widget: 'text'`, required), `value` and `summary` (text, required) and `type` (text). `field` pins its widget because the console turns a string sub-row named `field` into a field picker whose catalogue an object draft never fills. + - `publicSharing` (object form, Advanced, after `requiredPermissions`) — a `type: 'composite'` over all six keys of the share-link policy: `enabled` (switch), `allowedAudiences` and `allowedPermissions` (`widget: 'multiselect'` over their enum members), `maxExpiryDays` (number, at least 1), `redactFields` (`widget: 'string-tags'`) and `eligibility` (`type: 'code'`, `language: 'expression'`). + - `userActions` (object form, Advanced, under `managedBy`) — a `type: 'composite'` over the five affordance keys. `create`, `import`, `edit` and `delete` are each a boolean **or** a `{ enabled, visibleWhen, disabledWhen }` object, so they take `widget: 'json'`: the console renders a switch for a new entry or a stored boolean, and the object's own keys for a stored object, and never writes one arm over the other. `exportCsv` is a switch. + - `inlineColumns` (field form, Configuration, beside `inlineTitle`, shown on `master_detail` fields) — a `type: 'repeater'` over a **curated subset** of the twenty keys an inline grid column accepts: `name` (required), `label`, `width` and `defaultHidden`. The metadata-form reconciliation ledger records the nested `subset` row and names what is left to source and why: `type` opts a column out of hydration from the child field, the type-specific keys cannot be gated on a type the column takes from the child field at render, and the rules are copies of the child field's own. + + The help text states what the runtime does with each value, read from its consumer, and claims a refusal only where one exists. A misspelt `publicSharing.redactFields` entry is refused at publish and by `os validate`. `activityMilestones[].field`, a `{token}` in its `summary`, and `inlineColumns[].name` are judged by no authoring door, and their help texts say so and name what happens instead: the milestone never fires, the token renders empty, the column renders as plain text. + + The two new repeaters' row schemas also carry a JSON Schema `title` on every property, as every repeater row schema must: the four keys of an `activityMilestones` entry, and all twenty keys of `InlineGridColumnSchema`. Each title is a `.meta({ title })` call and nothing more. + + ⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves (its rows, and the titles above in its JSON Schema) and the translation keys `os i18n extract` walks, hence the regenerated `platform-objects` metadata-form bundles. Their 46 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. + + ⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers and one nested ledger row only. +- c736eaa: The `action` metadata-form panel no longer prints English field names and tooltips to a Chinese, Japanese or Spanish author: six keys — twelve string leaves — were decided leaf by leaf and rendered in `zh-CN`, `ja-JP` and `es-ES` (#19403). + + The five children of the `body` composite (`Language`, `Source`, `Capabilities`, `Timeout Ms`, `Memory Mb`) and the `Ai` exposure block read their English source in all three locales, `helpText` included. An en-echo is not automatically a defect, so each leaf carries a recorded reason in `action-body-panel-echo-decisions.test.ts` rather than a bulk rewrite — and four of the six keys had an **authored twin at the same schema key**: `hookForm` and `actionForm` declare the same composite over `HookBodySchema`, rendered on the hook panel and echoing here, byte-identical in `en`. `memoryMb` echoes on both panels, so that row records that it has no twin evidence and composes from the sibling key instead. + + - **Machine tokens stay English, checked at the schema before a word was rendered.** `body.language.helpText` names `expression` and `js` — the two `z.literal` discriminators of `HookBodySchema`. `body.capabilities.helpText` names `api.read`, `api.write`, `crypto.uuid` and `log` — four of the five `HookBodyCapability` enum members. `ai.helpText` names `ai.exposed=true` and `ai.description`, the two canonical keys of `ActionAiSchema`, a `strictObject` that declares five aliases of `exposed`. Rendering any of them would tell an author in their own language to write a value the schema refuses. All kept, alongside `import`, `ctx` and the schema bounds `256` and `40`. + - **Values only.** No key was added or removed: each translated bundle changes 12 values, the full flattened key sets are identical on all four bundles (893 keys, 0 added, 0 removed), and `en` is untouched. Regenerated with `pnpm i18n:extract`, which dropped the 12 provenance rows per locale that recorded these leaves as unauthored extractor fills and added none. + - **The panel is now pinned by a derived population**, and a new cross-panel assertion holds the five shared `HookBodySchema` children to ONE rendering across both forms that declare them — a disagreement no single-panel pin can see, because it lives between two derivations. + + Measured on the metadata-form catalogs: label keys echoing in all three locales fall **29 → 23** while the genuinely-translated control rises **509 → 515** (`zh-CN`) and **493 → 499** (`ja-JP`, `es-ES`), same population, same run. +- 4d0bd23: The metadata-type chooser no longer offers a Chinese, Japanese or Spanish author six entries written in English: the display pairs of `seed`, `mapping`, `api`, `doc`, `book` and `capability` — six keys, twelve string leaves — were decided leaf by leaf and rendered in `zh-CN`, `ja-JP` and `es-ES` (#19403). These six are the **bare** metadata types: they declare no form at all, so their registry `label` and `description` are the only strings an author ever sees for them, and until now every one of those strings was its English source. + + An en-echo is not automatically a defect, so every leaf carries a recorded reason in `bare-type-display-echo-decisions.test.ts`, naming the authored leaf its wording came from — and saying so out loud where no authored twin exists (`seed`, `book` and the word "export" have none, and the rows state that rather than leaning on one). + + - **The population is derived, not listed.** The ledger walks `DEFAULT_METADATA_TYPE_REGISTRY` crossed with one predicate read off the catalog's shape — a type is *bare* when its entry has no `fields` and no `sections` — so a metadata type added later with an unauthored display pair lands in the population automatically. Ten types qualify; the six decided here are the six that echoed, and the other four (`job`, `datasource`, `external_catalog`, `translation`) are already authored and stay in the walk as its control. + - **Machine tokens stay English, and five near-misses were read at the schema before a word was rendered.** `package` in two of the descriptions *is* a legal `_provenance` value and the schemas really do accept it — cleared, because no form asks an author for an envelope key and these types have no form at all. `rename` is **not** a `TransformType` member (but `map` is, and "field mapping" contains it — the token guard judges word boundaries, not substrings); `publish` is not a `SeedMode`; `pipeline` is not an api `type`; `groups` is a key that takes an array. `HTTP`, `URL`, `Markdown`, `API`, `ADR-0121`, `ADR-0046 §6`, `ADR-0066 D1` are kept verbatim in all three locales. Every reading is asserted against the live schema rather than described. + - **"Capability" is decided per meaning, in three positions.** The metadata type takes 能力 / ケイパビリティ / Capacidad, agreeing with the `body.capabilities` token list by re-deriving from the same objects-catalog evidence rather than borrowing its decision, and differing from it in Spanish number because this leaf names one capability. The object panel's `Capabilities` section — feature toggles, a different concept under the same English word — is deliberately untouched, and both the agreement and the non-agreement are asserted. + - **Values only.** No key was added or removed: the full flattened key sets are identical on all four bundles (893 keys, 0 added, 0 removed) with a negative control proving the comparator sees a one-key delta in both directions, and `en` is untouched. Regenerated with `pnpm i18n:extract`, which dropped exactly the 12 provenance rows per locale that recorded these leaves as unauthored extractor fills, and added none — leaving **no** metadata type's display pair recorded as a fill in any locale. + + Measured on the metadata-form catalogs: label keys echoing in all three locales fall **18 → 12** while the genuinely-translated control rises **520 → 526** (`zh-CN`) and **504 → 510** (`ja-JP`, `es-ES`), same population (893 string leaves, 538 of them labels), same run. The decidable remainder — a label plus its sibling `description`/`helpText` — falls **29 → 17**, because this round decides six labels *and* six descriptions and only the labels move the headline. +- 4045781: The standalone `field` metadata-form panel no longer prints English column heads and tooltips to a Chinese, Japanese or Spanish author: nine keys — eighteen string leaves — were decided leaf by leaf and rendered in `zh-CN`, `ja-JP` and `es-ES` (#19403). + + `Placeholder`, `Value Domain`, `Rows`, `Related List Filter` and the five row properties of `summaryOperations` (`Object`, `Function`, `Field`, `Relationship Field`, `Filter`) read their English source in all three locales, `helpText` included. An en-echo is not automatically a defect, so each leaf carries a recorded reason in `field-panel-echo-decisions.test.ts` rather than a bulk rewrite — and fourteen of the eighteen had an **authored twin at the same key path**: `object.fields.fields.*` is the same field editor embedded in the object panel, rendered there and echoing here, with seven of the twins byte-identical in `en`. + + - **Machine tokens stay English, checked at the schema before a word was rendered.** `valueDomain.helpText` names `iana_time_zone`, `iso_4217_currency` and `iso_3166_alpha2` — the three members of `ValueDomainSchema`, a `z.enum`. Rendering them as words would tell an author in their own language to write a token the schema refuses. Kept, as are `count` (a `summaryOperations.function` enum member), the spec key `inlineHelpText`, the operator `AND` and the worked example `status == received`. + - **Values only.** No key was added or removed: the three translated bundles are 18 insertions / 18 deletions each, the full flattened key sets are identical on all four bundles (893 keys, 0 added, 0 removed), and `en` is untouched. Regenerated with `pnpm i18n:extract`, which dropped the 18 provenance rows per locale that recorded these leaves as unauthored extractor fills. + - **The panel is now pinned by a derived population**, so a key added to `fieldForm` tomorrow is judged on the day it lands rather than a round later. + + Measured on the metadata-form catalogs: label keys echoing in all three locales fall **38 → 29** while the genuinely-translated control rises **500 → 509** (`zh-CN`) and **484 → 493** (`ja-JP`, `es-ES`), same population, same run. +- ecf56e7: The `hook` metadata-form panel no longer prints English field names and tooltips to a Chinese, Japanese or Spanish author: five keys — ten string leaves — were decided leaf by leaf and rendered in `zh-CN`, `ja-JP` and `es-ES` (#19403). With them the panel is finished: **zero** of its 47 string leaves now echoes its English source in all three locales. + + The keys are the execution controls — `Retry Policy` and its `Max Retries` / `Backoff Ms` children, the hook-level `Timeout Ms`, and `Memory Mb` on the `body` composite. Four of the five labels are the extractor's humanize of a camelCase key rather than authored English, so each is rendered as the concept with its unit in a parenthetical (`Backoff Ms` → 退避(毫秒) / バックオフ(ms) / Retroceso (ms)) instead of being touched up in English. An en-echo is not automatically a defect, so every leaf carries a recorded reason in `hook-execution-panel-echo-decisions.test.ts`, naming the authored leaf its wording came from. + + - **Machine tokens stay English — and one near-miss was read at the schema and cleared.** `timeoutMs.helpText` reads "Abort the hook after N milliseconds", and `abort` *is* a legal value of `HookSchema.onError` (`z.enum(['abort','log'])`) — but the key this tooltip describes takes a **number**, so no rendered word can land in it, and the schema's own description uses the word as the runtime's verb. Rendered. `retryPolicy.helpText` names `async`, which is `z.boolean().default(false)`, not an enum member — rendered, taking the panel's own authored 异步 / 非同期 / Asíncrono. The placeholder `N` and the bound `256` stay as written, and all four readings are asserted against the live `HookSchema` rather than described. + - **`body.capabilities` is reworded on both panels that declare it.** zh-CN 功能 and ja-JP 機能 read "feature" for what is a capability **token** from the `HookBodyCapability` enum. Every authored leaf of the objects catalog that names a capability renders it 能力 (zh-CN) and ケイパビリティ (ja-JP), so those are the words now used — on `hook` **and** `action` in one act, because both forms declare the same schema key. es-ES already read Capacidades and is unchanged. + - **Values only.** No key was added or removed: the full flattened key sets are identical on all four bundles (893 keys, 0 added, 0 removed) with a negative control proving the comparator sees a one-key delta, and `en` is untouched. Regenerated with `pnpm i18n:extract`, which dropped exactly the 10 provenance rows per locale that recorded these leaves as unauthored extractor fills, and added none. + - **A debt the previous round wrote down is paid.** Its cross-panel invariant compared 30 `HookBodySchema` pairs and excluded six of them, because `hook.fields.body.memoryMb` was an echo on both panels and an unauthored twin carries no evidence. Deciding it here empties the exclusion set: 30 of 30 pairs compared, `memoryMb` guarded on both panels. + + Measured on the metadata-form catalogs: label keys echoing in all three locales fall **23 → 18** while the genuinely-translated control rises **515 → 520** (`zh-CN`) and **499 → 504** (`ja-JP`, `es-ES`), same population (893 string leaves, 538 of them labels), same run. +- 0e658fb: The object editor's Capabilities panel and its `Validations` row no longer read English to a Chinese, Japanese or Spanish author: the eleven string leaves of `object.fields.enable.*` and `object.fields.validations.*` — nine labels and two helpTexts — were decided leaf by leaf and rendered in `zh-CN`, `ja-JP` and `es-ES` (#19403). These sit in the two sections `objectForm` ships collapsed, and until now every one of them was its English source in all three locales. + + An en-echo is not automatically a defect, so every leaf carries a recorded reason in `object-collapsed-sections-echo-decisions.test.ts`, naming the authored leaf its wording came from — and saying so out loud where no authored twin exists (`feeds` and `clone` have none, and the rows state that rather than leaning on one). + + - **One schema key, one rendering — and two of them were already rendered.** `object.fields['fields.trackHistory'].label` and `object.fields['fields.searchable'].label` carry the identical English strings one section away and were already 历史跟踪 / 履歴追跡 / Seguimiento de historial and 可搜索 / 検索可能 / Buscable. Those words are copied, not composed, and the copy is asserted, so the positions can only ever move together — `field.fields.searchable.label`, a third position on another panel, included. That pair is also the round's sharpest evidence the echoes were unauthored fills: the same English, the same schema key, one position authored and the other a byte copy. + - **The phantom-translation shortcut was refused, in writing.** `enable.apiEnabled.label` is `"Api Enabled"` — an extractor humanize, because `objectForm` declares no label there — and its correct English is `"API Enabled"`. Fixing the English would have differed in bytes, satisfied the echo predicate in all three locales and dropped the card's census while telling a `zh-CN` author nothing. It is not done here; the row is decided against the concept, and the ledger asserts that none of the three renderings is either English spelling. + - **ADR-0020 and the validations schema were read at the schema before a word was rendered.** The helpText's worked JSON example is kept byte-identical in every locale (this catalog's own convention, and load-bearing here because `type: "script"` is a literal member of the `ValidationRuleSchema` discriminator). Asserted against the live schema: `validations` takes an array and refuses a bare object, the example parses verbatim, `state_machine` is a member whose payload is a `transitions` table, and the `workflow` shape ADR-0020 retired is refused — as is `State-machine`, the hyphenated spelling the prose itself uses, which is why the prose is rendered rather than kept. `ADR-0020` and `API` stay verbatim, guarded on word boundaries rather than substrings. + - **The population is derived from the FORM, not from key names.** The ledger walks `objectForm` crossed with one predicate read off its shape — a section is in when the form ships it `collapsed: true` — by the same recursion the extractor uses to emit these keys. Two of four sections qualify, 45 leaves. A field added to either lands in the population automatically. Three controls run in the same walk: the 94 open-section leaves are excluded (with `fields.placeholder`, a key #19403's body samples, asserted out by name); `datasource` is *inside* the population and comes back non-echoing in all three locales; and the 32 `lifecycle.*` leaves come back echoing in `ja-JP` and `es-ES` only — a panel the card's all-three predicate reads as zero — carried as a declared, shrink-only deferral instead of being silently excluded. + - **Values only.** No key was added or removed: the full flattened key sets are identical on all four bundles (893 keys, 0 added, 0 removed) with a negative control proving the comparator sees a one-key delta in both directions, and `en` is untouched. Regenerated with `pnpm i18n:extract`, which dropped exactly the 11 provenance rows per locale that recorded these leaves as unauthored extractor fills, and added none. + + Measured on the metadata-form catalogs: label keys echoing in all three locales fall **12 → 3** while the genuinely-translated control rises **526 → 535** (`zh-CN`) and **510 → 519** (`ja-JP`, `es-ES`), same population (893 string leaves, 538 of them labels), same run. The decidable remainder — every string leaf, `helpText` included — falls **17 → 6**, because this round decides nine labels *and* two helpTexts and only the labels move the headline. +- 9529989: Decide the `page` Interface panel's en-echoes per leaf and render the decided ones in `zh-CN` / `ja-JP` / `es-ES` + + `page.fields['interfaceConfig*']` and the `page.sections.interface` heading shipped their English source byte-for-byte in all three translated metadata-form catalogs — 15 keys, 30 string leaves, the panel every list page is authored on. Each leaf was judged on its own evidence rather than translated wholesale: the `view` panel is the authored twin for most of them, `Interface`, `Airtable`, the `interfaceConfig` key names, the `Grid / Kanban / Calendar` renderer tokens and the `filter-mode` option labels stay English, and `interfaceConfig.source` departs from both of this catalog's same-string precedents because it names the page's data binding rather than source code or provenance. + + The verdicts and their reasons are pinned in `page-interface-panel-echo-decisions.test.ts`, whose population is derived from the `en` catalog, so a re-fill or a key added to the panel is red on the day it lands. +- 236cec1: Report metadata-form panel — the dataset-binding section heading, its semantic-layer description and the `drilldown` / `runtimeFilter` label and helpText pairs are now rendered in `zh-CN`, `ja-JP` and `es-ES` instead of shipping their English source (#19403). + + Six `en` leaves × three locales = 18 locale-leaves, decided **one at a time** rather than swept: an en-echo is not automatically a defect, so each carries a recorded verdict, its per-locale reason and the `en` source it was judged against, in `report-form-echo-decisions.test.ts`. The bundles and their `*.source-hashes.generated.ts` companions were regenerated with `pnpm i18n:extract`; key sets are unchanged (893 → 893, 0 added, 0 removed, `en` values changed 0) and the provenance companions dropped exactly those six rows per locale and added none. + + - **`runtimeFilter` is a byte copy of an authored twin.** `report.fields['blocks.runtimeFilter']` is the same schema key one repeater level down, where the form declares `label: 'Runtime Filter'` and a translator had already written 运行时筛选 / 実行時フィルター / Filtro en tiempo de ejecución. The top-level position is now the same three words, and the copy is asserted, so the two can only move together. + - **The semantic-layer claim was read at the schema before a word was rendered.** "Values are the dataset's measures; rows are its dimensions" is pinned: `DatasetSchema` declares `dimensions`/`measures` and refuses `values`/`rows`; `ReportSchema` does the reverse; and the joined-block alias table states the mapping itself (`measures` → `values`, `dimensions` → `rows`). + - **The phantom-translation shortcut is unavailable here, and that is asserted.** `reportForm` declares no label on either field, so both English strings are the extractor's humanize — but the humanize already lands on correct English, so no touch-up could satisfy the echo predicate; only a translation can. + + ⚠️ This empties the card's headline predicate — zero `.label` keys now echo in all three locales — and that zero is a property of the **predicate**, not of the surface: `object.fields.lifecycle.*` still ships 32 English leaves to `ja-JP` and to `es-ES` (64 locale-leaves) that the all-three reading cannot see. +- eec56c3: Keep the `userFilters` element tokens English in the translated metadata-form tooltips + + `metadataForms.view.fields.userFilters.helpText` names the legal values of `UserFiltersSchema.element`, a strict `z.enum(['dropdown', 'tabs', 'toggle'])`, and it is the only place the `view` panel names them at all. All three translated catalogs rendered those tokens as ordinary words — 下拉 / 标签页 / 开关, ドロップダウン / タブ / トグル, desplegable / pestañas / interruptor — so an author working in a translated locale was shown a value the schema refuses. + + The enum values are now verbatim English inside the translated sentence and the prose around them stays translated. The `page` Interface panel is a neighbour here, not a precedent: its tooltip keeps `None / Tabs / Dropdown` English too, but those are the `filter-mode` widget's UI mode names — capitalised, and `z.enum` is case-sensitive, so the enum refuses all three; `None` stands for the absence of the config rather than a value; and `toggle` is deliberately not offered there. What this change keeps verbatim is the enum's own tokens, which is the stricter requirement, because they are values an author types. + + `user-filters-element-tokens.test.ts` pins the repaired leaf in the three locales. It derives the accepted set from `UserFiltersSchema` and asserts set equality against it, so a value added to the enum reddens instead of going unnamed; it requires each tooltip to name that set and nothing else; and it holds each translated sentence's prose at both ends, so the assertion cannot be satisfied by copying the English sentence back in. +- 2548ba5: The Studio view form (`viewForm`, served by `METADATA_FORM_REGISTRY.view`) now offers `pagination` for every view type, not only grids. + + `pagination.pageSize` is the row bound every view type carries. The form used to place `pagination` inside the grid-only `Table options` section (shown when `type` is `grid` or unset), so an author editing any other view type could not see or set it without editing the metadata by hand. It now has its own collapsed `Pagination` section with no visibility condition. `Table options` keeps `resizable`, `compactToolbar`, `rowHeight` and `selection`, still for grids only. + + No schema changed: every view type already accepted `pagination`. `@objectstack/platform-objects` ships the new section's label and description in its metadata-form translation bundles (en, zh-CN, ja-JP, es-ES). +- a34c27c: The `zh-CN`, `ja-JP` and `es-ES` help text for `sys_user.role` told a Setup administrator to press the "Set Platform Role" action retired earlier — the `en` text had already moved on (renamed to describe `OS_PLATFORM_OWNER_EMAIL` and the `single`-posture `admin_full_access` grant) but the three translations were never updated to match. Retranslated the leaf into each locale as a faithful rendering of the current `en` text, with code spans (`OS_PLATFORM_OWNER_EMAIL`, `single`, `admin_full_access`, `sys_user_permission_set`) kept verbatim. + + No source-hash entry was added for this leaf — measured to be architecturally unreachable for a genuinely translated (non-literal-copy) leaf under the `objects`/`metadataForms` provenance mechanism (`source-hash.ts`, ruling #12069 Option A): `collectFilledFromHashes` records a hash only when the translated value is currently a byte copy of the source, and a real translation satisfies neither `value === currentSource` nor `previous[path] === hash(value)`, so it stays legacy-trusted by the ruling's own stated design. Verified empirically: mutating the source description and re-running `pnpm i18n:extract`, `pnpm check:i18n` and `pnpm check:i18n-stale-fill` reports nothing for this leaf either before or after this fix — this class of leaf has no gate-visible staleness detection today, which is the pre-existing status quo for every hand-translated leaf in the `objects` bundle, not a regression this PR introduces. +- 4ac9319: Studio's view property panel names the columns of its Columns, Sort and Tabs tables in the author's language, not only in English + + Clause-②: no + + `view.form.ts` now enumerates the row properties of the `columns`, `sort` and `tabs` repeaters, each with a `label` equal to its item schema's own `.meta({ title })`, so `os i18n extract` emits a `metadataForms.view.fields` key for each row property. The `en`, `zh-CN`, `ja-JP` and `es-ES` platform catalogs carry those keys. The translated catalogs reuse the word they already use for the same concept where they have one (`Label` → 显示名称 / 表示名 / Etiqueta, `Direction` → 排序方向 / 並び方向 / Dirección). + + The row children declare no `type`, so each row input's widget is still derived from the schema. The view schema itself is unchanged. +- 67c98f6: **BREAKING** — retire `currencyConfig.precision`: a currency's decimal places are its currency's (#19992). + + `currencyConfig.precision` was declared, validated against ISO 4217, and baked to `2` + into parse output — and **no renderer or runtime ever read it**. objectui's + `CurrencyField` derives an amount's decimal places from the currency's ISO 4217 + minor unit (2 for USD, 0 for JPY, 3 for KWD) and never looked at the key, so an + author who wrote `precision: 4` saw the same two decimals as everyone else. Its + only reader was its own contradiction check. ADR-0049 enforce-or-remove; triage + direction REMOVE under ruling 乙 on #19910 — 「a currency's decimal places are the + currency's, not a setting」. + + Clause-②: no + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }` | `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }` | + | `currencyConfig.decimals` / `currencyConfig.scale` (always refused, with a suggestion to write `precision`) | nothing — delete the key; the refusal now says why instead of suggesting `precision` | + | a field whose amounts need a different number of decimals | a different currency: the width is the currency's minor unit and is declared nowhere | + + **The one-line fix:** delete `precision` from every `currencyConfig`. ⛔ Do not move + the number to the field-level `precision`: that key is the amount's TOTAL digit count + (a DECIMAL(18,2) amount declares `precision: 18`), not its decimal places, and it is + unchanged by this release. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply + them by hand. + + ## The retirement kit + + - **`CurrencyConfigSchema.precision`** — removed from the shape. The schema is a + `strictObject`, so the route is strict deletion plus a `guidance` entry: an + authored key is refused as `unrecognized_keys` at `currencyConfig`, and the message + carries the prescription (``currencyConfig.precision` was removed in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer or runtime ever + read it: …``). `tsc` refuses a literal in a typed position too — the key is off + `CurrencyConfig`'s input type. + - **The `decimals` / `scale` aliases** — gone with their target. Each is now answered + with the same reason (`` `currencyConfig.scale` is not a currency configuration key, + and nothing replaces it: … ``) and no rename suggestion. + - **The ISO 4217 contradiction check** (the `.superRefine`) and **the + default-materializing `.overwrite()`** — both existed only for this key and are + removed. `CurrencyConfigSchema.parse({})` now returns exactly + `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`; `CurrencyConfigParsed` no + longer declares `precision`. The internal helpers `currencyPrecisionContradiction` + and `currencyFractionDigits` (never exported from a public entry) are removed; the + CLDR table they read stays, because the `iso_4217_currency` value domain reads its + key set. + - **The field designer form** — the field-level `precision` row's help text read + "Decimal places (e.g., 2 for $10.50)", the one reading the contract refuses. It now + reads "Total digits", matching the key's describe and the object designer's row; + the zh-CN / ja-JP / es-ES translations follow (`@objectstack/platform-objects`). + - **Registry** — `RETIRED_KEYS_BY_MAJOR[18]` gains `data/CurrencyConfig:precision`; + the protocol-18 step gains the D2 conversion `currency-config-precision-removed` and + its D3 entry `currency-config-precision-retired`, which states the two judgments the + strip cannot make: a width declared where the old check never looked (a `dynamic` + field, or a code with no known ISO 4217 minor unit) never applied, and code of your + own that read the served key must derive the width from the field's currency. + + ## What an operator with STORED metadata sees + + Nearly every stored currency field carries this key without anyone having written it: + the old `.overwrite()` baked `precision: 2` into parse output, so `sys_metadata` + object rows and built artifacts hold it. Nothing breaks at read: the conversion + `currency-config-precision-removed` is retired from the load path but replayed by the + stored-row and artifact seams, which strip the key from every field's + `currencyConfig` on objects and object extensions and serve the row canonical. The + strip is lossless — the key never had an effect — and the field-level `precision` is + never touched. `os migrate meta --stored --apply` rewrites the stored rows so the + per-row notice stops. + + +- d624002: fix(platform-objects): ten identity objects declare their record title instead of having `nameField: 'id'` stamped on them (#20059) + + Clause-②: no + + ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation, and an explicit `nameField` takes precedence over the render-only `titleFormat`. Ten identity objects declared a `titleFormat` and no title pointer. The registry's designate-only pass derived `id`, the first title-eligible field on each of them, and stamped `nameField: 'id'`, and a `/meta` read serves that stamp as if it were declared. A renderer that follows ADR-0079's order therefore showed the raw record id as the record page's title. + + Nine of them now declare `display_title`, a formula field with `returnType: 'text'` over the columns their `titleFormat` names, and point `nameField` and `displayNameField` at it: + + - `sys_account`: `{provider_id} - {account_id}`; + - `sys_business_unit_member`: `{user_id} in {business_unit_id}`; + - `sys_invitation`: `Invitation for {email}`; + - `sys_member`: `{user_id} ({role})`. A row without a role is titled by its user alone; + - `sys_scim_group_member`: `{scim_user_id} in {group_id}`; + - `sys_scim_projection_grant`: `{role} → {user_id}`; + - `sys_team_member`: `{user_id} in {team_id}`; + - `sys_two_factor`: `Two-factor for {user_id}`; + - `sys_verification`: `Verification for {identifier}`. + + This is the migration the `titleFormat` schema text prescribes: "a composite to a formula field designated as nameField". `sys_scim_subject` has a single-field title (`{user_id}`), so its `nameField` and `displayNameField` name `user_id` directly, as the same text prescribes for a single field. + + A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads now carry `display_title` on the nine objects. A formula is evaluated on the stored row, so where a `titleFormat` names a lookup (`user_id`, `team_id`, `business_unit_id`, `group_id`, `scim_user_id`), the formula's text carries the stored id of the related record, not its name. + + `titleFormat` stays on all ten objects, unchanged, for renderers that still read it first. `$search` resolution is unchanged: neither a formula field, a lookup nor `id` is ever a search target. +- 6a6a17b: fix(spec): a `joined` report draws no chart — `blocks[].chart` is removed and a container `chart` on a joined report is refused (#20161) + + Clause-②: no (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, + never by the level). + + A `joined` report draws each of its blocks as a table. The renderer's joined + branch returns before its one read of the report's `chart`, and nothing ever + read a block's `chart` at all. So a chart on a joined report, on the container + or on any block, parsed green, passed the `validate-chart-bindings` lint, and + plotted nothing. Both coordinates now answer at parse: + + ``` + FROM ReportSchema.safeParse({ name: 'overview', label: 'Overview', type: 'joined', + chart: { type: 'bar', xAxis: 'status', yAxis: 'task_count' }, + blocks: [{ name: 'open_block', dataset: 'tasks', rows: ['status'], values: ['task_count'], + chart: { type: 'pie', xAxis: 'status', yAxis: 'task_count' } }] }) + -> { success: true } // both charts silently never drawn + + TO -> { success: false, issues: [ + { code: 'unrecognized_keys', path: ['blocks', 0], + message: '… `report.blocks[].chart` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … Delete the key. …' }, + { code: 'custom', path: ['chart'], + message: 'a `joined` report draws no chart — it draws each block as a table and never reads `chart`, on the container or on a block. Delete `chart`; …' } ] } + ``` + + **Fix.** Delete the `chart`. The report renders exactly as before, because + neither value was ever drawn. To plot one of the slices a block shows, give it a + non-joined report of its own with that `chart`. + `os migrate meta --from 17` lists the mechanical edits for existing sources. + + **What does not change.** `chart` on a `tabular` / `summary` / `matrix` report is + untouched: it is that report's live embedded chart. A joined report with no + `chart` parses byte-identically to before, and a block keeps every other key. + + ### The retirement kit + + - **Schema.** `JoinedReportBlockSchema` is closed (`strictObject`), so `chart` is + removed from its shape and answered by its `guidance` table with the + prescription (build-schemas check (c) proof 4). `ReportSchema.chart` stays + declared; the joined arm of its refinement refuses it, beside the + `dataset` / `rows` / `columns` / `values` / `order` refusals already there. + - **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `ui/JoinedReportBlock:chart`, and + the D2 conversion `report-joined-chart-removed` (protocol 18, retired from the + load path) strips a block's `chart` and a joined container's `chart` from old + sources and stored `sys_metadata` rows as a lossless delete. Stored rows can + carry them: the Studio report form offered a block `chart` input until this + change. The family's D3 semantic entry, `ui-report-joined-chart-retired`, states + what the strip cannot decide: whether the chart was wanted. If it was, it moves + to a non-joined report of its own, because a joined report has no chart channel. + - **Form.** `reportForm` drops the block `chart` input and shows the container + `chart` only when `type` is not `joined`; the `platform-objects` metadata-form + translation bundles drop the `blocks.chart` label in all four locales. + - **Lint.** `validate-chart-bindings` no longer resolves the axes of a block chart + or of a joined container's chart against a dataset: it would be vouching for a + chart that is refused at parse and never drawn. A block's own `dataset` / + `rows` / `columns` / `values` are still checked. + - **Ledger and docs.** `liveness/report.json` names a reader for `chart` only on + non-joined reports and drops `chart` from the `blocks` row; + `content/docs/ui/reports.mdx` lists what a joined container refuses. + + +- 7db1332: Clause-②: no + + Five live structured metadata keys are authorable in the metadata form: `object.access`, `object.highlightFields`, `object.requiredPermissions`, `object.searchableFields` and `permission.adminScope`. Each was **declared** by its schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each now has exactly one row, whose control mirrors a row a registered form already carries for the same node shape: + + - `highlightFields` and `searchableFields` (Basics, beside `nameField`) — `widget: 'string-tags'`, the view form's `searchableFields` row: a free-text chip list over `string[]`. A field picker is not offered because the object draft carries no source object for one to read its catalog from. A misspelt entry is not dropped quietly: publishing refuses it (`object-field-ref-unknown`, `searchable-field-unknown`, both at `error`), and so does `os validate`. The object schema's own parse does not judge these names. + - `access` (Advanced, beside `sharingModel`) — a `composite` over one declared `default` select (`public` / `private`), the `lifecycle` row's shape. Absent still resolves to `public`. + - `requiredPermissions` (Advanced) — `widget: 'json'`, **never** `string-tags`: the value is a union of `string[]` and a `{read, create, update, delete}` map, and the tag widget reads a non-array as an empty list and writes the list back, which would silently replace a stored per-operation map. With the `json` hint the renderer resolves the face from the stored value's branch, so a stored map is edited as a map. + - `permission.adminScope` (System Permissions) — `widget: 'json'`, the hint every structured row on the permission form carries; the renderer derives a nested form over its six keys, and edits merge into the stored scope. + + The help text states what the runtime does with each value, including what absence resolves to. The renderer behaviour described above is objectui's metadata-admin form engine at this repository's `.objectui-sha` pin. + + ⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles, whose 12 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. + + ⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers only. +- c7ad16f: fix(driver-sql): a declared index that can never be built is logged at `error` and reported in drift + + **Clause-②: yes (widening)**: the exported `DriftOp` union gains one member, `unbuildable_index`. + No accept set changes. Nothing an author could write before is refused now. + + A declared index names a column that no declaration will ever create when: + + - the name is not a field of the object, for example a misspelling that the Studio save door + admits (`os validate` / `os build` already refuse it); or + - the name is a virtual `formula` field, which is computed on read and has no column. The same + applies to a field-level `unique` on a formula field. + + The SQL driver skips such an index at every sync. It used to say so at `warn`, and the drift + report dropped the index from the expected set, so `os migrate plan` showed nothing. For a + `unique` index, the declared constraint was not enforced and duplicate rows were accepted, + while everything looked normal. + + - **The sync logs the skip at `error`**, on the same durability channel as the duplicate-row + refusals in the same loop. One line per skipped index per sync names the object, the index, + each missing column with its reason (not a field of the object, or a formula field), and + whether the index is `UNIQUE`. The structured meta carries `index`, `missing` and `unique`. + - **Drift reports it** as a report-only entry: `kind: 'index_mismatch'`, `actual: '(absent)'`, + `category: 'needs_confirm'`, `severity: 'error'` for a unique index and `'warning'` otherwise. + Its op is the new member: + + ```ts + { type: 'unbuildable_index'; table: string; column?: string; indexName: string; + unique: boolean; missingColumns: string[] } + ``` + + `missingColumns` lists only the columns that will never materialize. A declared column that + is merely not added yet is pending additive work, not this finding. + + **What a consumer that reads `op.type` now sees.** A new value, `'unbuildable_index'`. It has + no reconciler arm, and none can exist, because there is no column to build over. The remedy is + a metadata edit. It is in `INDEX_DRIFT_OPS`, so `isIndexDriftOp` answers `true` and it never + triggers a SQLite table rebuild. `applyMigrationEntries` reports it `skipped` on every dialect. + `os migrate plan` lists it under "Needs confirmation", addressed by its index name. `os migrate + apply` counts it like any `needs_confirm` entry (so it asks for `--yes`), and then reports it + skipped. The artifact-pinned boot warns about it and still starts, because + only `destructive` entries refuse a boot. A `switch` over `op.type` that treats unknown values + as "not applied" needs no change. An exhaustive `switch` with a `never` check gets one more case + to handle. + + **The object form's help text follows.** The `indexes` → Fields help in the Studio object form + said the skip left "a warning in the server log". It now says an error, in English and in the + zh-CN, ja-JP and es-ES translations. Nothing else in the text changes. + + **The lint message follows too.** `object-field-ref-unknown`, on a misspelt `indexes[].fields` + name, said the SQL driver skips the index "with only a warning, and drift drops it too". It now + says the skip is logged at error and `os migrate plan` reports the index as unbuildable. The rule, + its severity and its prescription are unchanged. + + **Upgrade note:** on a database that already carries such an index, `os migrate plan` now + reports one entry per index, and so does the boot's drift warning. That entry clears only when + the metadata names stored fields or drops the index. +- 9bf5e67: fix(platform-objects): the zh-CN platform-object bundle no longer ships English labels, options and help as copies of the source (#20462) + + Clause-②: no + + A zh-CN console showed English on Setup surfaces: the Invite user dialog listed + the membership roles as 所有者 / 管理员 / Delegated Admin / 成员, and a team record + read MEMBER COUNT. The keys were present in `zh-CN.objects.generated.ts`, but + their values were byte copies of the English source that `os i18n extract + --fill=default` seeds. + + - 320 of the 362 zh-CN string leaves that equalled their `en` source are now + translated: labels, plural labels, select options, help, descriptions and + empty states. `delegated_admin` reads 受托管理员 on both + `sys_member` and `sys_invitation`, the word the console already uses for that + role. + - The other 42 stay English by design, and each is recorded with its reason in + `objects-zh-cn-echo-decisions.test.ts`: sign-in provider brands (Google, GitHub, + …), the bare `ID` initialism, protocol names (JWKS, IdP SSO URL, Message-ID, + UI / API), the OAuth credential names in the create-application result dialog, + and placeholders that show a value the admin types. + + `zh-CN.source-hashes.generated.ts` was regenerated by `pnpm i18n:extract`: it + records a leaf only while the leaf is a copy of its source, so it now lists + exactly those 42. `ja-JP` and `es-ES` are unchanged. +- 6427e2c: fix(platform-objects): the ja-JP and es-ES platform-object bundles no longer ship English labels, options and help as copies of the source (#20493) + + Clause-②: no + + A ja-JP or es-ES console showed English on Setup surfaces: the Invite user + dialog listed the membership roles with **Delegated Admin** among the + translated ones. The keys were present in `ja-JP.objects.generated.ts` and + `es-ES.objects.generated.ts`, but their values were byte copies of the English + source that `os i18n extract --fill=default` seeds. + + - ja-JP: 340 of the 383 string leaves that equalled their `en` source are now + translated: labels, plural labels, select options, help, descriptions and + empty states. `delegated_admin` reads 委任管理者 on both `sys_member` and + `sys_invitation`, the word the console already uses for that role. + - es-ES: 338 of the 392 are now translated the same way. `delegated_admin` + reads Administrador delegado on both objects, again the console's word. + - The rest stay as written by design, each recorded with its reason in a + per-locale ledger (`objects-ja-jp-echo-decisions.test.ts`, + `objects-es-es-echo-decisions.test.ts`). ja-JP keeps 43: sign-in provider + brands, the bare `ID` initialism, protocol names (JWKS, IdP SSO URL, + Message-ID, UI / API), the `Web` client type as the bundle already renders it, + the Cc / Bcc header abbreviations, and placeholders that show a value the + admin types. es-ES keeps 54: the same brands, `ID`, JWKS, Message-ID, + UI / API, `Web` and placeholders, plus words Spanish spells as English (Error, + Actor, Global, Variables), the loanwords the bundle already uses (Token, + Checksum, Slug) and Cc. + + `ja-JP.source-hashes.generated.ts` and `es-ES.source-hashes.generated.ts` were + regenerated by `pnpm i18n:extract`: each records a leaf only while the leaf is + a copy of its source, so they now list exactly those 43 and 54. zh-CN is + unchanged. +- c1d54db: feat(spec): a metadata-form repeater's row properties have a name — `DashboardHeaderAction` fields carry a JSON Schema `title`, and `resolveMetadataFormSchemaTitles` overlays a bundle's `metadataForms..fields..label` onto a derived JSON Schema (#16458) + + ## What was wrong + + The Studio property panel renders `dashboard.header.actions[]` as a table whose + column headers read `items.properties[k].title ?? k` from the JSON Schema + derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields + (`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback + arm ran for every locale, English included, and the maker saw machine keys. + Nothing could localise them either: the only channel, `resolveMetadataFormLabels`, + decorates the `FormFieldSpec` tree, which the table never reads. And the platform + catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared + no children under the composite, so `os i18n extract` emitted no + `header.showTitle` / `header.showDescription` / `header.actions` key and the + console shipped a private overlay for exactly those three. + + ## What changed + + - **`@objectstack/spec`** — `DashboardHeaderActionSchema`'s four fields author + `.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the + derived JSON Schema names each column. New export + `resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in + `@objectstack/spec/system`: every `metadataForms..fields..label` + at any locale of the chain becomes the `title` of the node the path addresses, + stepping through an array's `items` so a repeater ROW property is addressed + as `.` (`header.actions.label`) — the same path the + extractor emits. Pure; returns the input object itself when nothing applies. + `dashboardForm` enumerates the `header` composite's children + (`showTitle`, `showDescription`, `actions` with its four row properties) with + labels equal to the schema titles, pinned equal in `dashboard.test.ts`. + The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx` + → "Metadata authoring forms". + - **`@objectstack/rest`** — `GET /api/v1/meta` localises each entry's derived + `schema` beside its `form`, through that overlay. + - **`@objectstack/platform-objects`** — the four generated `metadata-forms` + catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`, + `ja-JP` and `es-ES`. + + Additive: no key removed, no accept set changed, no parsed output moved. + + `DashboardSchema.columns` deliberately still declares no `.default(12)`, and + the reason is stronger than the one #16458 assumed. The card reasoned that the + renderer already falls back to 12, which would make `.default(12)` + behaviour-preserving. Measured at objectui `origin/main` + (`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a + `columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4` + yields 12 and everything else yields **4** — and the next line switches the + whole layout on that value (`hasExplicitColumns = schema.columns != null || + inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the + default would therefore both retire the inference and flip every auto-flow + dashboard into the positioned grid. A default that silently materialises a key + is expensive to take back, so the round stopped at the declared condition and + left the key alone; see #16458. +- 72eeabd: fix(platform-objects): decide the `dataset` panel en-echoes per leaf, and derive the panel pin (#19403) + + The `dataset` metadata form — the ADR-0021 analytics semantic-layer editor — shipped its English + source in `zh-CN`, `ja-JP` and `es-ES` on 24 string leaves: the type display pair, all four section + headings, and both string leaves of its seven non-repeater fields. An author working in one of those + locales read English on the whole panel while the thirteen repeater row properties inside it, and the + `report` editor next to it, were translated. + + Each leaf was decided on its own evidence, not translated wholesale: the verdicts, their per-leaf + reasons and the `en` source each was judged against are recorded in + `dataset-panel-echo-decisions.test.ts`, which also derives the panel population from the `en` catalog + so a key added to this form is caught rather than missed. + + Two groups of tokens stay **English**, on an authored precedent rather than by habit: + + - the strict-enum values the measures section names — `sum/avg/count/…` (`AggregationFunction`) and + `ratio/sum/difference/product` (`DerivedMeasureOp`), both `z.enum` inside a `strictObject`. Rendering + them as words would tell an author in their own language to write a token the schema refuses. The + precedent is `report.fields.type.helpText`, which keeps `tabular/summary/matrix/joined` verbatim in + all three locales; + - the machine tokens an author types — `lookup` / `master_detail`, `relationship.field`, the worked + example `account.region`, and the `FROM` / `ON` SQL keywords the prose names. The precedent is + `object.fields.fields.reference.helpText`, which keeps `tree` and `lookup` verbatim inside otherwise + translated prose. + + Catalog values only. No key is added, renamed or removed in any bundle (24 insertions / 24 deletions + per translated bundle, a pure value replacement), no schema or export moves, and the three + `*.source-hashes.generated.ts` provenance tables lose exactly the 24 rows per locale that recorded + these leaves as unauthored extractor fills. + + Why `patch` rather than `skip-changeset`: `@objectstack/platform-objects` is not private and ships + `files: ["dist", …]`, and `src/metadata-translations/index.ts` imports all three translated bundles, so + the new leaves are published — measured on the built output rather than assumed. +- 9cc5010: Author the object form's data-lifecycle panel (ADR-0057) and the email-template + variables sample in `ja-JP` and `es-ES`. + + Thirty-three `en` leaves — sixteen labels and sixteen helpTexts under + `object.fields.lifecycle.*`, plus `email_template.fields.variables.helpText` — + shipped their English source in both locales while `zh-CN` had all thirty-three + authored. An author working in Japanese or Spanish read the whole retention / + TTL / rotation / archive panel in English. Each leaf is now decided per locale + with its reason and the `en` source it was judged against, recorded in + `object-lifecycle-panel-echo-decisions.test.ts` and pinned to the live bundle, + to the `en` source and to the form declaration that manufactures it. + + No keys are added or removed: values were authored by hand and the structure + regenerated with `pnpm i18n:extract`. + + Clause-②: no +- 6af2901: Translate the report and dataset form panel leaves that shipped their English source in every locale + + Four metadata-form keys — `report.fields.dataset`, `report.fields.values`, `report.fields.rows` and `dataset.fields.measures` — carried labels byte-identical to their `en` source in `zh-CN`, `ja-JP` and `es-ES`, so an author working in a translated locale read English on those two panels while everything around them was translated. Twelve label leaves and nine `helpText` leaves at the same four keys are now translated; each was judged individually, and the verdicts with their reasons are pinned in `report-dataset-panel-echo-decisions.test.ts`. No key was added, removed or renamed — the bundles' shape is unchanged. +- 3cf6449: fix(platform-objects,spec): the delete actions and the flow builder's Delete Record node name the `trash` icon, which still renders after the console's lucide 1.43 upgrade + + Clause-②: no + + The console build at the new objectui pin ships `lucide-react` 1.43, whose runtime `icons` record dropped one key, `Trash2`. The console resolves an authored icon name through that record, so `icon: 'trash-2'` now resolves to nothing and the button draws no glyph. `trash` draws the identical glyph, which objectui measured node for node when it made the same repair in its own tree. + + Four delete actions in `@objectstack/platform-objects` (OAuth application, organization, SSO provider, team) and the `delete_record` entry of the flow builder's default node palette in `@objectstack/spec` now say `trash`. The `BulkAction.icon` description's example names `trash` too. No key, default shape or export moves. An author's own `icon: 'trash-2'` keeps validating as before, but draws no glyph in the console; write `icon: 'trash'` to get the same glyph back. +- 576d5df: Translate the object field-editor panel leaves that shipped their English source in every locale + + Fourteen metadata-form keys under `object.fields['fields.*']` — the field editor on the object form (`placeholder`, `valueDomain`, `rows`, `lookupFilters`, `deleteBehavior`, `expression`, the four `summaryOperations` entries, `autonumberFormat`, `visibleWhen`, `readonlyWhen`, `requiredWhen`) — carried both their `label` and their `helpText` byte-identical to the `en` source in `zh-CN`, `ja-JP` and `es-ES`, so an author working in a translated locale read English on the most trafficked authoring panel in Studio while everything around them was translated. All 28 leaves are now translated in each locale; each was judged individually, and the 84 verdicts with their reasons are pinned in `object-field-editor-panel-echo-decisions.test.ts`, which also derives the panel's population so a re-fill or a newly added field is red on the day it lands. No key was added, removed or renamed — the bundles' shape is unchanged. +- 029d8a4: fix(platform-objects): `sys_user.role`'s help text names the platform-admin route that works on every tenancy posture, and keeps the unscoped grant `single`-only (#19875) + + The `role` field's `description` is authored metadata: it ships in the published bundle, is extracted into the `en` i18n bundle, and is the help text an administrator reads on the field in Setup. It said: + + > Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2) — grant platform-admin standing with an unscoped `admin_full_access` assignment in `sys_user_permission_set`. + + On a walled deployment (`OS_TENANCY_POSTURE` `group` or `isolated`) that remedy does nothing: since the walled legacy-anchor retirement, an unscoped `admin_full_access` row no longer confers platform-admin standing there. The only route there is the deployment's configured administrator list. The help text now reads: + + > Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2). To grant platform-admin standing, list the user's verified email in `OS_PLATFORM_OWNER_EMAIL`; under the `single` tenancy posture an unscoped `admin_full_access` assignment in `sys_user_permission_set` also confers it. + + This describes both anchors as they work today. The configured, email-verified address confers standing on every posture. The unscoped grant row still confers it under `single`, and nowhere else. No behaviour changes: this is a text correction only. + + - **`en.objects.generated.ts`** follows by regeneration (`pnpm i18n:extract`), not by hand. + - **The existing pin** on this description (`platform-objects.test.ts`) now also requires the text to name `OS_PLATFORM_OWNER_EMAIL` and to qualify the grant with `single`. Without that, restoring the unqualified sentence would pass every test. +- 4215417: `sys_user.role`'s field description and its `readonly` comment stop pointing at the retired Set Platform Role action (#15188) + + Both strings named `set_user_role` / "Set Platform Role", an action retired in #9968 — the description told an operator to press a button that no longer exists anywhere in the product. This is not a source comment: a field `description` is authored data that ships in the published bundle and is extracted into the i18n bundles, so it surfaces in the admin UI's field help and in generated reference material. The correct path was already there and already the only one: platform-admin standing comes from an unscoped `admin_full_access` grant in `sys_user_permission_set` (ADR-0068 D2), which is exactly what the #9968 removal note in the same file says. + + - **`description`** now reads "Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2) — grant platform-admin standing with an unscoped `admin_full_access` assignment in `sys_user_permission_set`." It states what the column IS (a vendor authentication-layer scalar that stays published as `user.role`) and where the operator actually goes, and it deliberately does not claim the scalar confers nothing: `judgePlatformAdmin` still reads `user.role === 'admin'` as the legacy fallback it has always been, so a pre-D2 deployment carrying the value is not locked out. Saying "this field grants nothing" would have replaced one false sentence with another. + - **The `readonly` comment** keeps its ADR-0092 anchor and now states the true reason the field is not editable — nothing writes it since #9968 — instead of naming a writer that is gone. + - **`en.objects.generated.ts`** follows by regeneration (`pnpm i18n:extract`), not by hand: the default locale's leaves are rewritten from the source on every run. + + **Deliberately unchanged, and pinned so it stays that way.** The same file carries a third mention inside the #9968 removal note — *"a working \"Set Platform Role\" button **was** a supported, one-user-at-a-time resurrection channel…"*. It is past tense, it narrates what was removed, and it is true; sweeping it up with the other two would turn a true sentence false. A new test pins the removal note's tombstone opener and that past-tense sentence as occurrence counts over the source text, so both directions fail: deleting the history drops a count to 0, and re-introducing the retired action's name in live prose pushes one past 1. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index 9be7140e040..f4196af6f56 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/platform-objects", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Core platform object schemas for ObjectStack — identity, security, audit, tenant, and metadata objects", "main": "dist/index.js", diff --git a/packages/plugins/embedder-openai/CHANGELOG.md b/packages/plugins/embedder-openai/CHANGELOG.md index b4d6799c826..bfacdbaf00a 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,442 @@ # @objectstack/embedder-openai +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index 3e29a6a985e..69e2a84bcf2 100644 --- a/packages/plugins/embedder-openai/package.json +++ b/packages/plugins/embedder-openai/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/embedder-openai", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "OpenAI-compatible embedder for ObjectStack — works against OpenAI, 阿里通义 DashScope, 智谱 BigModel, 硅基流动 SiliconFlow, 火山引擎 Doubao, MiniMax, Ollama, and any drop-in OpenAI-shape endpoint.", "main": "dist/index.js", diff --git a/packages/plugins/knowledge-memory/CHANGELOG.md b/packages/plugins/knowledge-memory/CHANGELOG.md index 1c79374df69..8c6a99b5148 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,466 @@ # @objectstack/knowledge-memory +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/service-knowledge@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index b7da0f383dd..772ff4f9f0c 100644 --- a/packages/plugins/knowledge-memory/package.json +++ b/packages/plugins/knowledge-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-memory", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "In-memory knowledge adapter for ObjectStack (dev / test reference implementation).", "main": "dist/index.js", diff --git a/packages/plugins/knowledge-ragflow/CHANGELOG.md b/packages/plugins/knowledge-ragflow/CHANGELOG.md index 9762e0d5128..a4d25e1d3d2 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,484 @@ # @objectstack/knowledge-ragflow +## 17.5.0 + +### Patch Changes + +- cb005e0: The RAGFlow adapter now reads the declared key: a source's RAGFlow binding comes from `adapterConfig.datasetId`, not `options.datasetId`. + + `KnowledgeSourceSchema` declares `adapterConfig` for adapter-specific configuration and is a plain `z.object` — it carries no `.passthrough()`, so any path that parses a source drops `options` before an adapter ever sees it. The adapter read `options` through a cast, which worked only because no path parses a source today. The cast is gone; there is no fallback that also reads `options` (Prime Directive #12 — one strict contract, no lenient consumer). + + Migration, `FROM` → `TO`, one line per source: + + ```ts + // FROM + { id: 'product_docs', adapter: 'ragflow', options: { datasetId: 'rgf_…' } } + // TO + { id: 'product_docs', adapter: 'ragflow', adapterConfig: { datasetId: 'rgf_…' } } + ``` + + The same move applies to `rerankModel`, `similarityThreshold` and `vectorSimilarityWeight`, which the adapter reads from the same bag. A source left on the old spelling is refused by name — `RAGFlow adapter requires source.adapterConfig.datasetId on source ''` — rather than silently retrieving nothing, so the upgrade is self-describing at the first call. Nothing an author could declare is removed: `options` was never a key `KnowledgeSourceSchema` accepted, which is why this carries no ADR-0087 conversion. + + The package's published `README.md` moves with the adapter and now compiles against it — it was the one block of the 44 that #18915 could not repair, because correcting the spelling alone would have compiled and stopped working. + + Clause-②: no +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/service-knowledge@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index baebbba5ab4..21c17bd7906 100644 --- a/packages/plugins/knowledge-ragflow/package.json +++ b/packages/plugins/knowledge-ragflow/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-ragflow", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "RAGFlow knowledge adapter for ObjectStack — production-grade RAG via the Apache 2.0 RAGFlow REST API.", "main": "dist/index.js", diff --git a/packages/plugins/organizations/CHANGELOG.md b/packages/plugins/organizations/CHANGELOG.md index 9a513a19a0d..9134364c835 100644 --- a/packages/plugins/organizations/CHANGELOG.md +++ b/packages/plugins/organizations/CHANGELOG.md @@ -1,5 +1,534 @@ # @objectstack/organizations +## 17.5.0 + +### Minor Changes + +- 79a046f: `claimOrphanOrgRows` and `claimOrgSeedOwnership` name the ObjectQL doors they write through — a package-private `OrgScopingEngine` interface replaces `ql: any` on both, and `OrgScopingQuerySlot` states the doors the plugin forwards rather than only the three it calls itself (#18211). + + The package's public entry is unchanged: `src/index.ts` exports exactly the nine names it exported before, byte for byte. What moved on the published surface is the two exported functions' signatures, and nothing else. + + Runtime behaviour is unchanged: the same guards run, the same rows are updated, and an engine without a `registry` still returns `[]` with a warning instead of throwing — `registry` is optional on the new type precisely so that tested path stays describable. + + - **Why a type and not a comment.** The tenant-audit census decides whether a write call site is an engine write by reading the **receiver's declared type**. An `any` receiver has no type to read, so both of these sites were reported as sites nothing could place — an error in that census, never a default, because a write it cannot see is a write the tenant-audit population does not certify. Naming the doors places both by type. The certified population moves 223 to 225 and both read as elevated (they write under `context: SYSTEM_CTX`). + - **Narrow on purpose**, following `OrphanCleanupEngine` in `@objectstack/plugin-sharing`: `OrgScopingEngine` declares `find`, `update` and an optional `registry`, and nothing else. Widen it by adding a door that is actually used, never by re-exporting the engine's full contract — and keep it package-private: the census reads the type declared at the receiver, never the package entry, so exporting it would widen a published surface and buy the fix nothing. + - **The slot change is a finding, not a refactor.** `OrgScopingQuerySlot` declared `registerMiddleware`, `find` and `getSchema` — but the plugin also hands that value to `claimOrphanOrgRows`, which writes through it. While the back-fill's parameter was `any` that coupling was invisible to the type system; naming the parameter turned it into a type error, and the slot now states it. + - **Type-level tightening for consumers.** A caller passing a value that does not structurally offer `find` and `update` no longer compiles. Such a caller already got `[]` and a warning at run time from the existing guards, so nothing that worked stops working — but the failure moves from run time to build time, which is why this is not a patch. The parameter type is inlined into the emitted declarations, so a consumer never needs to name it. + - ⛔ **No `UNTYPED_RECEIVERS` ledger row was added.** That ledger is documented shrink-only and keyed by (file, receiver); growing it by two rows to silence two sites runs against its own discipline, and a typed receiver needs no row at all. +- 74832b6: **Breaking (shipped as `minor` under the launch-window convention).** Under a **walled** tenancy posture (`group` / `isolated`), a legacy unscoped `admin_full_access` grant row no longer confers `PLATFORM_ADMIN`; platform standing there is derived from `OS_PLATFORM_OWNER_EMAIL` and from nothing else. The migration pointer that announced this since 17.3.0 is retired with it: `reportLegacyPlatformAdminGrant` and `resetLegacyPlatformAdminGrantReport` are **removed from `@objectstack/core`'s published entry** (#18336, #11663 leg L5). + + ⚠️ **The `single` posture is untouched, deliberately.** Its zero-config first-user promotion still mints that row and that row still confers `PLATFORM_ADMIN` — a development environment started for a moment cannot be asked to declare an administrator first. Choice 4A (#11974) rules that promotion correct, and the maintainer's 2026-09-08 ruling on #16682 is verbatim: 「retiring the walled write must not retire the `single` one」. The `single` half's disposition is #11979's. ADR-0131 D5, as amended 2026-09-17 (#18413), is the governing record. + + **What a walled deployment must do.** Declare each administrator's **verified** address in `OS_PLATFORM_OWNER_EMAIL` (comma-separated for several) before upgrading. A walled rig that upgrades with the variable undeclared and an unscoped grant row still in place has **zero** platform administrators; the bootstrap now says so **at error**, naming the variable, the row and its holder — L4 used to skip that line for exactly this rig, on the ground that the deprecation pointer carried the remedy instead, and both halves of that arrangement have now expired. + + - **17.3.0 opened the window, this closes it.** L4 (17.3.0) stopped the walled bootstrap from ever *writing* the row and started the once-per-process pointer; L5 stops the walled derivation from *reading* it. The window was time-boxed and loud by design (#11663 P5). + - **The retirement takes the ANCHOR, not the ROW.** Nothing here writes, deletes or re-owns any grant row — a walled holder keeps the `admin_full_access` permission set they hold, and loses only platform-admin *standing*: the rung and the built-in `platform_admin` position. That row's ownership is ADR-0131 C3's, on the v18 line. + - **No new query.** The posture gate reads the environment, never the engine, so the recorded query multiset is identical under both of its answers — measured, not asserted. Under a wall the guard's grade-1 scan is skipped outright, so that path issues one read fewer. + - **`@objectstack/plugin-auth` moves with it, at TWO readers.** `ensureDefaultOrganization`'s step-2 legacy fallback is keyed on the same expression: under a wall it no longer answers「which user is the platform admin?」from the oldest unscoped grant, so the account it would have bound as the Default Organization's `owner` — and handed the org's seeded rows to — is no longer selected. ⛔ That reader does not merely count the population, it **confers** on it, which is why it is keyed here rather than sequenced. Its bootstrap-trigger predicate retires the matching `sys_user_permission_set`-insert arm under a wall with it (cost only; the `sys_user` arms are untouched, and on a walled rig the declared owner's verifying update is the only write that ever grows the population). And: + - **`@objectstack/plugin-auth`'s break-glass guard moves with it.** `last-admin-guard.ts` enumerates the administrator population from the SAME anchor, and its contract is to answer the same question the derivation answers. Its grade-1 (grant-anchored) enumeration is now keyed on the identical expression, so under a wall the guard no longer counts a holder the derivation does not recognise. Consequence on a walled rig: a write that would end the last **config**-anchored administrator's standing is now REFUSED where it was permitted, and a write that removes the now-inert grant row is no longer refused as though it removed the last administrator. Under `single` the guard is unchanged. Its two zero-population refusals also gained a walled clause, because「restore the `admin_full_access` row」stopped being a remedy that ends the emptiness there. + - **`@objectstack/organizations`' walled bootstrap moves with it.** That package wraps `ensureDefaultOrganization` and is the runtime that actually performs the default-organization bootstrap on a walled deployment (plugin-auth's own wiring skips it there). With the helper's legacy fallback keyed off, a walled rig carrying a legacy grant row **no longer** has a Default Organization created for that holder, and that holder is no longer bound as its `owner`; the bootstrap waits for a declared administrator to verify instead. ⚠️ Named because the behaviour an operator gets **from this package** moves — its own source does not change, and the pin re-authored inside it is not the reason. + - **Why `@objectstack/runtime` and `@objectstack/plugin-hono-server` are named.** Neither package's own source changes. Both carry `export * from '@objectstack/core'` (`runtime/src/index.ts`, `plugin-hono-server/src/adapter.ts`) and their built `.d.ts` carry that statement, so the two removed names leave their published surfaces too. All publishable packages sit in one Changesets `fixed` group, so naming them moves no version — it is named so the tombstone reaches the CHANGELOG an upgrading consumer of THOSE packages greps. Precedent is mixed (a core-only declaration exists); this follows the `ApiRegistry` precedent, which named every package the removal reached. + + + +### Patch Changes + +- bc2ec80: Build freshness: these three packages now write the repo's build-input content + stamp as the last step of their own build, and are checked for freshness (not + merely existence) by `check:dev-prereqs`. + + What changes for a consumer: each tarball now carries two extra inert metadata + files inside `dist/` — `.build-input-hash` and `.build-input-hash-dts`, the same + pair `@objectstack/spec` has always shipped. Nothing is imported, executed or + resolved from them, no export moves and no runtime behaviour changes. + + Why: a sibling checkout that links these packages by `link:` compiles against + their `dist/`, so a dist built from an older tree surfaces as a type error + naming an import nobody touched, with the symbol present in `src/` the whole + time. A HEAD-versus-pin comparison is silent through that; a content stamp + written by the build itself is not. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [ee6fbd7] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [4f1a56b] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [c9246fa] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [d438b3a] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [2aac821] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [a754563] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [7d63088] +- Updated dependencies [87c37ae] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [374d9d3] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [2c87a48] +- Updated dependencies [dd2fd20] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [45c2cf9] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [9ca49eb] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/organizations/package.json b/packages/plugins/organizations/package.json index 287f92dac98..2c2b1e46516 100644 --- a/packages/plugins/organizations/package.json +++ b/packages/plugins/organizations/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/organizations", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Multi-organization runtime for ObjectStack — registers the `org-scoping` service that turns single-database row-level Organization isolation on: `organization_id` auto-stamp on insert, per-org seed replay, default-organization bootstrap, and the walled-posture membership-policy gate.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-approvals/CHANGELOG.md b/packages/plugins/plugin-approvals/CHANGELOG.md index 0d119e6ac36..cc35e0b4cb2 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,773 @@ # @objectstack/plugin-approvals +## 17.5.0 + +### Minor Changes + +- c8a006f: An approval `decide()` that resumes a subflow CHILD now tells the caller when that resume bubbles into a PARENT run that stranded — instead of answering full success with nothing to distinguish it from a healthy composition (#15556; the #16472 family ruling, decision batch #76, option A). + + **The composition.** A parent flow parks at a `subflow` node whose child hosts the `approval` node, so the approvals row names the CHILD run. The decision door resumes the child, the child completes, `bubbleToParent` resumes the parent, and the parent's own downstream node throws. The parent lands on the engine's `'stranded'` exit — it consumed its suspension and is now terminal, repairable only by an operator's `restoreConsumedSuspension` — and `bubbleToParent` already logged that at `error` (unchanged by this fix). What the caller was TOLD did not: `resumed: true`, no `resumeError`, and a `runId` naming the healthy child — identical to what a fully healthy composition answers. + + ``` + FROM service.decide(requestId, { decision: 'approve' }, ctx) + -> { finalized: true, decision: 'approve', runId: '', resumed: true } + // identical to a healthy composition's answer — no caller can tell + + TO service.decide(requestId, { decision: 'approve' }, ctx) + -> { finalized: true, decision: 'approve', runId: '', resumed: true, + resumeError: "RESUME_FAILED: … its own flow run '' resumed, but the " + + "subflow parent above it — run '' — consumed its suspension " + + "and is now stranded: ", + resumeFailure: { code: 'RESUME_FAILED', runId: '', status: 'stranded', repairable: true } } + ``` + + **Additive only — no migration.** `ApprovalDecisionResult.resumeFailure` was already declared (and pinned) in `@objectstack/spec` ahead of this card; this fix is the first producer that fills it. No existing field changes shape, no status code moves (the door still never throws for this shape — `AGENTS.md`'s "a failure handed to the caller" answer does not apply here, since before this fix no caller was told at all), and the door's `error` log line is untouched. A consumer that already ignores unknown fields sees no difference; a consumer that reads `resumeFailure` can now tell a bubbled parent strand from a clean resume without diffing `runId` against a durable run history. + + **What did not move, on purpose.** `RESUME_IN_PROGRESS` / `STORE_UNAVAILABLE` bubble outcomes stay the functional degradation they always were (`warn`, unreported on `resumeFailure`) — the #16472 ruling is scoped to the one exit the engine calls `'stranded'`. The sibling `recall` door (`ApprovalRecallResult.resumeFailure`, #15970) is a separate card and is not touched here. + + **New public surface — the reason for `minor` on both packages, not `patch`.** Getting the parent's strand from the engine to the approvals door without touching `packages/spec` or the wire-visible `AutomationResult` (which a raw REST `POST …/resume` also serves verbatim, so a field there would leak an undeclared key onto every subflow resume, not only an approvals-mediated one) needed a small new internal channel: + + - `@objectstack/service-automation`: `AutomationEngine` gains a new public method, `takeSubflowParentStrand(childRunId: string): SubflowParentStrand | undefined` — read-once (deletes on read), populated only by `bubbleToParent`'s `'stranded'` exit. `SubflowParentStrand` is a new exported interface (`{ runId, repairable: true, error }`). + - `@objectstack/plugin-approvals`: `ApprovalResumeSurface` (already exported from the package entry) gains a matching optional member, `takeSubflowParentStrand?(childRunId): { runId, repairable, error } | undefined`. + + Both are additive and optional; nothing existing changes shape or behaviour. Neither reaches any wire payload — `AutomationResult`, the REST resume door's response, and every other published contract are byte-for-byte unchanged. +- b0eb9a5: Approval nodes gain a fourth empty-slate policy — `onEmptyApprovers: 'fallback'` with a sibling `fallbackApprovers` list — so a rung that expands to nobody opens the request on people you named instead of on a slot nobody can act on. + + Until now an approval node whose approvers resolved to nobody had three endings, and none of them named anyone: `admin_rescue` (the default — the request opens on a dead `type:value` slot and waits for a privileged admin), `fail` (the run dies) and `auto_approve` (the record is waved through). All five graph approver types reach that dead end, and `{ type: 'manager' }` reaches it without anybody authoring a wrong value: `manager` omits `value`, so the literal the expansion falls back to is `manager:undefined`. + + ```ts + { + approvers: [{ type: 'manager' }], + onEmptyApprovers: 'fallback', + fallbackApprovers: [{ type: 'org_membership_level', value: 'owner' }], + } + ``` + + - **`fallbackApprovers` is the approver shape you already write** — the same entries as `approvers`, resolved by the same expansion, so every approver type, OOO delegation and `per_group` tagging behaves identically on it. It is not a second, reduced approver dialect. + - **The pairing is enforced in both directions.** `'fallback'` without a list is refused; a list under any other policy is refused too, because nothing would ever read it — a node that declares a rescue slate and silently ignores it is the failure this config shape is `.strict()` against. Both messages name both keys. + - **A fallback that itself resolves to nobody degrades to `admin_rescue`.** The run is never killed and the record is never waved through by a policy whose author only asked for different people; the log says both that the fallback fired and that it found nobody. + - **This is on the node, not on the `manager` rung** — the node is already where emptiness is decided, and a fallback is wanted for every approver type, not one of them. + - **`os lint` names the new escape and keeps firing without it.** `approval-approvers-may-resolve-empty` still reports a manager-only slate even when a fallback is declared: the rule reads shape, and a static check can no more prove a `fallbackApprovers` list resolves than it can read `sys_user.manager_id`. A seeded manager chain remains the one silencer. + +### Patch Changes + +- 9fca8eb: An approval `recall()` whose resume strands the run now tells the caller WHICH failure it was, in fields — `resumeFailure: { code, runId, status, repairable }` beside the prose `resumeError` — instead of one sentence a caller has to parse (#15970; the #16472 family ruling, decision batch #76, option A). + + **The shape.** A flow parks at an `approval` node; the reject branch's downstream node throws. The submitter recalls the request, which resumes the run down the `reject` edge — and that resume strands it. The withdrawal is durable and the call correctly does not throw, but the engine's own discriminator never reached the caller: `recall` resumes DIRECTLY rather than through `resumeRecordedOutcome`, and its `catch` kept `err.message` alone, discarding the `resumeStatus` (`AutomationResult.status: 'stranded'`) the error already carried one line before the result was built. `repairable` had a producer and, on this door, no consumer. + + ``` + FROM service.recall(requestId, { actorId }, ctx) + -> { request: { status: 'recalled' }, runId, resumed: false, + resumeError: "resume of run '' failed: " } + // prose only — nothing says the run is still repairable + + TO service.recall(requestId, { actorId }, ctx) + -> { request: { status: 'recalled' }, runId, resumed: false, + resumeError: "resume of run '' failed: ", + resumeFailure: { code: 'RESUME_FAILED', runId: '', + status: 'stranded', repairable: true } } + ``` + + **⛔ The no-throw stays, and that is the ruling's point.** The withdrawal and the record-lock release are the product of this call and they have already happened when the resume fails; making `recall` fail would be the wrong fix, not a stricter one. The door's `error` log line is untouched too, at the same level with the same context keys — the ruling left logging alone, and the report is a sibling of that line, not a replacement for it. + + **Two exits report, and the rest deliberately do not.** A report is stamped exactly where the engine's own verdict says `'stranded'`: this door's own resume stranding, and (the sibling half of #15556, whose producer landed one door over) a resume that SUCCEEDED while the subflow parent above it stranded — which answers `resumed: true` with the PARENT's `runId` on `resumeFailure`, exactly as `ApprovalRecallResult.resumed`'s docblock already declared. Every other exit answers as it always did, with no `resumeFailure` at all: a lost run's honest code is `RESUME_TARGET_LOST` and the tolerated duplicate's is `RESUME_IN_PROGRESS`, and this package's ADR-0112 ledger row admits exactly one code, so stamping `RESUME_FAILED` there would make the discriminator lie about which failure it was — the defect this fixes, one field over. Per the member's own docblock, an absent `resumeFailure` means no report was made, never that no run is stranded. + + **Additive only — no migration, and `patch` rather than `minor`.** `ApprovalRecallResult.resumeFailure` was already declared, exported and type-pinned in `@objectstack/spec` ahead of this card (`contracts/approval-service.ts`, `resume-failure-report.pin.test.ts`); this fix is the first producer that fills it. The delivered diff adds no exported symbol to `@objectstack/plugin-approvals` — nothing new is reachable from its published entry — and adds no key to a payload that did not already declare one. Nothing existing changes shape: a consumer that ignores unknown fields sees no difference, and one that reads `resumeFailure` can now branch on `repairable` and call `restoreConsumedSuspension` on the run the report names. +- 917b87e: `ApprovalService`'s privileged-override gate now resolves TENANT-admin standing from the ADR-0095 capability rung alone. Its tenant arm previously also admitted any principal whose `current_user.positions` contained the built-in identity names `org_owner` or `org_admin`, and a name on that array is not evidence of the capability behind it (#16166). + + `positions[]` carries two different things at once: the ADR-0068 D2 **projection** of a membership role, whose source of truth is `sys_member.role`, and ADR-0057 D4 `sys_user_position` assignment values. A stored assignment row spelling one of those built-in names therefore arrived on the array with no org-administration grant behind it and satisfied the override gate anyway — for `decideNode`, `recall` and the console's participant-visibility read, within that organization. This is the tenant half of the same defect the platform arm of the same predicate had (#15981), and it lands the same way: **read the rung, never the name.** + + - **The tenant rung is not the platform one.** ADR-0095 D3 resolves `TENANT_ADMIN` in `derivePosture` from the org-admin capability grants (`organization_admin` / `organization_admin_no_bypass`) and from nothing else, and those grants are what `packages/spec` declares that rung's source of truth. So the surviving two arms — the derived `posture` and the held capability — are one authority read in two spellings, kept apart only so a transport that never resolved `posture` still reads the grant. + - **The #3424 stuck-approval escape hatch is unchanged** for anyone who actually holds org-admin standing: a genuine `organization_admin` grant still overrides, still only inside its own organization, and the decision is still audited as `via_override`. + - **Who could notice.** A principal whose only claim to tenant-admin override was a stored `sys_user_position` row spelling `org_owner` / `org_admin` loses it. That row was never an assignment of the identity it spells — the platform refuses new ones on write — and the supported route to override standing is the org-admin capability grant, which the membership role provisions automatically for owners and admins. +- 29a1b3d: fix(approvals): the record-lock refusal names the record, not its primary key (#18153) + + Clause-②: no + + A record held by a live approval refused the write with + `record '' of '' is locked while an approval is in progress`. The + console copies that sentence into a toast verbatim, so an end user read an + opaque primary key and a machine identifier — neither of which tells them an + approval has the record — and a deny-path toast is exactly the string that ends + up in screenshots, screen recordings and support tickets. + + It now reads `Opportunity 'Acme renewal' is locked while an approval is in + progress, and cannot be edited until that approval is complete`, degrading to + `This Opportunity is locked …` when the object declares no resolvable title and + to `This record is locked …` when the registry is unreachable — ⛔ never back to + the id. The record id and the object's API name are not deleted: they move to + the CONSOLE (`logger.info`, alongside the pending request's id), which is where + a support path reads them and where a screen recording does not. + + **No read was added.** Both halves were already in hand at the refusal: the + object's `label` and its ADR-0079 title pointer come from the engine's in-memory + registry (`getSchema`), and the record itself is `ctx.previous`, the pre-image + the engine has already read — measured on all four update shapes (by-id, + `updateManyData`, predicate `multi`, unscoped `multi`), every one of which + dispatches the hook per row with `previous` bound. Deliberately NOT used: a + system-context read of the record on the deny path (it would title a row the + caller may not be allowed to READ — the very state this lock exists to gate) and + the `payload_json` snapshot (served redacted per reader). + + **Nothing else moved.** `RECORD_LOCKED` and its `409` are unchanged and pinned + in both directions, the `CODE: message` envelope is unchanged, and the three + OPERATOR-facing refusals in the same file — the two `PENDING_LOCK_LIMIT` cap + messages and the unanswerable-intersection message — still name the object's API + name, which is the useful thing to say to whoever has to rescope that write. + They are pinned byte for byte so a later "harmonise the lock's messages" sweep + cannot fold them into the end-user shape. + + A client asserting on the old sentence's text will need updating; a client + branching on `error.code` or the 409 needs no change. +- d7f7e34: Four readers of `FieldSchema.reference` gated the carrier with a truthiness test and then **propagated** it. `FieldSchema.reference` is declared an optional **string**, so the answer a reader owes for a carrier it cannot read is absence — and one of these four did worse than lose the information, it invented a name for it: + + ``` + out.push({ key, reference: String(f.reference) }) // -> reference: '[object Object]' + ``` + + Each site now reads the carrier through the one arbiter, `referenceCarrierOf`, and catches its refusal **at the site** — so the reader answers absence and reports, instead of aborting. That is the deliberate difference from `@objectstack/objectql`'s cascade seams, which let the same refusal propagate: those assert something positive about the schema on a write path, while these four are best-effort display and diagnostic readers whose own failure handling would have turned one unreadable field into a much wider loss. + + - **`@objectstack/plugin-approvals`** — `resolveLookupFields`. The stringified carrier was handed on as an object name to `engine.find()`, where it could never resolve and the failure was swallowed by the caller's `catch`. The field is now left out of the inbox display enrichment and logged; readable targets are unaffected. It is dropped rather than carried with an absent target because the sole consumer uses `reference` as the object name and has nothing to do with an entry carrying none. + - **`@objectstack/service-analytics`** — the ADR-0021 relationship → target-object resolver. An unreadable carrier became the joined table for a dataset's `include`; the resolver now answers `undefined`, which its existing fallback turns into the compiler's own refusal, plus one warning naming the field. + - **`@objectstack/cli`** — `os doctor`'s circular-dependency and unused-object checks, which put the carrier into a graph node and a name set. Both now report the unreadable carrier as a finding rather than skipping it, because "no circular references detected" and "defined but not referenced" are positive claims that an edge nobody could read cannot support. The same file's `collectViewObjectRefs` already narrowed its carrier this way. + + `null`, `undefined` and `''` are absence, not a wrong shape, and still pass silently at every one of these sites — a field is allowed to name no target. Each site's absence answer and its readable-target answer are pinned alongside the refusal. + + Upgrading: nothing conformant changes. A non-string `reference` is refused by `ObjectSchema.safeParse`, so a value in that shape only ever reaches these readers without having passed parse at all. +- 841a71e: `ApprovalService` inbox display enrichment resolves a reference field's target through `referenceTargetOf` instead of the materialized `reference` carrier, so a `{ type: 'user' }` field authored without one is enriched instead of silently dropped (#19198). + + `resolveLookupFields` admitted `user` fields but required an EXPLICIT `reference` on them. The spec declares exactly the opposite for that type: `IMPLICIT_REFERENCE_TARGETS` (`@objectstack/spec/data`) says a `user` field's target is "a CONSTANT OF THE TYPE, so `reference` on a `user` field materializes that constant; it does not supply it. Metadata authored without it (hand-written JSON, an AI author, a Studio form) is **fully specified, not under-specified**." So the one spelling the contract calls complete was the one the reader refused — and it refused it **silently**: the field was left out of `payload_display`, with no refusal and no diagnostic, and the reviewer read a raw user id where every other reference field showed a name. + + - **The target is now the arbiter's answer, not a carrier read.** `referenceTargetOf` is the same single arbiter the `$expand` gate and the expansion engine already ask (Framework#4443 / cloud#983 fixed the identical defect there); approvals was still reading `field.reference` raw. + - **Nothing else widens.** The admitted types are unchanged (`lookup`, `master_detail`, `user`), so a `lookup` / `master_detail` whose author-chosen target is absent still names nothing, is still left out, and still issues no read — `tree` is deliberately not added. + - **The unreadable-carrier behaviour is unchanged.** `referenceTargetOf` reads the carrier through `referenceCarrierOf`, the throw is still caught per field so one bad carrier cannot drop every reference field of the object, and the warning now names this reader (`ApprovalService.resolveLookupFields`) because the arbiter's own message names itself. + - **No authoring change.** Metadata that already spells `reference: 'sys_user'` resolves to the same target it always did; nobody has to restate the constant. +- 7e6ca17: fix(plugin-approvals, service-automation, service-messaging): five system objects title their records with a text formula instead of the raw id (#20015) + + Clause-②: no + + ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation, and an explicit `nameField` takes precedence over the render-only `titleFormat`. Five system objects declared `nameField: 'id'` beside a composite `titleFormat`. A renderer that follows ADR-0079's order therefore showed the raw record id as the record page's title for: + + - `sys_approval_request`, whose `titleFormat` is `{process_name} · {record_id}`; + - `sys_approval_action`, whose `titleFormat` is `{action} · {step_name}`; + - `sys_approval_approver`, whose `titleFormat` is `{approver} · {request_id}`; + - `sys_automation_run`, whose `titleFormat` is `{flow_name} · {node_id}`; + - `sys_http_delivery`, whose `titleFormat` is `{label} → {url}`. + + Each object now declares `display_title`, a formula field with `returnType: 'text'` over the same columns, and points `nameField` and `displayNameField` at it. This is the migration the `titleFormat` schema text prescribes: "a composite to a formula field designated as nameField". The record title is now the text the `titleFormat` described. Where a source column is nullable (`step_name`, `node_id`, `label`), a row without it is titled by the other column alone. + + A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads and write responses now carry `display_title`. For these objects the server-side title accessor (`resolveRecordTitle`) now returns the formula's text instead of the raw id. + + `titleFormat` stays on all five objects, unchanged, for renderers that still read it first. `$search` resolution is unchanged: a formula field is never a search target, and neither was `id`. +- d4c897e: fix(plugin-approvals, plugin-security, service-messaging, service-realtime): nine system objects that relied on `titleFormat` declare a title pointer, so their record title is no longer the raw id (#20044) + + Clause-②: no + + ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation, and an explicit `nameField` takes precedence over the render-only `titleFormat`. Nine system objects declared a `titleFormat` and no pointer. When such an object is registered, the registry's designate-only pass picks the first title-eligible field as `nameField`, and for these nine that field is `id`. A `/meta` read serves that pointer as if it had been declared, so a renderer that follows ADR-0079's order showed the raw record id as the record page's title. + + Eight of the titles are composites. Each of those objects now declares `display_title`, a formula field with `returnType: 'text'` over the same columns, and points `nameField` and `displayNameField` at it: + + - `sys_approval_delegation`: `{delegator_id} → {delegate_id}`; + - `sys_position_permission_set`: `{position_id} → {permission_set_id}`; + - `sys_user_permission_set`: `{user_id} → {permission_set_id}`; + - `sys_user_position`: `{user_id} → {position}`; + - `sys_notification_delivery`: `{channel} → {recipient_id}`; + - `sys_notification_preference`: `{user_id} · {topic} · {channel}`; + - `sys_notification_subscription`: `{principal} · {topic}`; + - `sys_presence`: `{user_id} ({status})`. + + `sys_notification_receipt`'s title is the single column `{state}`, so its `nameField` and `displayNameField` now name `state` directly. + + This is the migration the `titleFormat` schema text prescribes: "Migrate a single-field title to nameField, a composite to a formula field designated as nameField". The record title is now the text the `titleFormat` described. Every column these titles read is required, so the formulas carry no null guard. Each formula reads only its own row's columns, never a field of a looked-up record. + + A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads and write responses of the eight objects now carry `display_title`, and the server-side title accessor (`resolveRecordTitle`) returns the title text instead of the raw id. No row scope, permission set or API method changes. + + `titleFormat` stays on all nine objects, unchanged, for renderers that still read it first. The set of fields `$search` scans is unchanged: a formula field is never a search target, and neither was `id`. On `sys_notification_receipt`, `state` was already in the set and now leads it. No search-companion column is provisioned for any of the nine. + + The new `display_title` label and help text are in each package's English bundle. The zh-CN, ja-JP and es-ES bundles carry the generator's English fill for them, recorded in the source-hash companions. +- 4ef8247: fix(approvals): the dead-run sweep classifies every `ExecutionStatus` member, so a `refused` run releases its pending approval (#16433) + + `ApprovalService.releaseDeadRunRequests` guarded on a hand-copied four-member subset of `ExecutionStatus` — `completed`, `failed`, `cancelled`, `timed_out` — written when that enum had eight members. #14945 then appended `refused`, documented on the enum as *"Terminal, never resumed"*, and the subset did not grow with it. A run in `refused` was therefore skipped by the sweep, so a still-pending approval on it read as ALIVE, was never released, and kept its record lock forever. + + **Why this is shipped as a fix rather than left alone.** Nothing inside this repo drives a run to `refused` yet — that is #15788 (lane 2 of the #14945 ruling), still open. But `ApprovalService` takes a HOST-supplied automation surface through `attachAutomation`, so a host whose `getRun` already answers with the status the published spec declares sees the corrected behaviour the moment it upgrades, rather than on the day lane 2 lands. That is a real behaviour change in a published package, which is why it carries a bump instead of `skip-changeset`. + + The repair is not "add `refused`" — that yields a five-member hand-copy with the identical trap re-armed for the tenth member — and it is not "derive the terminal set from the enum" either, since `running` and `paused` are plainly not terminal and a wholesale derivation would default every future member to terminal, i.e. to releasing approvals out from under LIVE runs. Instead the file now declares a **total map** over `ExecutionStatus`, classifying each member `terminal` or `live`, from which the terminal set is derived. A tenth member fails to compile until someone classifies it, and fails a test as well. + + No API change: the classification is module-internal and the package barrel is untouched. +- 9540590: `restoreConsumedSuspension` reaches a NESTED run: the ancestors a stranded descendant cascade-failed are journalled too, and the chain is re-armed as one unit + + `resumeInternal`'s catch arm journalled the consumed suspension of the run that + threw, and nothing else. For a nested run the ancestors were handled on both + paths with no journal at all: up-bubble (`failAncestors` walks `$parentRunId` + and calls `failSuspendedRun` on each suspended ancestor) and delegation (the + parent frame sees a failed child with no retryable code and calls + `failSuspendedRun` on itself). `failSuspendedRun` was `forgetSuspendedRun(run, + 'failed')` plus a `failed` log record — it journalled nothing. + + So the leaf was restorable while every ancestor was recorded `failed` with its + pause consumed and no snapshot (`restoreConsumedSuspension(PARENT)` answered + `NO_CONSUMED_SUSPENSION`), and restoring the leaf completed it into a parent + that never continues: `bubbleToParent` found no parent suspension and logged. + The operator ended up worse off than before using the exit. + + `failSuspendedRun` now journals the pause it consumes whenever the descendant + whose failure consumed it is itself repairable — from the same single producer + and onto the same durable terminal row as the strand's own snapshot, so the + chain is repairable from any replica and after a restart, not only from the + process that stranded it. `restoreConsumedSuspension` then repairs the chain as + one unit: it walks down to the stranded descendant and up through the ancestors + it cascaded into, and re-arms every member DEEPEST FIRST, so an ancestor becomes + resumable only after the run it is parked awaiting is parked again. The entry + point does not matter — naming any member of the chain repairs all of it — and + the continuation is then re-issued once, on the run that was named. + + Additive on the wire and in the type: the result's existing fields still + describe the run the caller named, and the new `chain` key is present only when + the repair was a chain repair. `ChainRestoreEntry` is exported for it. The + narrower `IAutomationService.restoreConsumedSuspension` contract in + `@objectstack/spec` is unchanged and the HTTP door's payload is unchanged — the + door answers `{ runId, restored, reason }` as it always did. + + Every member goes through the same per-run call as a flat restore — its own + in-process claim, its own strict live-suspension read, its own two-witness read, + its own durable park — so idempotence and the #14333 advance claim hold per run + in the chain: a second restore finds every member parked and answers + `RUN_SUSPENDED` without minting a second pause anywhere. + + ⛔ No ancestor is stamped `'stranded'`. That word is the resume result of a run + that consumed its OWN pause and then threw downstream, and nothing re-arms an + ancestor by resuming it; stamping it would send an operator to retry a recovery + that cannot succeed. The parent frame's delegation result still carries no + status at all, and an ancestor's repairability is carried by the journal and by + this verb's answer. + + Journalling is EARNED, not applied to every cascade: an ancestor whose + descendant is beyond repair is still consumed without a snapshot, because + re-arming it would promise a chain repair that could not be completed. + + **`@objectstack/plugin-approvals`** reports the consequence rather than causing + it: `inspectStrandedRequests` asks the engine per run, so a cascade-failed + ancestor whose descendant is repairable now comes back `runState: + 'repairable'` instead of `'unrepairable'`, and restoring either row repairs the + pair. `'unrepairable'` keeps its other causes — a run that never paused, a + snapshot no longer held, and a cascade whose descendant was itself beyond + repair. No plugin logic changed; the docblocks that documented the old + limitation did. +- 6465cc0: Correct the `resolveRecordedContinuation` discriminator's stated invariant in + `approval-service.ts` to what was measured. The comment claimed the + `action: 'resubmit'` audit row was "at most one per request"; a `resubmit` whose + own resume strands opens no next round, so the row stays `returned` and a second + `resubmit` after `restoreConsumedSuspension` lands a second such row. The + comment now records that more than one row can exist, states why the read is + correct anyway (it is a presence check with `limit: 1`, deciding identically on + one row or two), and points at the pin that measured it. + + Prose only — no behaviour change, no door narrowed, no guard touched. The audit + trail's one-row-per-advancement shape is accepted residue; requiring one row per + advancement is a separate change. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [7465eeb] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index b32ced29383..08c0d10d1e5 100644 --- a/packages/plugins/plugin-approvals/package.json +++ b/packages/plugins/plugin-approvals/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-approvals", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Multi-step approval engine for ObjectStack — sys_approval_process + sys_approval_request + sys_approval_action + IApprovalService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-audit/CHANGELOG.md b/packages/plugins/plugin-audit/CHANGELOG.md index 90bf92d8239..addee1d80d2 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,662 @@ # @objectstack/plugin-audit +## 17.5.0 + +### Minor Changes + +- 271d6bb: Record the acting agent on the audit row — ADR-0090 D10 rule 4 dual attribution + + A `sys_audit_log` row written by an MCP OAuth client acting for a human used to + be byte-identical to a row that human wrote in the Console. The envelope carried + the delegation (`principalKind: 'agent'` + `onBehalfOf`), the row did not, and + nothing in between copied it: `assembleExecutionContext` consumed the OAuth + `azp` as a boolean and dropped the value, so the acting client did not exist + downstream of the door at all. + + The delegation now travels the whole way and lands on the row: + + - `ExecutionContext.performedBy` (`{ clientId }`) — decided at the `/mcp` OAuth + door, on the same branch that already decides `principalKind: 'agent'` and + `onBehalfOf`; a member of the closed entry field set like every other. + - `HookContext.provenance.performedByClientId` — the hook-layer carrier, beside + `flowRunId` and `attributedUserId`. Provenance, not `session`: no + caller-gating hook may read the client as the caller. + - `sys_audit_log.metadata` gains `{ performed_by, on_behalf_of }` on a delegated + write, and nothing at all on a personal one — the two shapes are told apart by + absence rather than by guesswork. + + Additive, and attribution only. `user_id` stays the human, so owner-stamping, + `current_user.*` RLS and the `sys_user` join are untouched (ADR-0073 D3 — + attribution is not ownership). `actor` is untouched too: ADR-0118 D1/D5 keeps + that column two-valued — a user id, or `null` for the system — and answers + "which non-user acted" with an added attribution field rather than a second + actor vocabulary. No existing row changes meaning, and no historical row is + rewritten. + + Rule 4's third element, the run id, is NOT delivered here and is not declared + either: nothing on the request path mints one today (`ExecutionContext.traceId` + is declared but resolved by no transport entry point), and declaring a carrier + nothing populates is the defect this change exists to close. +- 877dc03: The walled boot records platform-admin standing on the existing audit ledger, so «who held administrator standing, and from when» survives the move off the stored grant row (#18412). + + Platform-admin standing moved from a **stored grant row** to **config-derived, request-time resolution** (#11663 re-anchor, ADR-0131). The row carried its own history; config carries none. After the migration the only trace of a grant or a revocation was a change to `OS_PLATFORM_OWNER_EMAIL` plus a restart — the product keeps no environment-variable history and an auditor cannot read one. `sys_audit_log` recorded the ACTIONS all along; what had no writer at all was the **basis** of the authority behind them. + + The answer was already being computed and thrown away: `resolvePlatformAdminStanding` builds the per-entry summary at every walled boot and the bootstrap logs it at `info`. + + - **`@objectstack/plugin-audit`** — `sys_audit_log.action` declares one new value, `platform_admin_standing_change`, WRITER-FIRST (the only way a value is allowed onto that enum). Its rows appear on the shipped, unfiltered `recent` and `all_events` views; ⛔ no new list view, ⛔ no new object, ⛔ no new configuration key. + - **`@objectstack/plugin-security`** — the walled bootstrap compares the resolved standing against the last snapshot already on the ledger and writes **one entry per CHANGE of standing**, plus the **first-boot baseline**. A restarted rig writes nothing. Each row carries, per declared entry, the declared spelling, whether an account exists, whether it is verified, and which user id holds standing; `old_value` and `new_value` state both sides of the delta, and `old_value` is null on the baseline row and only there. + - **The `single` posture is untouched.** It still promotes the first registrant and still writes a durable grant row, so the durability this restores is walled-posture-specific. + - ⭐ **`organization_id` is NULL on this row, deliberately and by maintainer ruling** (2026-09-18, director batch #153 item 2). The record is deployment-level by construction: ADR-0131 §1.5 rejects inventing a platform organization in its own words («it is the natural repair and the wrong one … exists only to give NULL a new name»), a tenant id would file a whole-deployment fact behind one tenant's wall, and the first-boot baseline is written before any `sys_organization` row exists at all. This follows the tree's four existing deployment-level audit writers, and is the shape ADR-0131 D7 will later make structural by dropping the column. The exception is recorded beside the write, on the card, and in a pin — ⛔ it is not a gap waiting to be repaired. + - **Nothing here widens who holds standing or what standing permits.** The derivation site is untouched; this adds a RECORD of authority, never a grant of it. + - **Best-effort, and never fatal to boot.** A deployment that never mounted the optional `@objectstack/plugin-audit` skips silently — an unmounted ledger is a composition choice, not a fault. A ledger read that is REFUSED writes nothing and says so: «cannot tell» is not «first boot», and reading it that way would file a fresh baseline on every restart. A mounted ledger whose insert fails reports a durability degradation on the `error` channel. + +### Patch Changes + +- a6a1de4: **The read-audit failure report now speaks once per CAUSE instead of once per PROCESS, and prints the telemetry-datasource remedy only for the cause it is the remedy for.** + + `installReadAuditWriter`'s `reportReadAuditWriteFailure` (`read-audit.ts`) carried its own process-level `failureReported` boolean and its own fixed message literal — the third independent copy of the pair #15166 fixed in `audit-writers.ts` and #17452 fixed in `auth-event-audit.ts`. Both defects were live on a seam the repo has already declared durability-critical (`persistReadAuditRows` is registered in `DURABILITY_CRITICAL_CALLEES`): + + - **The first failure of any cause silenced every later failure of every other cause for the life of the process.** A server could keep losing record-view batches for hours to a second, unrelated fault with one `error` line at the top of the log describing the first — and record-view rows are written from a buffer off the request path, so no in-flight request is left to notice. The dedupe key is now the failure's identity, `auditFailureCauseKey`, imported from `audit-writers.ts` rather than re-spelled. A repeat of an already-reported cause still degrades to `debug`; a NEW cause gets its own `error` line, once. + - **The ADR-0057 §3.6 / `OS_TELEMETRY_DB` datasource guidance printed unconditionally**, so a fault with nothing to do with datasource routing (an `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` refusal, say) sent the operator to check something that was working. The guidance is not deleted and not weakened — it is asked for through the shared `isMissingTableError` predicate and printed for exactly the missing-table cause it was written for; every other cause now gets the driver's own verdict quoted at the head of the line plus the fix that matches it. + + **Behaviour that deliberately does not change:** the once-per-degradation anti-noise rule itself (a repeat of the same cause is still one line), the `error`-then-`warn` sink fallback (#9657), and the rule that an audit failure never reaches the read. + + No API, option or type moves; nothing an author writes changes. + + Clause-②: no +- 5636641: The activity-timeline summary resolves a reference field's target through `referenceTargetOf` instead of the materialized `reference` carrier, so a `trackHistory`'d `{ type: 'user' }` field authored without one is planned, read and rendered as a name instead of silently showing the raw id (#19264). + + `audit-writers.ts` admitted `user` as a reference type and then required an EXPLICIT `reference` on it. The spec declares exactly the opposite for that type: `IMPLICIT_REFERENCE_TARGETS` (`@objectstack/spec/data`) says a `user` field's target is "a CONSTANT OF THE TYPE, so `reference` on a `user` field materializes that constant; it does not supply it. Metadata authored without it (hand-written JSON, an AI author, a Studio form) is **fully specified, not under-specified**." So the one spelling the contract calls complete was the one the reader refused — and it refused it **silently**: the field was simply absent from the read plan, and the timeline rendered `usr_1` where every other reference field showed a name. + + - **Four sites, not two.** The target is the key of the `id → title` map, so it has two ends: the two read planners (`planTrackedLookupReads`, `planMilestoneTokenReads`) build the plan under it and the two renderers (`renderTrackedChangeSummary`, `renderMilestoneSummary`) look the resolved titles back up under it. All four now ask one helper, so repairing the plan alone cannot pay for a read whose result the renderer then fails to find. + - **Nothing else widens.** The admitted types are unchanged (`lookup`, `master_detail`, `user`), so a `lookup` / `master_detail` whose author-chosen target is absent still names nothing, is still left out, and still issues no read — `tree` is deliberately not added. + - **A padded carrier can no longer split the key.** The planners used to `trim()` and the renderers did not, so `reference: ' crm_account '` produced two keys and no title; one helper trims once for both ends. + - **The unreadable-carrier behaviour is unchanged.** `referenceTargetOf` reads the carrier through `referenceCarrierOf`, which throws for an object- or array-valued `reference`; that throw is caught at the helper because this code runs inside `writeAudit`'s summary composition, which is not inside the `try` that guards the audit row write — an escaping `TypeError` would turn a display-enrichment miss into a failure on the audited write's own path. Such a carrier is left out exactly as it was before. + - **No authoring change.** Metadata that already spells `reference: 'sys_user'` resolves to the same target it always did; nobody has to restate the constant. +- ab48938: A lost audit row is reported once per failure CAUSE, not once per process, and the first line names the cause instead of a fixed remedy. + + `reportAuditWriteFailure` — the best-effort catch around `persistAuditTrailRow` — deduped on a single process-wide boolean. After the first failure of any cause, every later failure of every *other* cause degraded to `debug` for the life of the process, so a long-running server could keep losing compliance rows for hours to a second, unrelated fault with one `error` line at the top of the log describing the first. `persistAuditTrailRow` is registered in the durability-degradation vocabulary precisely because a lost audit row must be reported at `error`. + + The dedupe key is now the failure's identity — the error `code` (or its absence) together with the object being audited. A repeat of an already-reported cause still degrades to `debug`, exactly as before; a new cause reports at `error`, once. The key is built from the `code` and **never** the message: a driver names the offending row in its message, so a message-keyed dedupe would grow one `error` line per failed write. Keyed on the code, the reported-cause set is bounded by the boot-declared object registry and the driver's code vocabulary and does not grow with traffic — measured at 65 lines for 6,500 failed writes and the same 65 for 26,000. + + The first `error` line now leads with the underlying code and message, which were already computed at the call site and passed only into the `debug` payload. The ADR-0057 §3.6 telemetry-datasource guidance is kept — it is the correct remedy for the "no such table" cause it was written for — but is now printed only for that cause, decided by the shared `isMissingTableError` predicate for both tables this writer writes. Previously it was printed unconditionally, so an organization refusal was answered with "check the datasource", sending the operator to inspect something that was working. + + `@objectstack/types` is added as a dependency for that predicate, rather than hand-rolling a second driver-error vocabulary. +- cb648cb: A lost auth-event row is reported once per failure CAUSE, not once per process, and the first line names the cause instead of a fixed remedy. + + `auth-event-audit.ts` — the writer behind the `login` / `logout` rows in `sys_audit_log` — carried its own, independent copy of both defects the record-level audit writer was fixed for. `reportAuthEventWriteFailure` deduped on a single process-wide boolean, so after the first failure of any cause, every later failure of every *other* cause degraded to `debug` for the life of the process: a long-running server could keep losing sign-in and sign-out rows for hours to a second, unrelated fault, with one `error` line at the top of the log describing the first. `persistAuthEventAuditRow` is registered in the durability-degradation vocabulary precisely because a lost audit row must be reported at `error`. + + The dedupe key is now the failure's identity — the error `code` (or its absence) together with the object the rows are about. A repeat of an already-reported cause still degrades to `debug`, exactly as before; a new cause reports at `error`, once. The key is built from the `code` and **never** the message: a driver names the offending row in its message, so a message-keyed dedupe would grow one `error` line per lost row. Keyed on the code, the reported-cause set is bounded by the driver's code vocabulary and does not grow with traffic — measured at one `error` line for 200 failed sign-ins carrying 200 distinct messages under one code, and the same one line for 200 carrying no code at all. + + The first `error` line now leads with the underlying code and message, which were already computed at the call site and passed only into the `debug` payload. The ADR-0057 §3.6 telemetry-datasource guidance is kept — it is the correct remedy for the "no such table" cause it was written for — but is now printed only for that cause, decided by the shared `isMissingTableError` predicate for the one table this writer writes. Previously it was printed unconditionally, so an organization refusal was answered with "check the datasource", sending the operator to inspect something that was working. + + The cause-key helpers are imported from the record-level writer in this same package rather than re-spelled here: a second copy of that key is how these defects reached this file, so a third spelling would repeat the mistake. No published export is added or changed. +- 8d4690b: fix(plugin-audit): record-view rows keep the VIEW instant instead of the buffer-drain instant (#16829) + + `sys_audit_log`'s `record_views` rows answer "when did this user look at this record?". Read auditing batches its INSERTs off the request path by design, so `buildRow` writes `created_at: event.viewedAt` rather than letting the column's `NOW()` default stamp a whole batch with one flush timestamp — up to `flushIntervalMs` after the fact, with read order inside the window destroyed. + + `persistReadAuditRows` wrote that row under `{ context: { isSystem: true } }`, and the module's comment cited that flag as what carried the view instant through. It never was. `isSystem` exempts a write from the readonly strip; the layer that decides `created_at` on an insert is the audit stamp hook `sys_stamp_audit_insert`, which reads `session.preserveAudit` and has never read `isSystem`. What was actually carrying the value was that hook's pre-#15964 line, `record.created_at = record.created_at ?? now` — client-preferred on every insert, with no flag and no privilege required. #15964 closed that accident (maintainer ruling 2026-09-06), and the ordinary branch has stamped `now` since: on this path, the flush instant. + + The write now declares both context keys, for two different layers: + + ```ts + await engine.insert( + 'sys_audit_log', + rows as any, + { context: { isSystem: true, preserveAudit: true } } as any, + ); + ``` + + `isSystem` still carries the readonly-strip exemption the row needs; `preserveAudit` is the one the stamp hook reads. `preserveAudit` is the ruled historical-import channel (#3493, reaffirmed by #15964's ruling) — the door audit left open for reinstating an original timeline — and a view row's original timeline is the moment of the view, so this use is inside its declared purpose rather than a bypass of it. + + **What changes for a deployment.** Only for deployments that opted objects in to record-view auditing (`AuditPlugin`'s `readAudit.objects`). Rows written from now on carry the view instant. ⛔ Rows already written under the flattened behaviour are not repaired by this change: their `created_at` is the drain time of the batch they were in, and the view instant they should have carried was never persisted anywhere else, so it cannot be recovered. Only builds cut from `main` after #15964 are affected — the objectql half has not shipped in a published version. + + **No exported symbol, schema, route or config key moves.** The only observable change is that a `created_at` this writer already intended to write now survives. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [63b6818] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [17005cc] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [922c755] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [4fef271] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [5dba7f3] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [afc3b64] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [2bbb462] +- Updated dependencies [90ff10a] +- Updated dependencies [3bd221d] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [8490127] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [ae0c90c] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [0b866bf] +- Updated dependencies [c839986] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [009da14] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [aa04ea2] +- Updated dependencies [172b4cf] +- Updated dependencies [4463966] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [b373596] +- Updated dependencies [7465eeb] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [a08e059] +- Updated dependencies [fe677ae] +- Updated dependencies [fc646cf] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [949e99b] +- Updated dependencies [16c5a33] +- Updated dependencies [16c5a33] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [16c5a33] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [cfe2387] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [a78f731] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [c74de10] +- Updated dependencies [db74b16] +- Updated dependencies [2b24b8b] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [c5d6b2b] +- Updated dependencies [2d91c9a] +- Updated dependencies [2f122b6] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [4a1df19] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [9801da1] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [b2b6a06] +- Updated dependencies [8538edf] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [92ea760] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [a016f08] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [0f38ab0] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/objectql@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index 41a6242dd9c..04040691d51 100644 --- a/packages/plugins/plugin-audit/package.json +++ b/packages/plugins/plugin-audit/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-audit", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Audit Plugin for ObjectStack — System audit log object and audit trail", "main": "dist/index.js", diff --git a/packages/plugins/plugin-auth/CHANGELOG.md b/packages/plugins/plugin-auth/CHANGELOG.md index 774154c2043..b0c20dc89cc 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,1328 @@ # Changelog +## 17.5.0 + +### Minor Changes + +- ee6fbd7: fix(plugin-auth): give the auth `basePath` default a single written definition (#16384) + + `'/api/v1/auth'`, the shipped default for `AuthPlugin`'s `basePath` option, was + written independently at four sites: the `AuthPlugin` constructor, two later + re-derivations inside `AuthPlugin` (`registerAuthRoutes`, the OIDC discovery + `.well-known` alias), and `AuthManager.configuredBasePath()`'s own fallback. + Nothing was broken by the duplication — `AuthPlugin` always supplies `basePath` + to `AuthManager`, so the manager's copy was dead on the live path and + unfalsifiable by construction: no test could have caught one copy drifting from + the other three. + + The default now lives in exactly one place, `DEFAULT_AUTH_BASE_PATH` (exported + from `@objectstack/plugin-auth`, declared beside `readMcpServerEnabledEnv` in + `auth-manager.ts`); all four sites import it instead of retyping the literal. + Every site evaluates byte-identically to before — this is a consolidation of + where the value is *written*, not a change to what any site *evaluates to*, and + in particular does **not** touch `AuthManager`'s `configuredBasePath` → + `rootedBasePath` → `getBasePath` normalisation chain (#16399) or the published + OAuth `iss` / RFC 8707 `aud` identifiers those getters produce. + + This is additive and non-breaking — no existing call site's behaviour changes — + but it does add one new named export (`DEFAULT_AUTH_BASE_PATH`) to the + package's public surface, which is what makes this `minor` rather than `patch`. +- d438b3a: The bulk identity import admits `manager_id`, resolved in a second pass keyed on the importer's identity key + + `POST /api/v1/auth/admin/import-users` now reads a `manager_id` column. Until + this change it matched `manager_id` **0** times — against a positive control of + `email` at 73 — so a CSV naming everyone's manager built the org chart for + nobody, silently: the column was dropped on create (the identity write path + composes its own better-auth body) and filtered out on upsert (it is not in + `SYS_USER_IMPORT_UPDATE_FIELDS`). With + `POST /api/v1/auth/admin/set-user-manager` shipped, the import surface was the + one remaining route that could populate the column at scale and did not. + + **What the cell holds is an identity key, not a user id.** A CSV author has the + manager's email or phone number, never their `usr_…` id, so the cell is read + with the same key the importer already keys rows by. One spelling, `manager_id` + — the phone column's three historical aliases are debt this key does not + inherit. + + **The pass is SECOND, and that is load-bearing.** A manager named in row 40 may + be created by row 90, so the links are applied after the row engine has + returned and every row in the batch exists. A resolve inside the per-row write + would refuse exactly that input and would appear to work only on a file whose + rows happened to arrive in dependency order. A manager who is *not* in the file + is resolved against the directory instead, so an org chart can be grown one + batch at a time. + + **Every refusal is the write surface's, applied per row.** The importer calls + `applyUserManagerLink` — the same derivation `POST /admin/set-user-manager` + runs — so self-assignment, a link that closes a cycle, a chain past the depth + cap, a manager provably outside every organization the user belongs to, and any + identity whose `sys_user.source` is `idp_provisioned` are refused on import + exactly as they are on the endpoint, with the endpoint's own `reason` + discriminator carried through. There is no second copy of those predicates. + + **A manager problem never costs the row its identity.** The user is created + either way; the failure is reported on that row — `rows[].manager` carries the + machine-readable outcome in the shape `rows[].delivery` already uses + (`unresolved`, or the refusal's own `reason`), and `rows[].error` carries the + sentence. It is ⛔ not a whole-import failure and ⛔ not a silent skip, and an + engine fault while linking is reported the same way rather than turning a 200 + that created N users into a 500 that reports none of them. No `rows[].code` is + stamped for a manager outcome: a row-level code would have to be registered in + the `packages/spec` error-code ledger, which this change is fenced out of, so + the machine-readable half lives on `rows[].manager` instead of on a code the + vocabulary does not carry. + + **New on the response.** `data.summary.manager` is + `{ linked, unresolved, refused }`, beside `data.summary.delivery`, and the + run-level `sys_audit_log` row records the same split. Row objects are typed as + the newly exported `IdentityImportRowResult`, whose `manager` member is an + `ImportManagerOutcome`. + + **Unchanged, deliberately.** `SYS_USER_PROFILE_EDIT_FIELDS` and + `SYS_USER_IMPORT_UPDATE_FIELDS` are untouched — the import reaches the column + by system context, the same way it already reaches `phone_number` and `role`, + and the same way the admin endpoint does. `manager_id` keeps `readonly: true` + on the column. Nothing derives a manager from org-unit membership. A dry run + does not run the pass at all and reports zeroes rather than half-answering + about links it could not evaluate. +- 74832b6: **Breaking (shipped as `minor` under the launch-window convention).** Under a **walled** tenancy posture (`group` / `isolated`), a legacy unscoped `admin_full_access` grant row no longer confers `PLATFORM_ADMIN`; platform standing there is derived from `OS_PLATFORM_OWNER_EMAIL` and from nothing else. The migration pointer that announced this since 17.3.0 is retired with it: `reportLegacyPlatformAdminGrant` and `resetLegacyPlatformAdminGrantReport` are **removed from `@objectstack/core`'s published entry** (#18336, #11663 leg L5). + + ⚠️ **The `single` posture is untouched, deliberately.** Its zero-config first-user promotion still mints that row and that row still confers `PLATFORM_ADMIN` — a development environment started for a moment cannot be asked to declare an administrator first. Choice 4A (#11974) rules that promotion correct, and the maintainer's 2026-09-08 ruling on #16682 is verbatim: 「retiring the walled write must not retire the `single` one」. The `single` half's disposition is #11979's. ADR-0131 D5, as amended 2026-09-17 (#18413), is the governing record. + + **What a walled deployment must do.** Declare each administrator's **verified** address in `OS_PLATFORM_OWNER_EMAIL` (comma-separated for several) before upgrading. A walled rig that upgrades with the variable undeclared and an unscoped grant row still in place has **zero** platform administrators; the bootstrap now says so **at error**, naming the variable, the row and its holder — L4 used to skip that line for exactly this rig, on the ground that the deprecation pointer carried the remedy instead, and both halves of that arrangement have now expired. + + - **17.3.0 opened the window, this closes it.** L4 (17.3.0) stopped the walled bootstrap from ever *writing* the row and started the once-per-process pointer; L5 stops the walled derivation from *reading* it. The window was time-boxed and loud by design (#11663 P5). + - **The retirement takes the ANCHOR, not the ROW.** Nothing here writes, deletes or re-owns any grant row — a walled holder keeps the `admin_full_access` permission set they hold, and loses only platform-admin *standing*: the rung and the built-in `platform_admin` position. That row's ownership is ADR-0131 C3's, on the v18 line. + - **No new query.** The posture gate reads the environment, never the engine, so the recorded query multiset is identical under both of its answers — measured, not asserted. Under a wall the guard's grade-1 scan is skipped outright, so that path issues one read fewer. + - **`@objectstack/plugin-auth` moves with it, at TWO readers.** `ensureDefaultOrganization`'s step-2 legacy fallback is keyed on the same expression: under a wall it no longer answers「which user is the platform admin?」from the oldest unscoped grant, so the account it would have bound as the Default Organization's `owner` — and handed the org's seeded rows to — is no longer selected. ⛔ That reader does not merely count the population, it **confers** on it, which is why it is keyed here rather than sequenced. Its bootstrap-trigger predicate retires the matching `sys_user_permission_set`-insert arm under a wall with it (cost only; the `sys_user` arms are untouched, and on a walled rig the declared owner's verifying update is the only write that ever grows the population). And: + - **`@objectstack/plugin-auth`'s break-glass guard moves with it.** `last-admin-guard.ts` enumerates the administrator population from the SAME anchor, and its contract is to answer the same question the derivation answers. Its grade-1 (grant-anchored) enumeration is now keyed on the identical expression, so under a wall the guard no longer counts a holder the derivation does not recognise. Consequence on a walled rig: a write that would end the last **config**-anchored administrator's standing is now REFUSED where it was permitted, and a write that removes the now-inert grant row is no longer refused as though it removed the last administrator. Under `single` the guard is unchanged. Its two zero-population refusals also gained a walled clause, because「restore the `admin_full_access` row」stopped being a remedy that ends the emptiness there. + - **`@objectstack/organizations`' walled bootstrap moves with it.** That package wraps `ensureDefaultOrganization` and is the runtime that actually performs the default-organization bootstrap on a walled deployment (plugin-auth's own wiring skips it there). With the helper's legacy fallback keyed off, a walled rig carrying a legacy grant row **no longer** has a Default Organization created for that holder, and that holder is no longer bound as its `owner`; the bootstrap waits for a declared administrator to verify instead. ⚠️ Named because the behaviour an operator gets **from this package** moves — its own source does not change, and the pin re-authored inside it is not the reason. + - **Why `@objectstack/runtime` and `@objectstack/plugin-hono-server` are named.** Neither package's own source changes. Both carry `export * from '@objectstack/core'` (`runtime/src/index.ts`, `plugin-hono-server/src/adapter.ts`) and their built `.d.ts` carry that statement, so the two removed names leave their published surfaces too. All publishable packages sit in one Changesets `fixed` group, so naming them moves no version — it is named so the tombstone reaches the CHANGELOG an upgrading consumer of THOSE packages greps. Precedent is mixed (a core-only declaration exists); this follows the `ApiRegistry` precedent, which named every package the removal reached. + + +- e6c34f6: The identity read routes now serve what `@objectstack/spec/identity` declares: `metadata` arrives DECODED on every organization route that reads the row back, and `updatedAt` is declared optional on `Organization` / `Member` / `Invitation` — the shape better-auth's own serializer documents (#18728). + + Clause-②: yes (widening) — `updatedAt` moves from required to optional on three published schemas, so the set a consumer may hand to `OrganizationSchema` / `MemberSchema` / `InvitationSchema` grows by exactly one shape: the key being absent. Nothing previously admitted is refused, nothing is renamed, and no producer is required to write it. Contract-review tier. + + Three published schemas could not parse a served response. `OrganizationSchema` declared `updatedAt` required and `metadata` an object; the four organization read routes (`setActive`, `get`, `delete`, `list`) carried no `updatedAt` at all and served `metadata` as the stored JSON text. `@objectstack/client` had recorded that as three 「not relayed」 notes rather than as a defect, and with zero in-repo consumers nothing went red — the audience was entirely external. Maintainer ruling C (batch #158 item 4) fixed the producer and made the one remaining key conditional on a measurement, which is what decided each half: + + - **`metadata` is decoded at the producer, unconditionally** — it is our column. plugin-auth's data adapter decodes `sys_organization.metadata` out of its stored JSON text on its READ verbs, so all four routes serve the object the spec declares, and an unset column is OMITTED rather than sent as `null`. ⛔ The write verbs are deliberately untouched: better-auth's own organization adapter decodes the `create` / `update` echoes itself and discriminates on the value still being a string, so decoding there would fold the create echo's `metadata` to `undefined`. Both directions are pinned. + - **`updatedAt` aligns to the documented wire** — ruling C's own fallback A, and its two conditions were measured against the installed better-auth 1.7.3 rather than assumed. The routes are better-auth's endpoints mounted through a single catch-all, each answering `ctx.json(...)` with no ObjectStack post-processing; and the vendor's `organization`, `member` and `invitation` models declare no `updatedAt` field, while its adapter factory's output transform iterates the declared fields only, so an undeclared column is dropped before any route sees it. Control, in the same file: the vendor's `team` and `organizationRole` models DO declare `updatedAt`, so the absence is a reading. For `member` and `invitation` there is additionally no column to serve — `sys_member` and `sys_invitation` are `managedBy: 'better-auth'`, the one disposition under which the platform injects no audit family, and neither declares `updated_at` itself. + - **`@objectstack/client` relays the schemas.** `OrganizationWire` is the spec's `Organization`, `OrganizationMemberWire` is `Member`, and `OrganizationInvitationWire` is `Invitation` with `status` narrowed per route plus the three members the platform adds on top (`teamId` and the two ADR-0105 D8 placement fields, which the non-strict schema strips). The three 「not relayed」 notes are gone. + - **The negative controls are the point.** "The client relays the spec schemas" and "the client stopped validating" look identical from a green positive test, so every accepted body is paired with a refused one — a required field genuinely missing, `metadata` still arriving as the stored JSON TEXT, and a `createdAt` or `updatedAt` present but not a datetime. `.optional()` widened the accept set by absence ONLY; a value that is there is still held to `z.string().datetime()`. + + **Not declared breaking, and the reason is the repo's own criterion** rather than the level being convenient. AGENTS.md binds the breaking class to removing or renaming something an author can write, and to the `(narrowing)` arm of the clause-② pair. Neither holds here: nothing is removed, renamed or retired; the one `packages/spec` edit only widens an accept set; and the `metadata` half is a producer brought into line with a contract this package has published all along — `OrganizationSchema.metadata` has declared an object since it was written, and the client's own comment called the served text 「not relayed」 rather than a shape anyone was promised. No ADR-0087 disposition is claimed because no breaking change is declared: no authored metadata moves, so `objectstack migrate meta` has nothing to visit, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. These three schemas are not metadata types — not in `DEFAULT_METADATA_TYPE_REGISTRY`, no authorable surface. ⚠️ Stated here rather than assumed silently, because it is the one judgement in this diff that the contract review the `Clause-②: yes` declaration commissions should confirm. + + **What a consumer notices**, and where it is delivered: `organization.metadata` was the stored JSON text and is now the decoded object, so a caller that decoded it itself drops that step. + + ```ts + // before — the caller decoded what the route sent + const meta = JSON.parse(org.metadata ?? '{}'); + // after — the producer decoded it; the key is ABSENT when unset + const meta = org.metadata ?? {}; + ``` + + The channel that reaches that caller is the compiler, on the line that used to work: `JSON.parse` no longer accepts the value. `updatedAt` needs nothing in either direction — it was never on this family's wire, so no caller can have been reading a value, and the declaration now says so out loud instead of promising one. +- 2aac821: MCP OAuth is eligible over plain HTTP when the deployment's own host is loopback **or a private / link-local address** — RFC 1918 `10/8`, `172.16/12`, `192.168/16`; RFC 4193 `fc00::/7`; `169.254/16`, `fe80::/10`. A **public** host keeps TLS-required and fail-closed, exactly as before (#19489). + + Clause-②: no + + Shape ruled by the maintainer (2026-09-21), with the semantics of Keycloak's `sslRequired=external`: 「要(开发模式)」 and 「19342 同意兼容」. Reasoning of record — a deployment that already serves its login form and session cookies over plain HTTP gains nothing from OAuth refusing plain HTTP; the refusal only removes MCP from that deployment. An intranet install and a developer's `os dev` bound to a LAN address are the same case, so development mode is **subsumed** and there is no separate dev-mode branch. + + - **⛔ No configuration key and ⛔ no environment variable.** A switch would be reachable on a public host, which is exactly the deployment this rule must keep refusing. `OS_ALLOW_INSECURE_OAUTH_HTTP` is not introduced in any form. + - **The public arm is unchanged.** `http://example.com` and `http://203.0.113.5` are refused as before; the MCP endpoint stays API-key-only and no OAuth metadata is advertised. Addresses that merely look private are refused with it: `172.15.x` / `172.32.x` fall outside RFC 1918, `fec0::/10` site-local (RFC 3879) falls outside `fc00::/7`, and a hostname that only begins with a private IPv4 string (`10.0.0.5.evil.com`) is a name, not an address. + - **A non-IP hostname over plain HTTP stays refused**, deliberately: the rule judges the host **literal** of the deployment's own canonical origin. `crm.corp` and `host.docker.internal` are not IP literals, so configure the base URL on the private address the deployment already binds (`http://192.168.1.10:3000`). Resolving the name in DNS was rejected — it makes a synchronous predicate depend on a round trip whose answer can be rebound — and judging the requester's peer address, which is what Keycloak does, was rejected because it cannot decide what a deployment **advertises** at mount time and because a plain-HTTP reverse proxy on a public address makes every requester look internal. + - **One loud startup line on every plain-HTTP deployment** — the rule's verdict decides WHICH sentence, not whether one is emitted. On an origin it ACCEPTS: `OAuth is served UNENCRYPTED`, followed by the issuer URL and what crosses the wire in the clear. On a PUBLIC plain-HTTP origin, whose OAuth track this same rule leaves dark: the separate line `OAuth discovery is served over PUBLIC plain HTTP`, naming the `.well-known` authorization-server documents this deployment publishes over an unencrypted public origin, stating that the MCP OAuth track is disabled and that TLS is the remedy. The accepted-transport sentence is never printed there — it would assert a transport this deployment was refused. Neither line under TLS. The maintainer worded the notice 「OAuth 未加密:仅限可信内网」; that sentence is carried as the line's meaning and kept verbatim in the code comment beside the call, the emitted strings being English per this repository's convention. + + Nothing an author writes changes: `isOAuthEligibleBaseUrl` keeps its signature, no export is added or removed, and no published payload gains a key. A deployment on a public host sees no behaviour change at all. +- 65352b7: **plugin-auth: under the `open` audience posture, the deployment can turn email verification off** + + Clause-②: yes + + Under `audience.posture: 'open'`, an explicit `emailAndPassword.requireEmailVerification: false` + declared by the **deployment** is now honoured instead of refused at config entry. The deployment + declares it through its stack config (the `AuthManager` constructor), host code calling + `AuthManager.applyConfigPatch()`, or the `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false` env override + of the `auth.require_email_verification` setting. A sign-up is then signed in at once, with no + verification mail. This is for a deployment with no mail transport that trusts its sign-ups, + such as a pre-production environment, which otherwise dead-ends every new account at the verify + page. + + Nothing changes for anyone who does not opt out: + + - `open` with the value absent or `true` still forces verification on. + - `email_domain` still refuses an explicit `false`, from any source, with the same message. The + domain allowlist is the only gate there, so an unverified sign-up could claim a colleague's + address. + - `invite_only` is unchanged. + - A `false` stored only through the settings console is still refused under `open`. The console + can agree with the deployment's opt-out, never make one. + + The opt-out is loud. `AuthPlugin` logs one warning at boot naming the posture and the + consequence: anyone can register an address they do not control, and an organization invitation + sent to that address can then be accepted by that account. `getPublicConfig()` reports + `requireEmailVerification: false`, the value actually wired, because the wiring and the + advertisement now read one resolver. + + `AuthManager.applyConfigPatch()` takes an optional second argument, + `{ requireEmailVerificationFrom: 'deployment' | 'console' }`. It defaults to `deployment`; the + settings binding passes `console` for a stored value and `deployment` for an env override. +- 344d475: fix(plugin-auth)!: `POST /admin/create-user` reads the deployment's membership policy instead of hard-coding `auto` (#16683) + + **BREAKING** — the membership this published endpoint writes moves for existing inputs on `invite-only` deployments. The route, its request body, its response fields and every exported signature are byte-identical; what changes is what an existing call does on a deployment that declared a non-default policy, stated as a FROM/TO pair below. + + ADR-0093 D1 makes the deployment's `membershipPolicy` the one answer to "does this new account get an organization membership", and enumerates the `invite-only` flows as a closed set — "which endpoint created the user" is explicitly not a determinant. The `user.create.after` reconciler and the D6 backfill both read it through `AuthManager.getMembershipPolicy()`. This endpoint did not: its belt-and-suspenders bind handed the reconciler a literal `'auto'`, so it was the one membership-writing path in the product that ignored the setting. + + FROM: on a deployment declaring `membershipPolicy: 'invite-only'`, an account created through `POST /api/v1/auth/admin/create-user` was bound to the default organization anyway, and the 200 response answered `membershipCreated: true`. The `user.create.after` reconciler had already declined to bind it; this endpoint bound it afterwards. + + TO: the same call creates the account and binds no membership. The response answers `membershipCreated: false` and omits `organizationId`, and the audit row records the same. The account is created and can sign in — `invite-only` withholds the membership, not the login. + + Who is affected: only deployments that set `auth.membership_policy` (or `OS_AUTH_MEMBERSHIP_POLICY`) to `invite-only`. Under the default `auto` posture behaviour is unchanged in every observable respect — response body, `sys_member` write and audit metadata — and that equivalence is pinned by a test rather than asserted here. + + If you relied on admin-created accounts acquiring a membership on an `invite-only` deployment, the supported way to keep it is to bind the membership explicitly (the `add_member` action / `POST /organization/add-member`), which is what `invite-only` means: memberships are granted deliberately, never as a side effect of account creation. Setting the deployment back to `auto` restores the old behaviour for every path at once, including sign-up. + + The direction of the old defect was open, not closed: it GRANTED a membership the operator had configured the platform to withhold, and reported success while doing it. An operator who set `invite-only` specifically to keep a shared organization identity off their users got one anyway. + + +- 374d9d3: **BREAKING** — `GET /api/v1/auth/get-session` answers an anonymous caller with the + declared ADR-0112 failure envelope and HTTP 401, instead of HTTP 200 wrapping a JSON `null`. + + Until now an unauthenticated session read answered: + + ``` + HTTP 200 + null + ``` + + `ObjectStackClient.auth.me()` declares `Promise`, and + `SessionResponseSchema` requires `data.session` and `data.user` — so no value of that type + means "nobody is signed in", and the most ordinary call a logged-out caller can make + resolved to something outside the method's own declared type. Ruled by the director seat + (decision batch #117 item 4) under the charter rule + 「spec 与代码不一致默认改代码,改协议单独立卡非选项」: the implementation is corrected to + the published contract. `SessionResponseSchema` is untouched. + + What changes on the wire: + + - **An anonymous or unresolvable credential ⇒ `401` with `error.code: 'UNAUTHENTICATED'`** + and the message `Sign in first`, the same body a raw `/admin/` mount already answers the + same caller with. No error code is minted: `UNAUTHENTICATED` is an existing + `StandardErrorCode` member, derived from the status through ADR-0112's own map, so + `ERROR_CODE_LEDGER` is unchanged. + - **Unchanged:** a signed-in read still answers `200` with `{ user, session }`, + byte-identical. Every other `/auth/*` route is untouched, and so is the `404` that a + method this route does not serve already answered — this change never invents a route. + - **Also unchanged:** better-auth's JS API. `auth.api.getSession()` still returns `null` for + an anonymous caller, so every internal identity read — execution-context resolution, the + platform-admin gates, the SSO bridges — behaves exactly as before. Only the wire moves. + + **`@objectstack/client`:** `client.auth.me()` now **rejects** for an anonymous caller + instead of resolving with `null` — the SDK throws on every non-2xx before unwrapping. Every + value the method resolves with is now inside its declared `SessionResponse`. Callers that + inspected the resolved value must move to a `catch`: + + ```ts + try { + const session = await client.auth.me(); + // …signed in + } catch (err: any) { + if (err.code === 'UNAUTHENTICATED') { + // …signed out; err.httpStatus is 401 + } + } + ``` + + A caller that branches on the HTTP status directly reads `401` plus + `error.code: 'UNAUTHENTICATED'` where it used to read `200` plus an empty body. + + +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- e758131: fix(plugin-auth): a `single`-posture deployment holding more than one organization is reported at `error` instead of booting silently (#17010) + + ADR-0131 §1.2(3) states that its precondition — many organizations with the organization wall inert — 「is today a refused boot」. It is not. A deployment that never REQUESTS a walled posture and simply HOLDS more than one `sys_organization` row under `single` boots, serves, and says nothing: `resolveDefaultOrgId` answers the bootstrap org, else the sole org when exactly one exists, else `null` — silently. The harm then surfaces far away and looks like an unrelated data outage: users reconciled from then on are bound to no organization, a platform admin reads zero rows of every organization-stamped object while analytics still counts them, and system-context writes are refused `ambiguous-organization` by the per-write guard. + + The tenancy service now takes a `count(sys_organization)` census on that same seam and reports at `error` when a non-walled deployment holds more than one, naming the posture it DECLARED, the count it HOLDS, and the two ways out: declare a walled posture (`OS_TENANCY_POSTURE=group` / `isolated`, plus the `@objectstack/organizations` package that activates it), or hold one organization and model the sub-units as business units. + + **The boot is not refused.** This change only reports; whether the boot should instead be refused stays open for the maintainer, and nothing here has to be undone if that is the answer. The per-write `ambiguous-organization` refusal is untouched. + + Cost is one `count()` per process: the census sits downstream of the walled-posture early return (a `group`/`isolated` deployment pays nothing and says nothing) and downstream of the memoized resolution, and an engine that cannot answer stays silent rather than guessing. A healthy install — exactly one organization, or none bootstrapped yet — is silent by construction. +- 9bd4344: feat(auth)!: adopt better-auth's account-issuer rollback — drop `sys_account.issuer`, retire the backfill, lift the `@better-auth/*` family to an exact `1.7.3` (#17440) + + + + **BREAKING** — a platform object drops a declared field and `@objectstack/plugin-auth` + drops six published symbols. Shipped as `minor` under the launch-window convention + (`major` is refused by `check-changeset-no-major`; breaking-ness is carried by this + banner plus the ADR-0087 disposition above). The hand-migration prescription is + registered under protocol major 18 as `sys-account-issuer-retired`. + + better-auth `1.7.3` removed the issuer-scoped account identity outright + (`better-auth/better-auth#10909`): `createLocalAccountIssuer` is deleted, + `accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the + `account.issuer` column and its unique index are gone from `get-tables`. There is no + drop-in replacement. `#16186` pinned the family at an exact `1.7.2` as a stopgap; this is + the durable half, per the maintainer ruling of 2026-09-10 on `#16629`. + + ## 迁移:FROM → TO + + | FROM | TO | the one-line fix | + |:--|:--|:--| + | `sys_account.issuer` (column + `{ fields: ['issuer','account_id'], unique: true }`) | — | nothing replaces it; identity is `(provider_id, account_id)`, declared UNIQUE on `sys_account` since the object was created | + | reading `account.issuer` off a row or off `client.accounts.list()` | `sys_sso_provider.issuer`, resolved through the account's `provider_id` | `provider_id` is unique per environment, so it names the authority on its own | + | `backfillAccountIssuer(ql, …)` | — | delete the call; there is no successor pass | + | `CREDENTIAL_ISSUER` / `oauthIssuerFor(id)` | — | drop the argument; `internalAdapter.createAccount({ userId, providerId, accountId, password })` takes no `issuer` | + | `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult` | — | delete the import; the compiler names every site | + | `@better-auth/*` at an exact `1.7.2` (eleven members) | an exact `1.7.3` (eleven members) | the family moves as ONE line — `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions | + + ## ⭐ Existing deployments: run the pre-flight BEFORE the column is dropped + + Uniqueness moves from `(issuer, account_id)` to `(provider_id, account_id)` — a + **narrower** key. Two rows sharing `provider_id` + `account_id` and differing only in + `issuer` are legal under the old key and are ONE account under the new one. + + ``` + os migrate account-issuer # read-only; exits non-zero when the drop must not proceed + # … take a backup (the operator's act, and the apply step's precondition) … + os migrate apply --allow-destructive + os migrate account-issuer # post-check: reads zero + ``` + + The pre-flight reads **rows**, never the index declaration. `syncDeclaredIndexes` logs a + plain UNIQUE whose CREATE failed on existing duplicates onto the durability channel and + lets the boot continue (`#14902` / `#15479`), so a database can carry the declaration + without the constraint — and on such a database the drop does not fail loudly, it + degrades silently: the rows become indistinguishable and a sign-in can resolve onto the + wrong user's account. `os migrate apply --allow-destructive` re-runs the same pre-flight + and refuses the drop before writing any DDL. A read that throws, or a scan that + truncates, refuses too — an unread table is not a clean one. + + ⛔ Colliding rows are never merged or dropped for you: which row survives is application + knowledge, and two different people can be behind one colliding key. Keep the row whose + provider account is live, delete the rest so a fresh sign-in re-links, and re-run. + + The boot refusal is unchanged and needs no new machinery: a runtime already refuses to + start against unapplied destructive drift, naming the command to run, and never + auto-migrates. + + ## ⚠️ A `provider_id` re-pointed at a different IdP must have its bindings REBUILT + + This is the one case `issuer` still discriminated. After the drop no column records which + IdP vouched for a row, so if a re-pointed provider's new IdP mints a subject the old one + had already issued to somebody else, the key resolves that sign-in onto the other + person's account. Under the old key that failed loudly (`unable_to_link_account`); under + the new one it is silent. + + ⇒ `sys_sso_provider` now **refuses an `issuer` change while `sys_account` rows are still + bound to that `provider_id`** (`RESOURCE_CONFLICT` / 409). Delete the provider's account + bindings first; each user re-links on their next sign-in. + + ## Why the column was a liability, not an asset + + A credential row whose `issuer` was not the local credential issuer was invisible to + `findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not + found" warn pointing at the `sys_user` row rather than at the account. **Four checklist + items had that recorded as a knownGap, each rediscovering it.** Its discriminating power + here was near zero anyway: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: + true }`, so `provider_id → issuer` is a function within an environment. + + ## Also in this change + + `pnpm check:vendor-export-contract` (from `#16186`) keeps its exactness requirement and + still resolves every named symbol — its self-test re-anchors from the now-retired + `@better-auth/core/db` specimen onto a live edge, and gains a case asserting the two + deleted names are imported nowhere. `#11627`'s hash-shadow-key machinery is untouched: it + is a generic driver capability serving five UNIQUE members of the >768-char class. + +### Patch Changes + +- 0f95f43: docs(identity): re-point the cloud-identity `ADR-0024` citations at the records that decide them (#14361) + + From this repository's point of view `ADR-0024` names two unrelated decisions. + `docs/adr/0024-mcp-connectors.md` is *MCP Servers as Connectors* — an open, + vendor-neutral tool protocol, with a Decision section numbered §1–§5 and no + D-lettered clauses at all. The identity surface's citations mean something else + entirely: the identity-and-access decision taken in `objectstack-ai/cloud` as + its own ADR-0024, whose open mechanism half has been mirrored into this repo + since 2026-09-07 as + [ADR-0135](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0135-identity-and-access-architecture.md). + A reader following one of those citations landed on a real page about the wrong + subject, which is worse than a dangling id: a plausible-looking record invites + belief rather than a second question. + + 79 citation lines were read one at a time and re-pointed. 73 mean a clause + ADR-0135 restates and now name it with its letter — D4 (source-of-truth marking, + managed vs env-native), D5.2 (the break-glass last-administrator invariant), D6 + (SSO per production environment, including the opt-in DNS domain-verification + clause this tree spelled `ADR-0024 ②`) and D9 (environment users and + organization membership). 6 mean a clause ADR-0135 deliberately leaves in the + cloud record and now carry the anchors gate's cross-repo qualifier + `cloud ADR-0024`: `V1` (the SSO default-role provisioning, the roadmap and + commercial framing) and `§7` (the `ai_seat` synthesis, which ADR-0135 does not + restate). + + What actually reaches a consumer of these packages: + + - `@objectstack/plugin-auth` — the **operator-facing break-glass refusal + detail** now reads `break-glass invariant, ADR-0135 D5.2 — an environment must + always keep at least one administrator who can sign in`. The condition that + raises it, its status, its error code and the rest of its wording are + unchanged; only the ADR number moves. ⚠️ A deployment that greps that message + for the literal `ADR-0024` should grep for `ADR-0135`. The guard's + registration log line moves the same way. + - `@objectstack/platform-objects` — `sys_sso_provider`'s `domain_verified` field + help text, its `protection.reason`, and the matching leaf in all four shipped + locale bundles (`en`, `es-ES`, `ja-JP`, `zh-CN`). + - `@objectstack/spec` — the doc comment above `AuthConfigSchema`'s + `ssoDomainVerification`, published both in `dist/` and as + `src/system/auth-config.zod.ts`. + - `@objectstack/core`, `@objectstack/cli` — doc comments only, published in + `dist/`; no runtime string and no behaviour. + + No behaviour moves. No schema accepts or refuses anything it did not accept or + refuse before, no security or permission semantics are touched, and no ADR + record is written or edited. Bare `ADR-0024` still resolves exactly as it did: + the 15 citations that mean the local MCP-connectors record are byte-identical to + `main`, and `check:adr-anchors` reports the same resolving-citation totals before + and after. Historical archives are deliberately untouched — 36 CHANGELOG lines + across seven packages, and the 22 lines under `docs/adr/`, which is a governed + surface this change does not enter. +- 825d70f: docs(identity): re-point the SCIM/identity `ADR-0071` citations at the records that mean them (#14361) + + From this repository's point of view `ADR-0071` named two unrelated decisions, + and only one of them had a record here. `docs/adr/0071-dataset-semantic-layer-depth.md` + is *Dataset semantic-layer depth — multi-hop joins*. The identity and SCIM + citations mean something else entirely: the enterprise-identity decision taken in + `objectstack-ai/cloud`, whose open mechanism half has been mirrored into this + repo since 2026-09-07 as + [ADR-0134](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0134-env-side-scim-provisioning.md). + So a reader following one of those citations landed on a real page about the + wrong subject — worse than a dangling id, because a plausible-looking record + invites belief rather than a second question. + + 44 identity-meaning citations now name the record that holds the decision they + describe. 43 of them read `ADR-0134` (the open mechanism half: effective SCIM + forces the better-auth `admin` plugin on, `active:false` lands as a ban plus + session revocation, the SCIM 2.0 Service Provider mounts in the environment, and + the seven stable `sys_scim_*` models). One reads `cloud ADR-0071` — the + "paid Identity lifecycle" note in `auth-manager.ts`, which names the commercial + half that deliberately stays in the cloud record. + + What actually reaches a consumer of these packages: + + - `@objectstack/plugin-auth` — the **operator-facing construction-time refusal** + raised when SCIM is effective beside an explicit `plugins.admin: false` now + cites ADR-0134 instead of ADR-0071. The condition that triggers the refusal, + its wording otherwise, and the two documented ways out are unchanged; only the + ADR number in the sentence moves. ⚠️ A deployment that greps that message for + the literal `ADR-0071` should grep for `ADR-0134`. + - `@objectstack/spec` — the `admin` flag's `.describe()` text (shipped both as + `src/system/auth-config.zod.ts` and in the generated `json-schema/` bundle), + and therefore the generated `content/docs/references/system/auth-config.mdx` + reference page app authors read. + - `@objectstack/platform-objects` — the `protection.reason` strings on the eight + `sys_scim_*` identity objects and on `sys_user`. + + No behaviour moves. No schema accepts or refuses anything it did not accept or + refuse before, no security or permission semantics are touched, and no ADR + record is written or edited. Bare `ADR-0071` still resolves exactly as it did: + the 22 dataset-meaning citations are byte-identical to `main` and + `check:adr-anchors` reports the same 35477 resolving citations before and after. + Historical archives — the six package CHANGELOGs — are deliberately untouched. +- 4f1a56b: `sys_user.manager_id` gains an admin write surface: `POST /api/v1/auth/admin/set-user-manager` + + `{ type: 'manager' }` is the canonical first rung of a tiered approval ladder, + and it resolves `sys_user.manager_id` — a column **no product surface could + write**. Measured: the generic data path refuses it (the ADR-0092 D2 + managed-update whitelist for `sys_user` is `{name, image, locale}`), the admin + bulk import does not carry it (`admin-import-users.ts` matches `manager_id` 0 + times, against a control of `phone_number` 8), and the column is `readonly` on + the user form. So on any install without a directory sync the rung expanded to + nobody, the request opened on a slate no one could act on, and under the + default `lockRecord: true` the record stayed locked. + + **The endpoint.** A platform admin posts `{ userId, managerId }`; `managerId: + null` clears the link. It is an ObjectStack mount on the raw app ahead of the + better-auth catch-all — the same family as `POST /api/v1/auth/admin/unlock-user` + — platform-admin gated (ADR-0068) and ledgered in `auth-route-ledger.ts`. + + **It is not a new editable profile column, and that is the design.** The + handler runs under a **system context**, so it reaches the column by context + rather than by a whitelist entry — the same way `admin-import-users` already + reaches `phone_number` and `role`. `SYS_USER_PROFILE_EDIT_FIELDS` is + untouched, `MANAGED_EXTENSION_EDITABLE_FIELDS.sys_user` stays `{locale}`, and + `sys_user.manager_id` keeps `readonly: true`, so ADR-0092 D4 still holds by + construction. Since ADR-0092 D5's amendment made Tier-1 membership imply + self-editability, admitting the column to Tier 1 would have handed every member + their own first-rung approver and a widening of their own `own_and_reports` + read scope; it is not admitted. + + **Five refusals, every one enforced at the write** — the only manager-chain + walkers in the open tree are single-hop, so nothing downstream catches a bad + link: self-assignment; a link that closes a cycle (the walk is itself + cycle-safe, so a pre-existing loop is reported rather than hung on); a chain + past the depth cap that ADR-0057 D3's bounded rollups require; a manager + provably outside every organization the user belongs to (beside, not instead + of, the existing routing-time screen); and any identity whose `sys_user.source` + is `idp_provisioned`, where the directory stays the one authoring surface. + + **`@objectstack/lint`** keeps the `approval-approvers-may-resolve-empty` + advisory and its `stackWiresManagerChain` silencer — the dead end it reports + survives the write surface, because a static check still cannot read the + column; only its *cause* became recoverable. What changed is the remedy text, + which named a column with no route and now names the endpoint, its body, how to + clear the link, and what it refuses. The Approvals guide carries the same + rewrite in prose. + + **Why `patch` and not `minor`.** No new exported symbol is reachable from + either published entry: `admin-set-user-manager.ts` is deliberately not + re-exported from `plugin-auth/src/index.ts` and is not named in the package's + `exports` map, so none of `runSetUserManager`, `MAX_MANAGER_CHAIN_DEPTH`, + `SetUserManagerDeps`, `SetUserManagerEngine`, `SetUserManagerResult` or + `SetUserManagerRefusalReason` appears in the built `dist/index.d.ts`. No + already-published payload gains a key — the endpoint's response is a new + payload, not a new field on an old one. A new **route** is wire, and wire + compatibility is not the grading floor. +- c9246fa: fix(plugin-auth): `/sign-in/email` and `/sign-up/email` now attach the `session` their declared `SessionResponse` envelope requires (#17234) + + Both routes answered `{ token, user }` (`/sign-in/email` also carries + `redirect`) with no `session` member anywhere in the body or the response + headers, so `SessionResponseSchema.safeParse` on `auth.login()` / `auth.register()`'s + return value always reported a `data.session` issue — the second of two + departures measured on #17234 (`success` was closed in the previous round). + + **The fix is a read, never an invention.** better-auth stores sessions in the + database by default and `internalAdapter.createSession` is awaited to + completion — including the write — before either endpoint returns its + `{ token, user }` body (measured against the installed `better-auth@1.7.3`, + `dist/db/internal-adapter.mjs:247-319`). So the row the response's own `token` + names is already committed by the time this repo's global `after` hook runs. + The fix reads it back through `internalAdapter.findSession(token)` — the exact + seam `/get-session` already uses for `data.session` — and attaches it. No id or + expiry is ever fabricated; a read that fails for any reason (no + `internalAdapter`, no row, any error) leaves the response exactly as + better-auth wrote it. + + ``` + FROM POST /api/v1/auth/sign-in/email -> 200 { redirect, token, user } + TO POST /api/v1/auth/sign-in/email -> 200 { redirect, token, user, session } + + FROM POST /api/v1/auth/sign-up/email -> 200 { token, user } + TO POST /api/v1/auth/sign-up/email -> 200 { token, user, session } + ``` + + `session` is the SAME row a following `/get-session` call reads (same `id`, + same `expiresAt`, same `userId`) — one row read twice, not two arrangements — + and `session.token` is the same UNSIGNED credential the body already carried + at `token` / `data.token`, not a second credential this fix introduces. + + ⛔ **No wire byte moves on any other member.** `token`, `user`, `redirect` are + byte-identical; `data.token` and the client's auto-`this.token = data.token` + are unchanged and pinned. `auth.me()` / `auth.refreshToken()` (`/get-session`, + #16760) are untouched — this change is scoped to the two credential-issuing + routes. + + This is additive on an already-declared field — `SessionResponseSchema.data.session` + existed in `@objectstack/spec` before this card; the two routes simply did not + serve it. No schema changes, no new exported symbol, no new key on any + published payload. +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- a754563: A deployment that serves its OAuth authorization-server discovery documents over **public plain HTTP** now says so at startup. Previously it was the quietest configuration on the box: the `.well-known` discovery routes go up regardless of transport, while the only line that mentioned the refused transport sat inside the MCP-surface condition — so a public plain-HTTP boot with `OS_MCP_SERVER_ENABLED=false` emitted no warning at all. + + Eligibility now decides **which** sentence is emitted, never **whether** one is: + + - an origin the transport rule ACCEPTS (loopback / private / link-local) keeps its line, `OAuth is served UNENCRYPTED`; + - an origin it REFUSES (a public host) gets a new, distinct line, `OAuth discovery is served over PUBLIC plain HTTP`, naming the issuer and the discovery documents, stating that the MCP OAuth track is disabled, and pointing at TLS as the remedy. + + Both are emitted once, at mount; neither under TLS. ⛔ No configuration key and ⛔ no environment variable gates either line. + + **No admission decision changes.** `isOAuthEligibleBaseUrl` and every transport-rule predicate are untouched, the discovery routes are mounted exactly where and when they were before, no export is added or removed, and no published payload gains a key. The change is two log lines and a comment. + + The startup line is also now written in English, this repository's convention for code artefacts. The maintainer's wording 「OAuth 未加密:仅限可信内网」 is carried as the sentence's meaning and kept verbatim in the code comment beside the call. + + Clause-②: no +- 7d63088: **plugin-auth: a refused auth setting no longer drops the other settings saved with it** + + Clause-②: no + + The `auth` settings pass used to go to `AuthManager.applyConfigPatch()` as ONE patch. When the + manager refused one key in it, the whole patch was dropped: password policy, MFA, rate limits, + session lifetime and social providers from that pass were not applied. The settings console + still showed every one of them as saved, and the only trace was a `warn`. + + Reachable examples: + + - posture `open` (declared in stack config, or opened earlier through the console) with + `auth.require_email_verification: false` stored through the console; + - posture `email_domain` with `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false`; + - the SCIM/admin coherence refusal on the `plugins` block, once `OS_SCIM_ENABLED` appears after + the manager was constructed with `plugins.admin: false`. + + Now the pass is applied in pieces, split where the manager can refuse. Each key that lands in + the `emailAndPassword` or `plugins` block is applied on its own; every other key goes out in one + application the manager does not validate. `mfa_required` stays one piece with the `twoFactor` + plugin it turns on, so MFA is never enforced without its enrollment endpoints. The manager's + verdict on each piece is the verdict; no validation moved into the plugin. + + A refusal is logged once, at `error`, naming the key: + `[auth] auth settings REFUSED (auth.require_email_verification) — the standing runtime value + keeps ruling …`, followed by the manager's own message, which carries the remedy. Every other + setting in the pass still applies. A pass that fails as a whole, such as a settings namespace + that cannot be read, is also logged at `error` (`[auth] auth settings NOT APPLIED — …`). + + The old `Auth: failed to apply auth settings:` warning is gone. A log alert that matched it + should match `[auth] auth settings REFUSED` and `[auth] auth settings NOT APPLIED` instead. +- 87c37ae: The `no_sign_in_account_at_boot` boot report states the email-verification rule each audience posture enforces. + + Clause-②: no + + The report's recovery advice said an `open` or `email_domain` posture forces email verification on the invited login. Later in the same message it said an `open` deployment can turn verification off. The first sentence stopped being true when posture `open` began honouring a deployment's opt-out. The message now states the rule once, as the auth plugin enforces it, and applies it to the invited login and to a new self-registered address alike: + + - `email_domain` always forces email verification on. + - `open` forces it on unless the deployment turns it off, with `emailAndPassword.requireEmailVerification: false` or `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false`. A `false` stored only through the settings console is refused. + - `invite_only` follows the deployment's declaration and is off by default. + + The advice keeps its order: on a deployment with no mail transport, close the posture back to `invite_only`, with verification left at its default off, before the invited person registers. + + Text only: no admission decision, verification default or accepted value changes. +- bc2ec80: Build freshness: these three packages now write the repo's build-input content + stamp as the last step of their own build, and are checked for freshness (not + merely existence) by `check:dev-prereqs`. + + What changes for a consumer: each tarball now carries two extra inert metadata + files inside `dist/` — `.build-input-hash` and `.build-input-hash-dts`, the same + pair `@objectstack/spec` has always shipped. Nothing is imported, executed or + resolved from them, no export moves and no runtime behaviour changes. + + Why: a sibling checkout that links these packages by `link:` compiles against + their `dist/`, so a dist built from an older tree surfaces as a type error + naming an import nobody touched, with the symbol present in `src/` the whole + time. A HEAD-versus-pin comparison is silent through that; a content stamp + written by the build itself is not. +- efa2533: fix(plugin-auth): build ONE better-auth instance per boot, so the RFC 8707 resource row is seeded once (#17176) + + `AuthManager.getOrCreateAuth()` assigned its `this.auth` memo only after `createAuthInstance()` had resolved, and that function awaits a dynamic `import('better-auth')`, the plugin list, the password hasher and finally better-auth's own `$context`. Every caller arriving inside that window read `this.auth === null` and started its own build, so overlapping callers constructed one better-auth instance each — measured: three concurrent `getAuthInstance()` calls returned three distinct instances. + + The boot has such callers. `AuthPlugin` dispatches `registerOidcDiscoveryRoutes()` with `void` from its route-mounting `kernel:ready` hook, which returns while that call is still pending, and a later `kernel:ready` hook reads the instantiated social providers off the instance for the account-issuer backfill. + + Each duplicate instance re-runs every better-auth plugin's `init`, and `@better-auth/oauth-provider` seeds the RFC 8707 `sys_oauth_resource` row from there. Its seed is already check-then-insert — `findOne` by `identifier`, then `create` only on a miss — so on a warm database every instance finds the row and inserts nothing. On a FRESH one all of them miss together, all of them insert, and the unique index refuses all but the first: the `Insert operation failed {object: sys_oauth_resource}` line on the first boot of a fresh project. + + `getOrCreateAuth()` now holds the in-flight build so concurrent callers share it. The seed runs once per process on every driver, because there is only one plugin `init` to run it. Two consequences of the new in-flight slot: `setRuntimeBaseUrl()` now reports "already created" for a build in flight (it silently no-opped before), and `applyConfigPatch()` discards a build composed from the pre-patch configuration instead of letting it install itself. + + No log level changed, in this package or any other. +- 2c87a48: Gate the `no_sign_in_account_at_boot` boot report on whether the deployment has a delegated sign-in path. + + The report fires on one store shape — human `sys_user` rows, zero `sys_account` rows — and calls it unrecoverable. On a deployment whose sign-in is delegated to an identity provider that shape is the healthy resting state: `ssoOnlyMode` states it in the auth config contract ("managed (IdP-provisioned) users simply hold no local credential") and names cloud-as-IdP. Such a kernel logged the report at `error` on every boot, including boots that had just served a successful SSO sign-in. + + The report now also reads the runtime's sign-in wiring — SSO-only mode declared, a configured social/OIDC provider, or enterprise SSO with at least one registered `sys_sso_provider` — and stays silent at `error` when one of them holds, recording the shape at `debug` under the same grep token with the reason named. + + Unchanged: `probeSignInAccountsPresence` keeps its existence-only predicate, and a deployment with no delegated sign-in path — including one that merely switched the SSO plugin on with no identity provider registered — still reports at `error`. +- dd2fd20: fix(plugin-auth): one base-path normalisation chain, and an MCP resource identifier that is always a URL + + `AuthManager` derived its base path in three independent places. `getMcpResourceUrl()` + read `this.config.basePath` directly and added no leading slash, so a `basePath` + configured without one produced a value that is not a URL at all: + + basePath 'api/v1/auth' -> http://localhost:3000api/v1/mcp + + `new URL()` throws on that (`3000api` is not a port), so the RFC 9728 path-inserted + well-known route derived from it throws too, and `@better-auth/oauth-provider` 1.7.2 + refuses to seed the `sys_oauth_resource` row from it at plugin init ("resource + identifier ... must be an absolute URI (RFC 8707 §2)"). With + `enforcePerClientResources` at its `true` default, every MCP client was then refused + for want of a link row. That input class could never mint or match a token, so + repairing it re-selects nothing. + + There is now exactly one read of the configured value and one chain above it: + + configuredBasePath() the configured value VERBATIM — what better-auth is handed + └─ rootedBasePath() + a leading slash when absent (better-auth's own rule) + ├─ getAuthIssuer() = origin + this + └─ getBasePath() = this, trailing slashes stripped + └─ getMcpResourceUrl() = origin + this minus `/auth` + `/mcp` + + `getAuthIssuer()` and `getBasePath()` answer byte-identically to before for every + spelling. Only `getMcpResourceUrl()` moves, and only for a non-canonical `basePath`: + a missing leading slash (was not a URL), repeated trailing slashes, or a configured + `/` (was a `//mcp` path no mount serves). A canonical `basePath` is unchanged on all + three getters. +- d2c1d19: fix(objectql)!: `beforeUpdate` receives the record the engine intends to persist, and the caller's submission travels on `ctx.submitted` (#16344) + + + + **BREAKING** — what a `beforeUpdate` handler reads on `ctx.input.data` changes. A `readonly` field the caller supplied a value for is no longer there. The hidden set is the update strip's own subject set: author-declared `readonly: true` **and** the types whose value the runtime owns end to end (`autonumber`, implicitly read-only since #5503). `readonlyWhen` locks are deliberately not hidden. + + ## The defect + + On update, a value sent for a field declared `readonly: true` was correctly **not persisted** — and was still handed to the object's `beforeUpdate` hook. A hook deriving columns from the incoming record therefore derived them from a value the row would never contain, and **those derived writes persisted**, because they are the hook's own. + + Measured on a real app (17.2.0, sqlite, dev runtime) and reproduced in `packages/objectql/src/engine-readonly-hook-input.test.ts`. One `PATCH { actual_value: 380, target_value: 1, weight: 1 }` against a `readonly` `target_value`: + + ``` + read back: target_value 400 weight 10 ← the strip worked + score 1.2 calc_trace "实际 380 / 目标 1 … 权重 1%" + ``` + + The row's own audit trail cites values the row does not hold. No error, no warning, 200, and `droppedFields` correctly reporting the strip the whole time — every channel said the write was fine, because by every channel's own lights it was. The only way for an application to be safe was for every hook to re-read its read-only columns and ignore the incoming record, which defeats declaring them read-only at all. + + ## What changed + + **`ctx.input.data` on `beforeUpdate` is now the record the engine intends to persist.** Caller-supplied values for `readonly` fields are taken out of the hooks' view before the before phase is dispatched, and handed back at the engine's post-hook confluence — so the payload every engine-owned consumer below reads is byte-for-byte what it read before. `onFieldsDropped` reports the same fields with the same `readonly` reason, the read-only WARN says the same sentence, and `strictReadonlyWrites` refuses exactly the same writes. + + **The caller's submission travels on a new `HookContext` member, `ctx.submitted`** (`@objectstack/spec`, `HookContextSchema`) — the payload as sent, snapshotted at engine entry before any middleware or hook stamp, frozen, and documented as *diagnostics only, never the persist image*. It is bound on the update verb, both phases, and every per-row dispatch of one caller write. + + Two things deliberately did **not** move: + + - **The enforcement pass is still after the hooks.** It is the only point that can tell a hook's stamp from a caller's forgery (`hookWrittenKeys`), so a `beforeUpdate` that stamps a read-only column still lands — including when the caller echoed the same key back, which is the whole subject of #5591 / #14088. + - **`beforeInsert` is untouched.** The create side's strip position is settled post-hook by ruling C (#14147, "one semantics, one enforcement point"), and `readonlyWhen`-locked fields stay hook-writable per #9107. + + `@objectstack/plugin-auth`'s ADR-0092 identity write guard is migrated onto the new member in the same change, which is why nothing degrades: its 403 and its security warn still name the non-whitelisted field the caller sent. Without that migration the identical request answers `None of the submitted fields (—) are editable` — as strong a refusal, saying nothing about what was refused. Both readings are pinned side by side in `identity-write-guard.test.ts`. + + Ruled 2026-09-08 (maintainer, verbatim 「批 #87 同意」, director seat, decision batch #87). The refused primary was the same strip move **without** the new member: the ADR-0092 diagnostic degrades and every third-party `beforeUpdate` guard reading `ctx.input.data` degrades with it, silently. The refused alternative on the other side was documenting that hooks must read read-only columns from `ctx.previous` — which outsources the invariant to every application, the exact shape triage had already rejected. + + ## Who is affected + + A `beforeUpdate` handler that **reads a `readonly` field (declared, or runtime-owned) out of `ctx.input.data`**, on a non-`isSystem` write. Three shapes, and the fix is one line each: + + - **deriving a value from it** — this is the defect; the handler now derives from `ctx.previous`, or from `ctx.input.data` with the payload's absence meaning "unchanged", which is what it always meant for a field the caller never sent. + - **reporting on what the caller sent** (a guard naming the offending key) — read `ctx.submitted`. + - **a self-assignment** (`data.x = data.x`) on such a field — this used to promote the caller's forged value to hook-owned and commit it. It is now a **no-op**: the key the hook reads is gone, so the line re-creates it holding `undefined`, and the engine treats set-to-undefined of a hidden read-only key as the no-op it is — deleting the key, dropping it from the hook-write record, and letting the ordinary hand-back put the caller's value back for the strip to judge. **The stored value stands**, and the write reports exactly as it would with no hook at all (stripped, `onFieldsDropped`, the WARN, `strictReadonlyWrites` refusing). Persisting the `undefined` instead would erase the stored value on the memory driver and hand knex an undefined binding on a SQL one — neither is the record the engine intends to persist. That laundering route closing is intended, and it is re-pinned in both directions rather than removed. + + ⚠️ **The sharpest edge is a sandboxed `body` hook, and it is a refusal rather than a quiet change.** A body that reaches *through* such a key — `ctx.input.locked_meta.who = 'hook'` — now dereferences `undefined` and throws, and a `body`'s default `onError` is `abort`, so the caller's **whole write is rejected** where it used to succeed. What that body used to do was persist a value derived from the caller's forgery, so refusing is the correct direction; but the message the author sees is a raw `TypeError` from their own dereference and names nothing actionable. Measured end to end through a real QuickJS sandbox and pinned in `packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts`. + + A body hook cannot read `ctx.submitted`: it is deliberately not marshalled onto the sandbox face, for the reason `dispatch.scope` is not — that face is assembled key by key, and a key added there is a second published contract with its own compatibility story. A body deriving a column from a read-only field reads **`ctx.previous`**, the stored row, which is the correct source either way. + + ⚠️ **One ADR-0092 boundary changes a status code, and no in-repo object hits it today.** On an object whose UPDATE whitelist admits a field that is ALSO declared `readonly`, a whitelist-only payload now answers **403** where it used to answer **200 having written nothing**. The identity write guard composes its refused list from what the engine left it, and a whitelisted key is excluded from that list by design, so the refusal reads `None of the submitted fields (—) are editable` — naming nothing. The write was already being dropped by the read-only strip before this change; what moves is that the caller is now told, and told imprecisely. `sys_user`'s three writable fields are not read-only, so nothing in this repository is on that boundary; an application that puts a `readonly` field in an UPDATE whitelist should take it out, which is what the whitelist meant either way. + + An `isSystem` caller sees no change at all: the strip has never applied to one, and neither does the hide. +- 96684bb: fix(plugin-auth): let `ImportProtocolLike` type the admin import protocol's members (#17422) + + `admin-import-users.ts` is the only hand-written in-repo implementor of the runner's `ImportProtocolLike`, and it annotated all three required members `args: any`. An explicit parameter annotation wins over the contextual type, so #16952's newly declared request dialect held every implementor except this one — the one with a demonstrated history: before #16950 this file read `args?.query?.$filter ?? {}`, the runner moved to the canonical spelling, the read went `undefined`, and the `?? {}` default degraded the import's duplicate probe into match-everything, so `POST /api/v1/auth/admin/import-users` updated the wrong users without a sound. + + The three annotations are deleted, so `findData` / `createData` / `updateData` are typed by the contract they implement. Measured: with the annotations gone, reading a retired wire alias (`args.query?.$filter`) is `TS2339 Property '$filter' does not exist on type 'QueryInput'`; with `args: any` restored the identical probe type-checks at exit 0. + + `FindDataRequest` declares `query` optional, so `findData` now states its refusal in code — a thrown `Error` carrying the already-registered `INVALID_REQUEST` code — instead of relying on an incidental `TypeError` from a property read on `undefined`. No `??` fallback and no optional chaining were added: both spell match-everything, which is the defect this closes. + + No API, request body, response shape or exported signature changes. A caller that reaches `findData` through `runImport` always supplies `query`, so no supported call moves; only a protocol call that was already failing now fails with a code attached. +- 45c2cf9: MCP OAuth: refuse a `client_credentials` (machine-to-machine) access token + + `AuthManager.verifyMcpAccessToken` resolved an M2M access token to a + principal — a machine ran as an authenticated member, stamping a user id that + belongs to no user into `created_by` / `updated_by` and owner columns — while + the method's own contract declared such tokens rejected. The contract's + premise was that they carry no `sub`; the OAuth provider stamps + `sub = user?.id ?? client.clientId`, so the premise was never true and the + rejection it described could never fire. + + The subject and the client identity are now read as a pair, the way RFC 9068 + defines them for a JWT access token: `client_id` is REQUIRED (§2.2), and `sub` + is the resource owner for a grant that had one or an identifier for the client + application for a grant that did not (§2.2.3.1). A token whose `sub` equals its + own `client_id` / `azp` therefore assembles no principal, and the MCP HTTP door + answers `401`. A token carrying neither client claim is refused as well: the + check has no input, and a check that cannot run must not silently pass. + + Unchanged: interactive OAuth clients (authorization code + PKCE) resolve + exactly as before, and the headless track is untouched — `x-api-key` / + `Bearer osk_…` over HTTP and `OS_MCP_STDIO_API_KEY` over stdio are a separate + chain with a separate credential shape, and remain the supported way for a + machine to call this platform. +- 9ca49eb: `runAdminImportUsers`'s hand-written `ImportProtocolLike` reads the CANONICAL QueryAST (`where` / `limit`) — the payload `@objectstack/rest`'s import runner sends as of this same release — instead of the wire-only `$filter` / `$top`. + + `POST /api/v1/auth/admin/import-users` reuses the shared import runner but swaps in an identity-specific protocol, because an identity write is `auth.api.createUser` and not an engine insert. That protocol is hand-written, so it never passes through `ObjectStackProtocolImplementation` — the normalizer that folds `$filter` onto `where` and `$top` onto `limit` for a caller arriving off the HTTP door. It has to read the canonical keys itself. + + - **A mismatch here does not produce a missing filter, it produces an unbounded one.** `const where = args?.query?.$filter ?? {}` turns an unread key into an empty filter, and an empty filter constrains nothing: the upsert duplicate probe stops discriminating, `findExisting` matches rows it was given no key for, and an admin import updates the WRONG user. Both halves are measured in `admin-import-users.test.ts` — the email-match case reported `updated: 2` where one of the two rows was new, and the phone-match case sent a probe carrying no `where` at all. + - **One dialect, and no default behind it.** The two reads are now `args.query.where` and `args.query.limit`, with no `??`. A default here would not be tolerance for an older caller — this handle is fed by the runner, never off the wire — it is precisely the lenient fallback that converts a spelling mismatch into a silent match-everything. A request that arrives without a `query` now costs a loud `TypeError` instead. + + ⚠️ No published version shipped the mismatch. The runner's rewrite and this adapter land in the same release, and `@objectstack/plugin-auth` depends on `@objectstack/rest` at an exact workspace version, so the two cannot be installed apart. What this entry records is why they move together — and what the same mismatch costs any OTHER hand-written `ImportProtocolLike`, which the `@objectstack/rest` entry calls out for implementors. +- ab1c585: `POST /two-factor/verify-totp` and `/two-factor/verify-otp` now echo the user row as it stands when the response is written, instead of the pre-rotation snapshot the vendor closes over. + + On the enrolment lane — a signed-in caller confirming a new factor — better-auth writes `twoFactorEnabled: true`, rotates the session, and only then calls the `valid(ctx)` closure it built at entry. That closure still holds the pre-rotation session, so a successful verification answered `user.twoFactorEnabled: false` to the very caller who had just switched 2FA on. An account portal reading that body renders the factor as still OFF right after enrolment, and a bearer client that caches the echoed user carries the wrong flag until its next `get-session`. + + `two-factor-rotated-token-echo` already repaired the body's other stale member, `token`, on exactly these routes and on exactly this predicate — the response staged a session cookie whose token differs from the one echoed. The `user` member is stale for the same reason, so it is repaired under the same predicate rather than a new one. + + - **Two narrowings, both load-bearing.** Only the members the vendor already echoed are written, so the published payload shape (`AuthWireUser`) cannot widen — better-auth's own output filter is a deny-list, and forwarding a raw row would put every column it happens to carry on the wire. And the row is re-read through `internalAdapter` by the id the response itself published, so the repair travels the same output transform that produced the echo (a driver that stores booleans as `1`/`0` cannot change a member's wire type) and can never substitute a different principal into a response. + - **`/two-factor/verify-backup-code` is untouched.** It does not rotate and already echoed the live row; it is in neither path list, its row is not read, and it is pinned as a negative control on both the in-memory engine and a real `SqlDriver` — an unconditional re-read would have "fixed" the broken lane and quietly rewritten one that was already right. + - **The failure posture is inherited.** A row read that throws or answers nothing degrades to the vendor's own echo, never to a failed verification and never to a lost `token` repair, which is written first for that reason. + + `@objectstack/client` drops the `AuthTwoFactorVerificationResult.user` warning that told callers to re-read the session for the live flag; the wire shape it declares is unchanged. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [2d81e39] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [a370073] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [690f083] +- Updated dependencies [e7fea46] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [a9096af] +- Updated dependencies [497655f] +- Updated dependencies [4be4e04] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [4d2008c] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [6e4024c] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [df1b275] +- Updated dependencies [1aa5026] +- Updated dependencies [879b512] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [5941246] +- Updated dependencies [564ac2f] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [2b321a4] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [bc80e16] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [7e6ca17] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [d4c897e] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [95ab93f] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [9401b84] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [329ea2e] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [585c9af] +- Updated dependencies [2dccb7d] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [2bcd5cf] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [cc40033] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [a36a691] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [95f729a] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [5049a3c] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [7fa3e3e] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [5c7aa46] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [8e02859] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [397572e] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [9449512] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [fb194c7] +- Updated dependencies [b057434] +- Updated dependencies [b43a814] +- Updated dependencies [1378ec7] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [7010085] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [e77a23f] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [e07eecf] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [dfb42c5] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [777d0c2] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/service-messaging@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 64b0d002d43..1f9a73e07dd 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-auth", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Authentication & Identity Plugin for ObjectStack", "main": "dist/index.js", diff --git a/packages/plugins/plugin-dev/CHANGELOG.md b/packages/plugins/plugin-dev/CHANGELOG.md index bd54f175135..5e24c7d9ed2 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,706 @@ # @objectstack/plugin-dev +## 17.5.0 + +### Minor Changes + +- a9fb83e: fix(core,runtime,plugin-dev,plugin-security): a release artifact whose `packages` is `null` is refused as malformed, never read as absent (#19926) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on a value the schema already refuses, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the level). + + `ObjectStackDefinitionSchema.packages` is `z.array(ArtifactPackageSchema).optional()`, and `.optional()` admits `undefined`, not `null`. The schema refused `packages: null` (`invalid_type`), and `composeStacks` refused it with two or more inputs (`STACK_SCHEMA_INVALID`, `status: 422`). The runtime readers below read it as absent instead: a single-package artifact whose own top level is the one package body. Those readers now follow the declaration. An absent `packages` is `undefined` and nothing else; `null` is one of the present, non-array values the rule beside `AssembledPackageBodySchema` calls malformed, like `{}`, `0` or `'x'`, and it is refused with the same envelope: `INVALID_ARTIFACT_PACKAGES`, `status: 422`. No error code is added. + + - **`@objectstack/core`**: `resolveArtifactPackageOrder` refuses `packages: null` where it returned `[artifact]`. The refusal message names the value `null`, not `object`. The resolver's callers that hand it the whole artifact raise the refusal: the kernel `manifest` service's `register()` (`ObjectQLPlugin`) and `@objectstack/verify`'s collection reader for a collection the stack's top level does not carry. + - **`@objectstack/runtime`**: `resolveArtifactCollections` drops `null` from its absent branch, so `AppPlugin`, `createStandaloneStack`, `loadArtifactBundle`'s runtime-module merge and `resolveProjectDatabaseUrl` answer a `packages: null` artifact exactly as they already answer `packages: {}`. `carriedPackageIds`, and `resolveArtifactGrantBinding` for an artifact whose `grantedPermissions` is a record, read the package list through the core resolver and raise its refusal too. + - **`@objectstack/plugin-dev`**: the i18n detector's private absent guard moves in lockstep with the resolver's absent branch, so `devI18nPluginOptions` reaches the resolver and raises its refusal when the `i18n` config (on the stack or its `manifest`), a non-empty `manifest.translations` and a non-empty top-level `translations` do not answer first. `DevPlugin` keeps its posture: it reports the metadata defect on its `error` line and boots on the in-memory i18n fallback. + - **`@objectstack/plugin-security`**: `appSecurityPluginOptions` has no guard of its own and raises the resolver's refusal for `packages: null`. + - **What does not change**: the schema; an absent `packages` (no key, or an explicit `undefined`), which still returns the caller's own object by identity; a well-formed `packages[]`; and `composeStacks` with a single input, which still returns that input by identity. + + No in-repo producer writes `packages: null`, and `os build` and `os validate` refuse it at the schema before any reader runs. For a single-package artifact, leave the `packages` key out. + +### Patch Changes + +- dc9e29b: `DevPlugin`'s boot posture on a malformed stack is now written down: dev boot tolerates and reports; refusing belongs to the production doors, which are not uniform about it (#15292). + + Clause-②: no + + No behaviour changes. `DevPlugin` already degraded a stack the platform would reject, and the posture — ruled, not invented here — is that it should: the contract refuses at the production door, while the developer's inner loop tolerates incomplete input and never hides it. Metadata that is incomplete halfway through an edit is the normal state of a project under active development, so refusing at dev boot would charge the cost to the only user group this plugin exists for, for a consistency the production doors already provide. What was missing was the written posture and one load-bearing correction to it. + + - **The two branches are not one defect handled two ways.** `new AppPlugin(stack)` reads `manifest.id` / `manifest.name` and nothing else, so a malformed `packages[]` passes the constructor untouched and is refused one branch later: `AppPlugin.init()`'s LAST statement hands the bundle to the `manifest` service, whose `register()` calls `resolveArtifactPackageOrder` unguarded, and `DevPlugin`'s child-`init()` loop degrades that refusal to an `error` line. The lazy `collections` getter is not on that path at all — it is not read during `init()`, and its first read is in `AppPlugin.start()`, where it reaches the same refusal on the same bytes. Both in-file comments that named the constructor as the stack's parse door (*"a malformed stack throws HERE"*, and §3b's *"twenty lines above, `new AppPlugin(...)` parses the SAME object"*) overclaim for that reason, and both are corrected in this PR. + - **The two malformations are exact complements, measured with a lit control.** An app payload with no `manifest.id` / `manifest.name` throws from the constructor (a bare `Error`, no ADR-0112 `code` / `status`) and is invisible to the package-list parse; a `packages[]` entry that is not a package entry (ADR-0130 D4) is invisible to the constructor and refused by the parse as `INVALID_ARTIFACT_PACKAGE_ENTRY` / `422`. A stack carrying neither is silent on both. So a clean boot past one branch is no evidence about the other — which is why the division is now documented rather than left to be re-derived. + - **Tolerating is not hiding.** The posture's second half is that a boot which skipped something is never byte-identical to a healthy one: a silent degrade lets an author, or a coding agent, read "it started" as "I wrote it correctly". + - **The production doors are not uniform, and every carrier now says so.** A malformed `packages[]` fails `ObjectStackDefinitionSchema` — `packages: z.array(ArtifactPackageSchema)`, the SAME entry schema the runtime parse uses — and both `os validate` and `os build` parse the lowered stack against it and exit 1 (`validate.ts` step 2; `compile.ts` step 3). `lowerCallables` passes a non-`{ manifest: object }` entry through untouched, so the verdict transfers to what the CLI actually parses. An app payload with no `manifest.id` parses green at BOTH: `os validate` reports it only as the structural advisory *"Missing manifest.id — required for deployment"*, which fails only under `--strict` (both exit faces read one `warnings` list — the `--json` ternary and the text face's `if (flags.strict)` block), and `os compile` "never computes them at all" in its own words, so `os build` is silent on it. The flat "`os validate` / build / publish refuse" overstated BOTH doors for that half, and `publish` is simply not a door this card measured, so it is no longer claimed. + - **What ships**: the `DevPlugin` docblock (which reaches the published `dist/*.d.ts`), the two in-file comments named above, and `content/docs/plugins/packages.mdx`, plus a test pinning the posture, its division and the init-time path the refusal actually takes. The wording of the malformed-metadata diagnostic itself is deliberately not pinned — that text is a sibling change. +- 35e549b: `DevPlugin` now loads `@objectstack/setup` and `@objectstack/account` through literal `import('…')` specifiers, like every other declared dependency it loads, instead of one variable specifier shared by a loop (#20376). + + Clause-②: no + + - **What was wrong.** The setup / account app-package loop imported `spec[0]`. A variable specifier cannot be resolved when the file is transformed. Under vitest, every `DevPlugin.init()` in a test therefore made two round trips to the main test process, even with both packages mocked, inside every clocked test window that boots `DevPlugin`. The main process is shared by the whole run, so on a busy CI shard those round trips wait on other files' work, against this package's 5000 ms test budget. + - **What changes.** Each loop entry carries its own literal loader. The `try` / `catch` and the absent-package report around each load are unchanged: a missing package is still logged as, for example, `✘ @objectstack/setup not installed — skipping its app`, and a present one that fails is still reported as present-but-failed. + - **Unchanged.** Nothing an author or operator configures or sees changes. Both packages were already declared dependencies of `@objectstack/plugin-dev`, and both the ESM and the CJS build keep a native `import("…")` for each. +- 50bc9c7: Operator-facing text no longer tells an open-source install that multi-organization + operation requires a subscription. + + ADR-0132 moved the `org-scoping` registrar into open core — `@objectstack/organizations` + is Apache-2.0, carries no licence check, and declares both walled postures (`group` and + `isolated`) as its own constant. The messages an operator actually reads had not followed: + + - `os serve`'s install remedy for a walled posture ended "this runtime is closed-source and + is NOT on the public npm registry ... Without one this bullet is not followable" — it now + says the runtime is Apache-2.0 and on the public registry, and notes that a commercial + deployment resolves the same package name to its own private, licence-gated build. + - The `isolated` posture hint rendered by `os serve` and `os doctor` no longer calls the + runtime "enterprise". + - `os verify`'s `--org-scoped` flag description drops the same word. + - The dev stack's degraded-tenancy warning and its stage-2 mount refusal no longer describe + the package as the enterprise runtime. + + Text only — no control flow, no identifiers, no behaviour change. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [fdeeea0] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [3c48234] +- Updated dependencies [7843663] +- Updated dependencies [08b213e] +- Updated dependencies [ce57857] +- Updated dependencies [2d81e39] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [ee6fbd7] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [4f1a56b] +- Updated dependencies [f39ea95] +- Updated dependencies [6059b29] +- Updated dependencies [89a652b] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [4af758d] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [63b6818] +- Updated dependencies [c9246fa] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [cea85fd] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [c54d8d6] +- Updated dependencies [eea7ccc] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [310760d] +- Updated dependencies [2b6a207] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [17005cc] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [b5cbfef] +- Updated dependencies [d438b3a] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [4d2008c] +- Updated dependencies [abb01f1] +- Updated dependencies [cf39b83] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [6e4024c] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [922c755] +- Updated dependencies [74832b6] +- Updated dependencies [c17ff70] +- Updated dependencies [df1b275] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [877dc03] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [21b7c12] +- Updated dependencies [74327d3] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [5941246] +- Updated dependencies [4efb988] +- Updated dependencies [8015dc8] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [ef256e6] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [4fef271] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [2767af8] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [215840f] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [13d5294] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [2b321a4] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [c02fa12] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [0862063] +- Updated dependencies [5c5b67f] +- Updated dependencies [f9977c1] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [2aac821] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [a754563] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [3fd3a4f] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [5dba7f3] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [a5afe38] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [bc80e16] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [afc3b64] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [0e90a8d] +- Updated dependencies [ecf90b2] +- Updated dependencies [2bbb462] +- Updated dependencies [90ff10a] +- Updated dependencies [3bd221d] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [b7b6cdd] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [8490127] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [ae0c90c] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [0b866bf] +- Updated dependencies [c839986] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [009da14] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [55cd8d4] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [aa04ea2] +- Updated dependencies [172b4cf] +- Updated dependencies [b9e9609] +- Updated dependencies [b81da66] +- Updated dependencies [4463966] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [26550c6] +- Updated dependencies [8e9a425] +- Updated dependencies [e7f69db] +- Updated dependencies [b373596] +- Updated dependencies [7465eeb] +- Updated dependencies [84156c7] +- Updated dependencies [ed3546f] +- Updated dependencies [e0f17a3] +- Updated dependencies [7766b62] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [d4c897e] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [95ab93f] +- Updated dependencies [fa00ebf] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [a08e059] +- Updated dependencies [fe677ae] +- Updated dependencies [fc646cf] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [949e99b] +- Updated dependencies [16c5a33] +- Updated dependencies [16c5a33] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [16c5a33] +- Updated dependencies [9401b84] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [329ea2e] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [cfe2387] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [585c9af] +- Updated dependencies [2dccb7d] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [2bcd5cf] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [0d3ec47] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [cc40033] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [a78f731] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [a36a691] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [c74de10] +- Updated dependencies [db74b16] +- Updated dependencies [2b24b8b] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [95f729a] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [c5d6b2b] +- Updated dependencies [2d91c9a] +- Updated dependencies [2f122b6] +- Updated dependencies [b285508] +- Updated dependencies [5049a3c] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [4a1df19] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [7fa3e3e] +- Updated dependencies [de8c973] +- Updated dependencies [9801da1] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [5c7aa46] +- Updated dependencies [7d63088] +- Updated dependencies [87c37ae] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [8e02859] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [397572e] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [45f428d] +- Updated dependencies [9449512] +- Updated dependencies [b2b6a06] +- Updated dependencies [8538edf] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [fb194c7] +- Updated dependencies [b057434] +- Updated dependencies [b43a814] +- Updated dependencies [1378ec7] +- Updated dependencies [f6ceddc] +- Updated dependencies [92ea760] +- Updated dependencies [4c42fd1] +- Updated dependencies [76ddab7] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [374d9d3] +- Updated dependencies [ea4d164] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [2c87a48] +- Updated dependencies [dd2fd20] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [b940f32] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [2266438] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [cefe068] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [e77a23f] +- Updated dependencies [f9e16d8] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [a016f08] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [96451ec] +- Updated dependencies [45c2cf9] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [dfb42c5] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [e6965dd] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [ca31ff6] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [0ced0aa] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [470746a] +- Updated dependencies [b7c792b] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [6ff5b56] +- Updated dependencies [777d0c2] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/plugin-hono-server@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/service-i18n@17.5.0 + - @objectstack/service-realtime@17.5.0 + - @objectstack/service-storage@17.5.0 + - @objectstack/account@17.5.0 + - @objectstack/setup@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index 4e9a8ac721f..bcf30e3f00d 100644 --- a/packages/plugins/plugin-dev/package.json +++ b/packages/plugins/plugin-dev/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-dev", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Development Assembly Plugin for ObjectStack — wires the real platform stack for zero-config local development", "main": "dist/index.js", diff --git a/packages/plugins/plugin-email/CHANGELOG.md b/packages/plugins/plugin-email/CHANGELOG.md index 6a7fa513b67..87d854450c3 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,614 @@ # @objectstack/plugin-email +## 17.5.0 + +### Patch Changes + +- cd5fdaa: docs(email): the shipped carriers said "best-matching locale"; the resolver matches `(name, locale)` exactly (#18499) + + Clause-②: no — no accept set moves and no published payload key changes; the + corrected prose ships as JSDoc in each package's `dist/*.d.ts` (and, for + `@objectstack/service-messaging`, inside the bundled `dist/index.js`), which is + why this is a changeset rather than `skip-changeset`. + + `packages/plugins/plugin-email/src/template-loader.ts` already enumerates + "the EmailService picks the best-matching locale" as a FALSE declaration, and + three shipped carriers still stated it. Measured against the code at this + branch's base rather than against the card's transcription: + + - `createSysEmailTemplateLoader.load` — `locale` given ⇒ exact `{ name, locale }` + match ordered by `id`, or `null`; `locale` absent ⇒ `{ name, locale: 'en-US' }` + first, and only if that misses `{ name }` ordered by `locale` ascending; + - `EmailService.resolveAndRenderTemplate` — `wanted = input.locale?.trim() || + 'en-US'`, then exactly one retry at the literal `'en-US'` when the call NAMED a + locale, then `TEMPLATE_NOT_FOUND`; the unpinned rung is reachable only for a + call that named no locale. + + No language-subtag folding anywhere on that path, and nothing that could be + called a "best match". Corrected: + + - `sys_email_template`'s object doc (`@objectstack/platform-objects`) now states + the exact match, the single `en-US` rung and the no-locale last resort; + - `sys_notification_template.locale`'s sibling-declaration comment + (`@objectstack/service-messaging`) said "both resolve a template by + best-matching locale", which was false in a second way: the two resolvers do + not agree. `NotificationTemplateStore.load` walks `(topic, channel, locale)` + through a candidate list — the named tag, its primary subtag, then + `DEFAULT_LOCALE` (`'en'`) — so it DOES fold a subtag, where + `sys_email_template` does not. Only the shared 16-char BCP-47 bound is shared; + the resolution is not, and the comment now says so; + - `template-loader.ts`'s own "What was wrong" block quoted two sentences it can + no longer quote — one was already stale at this base (the + `EmailTemplateDefinitionSchema.locale` text it reproduces has zero occurrences + in `packages/spec` today) and the other is corrected above. Both bullets are + now cited rather than quoted, so a later rewording cannot strand them again. + + No resolution behaviour changes: every edit in this changeset is prose. +- 8dba7aa: docs(plugin-email): the `TemplateLoader` docblock opened on a "best match" its own next paragraph denies (#19507) + + Clause-②: no — no accept set moves, no published payload key changes, no + export is added or removed. The corrected prose ships as JSDoc in + `@objectstack/plugin-email`'s `dist/index.d.ts` (the package publishes `dist`), + which is why this is a changeset rather than `skip-changeset`. + + `TemplateLoader`'s docblock in `packages/plugins/plugin-email/src/email-service.ts` + contradicted itself inside one paragraph. It opened with *"Returns the + best-matching row for `(name, locale)`"* and then, two lines later, correctly + said *"`locale` set → an EXACT match for that locale, or `null`"*. + `SendTemplateInput.template` (`packages/spec/src/contracts/email-service.ts`) + declares the opposite of the opening in as many words: there is no "best match" + and no language-subtag folding, the locale row is resolved by the exact ladder. + + The opening now states what `createSysEmailTemplateLoader` implements, measured + against the code at this branch's base rather than against any transcription of + it — `load(name, locale)`: + + - `locale` given ⇒ `first({ name, locale }, BY_ID)`, an exact `(name, locale)` + match ordered by `id`, or `null`; + - `locale` absent ⇒ `first({ name, locale: 'en-US' }, BY_ID)` first, and only + when that misses, `first({ name }, BY_LOCALE)` — the bundle's lowest locale + tag, ordered; + - no branch asks the store to pick a locale, and none folds a subtag. + + This is the fourth shipped carrier of the same false declaration and the first + outside the set #18499 enumerated: that probe was written as the literal strings + `best-matching locale` / `picks the best`, and this sentence says + "best-matching **row**", so it was never in the hit set. A carrier set built + from literal strings is blind to its own synonyms. + + No resolution behaviour changes: the edit is prose. `createSysEmailTemplateLoader`, + the `sendTemplate` ladder and every `where` clause are untouched. +- df3ba16: A template's plain-text faces — the subject and `body_text` — now render their `{{x}}` values verbatim instead of HTML-escaping them, so a link in the text part keeps its literal `&` (#20374). + + Clause-②: no + + - **What was wrong.** `EmailService` rendered every face of a `sys_email_template` row through the HTML escaper. In the text/plain part of the built-in verification, password-reset, invitation and magic-link mails the link read `…?token=…&callbackURL=%2F`: a plain-text client, or a user copying the link, got a parameter named `amp;callbackURL`, and the post-verification redirect fell back to `/`. The same escaping put `&` / `'` into subjects built from names such as `R&D` or `O'Brien`. + - **What changes.** Escaping now follows the face. `body_html` is markup and is rendered exactly as before: `{{x}}` HTML-escaped, `{{{x}}}` not. The subject and `body_text` are plain text: every hole renders its value as-is, and triple braces mean the same as double there. The switch is in the renderer, so it covers every row that reaches `sendTemplate` / `renderTemplate` — the built-in auth templates, declared `emailTemplates` and rows authored in Studio — with no template edit. + - **Who sees it.** The persisted `sys_email.body_text` and `subject`, the delivered text part and Subject header, and `IEmailService.renderTemplate()`'s `text` / `subject` (which the messaging inbox channel stores as a notification's body and title). A row with no `body_text` is unchanged: its text part was already derived from the HTML with the entities decoded. + - **Unchanged.** The exported `renderTemplate()` helper is still the HTML renderer. Nothing an author writes needs to change. +- f572a7e: `@objectstack/plugin-email` declares `nodemailer` `^10.0.2` (was `^9.1.1`), clearing GHSA-6vj9-mwq6-2f5v (5.9): nodemailer's process-global DNS cache kept the TLS `servername` per host, so a second SMTPS transport to the same host could inherit the first transport's SNI and certificate identity and send its credentials to the wrong TLS virtual host. Every release from 5.0.0 through 10.0.1 is affected and the fix ships only in 10.0.2, so the 9.x line has no patched release and the major is taken. + + Clause-②: no + + No exported symbol, option key, payload key or accept/reject verdict of ours moves; the published surface is unchanged and grades `patch`. What an operator can see is nodemailer's own behaviour inside the 10.x line this range admits: + + - **Node.js floor.** nodemailer 10 declares `node >= 20`. `@objectstack/core`, which this package depends on, already declares `node >= 22`, so no install that could load the plugin is excluded. + - **Module shape.** nodemailer 10 is a TypeScript rewrite shipping both ESM and CommonJS builds with bundled declarations. `SmtpTransport` loads it lazily and reads `createTransport` off the namespace or its `default`; measured on 10.0.2, 10.0.10, 10.0.11 and 10.0.12, both entry points expose it both ways, so the loader is unchanged. + - **Contradictory TLS flags in `transportOptions`.** From nodemailer 10.0.12 (the version a fresh install resolves today), `requireTLS` wins over `ignoreTLS` / `opportunisticTLS`. `SmtpTransport` sets `requireTLS` itself whenever TLS is on and the port is not 465. On such a port, a `transportOptions: { ignoreTLS: true }` escape-hatch override ran a cleartext session under nodemailer 9. It now performs the required STARTTLS upgrade or fails the send, which is the behaviour `secure: true` already documents. To connect in the clear on purpose, set `secure: false`. + + The devDependency on `@types/nodemailer` is dropped: nodemailer 10 ships its own declarations, and TypeScript resolves `nodemailer` to them (`dist/esm/nodemailer.d.ts`) before any `@types` package. + + The same sweep also moves two transitive packages. Neither release changes anything, and they are listed here so all seven findings can be read in one place. Both are workspace overrides in `pnpm-workspace.yaml`, and each dependent's declared range already admits the fixed version: + + - `ip-address` 10.4.0 and 10.5.0 → one copy on `^10.5.1`, for GHSA-2vr4-cq9g-pvrc (6.9) and GHSA-rpw4-54j3-4h4q (6.3). It reaches the tree through `@modelcontextprotocol/sdk` → `express-rate-limit` and through `mongodb` → `socks`. + - `undici` 7.29.0 → `^7.29.1` (a target-only lift of the existing pin) and 8.9.0 → `^8.10.2` (a new 8.x selector), for GHSA-3wwx-pv8p-q78v (5.9). Both copies are dev-only, through `ai` and `jsdom`. + + `osv-scanner.toml` keeps zero exemptions and is untouched. +- ca31ff6: Take the fix for the fifteen OSV advisories that turned `Validate Package Dependencies` red on every PR. + + The advisory database moved; the lockfile did not. `origin/main`'s `pnpm-lock.yaml` is byte-identical to the tree that scanned GREEN the day before and RED the day after, so this is a repo-wide condition rather than any PR's regression, and every one of the fifteen names a published fix version — the take-the-fix path `osv-scanner.toml`'s header describes, not the exemption path. That ledger keeps its zero entries and is untouched here, as is `.github/workflows/validate-deps.yml`. + + Two published packages change what a downstream install resolves, which is what this changeset grades: + + - **`@objectstack/plugin-email`** declares `nodemailer` `^9.1.1` (was `^9.0.5`), clearing GHSA-2x7j-588g-ccc2 (7.5), GHSA-cc9r-2j5m-2m83 (6.5), GHSA-wmmp-3585-3rmp (6.5) — all fixed in 9.1.0 — and GHSA-8m3c-c648-2xjj (5.9), fixed in 9.1.1. The range takes the higher of the two fix lines so one floor covers all four. The 10.x major is deliberately not taken. + - **`@objectstack/plugin-hono-server`** declares `hono` `^4.13.5` (was `^4.13.2`), clearing GHSA-crvj-82cr-hjcx (5.9), GHSA-g6gw-c38x-mqfc (5.3) and GHSA-gqvv-2mrq-wpjv (6.5). + + No exported symbol, payload key or accept/reject behaviour of ours moves — the published surface is unchanged and both grade `patch`. + + The rest of the sweep releases nothing and is named here only so the set is readable in one place: the `sharp` override target lifts to `^0.35.4` (GHSA-rgj7-g3m4-5g8c, 8.9) and the `hono` override target to `^4.13.5`, both target-only lifts whose selectors already sit at the compatibility boundary; the private docs app takes `next` 16.3.3 (GHSA-2xp9-vwfh-vxw4 9.5 and GHSA-p293-qw3h-jr36 9.0, the two Criticals); and the `vitest` devDependency line takes 4.1.11 across the workspace, with `@vitest/coverage-v8` moved in lockstep because its peer on `vitest` is exact (GHSA-82fw-gwwq-j7x9, 5.9, which flagged both `vitest` and `@vitest/mocker`). + + `hono` was flagged at TWO resolved versions and both are gone: the override lift is what collapses them. The transitive copy `@modelcontextprotocol/sdk` pulled sat exactly on the old `^4.12.34` floor and so was never re-resolved, while our own three declarations floated up to 4.13.2; `^4.13.5` excludes the floor, both edges re-resolve, and the tree now holds one `hono`. A bump that moved only our declarations would have left the transitive copy flagged and the gate red. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [7465eeb] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index 29496b7db81..d8a16b5576a 100644 --- a/packages/plugins/plugin-email/package.json +++ b/packages/plugins/plugin-email/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-email", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Email service plugin for ObjectStack — IEmailService + transport-pluggable outbound delivery with sys_email persistence.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-hono-server/CHANGELOG.md b/packages/plugins/plugin-hono-server/CHANGELOG.md index 5afdbf01cb8..a10375b92f4 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,661 @@ # @objectstack/plugin-hono-server +## 17.5.0 + +### Minor Changes + +- 89a652b: feat(cli): `objectstack dev --cert --key ` terminates TLS in the dev process, and the canonical origin follows the listener (#16804) + + An interactive MCP client refuses to start an OAuth sign-in against a non-TLS + URL, so the self-serve identity path the product advertises — "interactive + clients just open a browser login" — could not be exercised against a local dev + server at all. The only way round it was a hand-built https reverse proxy plus + `OS_AUTH_URL`, a page of setup that every developer, demo and video recording + repeated off-camera. + + **Bring your own certificate.** Nothing here generates one, and nothing here — + not the code, not `--help`, not any doc page — says anything about installing a + certificate into a system trust store. 「⛔ 不生成自签 CA;⛔ 不打印、不文档化任何 + 「把 CA 装进系统信任库」的指引——信任库是开发者自己的事」. The trust store is the + developer's own business; this feature's whole job is to *use* the certificate + they already have. + + ```bash + objectstack dev --cert ./localhost.pem --key ./localhost-key.pem + ``` + + Both flags are required together — half a pair is refused by name — and an + unreadable file is refused rather than degraded to a plain-http listener. + + **What follows the listener.** With both flags given, everything this boot + advertises is `https://localhost:`: the two `/.well-known/*` discovery + documents, the CSRF allow-list, the ready banner's `API:` / `MCP:` rows, the + `🤖 MCP server` connect hint, and the runtime state file the `os dev` parent and + external supervisors dial. Only the built-in default at the end of the base-URL + chain moves — `OS_AUTH_URL`, `BETTER_AUTH_URL` and `OS_BASE_URL` keep winning, + an `http://` value included, because they name where a deployment is *reached* + rather than what this process *bound*. + + **Without the flags nothing changes**, byte for byte — pinned by ablation legs + rather than asserted. + + `@objectstack/plugin-hono-server` gains the option this is built on: + `HonoPluginOptions.tls` (`{ cert, key }` PEM bytes) makes the adapter bind a TLS + listener with the same fetch handler, the same route table and the same graceful + drain. Absent, the listener is plain http exactly as before. +- 74832b6: **Breaking (shipped as `minor` under the launch-window convention).** Under a **walled** tenancy posture (`group` / `isolated`), a legacy unscoped `admin_full_access` grant row no longer confers `PLATFORM_ADMIN`; platform standing there is derived from `OS_PLATFORM_OWNER_EMAIL` and from nothing else. The migration pointer that announced this since 17.3.0 is retired with it: `reportLegacyPlatformAdminGrant` and `resetLegacyPlatformAdminGrantReport` are **removed from `@objectstack/core`'s published entry** (#18336, #11663 leg L5). + + ⚠️ **The `single` posture is untouched, deliberately.** Its zero-config first-user promotion still mints that row and that row still confers `PLATFORM_ADMIN` — a development environment started for a moment cannot be asked to declare an administrator first. Choice 4A (#11974) rules that promotion correct, and the maintainer's 2026-09-08 ruling on #16682 is verbatim: 「retiring the walled write must not retire the `single` one」. The `single` half's disposition is #11979's. ADR-0131 D5, as amended 2026-09-17 (#18413), is the governing record. + + **What a walled deployment must do.** Declare each administrator's **verified** address in `OS_PLATFORM_OWNER_EMAIL` (comma-separated for several) before upgrading. A walled rig that upgrades with the variable undeclared and an unscoped grant row still in place has **zero** platform administrators; the bootstrap now says so **at error**, naming the variable, the row and its holder — L4 used to skip that line for exactly this rig, on the ground that the deprecation pointer carried the remedy instead, and both halves of that arrangement have now expired. + + - **17.3.0 opened the window, this closes it.** L4 (17.3.0) stopped the walled bootstrap from ever *writing* the row and started the once-per-process pointer; L5 stops the walled derivation from *reading* it. The window was time-boxed and loud by design (#11663 P5). + - **The retirement takes the ANCHOR, not the ROW.** Nothing here writes, deletes or re-owns any grant row — a walled holder keeps the `admin_full_access` permission set they hold, and loses only platform-admin *standing*: the rung and the built-in `platform_admin` position. That row's ownership is ADR-0131 C3's, on the v18 line. + - **No new query.** The posture gate reads the environment, never the engine, so the recorded query multiset is identical under both of its answers — measured, not asserted. Under a wall the guard's grade-1 scan is skipped outright, so that path issues one read fewer. + - **`@objectstack/plugin-auth` moves with it, at TWO readers.** `ensureDefaultOrganization`'s step-2 legacy fallback is keyed on the same expression: under a wall it no longer answers「which user is the platform admin?」from the oldest unscoped grant, so the account it would have bound as the Default Organization's `owner` — and handed the org's seeded rows to — is no longer selected. ⛔ That reader does not merely count the population, it **confers** on it, which is why it is keyed here rather than sequenced. Its bootstrap-trigger predicate retires the matching `sys_user_permission_set`-insert arm under a wall with it (cost only; the `sys_user` arms are untouched, and on a walled rig the declared owner's verifying update is the only write that ever grows the population). And: + - **`@objectstack/plugin-auth`'s break-glass guard moves with it.** `last-admin-guard.ts` enumerates the administrator population from the SAME anchor, and its contract is to answer the same question the derivation answers. Its grade-1 (grant-anchored) enumeration is now keyed on the identical expression, so under a wall the guard no longer counts a holder the derivation does not recognise. Consequence on a walled rig: a write that would end the last **config**-anchored administrator's standing is now REFUSED where it was permitted, and a write that removes the now-inert grant row is no longer refused as though it removed the last administrator. Under `single` the guard is unchanged. Its two zero-population refusals also gained a walled clause, because「restore the `admin_full_access` row」stopped being a remedy that ends the emptiness there. + - **`@objectstack/organizations`' walled bootstrap moves with it.** That package wraps `ensureDefaultOrganization` and is the runtime that actually performs the default-organization bootstrap on a walled deployment (plugin-auth's own wiring skips it there). With the helper's legacy fallback keyed off, a walled rig carrying a legacy grant row **no longer** has a Default Organization created for that holder, and that holder is no longer bound as its `owner`; the bootstrap waits for a declared administrator to verify instead. ⚠️ Named because the behaviour an operator gets **from this package** moves — its own source does not change, and the pin re-authored inside it is not the reason. + - **Why `@objectstack/runtime` and `@objectstack/plugin-hono-server` are named.** Neither package's own source changes. Both carry `export * from '@objectstack/core'` (`runtime/src/index.ts`, `plugin-hono-server/src/adapter.ts`) and their built `.d.ts` carry that statement, so the two removed names leave their published surfaces too. All publishable packages sit in one Changesets `fixed` group, so naming them moves no version — it is named so the tombstone reaches the CHANGELOG an upgrading consumer of THOSE packages greps. Precedent is mixed (a core-only declaration exists); this follows the `ApiRegistry` precedent, which named every package the removal reached. + + +- cefe068: fix(plugin-hono-server): an escaped throw that declares an ADR-0112 envelope is answered as that envelope, not as a bare `500 INTERNAL_ERROR "No response from handler"` (#16545) + + `HonoHttpServer.wrap()` is the seam **every direct-mount route passes** — `get` / + `post` / `put` / `delete` / `patch` each register `this.wrap(handler)`, and + `IHttpServer` is how `service-datasource`, `packages/rest` and the dispatcher + bridge all mount. Until now a throw that escaped a route handler was answered + there as `500 { code: 'INTERNAL_ERROR', message: 'No response from handler' }`, + with the thrown value discarded — so a producer that had *declared* its refusal + lost both halves of the declaration on the way to the caller. + + The measured case: `service-datasource`'s `requireDatasourceAdmin` re-raises + `AuthzStoreUnavailableError` (declared `status: 503`, declared `code: + SERVICE_UNAVAILABLE`) when the authorization store cannot be read — deliberately, + per the #13279 ruling that an unreadable store licenses no verdict. The operator's + outage reached the caller as a generic fault naming the wrong component: the + declared code never arrived, and the message said "No response from handler". + + **What changed.** An escaped throw carrying **both** a declared ADR-0112 status + (a key of `HttpStatusErrorCodeMap`) **and** a code registered in `ErrorCode` + (`StandardErrorCode` ∪ `ERROR_CODE_LEDGER`) is now rendered as that envelope, + with the producer's `details` and `userMessage` channels forwarded. The status + and code are read through `resolveThrownHttpError` — the one rule the REST + registrar and the dispatcher already share — so this seam agrees with the other + doors by construction rather than by a second ladder. + + **What did NOT change**, pinned in the same PR: + + - an escaped throw that is **not** such an envelope answers exactly the bytes it + answered before — 500, no cause in the body. A partial declaration (status but + no code, code but no status), an unregistered code, and a status ADR-0112 does + not declare all take that arm; + - a handler that simply wrote nothing is untouched; + - a handler that **wrote and then threw** keeps what it wrote; + - the `notFound` fallback seam still answers `Fallback handler failed` — a + fallback that threw is a broken consumer, not a refusal it declared; + - ⛔ no error code is minted and no ledger row is added. A code on this path that + is not registered is a ledger gap under the #16404 ruling, and takes the + unchanged 500 arm rather than being registered in passing. + + The 5xx disclosure filter every door emitting a thrown message already runs + (`looksLikeInternalErrorLeak`, #3867 / #8086) is applied here from this seam's + first day: a driver dump on a declared 5xx is withheld, where the old bare 500 + disclosed nothing at all. The escaped-throw diagnosis (#5848) still fires exactly + once at `error`, and now names the answer that was really sent instead of + claiming an opaque 500. + + ⚠️ **Known-unreached door, stated rather than left silent.** A route mounted + through `getRawApp()` funnels through neither `wrap()` nor any registrar wrapper, + so it is **not** repaired by this change and still answers a non-envelope + `text/plain` 500. That is out of this card's scope by the `domain:cli` seat's + ruling and is filed separately. + +### Patch Changes + +- 3c48234: The UI auto-discovery block in `HonoServerPlugin.start()` no longer names the retired plugin type `ui-plugin`: the `plugin.type === 'ui-plugin'` disjunct and its "Support legacy" comment are removed, and the block mounts `type: 'ui'` plugins only (#15638). + + Clause-②: no + + - **Why nothing a plugin declares moves.** `ui-plugin` is not a member of the closed plugin-type set (`'standard'` plus `CORE_PLUGIN_TYPES`), and `kernel.use()` already refuses it on both published kernels, before the block can see it, with `PLUGIN_CONTRACT_VIOLATION ... at 'type'`. `LiteKernel.use()` throws it as-is; `ObjectKernel.use()` throws it behind its `Failed to load plugin: NAME - ` prefix. The disjunct was reachable through neither kernel's `use()`, so the deletion changes no accept or reject verdict for any declared value, and the refusal is the generic closed-set one. There is no message specific to this spelling. + - **The one object that stops mounting.** The contract validates at `use()` and stores the plugin object by reference, so an object admitted as `ui` that then rewrites its own `type` to `ui-plugin` before `start()` used to be mounted by the removed disjunct. It no longer is. + - **Fix**: declare `type: 'ui'`, with `staticPath` and `slug` (both required for that type). +- 0318faf: feat: the server answers `current_user.can(object, verb)` in an option's `visibleWhen` (#18783) + + A `select` / `multiselect` / `radio` / `checkboxes` option can gate itself on the acting subject's grants: + + ```ts + stage: Field.select({ + label: 'Stage', + options: [ + { value: 'open', label: 'Open' }, + { value: 'escalated', label: 'Escalated', visibleWhen: "current_user.can('crm_account', 'edit')" }, + ], + }), + ``` + + `@objectstack/formula` answers `can` from `EvalContext.permissions` and refuses loudly when none is passed — and until now nothing on the write path passed one. Every authenticated write that picked such an option took the evaluator's fail-open branch: the value was admitted, one `warn` said the predicate "failed to evaluate", and the gate was never enforced for anyone. + + **What changes.** The write path now evaluates the predicate with the subject's effective object permissions — on `insert` (single and batch), by-id `update`, bulk `update`, and the `validate()` preview. A subject whose map withholds the verb is refused with `VALIDATION_FAILED` and a field error `invalid_option` on that field; a subject who holds it is admitted. Options whose `visibleWhen` never calls `can` are unaffected. + + **Where the map comes from — one producer.** + + - `@objectstack/plugin-security` implements `ISecurityService.getEffectiveObjectPermissions` (declared optional in `@objectstack/spec`) and registers the same method on the engine. + - `@objectstack/objectql` gains `registerEffectiveObjectPermissionsResolver(fn)`. The engine asks it at most ONCE per write (an N-row bulk update is one resolution), only when a picked option's predicate calls `can`, never for a write with no acting user, and never keeps the answer past the write. The answer goes through formula's `toEvalPermissions`, so a map that is not the published shape is refused rather than answered from. + - `@objectstack/core` exports `buildEffectiveObjectPermissions`: the most-permissive merge plus the super-user seed, wildcard fold, managed-write clamp and `apiOperations` annotation. `/auth/me/permissions` builds its `objects` slot with it and the new security method returns it, so the console and the server's own `can()` read the same map. The four folds (`foldWildcardSuperUser`, `clampManagedObjectWrites`, `seedSuperUserRestrictedObjects`, `annotateEffectiveApiOperations`) and the `ManagedSchemaLike` / `ApiExposureSchemaLike` types moved from `@objectstack/plugin-hono-server` to `@objectstack/core`; `@objectstack/plugin-hono-server` re-exports them under the same names, so no import changes. The `/auth/me/permissions` response is byte-identical for the same resolved sets (measured on five fixtures against the previous build). + + **Failure stance.** + + - If the security service cannot resolve the map, a write that needs it is refused with the resolution's own error — fail closed. It is never read as "no grants". + - With no security plugin, or an engine older than the seam, there is no permission data. The gate stays unevaluable and the value is admitted with the same `warn` as before, which names the missing input. The security plugin logs one `warn` at start when the engine lacks the seam. + + **Plain-wildcard coverage, closed in this release.** `can()` reads only the per-object entries of the map. Before #20083, `/auth/me/permissions` listed an object for a `'*'` wildcard grant only when that grant carried a super-user bit, so a subject whose access to an object came only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — got `false` from `current_user.can()` for that object, although the data plane admits the write, and was refused on a `can`-gated option. That gap is closed in this same release by #20083 (`.changeset/20083-effective-map-plain-wildcard.md`): `buildEffectiveObjectPermissions` now puts each set's plain `'*'` on the registered public objects that set does not name, so that population's map — and any client that answers `can()` from the same `/auth/me/permissions` map — carries an entry for each object the wildcard covers, with the wildcard's grants, narrowed on a guarded managed object by the same managed-write clamp as every other entry. The map also differed from `PermissionEvaluator.checkObjectPermission` for subjects holding a super-user wildcard: an entry the super-user set itself names narrower read as granted, which is closed in this same release (`.changeset/20136-super-user-fold-per-set.md`). Its missing `transfer` is closed in this same release (`.changeset/20134-super-user-entries-every-bit.md`). + + **No spec key, route or config key is added or removed.** +- 2767af8: `/auth/me/permissions` now reports an unrestricted object's effective operation set whenever the export axis withholds `export`, so the Console stops rendering an Export button the server answers `403 EXPORT_NOT_PERMITTED` (#18931). + + `Clause-②: no` + + The endpoint builds its per-object map in four passes — seed, fold, clamp, annotate. `seedSuperUserRestrictedObjects` resolved each registered schema **without** the export slot and skipped every `unrestricted` one; `annotateEffectiveApiOperations` resolves **with** it and iterates existing entries only. Two predicates for one question, and they disagreed on exactly one population: a principal whose only grant is a `'*'` wildcard carrying `modifyAllRecords` and no `allowExport` — which, since #8681 removed the wildcard export grant from the built-in admin sets, is every platform administrator holding no app-authored set. + + For that principal an unrestricted object got no entry, so annotate never saw it and the response said nothing about it at all. The client reads `apiOperations: undefined`, takes the default-allow path #3391 gave it, renders **Export**, and the click is refused. A sibling object declaring `apiMethods` got an entry, an `apiOperations` without `export`, and no button — the same principal, the same session, two answers. + + - **The seed and annotate cannot diverge again.** Since #20134 the seed places an entry for every registered object a super-user wildcard reaches that the merge left without one, and `annotateEffectiveApiOperations` alone decides which entries carry `apiOperations`: an object that is unrestricted **and** keeps `export` gets none — unless its `enable.apiEnabled` is `false`, which is annotated `[]` since #20135 because the REST door answers 404 for every verb on it. + - **The export axis is the only axis this reaches.** Measured across the `enable` shapes an unrestricted object can carry: withholding `export` subtracts `export` and nothing else, and `mode` stays `unrestricted` either way — which is why the old `mode`-only guard could not tell the two cases apart. The CRUD axis needed no annotation and still gets none. + - **What the response gains**: for such a principal, one entry per unrestricted object, each the full closure minus `export`. Its CRUD bits are folded to what its wildcard grants (all four for the built-in admin sets) — the same answer the client already computed by falling back to `'*'`, now stated explicitly rather than inherited. + - **Denial is unchanged.** `enforceExportPermission` → `security.canExport` still answers `403 EXPORT_NOT_PERMITTED`, and no request that was refused is now accepted. This is the affordance half: the channel that is supposed to tell the client now does. +- 215840f: `/auth/me/permissions` now answers a wildcard-only `viewAllRecords` principal instead of staying silent about every object it can reach. + + `seedSuperUserRestrictedObjects` was guarded to `modifyAllRecords` super-users alone. A principal that reaches an object only through a wildcard `viewAllRecords` grant therefore got **no entry at all**: the client fell back to its default-allow path and rendered write and Export affordances the server answers `403 EXPORT_NOT_PERMITTED`. Same silence, same consequence, different principal class from the one framework#18931 closed. + + - **One predicate admits both classes.** The seed now asks the wildcard READ bypass — `viewAllRecords || modifyAllRecords` — which is the same question `foldWildcardSuperUser` already asks to decide whose `allowRead` it pulls true, and the same one `PermissionEvaluator` applies server-side. It is now a single module-local reading both call sites share, so the seed can never materialise an entry for a principal the fold leaves entirely false. + - **A plain wildcard grant carrying neither bypass bit is still not seeded.** That is what makes the admission the read bypass rather than "any wildcard": the fold pulls nothing true for it, so a seeded entry would be an all-false claim with no server behaviour behind it. + - **The seeded entry is the truth, not an overreach.** It starts `{allow*: false}`, the fold pulls `allowRead` true, and the write bits stay false. The seed only ever touches objects with **no explicit entry**, and on those a viewAll-only principal really can only read — so "explicit false" for edit is what is true about it, where the silence it replaces was not. + - **`apiOperations` is attached through the predicate already shared with the modify-all class** — an unrestricted object whose export stays allowed still gets no `apiOperations` (the entry itself is seeded since #20134), because for it the client's default-allow path is already right — except an object with `enable.apiEnabled: false`, annotated `[]` since #20135 because the REST door refuses every verb on it. + + ⚠️ **This is a deliberate behaviour change on an existing published channel, ruled rather than inferred.** For a viewAll-only principal a client that reads "no entry" as default-allow now reads an explicit `allowEdit: false` instead. Two pins asserting the old silence (`toBeUndefined` for the viewAll-only principal, one of them added by the framework#18931 PR that pinned this boundary while saying the pin was not a ruling that the silence was correct) are inverted on purpose under that ruling. Payload growth is the same one-entry-per-object framework#18931 accepted, now also for viewAll principals. +- ca31ff6: Take the fix for the fifteen OSV advisories that turned `Validate Package Dependencies` red on every PR. + + The advisory database moved; the lockfile did not. `origin/main`'s `pnpm-lock.yaml` is byte-identical to the tree that scanned GREEN the day before and RED the day after, so this is a repo-wide condition rather than any PR's regression, and every one of the fifteen names a published fix version — the take-the-fix path `osv-scanner.toml`'s header describes, not the exemption path. That ledger keeps its zero entries and is untouched here, as is `.github/workflows/validate-deps.yml`. + + Two published packages change what a downstream install resolves, which is what this changeset grades: + + - **`@objectstack/plugin-email`** declares `nodemailer` `^9.1.1` (was `^9.0.5`), clearing GHSA-2x7j-588g-ccc2 (7.5), GHSA-cc9r-2j5m-2m83 (6.5), GHSA-wmmp-3585-3rmp (6.5) — all fixed in 9.1.0 — and GHSA-8m3c-c648-2xjj (5.9), fixed in 9.1.1. The range takes the higher of the two fix lines so one floor covers all four. The 10.x major is deliberately not taken. + - **`@objectstack/plugin-hono-server`** declares `hono` `^4.13.5` (was `^4.13.2`), clearing GHSA-crvj-82cr-hjcx (5.9), GHSA-g6gw-c38x-mqfc (5.3) and GHSA-gqvv-2mrq-wpjv (6.5). + + No exported symbol, payload key or accept/reject behaviour of ours moves — the published surface is unchanged and both grade `patch`. + + The rest of the sweep releases nothing and is named here only so the set is readable in one place: the `sharp` override target lifts to `^0.35.4` (GHSA-rgj7-g3m4-5g8c, 8.9) and the `hono` override target to `^4.13.5`, both target-only lifts whose selectors already sit at the compatibility boundary; the private docs app takes `next` 16.3.3 (GHSA-2xp9-vwfh-vxw4 9.5 and GHSA-p293-qw3h-jr36 9.0, the two Criticals); and the `vitest` devDependency line takes 4.1.11 across the workspace, with `@vitest/coverage-v8` moved in lockstep because its peer on `vitest` is exact (GHSA-82fw-gwwq-j7x9, 5.9, which flagged both `vitest` and `@vitest/mocker`). + + `hono` was flagged at TWO resolved versions and both are gone: the override lift is what collapses them. The transitive copy `@modelcontextprotocol/sdk` pulled sat exactly on the old `^4.12.34` floor and so was never re-resolved, while our own three declarations floated up to 4.13.2; `^4.13.5` excludes the floor, both edges re-resolve, and the tree now holds one `hono`. A bump that moved only our declarations would have left the transitive copy flagged and the gate red. +- 0ced0aa: **`getRawApp()` mounts now answer an escaped throw with the declared ADR-0112 envelope.** A route mounted on the Hono handle funnels through neither the adapter's `wrap()` nor any registrar wrapper, so an escaped throw was answered by Hono's own default handler — `500 text/plain "Internal Server Error"`, no `success` flag, no `code`, and the thrown value's own declared `status` / `code` discarded. A transport error seam on the raw handle now renders the same throw-to-envelope rule a direct-mount route already used, so both doors answer one shape: a throw declaring `503` / `SERVICE_UNAVAILABLE` answers `503 application/json` with `{"success":false,"error":{"code":"SERVICE_UNAVAILABLE",…}}`, and a throw declaring no envelope still answers `500` with no cause in the body. + + The escape hatch is unchanged: consumers still mount framework-natively, still stay outside `getMountedRoutes()`, and still need no adapter verb. A thrown value carrying its own `Response` (Hono's `HTTPException`) keeps the response it declared. A consumer that installs its own `getRawApp().onError(...)` replaces the seam. + + Also fixed alongside it: `afterResponse` observers — and therefore `http_requests_total{status}` — reported a hard-coded `500` for any request that ended in a throw, which stops being the status actually sent once a declared envelope is rendered. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/observability@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index 9f0383e009e..d43108da7ec 100644 --- a/packages/plugins/plugin-hono-server/package.json +++ b/packages/plugins/plugin-hono-server/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-hono-server", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Standard Hono Server Adapter for ObjectStack Runtime", "main": "dist/index.js", diff --git a/packages/plugins/plugin-pinyin-search/CHANGELOG.md b/packages/plugins/plugin-pinyin-search/CHANGELOG.md index 5535204f49b..0ee83cbe22b 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/plugin-pinyin-search +## 17.5.0 + +### Patch Changes + +- Updated dependencies [0f95f43] +- Updated dependencies [7f62536] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [baf9745] +- Updated dependencies [271d6bb] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [63b6818] +- Updated dependencies [ada2869] +- Updated dependencies [eea7ccc] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [17005cc] +- Updated dependencies [d3a2331] +- Updated dependencies [5ba2ec3] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [922c755] +- Updated dependencies [74832b6] +- Updated dependencies [ef67b47] +- Updated dependencies [a675ad4] +- Updated dependencies [1f05ea4] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [0318faf] +- Updated dependencies [4fef271] +- Updated dependencies [a484966] +- Updated dependencies [875e9ad] +- Updated dependencies [adbdbc5] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [5dba7f3] +- Updated dependencies [afc3b64] +- Updated dependencies [2bbb462] +- Updated dependencies [3bd221d] +- Updated dependencies [4d7e740] +- Updated dependencies [8490127] +- Updated dependencies [ae0c90c] +- Updated dependencies [a9fb83e] +- Updated dependencies [0b866bf] +- Updated dependencies [c839986] +- Updated dependencies [009da14] +- Updated dependencies [aa04ea2] +- Updated dependencies [4463966] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [b373596] +- Updated dependencies [7465eeb] +- Updated dependencies [a08e059] +- Updated dependencies [fe677ae] +- Updated dependencies [fc646cf] +- Updated dependencies [949e99b] +- Updated dependencies [16c5a33] +- Updated dependencies [16c5a33] +- Updated dependencies [16c5a33] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [cfe2387] +- Updated dependencies [4df101c] +- Updated dependencies [e5cf27d] +- Updated dependencies [615c468] +- Updated dependencies [89f87f2] +- Updated dependencies [a78f731] +- Updated dependencies [3062e50] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [c74de10] +- Updated dependencies [db74b16] +- Updated dependencies [2b24b8b] +- Updated dependencies [c5d6b2b] +- Updated dependencies [2f122b6] +- Updated dependencies [4a1df19] +- Updated dependencies [1c1b8c8] +- Updated dependencies [9801da1] +- Updated dependencies [fb38607] +- Updated dependencies [b2b6a06] +- Updated dependencies [8538edf] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [92ea760] +- Updated dependencies [4c42fd1] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [c3ebe4a] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [2bed4c3] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [d2c1d19] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [a016f08] +- Updated dependencies [6e3462d] +- Updated dependencies [0f38ab0] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5a95b0e] +- Updated dependencies [07150b3] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [f04be62] +- Updated dependencies [8c9bd8f] + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/objectql@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index a925269ae88..90f6a78e4c5 100644 --- a/packages/plugins/plugin-pinyin-search/package.json +++ b/packages/plugins/plugin-pinyin-search/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-pinyin-search", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Pinyin search recall for ObjectStack — populates the hidden `__search` companion column (full pinyin + initials of the display/name field) so `$search` hits CJK names typed as pinyin. Locale-gated via OS_SEARCH_PINYIN_ENABLED (#2486).", "main": "dist/index.js", diff --git a/packages/plugins/plugin-security/CHANGELOG.md b/packages/plugins/plugin-security/CHANGELOG.md index 02025fa8143..aa59f334c6b 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,1802 @@ # @objectstack/plugin-security +## 17.5.0 + +### Minor Changes + +- f39ea95: fix(plugin-security)!: a `sys_user_position` write whose `position` names no `sys_position` row in the writer's catalog is refused (#16712, #20297), instead of answering 201 over an assignment that grants nothing + + Clause-②: yes + + + + **BREAKING** accept-set narrowing on the `sys_user_position` write path — + shipped as `minor` under the launch-window convention (`check-changeset-no-major` + refuses `major`; breaking-ness is carried by this banner and the ADR-0087 + disposition, not by the level). Maintainer-confirmed ruling on #16712 (option A), + with the catalog it reads settled on #20297: the writer's organization, under + the platform's standing tenancy rule. + + **What changed.** `sys_user_position.position` is the position's machine NAME + (`sys_position.name`), but it is declared `Field.text`, so a value naming no + catalog row — most often the position's record ID, written where its name + belongs — was stored with a `201` and then resolved to nothing: the holder got + no permission set, no sharing rule reached them, and they signed in to an app + that reads nothing, with no error anywhere. Such a write is now refused: + + - `400 VALIDATION_FAILED`, one `fields[]` entry per offending value at + `field: 'position'`, `code: 'reference_not_found'`, + `constraint: { target: 'sys_position', targetField: 'name' }` — the same + envelope a bad `user_id` or `organization_id` on the same row already gets. + - The message names the value and says the column takes the catalog NAME. When + the value is the record id of a position the writer's own organization can + see, it names that position and says to write its name. + + **Which writes.** Every non-system insert (one row or a batch, refused whole), + every non-system update by id that CHANGES `position`, and every predicate + update (`multi: true`) that sets it. The check runs after authorization: a + caller who may not write the table is still refused `403` on authority and never + sees the catalog verdict. + + **Whose catalog.** The writer's: the positions of the writer's own + organization plus the organization-less ones — the same reach the engine gives + its own lookup-reference check. A name that only ANOTHER organization's + catalog carries is refused exactly like any unknown name, with the same + envelope and the same message, so the answer says nothing about other + organizations. On a single-organization deployment the declared positions + carry no organization and any other position can only carry the one + organization there is, so every writer sees the whole catalog. A writer whose + context names no organization sees every organization's positions. + + **What did not change.** + + - A **deactivated** position is still a catalog row: an assignment naming it is + accepted and, as before, grants nothing (ADR-0049). + - **Stored rows** are untouched. An update that edits another column, or echoes + the unchanged `position` back, is not judged, so an existing row whose name + is no longer in the catalog stays editable. + - **System-context writes** are not judged — the seed loader (which on a fresh + single-organization boot writes `stack.data` before the declared position + catalog exists), invitation acceptance and the platform's own bootstraps. That + is the same stand-down the engine's lookup check takes. + + **Who is affected.** A client, script or AI author that writes a position's id, + a misspelled name, a name not yet created, or — on a deployment that walls + organizations off — a name only another organization has. The fix is the one + the refusal names: write the name of a position in the writer's own catalog, or + create the position there first. +- c54d8d6: A **permission-set name collision now reaches the author**. When a package declares a permission set whose name a *different* package already owns, `bootstrapDeclaredPermissions` refuses to write into that row — correct under ADR-0086 D4, and unchanged — but the refusal is no longer invisible (#17516). + + Measured on the pre-change tree, with a collision seeded and **no logger passed**: + + ``` + skippedForeign = 1 (the entire declared set was dropped) + author-visible console lines = 0 (log, info, warn, error, debug — all five) + diagnostic records on outcome = undefined + ``` + + The branch reported through `logger?.warn?.(…)` — optionally chained **twice** — so a caller that passed no logger produced no output at all, and a package's whole declared permission set vanished with one internal counter incremented. The comment there said *"refuse loudly"*; nothing about it was loud. Same case after the change: + + ``` + skippedForeign = 1 (unchanged — the skip is not what was wrong) + author-visible console lines = 1 warn: [security] [permission_set_name_collision] … + diagnostic records on outcome = 1 { name, declaredBy, ownedBy, message, fix } + ``` + + - **It prints with no sink injected.** `reportPermissionSetNameCollisions` falls back to `console.warn`, per the #10556 ruling that silent-by-declaration is rejected — an injected host sink still replaces it rather than printing beside it. The call keeps the receiver (a property-access call, never a detached `logger.warn ?? console.warn`), so a class-based host sink does not throw. + - **The refusal is also readable without a log.** `PermissionSeedOutcome` gains an optional `collisions` array carrying one diagnostic per dropped set — absent, never `[]`, when the pass hit none. A counter with no record is what made the drop undiagnosable. + - **One derivation, so two doors cannot drift.** `permissionSetNameIsForeign`, `permissionSetNameCollisionDiagnostic` and `formatPermissionSetNameCollisionDiagnostic` are exported from the package entry so a compile-time door consumes them rather than re-deriving the predicate or re-spelling the wording — the shape #14553 established for `navigationContributions`. ⚠️ Only the **runtime** door ships here; the compile-time door (`os build` / `os validate`) lives in another package and is not part of this change. + - **A stable, greppable token**, `permission_set_name_collision`, is stamped as `event` on every report. It is a snake_case data value, not an ADR-0112 error code: it is never routed to `error.code` and never reaches a wire refusal, the same discrimination the sibling `position_name_fold_grant` token already makes in this package. + - **The branch comment's premise is corrected.** It claimed package-namespaced object api names make set-name collisions a packaging bug rather than a merge case. **ADR-0130 D1 falsifies that** — N packages may co-own one namespace — so a collision is a legal configuration that gets *more* common, not an error that should never happen. The diagnostic's `fix` text names both legal resolutions. + + ⛔ **No wire byte moves and no skip changes.** The foreign row is still never written; `skippedForeign` still counts it; the ADR-0086 P2 publish materializer still returns its existing `permission set name is owned by another package` failure text. A non-colliding pass stays completely silent on all five console channels, asserted over a pass that really does seed and re-seed. +- b5cbfef: A **capability name collision now reaches the author**. When a package declares a capability whose name a *different* package already owns, `bootstrapDeclaredCapabilities` refuses to write into that row — correct under ADR-0086 D4, and unchanged — but the refusal is no longer invisible (#18023). + + Measured on the pre-change tree, with a collision seeded and **no logger passed**: + + ``` + skippedForeign = 1 (the declaration was dropped) + author-visible console lines = 0 (log, info, warn, error, debug — all five) + diagnostic records on outcome = undefined + ``` + + The branch reported through `logger?.warn?.(…)` — optionally chained **twice** — so a caller that passed no logger produced no output at all, and a package's whole declared capability vanished with one internal counter incremented. This module's own header said such a row was "skipped loudly"; nothing about it was loud. Same case after the change: + + ``` + skippedForeign = 1 (unchanged — the skip is not what was wrong) + author-visible console lines = 1 warn: [security] [capability_name_collision] … + diagnostic records on outcome = 1 { name, declaredBy, ownedBy, grantedBy, message, fix } + ``` + + **What the author is told is axis-specific, and deliberately not a copy of the permission-set wording.** On that axis the entire declared set is not materialized and none of its permissions are in effect. Here the capability name still *resolves* — the owning package's row answers for it, and the seeder still reports the name as materialized so the back-compat derivation does not clobber that row. What is lost is narrower and is now stated precisely: the declaring package's authored `label`, `description` and `scope` are not applied, and `sys_capability.package_id` attributes the capability to the other package, so the declaring package has no provenance claim over it. The record also names the bootstrap permission set(s) that grant the capability, so the blast radius does not have to be looked up. + + New published surface on `@objectstack/plugin-security`, for the same reason the permission-set diagnostic is published — the author-time door must consume one derivation rather than re-spell it: + + - `CAPABILITY_NAME_COLLISION` — the stable `capability_name_collision` grep token. + - `capabilityNameCollisionDiagnostic()` / `CapabilityNameCollisionDiagnostic` — the record. + - `formatCapabilityNameCollisionDiagnostic()` — the one-line rendering. + - `reportCapabilityNameCollisions()` — the report channel, which prints through `console.warn` when no sink is injected and keeps the receiver when one is, so a class-based host logger does not throw. + + ⛔ **The owner-comparison predicate is not duplicated.** Both axes call the existing `permissionSetNameIsForeign`, and the capability seeder's branch now routes through it instead of its own `===`, so a nullish owner reads FOREIGN on both axes by construction. + + `CapabilitySeedOutcome` gains an optional `collisions` key carrying those records, so a caller that reads no log at all can still ask what happened. It is absent, never `[]`, when a pass collided on nothing. +- a83dbb6: A package whose `manifest.permissions` carries the ADR-0025 capability grant is now NAMED when the audience-binding reconciler skips it, instead of vanishing; and both halves of the `permissions` key now point at each other in the spec (#18031). + + `permissions` has two incompatible readings and the package registry stores both in the same slot. At the AUTHORING stage `ManifestSchema.permissions` is the capability grant a plugin requests — the legacy flat `string[]`, or the structured `{ services, hooks, network, fs }` block (ADR-0025 §3.2). At the ASSEMBLED stage the collection wins and the same key is the ADR-0090 `PermissionSet[]` collection (`AssembledPackageBodySchema`, ADR-0130 D4). `SchemaRegistry.installPackage` records whichever stage its caller handed it. + + - **`collectDeclaredSuggestions` reports the reading it cannot use.** It wants the assembled one. Handed the authoring one it returned an empty list and logged nothing: the structured arm is an object, so `Array.isArray(manifest.permissions)` was false and the value never entered the loop; every member of the legacy arm is a bare string, so `consider`'s `typeof ps !== 'object'` line dropped all of them. A package declaring the other reading produced no `sys_audience_binding_suggestion` row, no prompt and no log. It now warns once per engine per package and arm, naming which arm it found, what is lost if the author meant permission sets (no admin is ever prompted to bind the set, and the deployment goes on looking healthy), and where the sets belong — the package's own `defineStack({ permissions: [ … ] })`, which is what the assembled body carries. + - **`warn`, not `error`, and deliberately.** Nothing here claims to have persisted anything, so this is a functional degradation — a prompt that is not offered. Same reasoning, one step weaker, as the write-refusal report beside it, and the same sink (`SuggestionDeps['logger']`, which declares no `error`). + - **Reported once per engine per package+arm.** The pass runs at boot, after every package-door `permission` publish and on every list call, while a manifest's shape is fixed for as long as that package is installed; an undeduplicated line would repeat on every console page load and be skimmed past. + - **The spec half is declaration text only — no key, export, arm or accept-set moved.** `ManifestSchema.permissions` now says it describes the AUTHORING stage and names the assembled-stage counterpart; the stack collection `permissions` names the manifest-stage grant; and `InstalledPackageSchema.manifest` says it is the authoring STAGE rather than "whatever was stored", pointing at `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` for the stage a `defineStack()` host installs. + - ⛔ **The union at the key was NOT widened, and must not be.** Widening a manifest key into a union of both stages is road C of #14242, rejected by name by the maintainer on 2026-09-02 in favour of road B — declare the assembled stage rather than widen the authoring one — because a union at the key makes neither stage checkable (Prime Directive #12). That ruling is why the fix here is a report and a cross-reference rather than a schema change. +- cf39b83: **The five remaining seeder refusals now reach the author.** The two declared-metadata seeders refuse to write in five more places, and every one of them reported through `logger?.warn?.(…)` — optionally chained **twice**, so a caller that injected no logger got no output at all (#18091). + + Measured on the pre-change tree, each site driven with **no logger passed** while all five console channels were spied, beside the two already-repaired axes as lit controls in the same harness: + + ``` + counter author-visible lines + curated platform capability refused skippedPlatform = 1 0 + capability declaration unowned skippedUnowned = 1 0 + capability rows unreadable unreadable = 1 0 + permission set declaration unowned (no counter at all) 0 + permission set rows unreadable unreadable = 1 0 + LIT CONTROL capability_name_collision skippedForeign = 1 1 + LIT CONTROL permission_set_name_… skippedForeign = 1 1 + ``` + + Every one of those zeros is now a 1, with the counters unchanged. + + ⛔ **No skip changed.** They are correct under ADR-0086 D4 (a package never writes into a foreign record) and ADR-0086 D3 (a package-managed row with no `package_id` makes uninstall undefined). The defect was only that the refusal never reached the author who caused it. + + **Each site words its own consequence** — the reason a mechanical copy was rejected. A curated-platform-name hijack still *resolves* against the curated row, so nothing is denied and only the authored metadata and the provenance claim are lost; an unowned **capability** has three different outcomes depending on what already stands in `sys_capability`; an unowned **permission set** keeps every grant working (the evaluator resolves declared sets through the metadata registry) and loses only the *record* — the Setup surface, the provenance axis and uninstall; and an unreadable read compared nothing, so nothing is lost and nothing arrived either. One generic "declaration skipped" line would send the first author hunting for a broken grant that is not broken. + + **What is shared is exactly one thing: where the line goes.** This shape had already been repaired one instance at a time twice, each repair restating the same two lines at its own call site. `reportThroughSink()` is now the single derivation, so a sixth refusal site cannot re-earn this card. It also improves on both spellings it replaces: a host sink that lies about its shape used to buy safety with silence (`logger?.warn?.(…)`) or noise with a throw (`logger.warn(…)`) — the `typeof` guard buys neither, and keeps the receiver so a class-based host logger does not throw. + + New published surface on `@objectstack/plugin-security`, on the criterion the two existing collision diagnostics state and no wider — a refusal an **author** can cause has a second door by construction (`@objectstack/lint`, `os build` / `os validate`), and both of these are decidable from the declaration alone with no database: + + - `CAPABILITY_PLATFORM_NAME_REFUSED` / `capabilityPlatformNameRefusedDiagnostic()` / `reportCapabilityPlatformNameRefused()` and the `CapabilityPlatformNameRefusedDiagnostic` record. + - `CAPABILITY_DECLARATION_UNOWNED` / `capabilityDeclarationUnownedDiagnostic()` / `reportCapabilityDeclarationUnowned()` and the `CapabilityDeclarationUnownedDiagnostic` record. + - `PERMISSION_SET_DECLARATION_UNOWNED` / `permissionSetDeclarationUnownedDiagnostic()` / `reportPermissionSetDeclarationUnowned()` and the `PermissionSetDeclarationUnownedDiagnostic` record. + + ⛔ The two unreadable-rows summaries are deliberately **not** published: an unreadable database is a runtime condition no compile-time door can raise, so they stay package-private for the reason `position_name_fold_grant` does. + + ⚠️ The end-of-pass `logger?.info?.(…)` summary in each seeder keeps its outer `?.` **deliberately**. A pass that did its work and refused nothing must stay silent on every console channel with no sink injected; routing a healthy boot's info line to the console would turn that control into noise and buy no author anything. The refusal channel is the one where silence was the defect. +- 6e4024c: `security explain` refuses an object name that does not exist instead of reporting `denies` — a typo is no longer indistinguishable from a permission decision (#18253). + + `GET/POST /api/v1/security/explain?object=leave_requst` used to walk all nine layers for a name nobody declared and answer `200` with `allowed: false` and `object_crud: 'denies'` — the byte-identical pair a **real** denial answers. Only the layer prose differed (#10401/#10424), and no client branches on prose, so the tool an administrator opens to ask "why can this person see this record" answered confidently about a record that does not exist. + + It now answers `404` with `error.code: 'OBJECT_NOT_FOUND'`. + + - **The engine decides, the door maps.** `explainAccess` throws `ExplainObjectNotFoundError` (plugin-security `errors.ts`), so every caller of `ISecurityService.explain` gets the refusal, not only the HTTP one; the REST route turns it into the status. A judgement made at the door would have been loud in one caller and silent in the other. + - **Nothing was newly minted.** `OBJECT_NOT_FOUND` at 404 is what this platform already answers for an unregistered object name (`mapDataError`, `packages/rest/src/error-response.ts`) and is a `StandardErrorCode` member, so no ledger row and no `packages/spec` change carries it. The body is emitted through the `/security/explain` family's one refusal emitter (#8073), so it is the ADR-0112 D5 envelope by construction. + - ⚠️ **Only one of the three unresolved causes moved.** An `unpublished_draft` declaration EXISTS (its remedy is "publish it") and a `metadata_unavailable` read did not answer, so both keep today's `denies` explanation — asserting absence there would state as fact the half the condition made unknowable. + - **Callers that branch on the verdict.** A client that treated `allowed: false` as "denied" for a misspelled object now meets a `404` refusal instead of a `200` decision. That is the point of the change, and it is the only wire movement: a resolvable object's report is byte-identical. +- 74832b6: **Breaking (shipped as `minor` under the launch-window convention).** Under a **walled** tenancy posture (`group` / `isolated`), a legacy unscoped `admin_full_access` grant row no longer confers `PLATFORM_ADMIN`; platform standing there is derived from `OS_PLATFORM_OWNER_EMAIL` and from nothing else. The migration pointer that announced this since 17.3.0 is retired with it: `reportLegacyPlatformAdminGrant` and `resetLegacyPlatformAdminGrantReport` are **removed from `@objectstack/core`'s published entry** (#18336, #11663 leg L5). + + ⚠️ **The `single` posture is untouched, deliberately.** Its zero-config first-user promotion still mints that row and that row still confers `PLATFORM_ADMIN` — a development environment started for a moment cannot be asked to declare an administrator first. Choice 4A (#11974) rules that promotion correct, and the maintainer's 2026-09-08 ruling on #16682 is verbatim: 「retiring the walled write must not retire the `single` one」. The `single` half's disposition is #11979's. ADR-0131 D5, as amended 2026-09-17 (#18413), is the governing record. + + **What a walled deployment must do.** Declare each administrator's **verified** address in `OS_PLATFORM_OWNER_EMAIL` (comma-separated for several) before upgrading. A walled rig that upgrades with the variable undeclared and an unscoped grant row still in place has **zero** platform administrators; the bootstrap now says so **at error**, naming the variable, the row and its holder — L4 used to skip that line for exactly this rig, on the ground that the deprecation pointer carried the remedy instead, and both halves of that arrangement have now expired. + + - **17.3.0 opened the window, this closes it.** L4 (17.3.0) stopped the walled bootstrap from ever *writing* the row and started the once-per-process pointer; L5 stops the walled derivation from *reading* it. The window was time-boxed and loud by design (#11663 P5). + - **The retirement takes the ANCHOR, not the ROW.** Nothing here writes, deletes or re-owns any grant row — a walled holder keeps the `admin_full_access` permission set they hold, and loses only platform-admin *standing*: the rung and the built-in `platform_admin` position. That row's ownership is ADR-0131 C3's, on the v18 line. + - **No new query.** The posture gate reads the environment, never the engine, so the recorded query multiset is identical under both of its answers — measured, not asserted. Under a wall the guard's grade-1 scan is skipped outright, so that path issues one read fewer. + - **`@objectstack/plugin-auth` moves with it, at TWO readers.** `ensureDefaultOrganization`'s step-2 legacy fallback is keyed on the same expression: under a wall it no longer answers「which user is the platform admin?」from the oldest unscoped grant, so the account it would have bound as the Default Organization's `owner` — and handed the org's seeded rows to — is no longer selected. ⛔ That reader does not merely count the population, it **confers** on it, which is why it is keyed here rather than sequenced. Its bootstrap-trigger predicate retires the matching `sys_user_permission_set`-insert arm under a wall with it (cost only; the `sys_user` arms are untouched, and on a walled rig the declared owner's verifying update is the only write that ever grows the population). And: + - **`@objectstack/plugin-auth`'s break-glass guard moves with it.** `last-admin-guard.ts` enumerates the administrator population from the SAME anchor, and its contract is to answer the same question the derivation answers. Its grade-1 (grant-anchored) enumeration is now keyed on the identical expression, so under a wall the guard no longer counts a holder the derivation does not recognise. Consequence on a walled rig: a write that would end the last **config**-anchored administrator's standing is now REFUSED where it was permitted, and a write that removes the now-inert grant row is no longer refused as though it removed the last administrator. Under `single` the guard is unchanged. Its two zero-population refusals also gained a walled clause, because「restore the `admin_full_access` row」stopped being a remedy that ends the emptiness there. + - **`@objectstack/organizations`' walled bootstrap moves with it.** That package wraps `ensureDefaultOrganization` and is the runtime that actually performs the default-organization bootstrap on a walled deployment (plugin-auth's own wiring skips it there). With the helper's legacy fallback keyed off, a walled rig carrying a legacy grant row **no longer** has a Default Organization created for that holder, and that holder is no longer bound as its `owner`; the bootstrap waits for a declared administrator to verify instead. ⚠️ Named because the behaviour an operator gets **from this package** moves — its own source does not change, and the pin re-authored inside it is not the reason. + - **Why `@objectstack/runtime` and `@objectstack/plugin-hono-server` are named.** Neither package's own source changes. Both carry `export * from '@objectstack/core'` (`runtime/src/index.ts`, `plugin-hono-server/src/adapter.ts`) and their built `.d.ts` carry that statement, so the two removed names leave their published surfaces too. All publishable packages sit in one Changesets `fixed` group, so naming them moves no version — it is named so the tombstone reaches the CHANGELOG an upgrading consumer of THOSE packages greps. Precedent is mixed (a core-only declaration exists); this follows the `ApiRegistry` precedent, which named every package the removal reached. + + +- 877dc03: The walled boot records platform-admin standing on the existing audit ledger, so «who held administrator standing, and from when» survives the move off the stored grant row (#18412). + + Platform-admin standing moved from a **stored grant row** to **config-derived, request-time resolution** (#11663 re-anchor, ADR-0131). The row carried its own history; config carries none. After the migration the only trace of a grant or a revocation was a change to `OS_PLATFORM_OWNER_EMAIL` plus a restart — the product keeps no environment-variable history and an auditor cannot read one. `sys_audit_log` recorded the ACTIONS all along; what had no writer at all was the **basis** of the authority behind them. + + The answer was already being computed and thrown away: `resolvePlatformAdminStanding` builds the per-entry summary at every walled boot and the bootstrap logs it at `info`. + + - **`@objectstack/plugin-audit`** — `sys_audit_log.action` declares one new value, `platform_admin_standing_change`, WRITER-FIRST (the only way a value is allowed onto that enum). Its rows appear on the shipped, unfiltered `recent` and `all_events` views; ⛔ no new list view, ⛔ no new object, ⛔ no new configuration key. + - **`@objectstack/plugin-security`** — the walled bootstrap compares the resolved standing against the last snapshot already on the ledger and writes **one entry per CHANGE of standing**, plus the **first-boot baseline**. A restarted rig writes nothing. Each row carries, per declared entry, the declared spelling, whether an account exists, whether it is verified, and which user id holds standing; `old_value` and `new_value` state both sides of the delta, and `old_value` is null on the baseline row and only there. + - **The `single` posture is untouched.** It still promotes the first registrant and still writes a durable grant row, so the durability this restores is walled-posture-specific. + - ⭐ **`organization_id` is NULL on this row, deliberately and by maintainer ruling** (2026-09-18, director batch #153 item 2). The record is deployment-level by construction: ADR-0131 §1.5 rejects inventing a platform organization in its own words («it is the natural repair and the wrong one … exists only to give NULL a new name»), a tenant id would file a whole-deployment fact behind one tenant's wall, and the first-boot baseline is written before any `sys_organization` row exists at all. This follows the tree's four existing deployment-level audit writers, and is the shape ADR-0131 D7 will later make structural by dropping the column. The exception is recorded beside the write, on the card, and in a pin — ⛔ it is not a gap waiting to be repaired. + - **Nothing here widens who holds standing or what standing permits.** The derivation site is untouched; this adds a RECORD of authority, never a grant of it. + - **Best-effort, and never fatal to boot.** A deployment that never mounted the optional `@objectstack/plugin-audit` skips silently — an unmounted ledger is a composition choice, not a fault. A ledger read that is REFUSED writes nothing and says so: «cannot tell» is not «first boot», and reading it that way would file a fresh baseline on every restart. A mounted ledger whose insert fails reports a durability degradation on the `error` channel. +- 21b7c12: The `everyone`-anchor doors now pass the stack's declared capabilities, so an app capability token a stack DECLARES no longer makes its `isDefault` set unbindable (#18535). + + ADR-0090 D5 rules the `everyone`-anchor offending list as 「平台系统权限;带 package provenance 的应用声明 capability 令牌不计」, and PR #17811 landed the predicate that implements it: `describeHighPrivilegeBits(def, context?)` excuses a `systemPermissions` name when the caller says this stack declared it. No consumer in this package passed a context, so all three doors kept judging an app's own gate exactly like `manage_users` — declared ≠ enforced on a contract both the ADR and the spec had already ruled, and an app that declared a capability its navigation gates on could not ship the "every employee holds this" set those gates need. + + All three now read one source — the stack's `capabilities:` declarations, through `readDeclaredCapabilityContext` (registry first, metadata service as the fallback, exactly as the `sys_capability` seeder reads them): + + - **the boot binding** (`bindBaselineToEveryone`) — the ADR-0090 D5 bind of the configured baseline set(s) to this organization's `everyone` anchor; + - **the engine write gate** on a `sys_position_permission_set` insert/update, read at most once per pass and only once an anchor row is in play; + - **`confirmAudienceBindingSuggestion`**'s early refusal, which is the friendly rendition of that same gate — one source is what keeps it from answering "confirmed" and then having its own insert refused under it. + + **Why the declarations and not the `sys_capability` rows.** The predicate's docblock names the rows at boot, but the boot binding runs BEFORE `bootstrapDeclaredCapabilities` seeds them (the bind must follow `bootstrapBuiltinRoles`, which seeds the anchor, and precede the suggestion reconciliation), so the rows are empty there on a first boot. Reading them would refuse every declared token one layer in. + + **Two things do not move.** The platform floor is absolute — declaring a capability named `manage_users` launders nothing, because the predicate applies `PLATFORM_CAPABILITY_NAMES` itself — and an UNDECLARED name still refuses at every door, as does every unreadable or empty declaration list (「omission refuses」). The `guest` tier is untouched: the predicate drops the context for it by contract. + + **What changes for a consumer:** a permission set whose `systemPermissions` names only capabilities the stack declares, marked `isDefault: true`, now binds to `everyone` at boot instead of logging `refusing to bind fallback set to everyone`. If you were relying on that refusal to keep such a set unbound, remove the token from the set or stop declaring the capability. + + Clause-②: yes (widening) +- 8015dc8: **The permission-set seeder's unowned refusal now moves a counter.** `PermissionSeedOutcome` gains a required `skippedUnowned`, incremented on the `!packageId` branch of `upsertPackagePermissionSet` — the branch that refuses to materialize a declared set with no resolvable owner (#18571). + + ⛔ **The refusal itself is unchanged.** A `managed_by:'package'` row with no `package_id` makes uninstall undefined, which is exactly the ADR-0086 D3 ambiguity the branch exists to prevent. This card adds a channel, not a verdict. + + #18564 gave that refusal its author-visible line. What it left is the programmatic half: the outcome came back with all six counters at zero, so a caller that reads no log at all — a boot report, a Setup surface, a test — could not tell a pass that refused a declaration from a pass with nothing to do. Measured against the sibling axis, which has counted the same refusal at the same boundary since #4967: + + ``` + axis unowned refusal counter moved author-visible line + capability skippedUnowned 1 1 + permission set (before) — none declared — 0 1 + permission set (after) skippedUnowned 1 1 + ``` + + **Required, not optional** — like `skippedForeign` and unlike `deleted`. An absent key on a *refusal* count reads exactly like a pass with nothing to refuse, which is the defect restated. All four construction sites initialize it (`bootstrapDeclaredPermissions`, `upsertPackagePermissionSet`, `upsertEnvPermissionSet`, `retirePermissionSetRecord`), so every door that returns this outcome answers the question. + + **Both doors onto the branch increment it.** The boot catalog loop aggregates it alongside the five counters it already forwarded; the ADR-0086 P2 publish materializer passes no collector and returns its own outcome, so a fix wired only into the aggregation would have left that caller as silent as before. + + **The accounting closes.** `seeded + updated + unchanged + skippedEnvAuthored + skippedForeign + skippedUnowned + unreadable` is now the number of named declarations a pass read — pinned by a conservation test modelled on the capability axis'. Before this counter that sum was short by every unowned declaration. (Pre-existing caveat, unchanged and outside this change: a write the engine rejects increments no counter; it is reported through `SeedWriteRefusals`.) + + New published surface on `@objectstack/plugin-security`: the `skippedUnowned` member of the barrel-exported `PermissionSeedOutcome`. Reading an outcome is unaffected — the member only adds a number to read. + + The sweep for construction sites covered this repository, the pinned `objectui` checkout and the downstream app repositories available to it, and found none outside the package. That is what was measured, and it cannot speak for a consumer outside those trees. So: if you construct a `PermissionSeedOutcome` yourself — a test double standing in for the seeder is the shape that does — add `skippedUnowned: 0`. Nothing else changes. +- 1f05ea4: A validation rule can read one hop through a lookup — `record.account.type` on an opportunity resolves the owning account's field instead of faulting (#18682) + + Clause-②: yes (widening) + + A validation predicate could only read the record it guards. A `lookup` / + `master_detail` field carries an **id**, so the natural cross-object rule — + "a partner account may not carry an opportunity over 10000" — faulted with + `runtime: No such key: type`, and because a broken validation is fail-closed it + rejected every write on the object. The capability mainstream platforms provide + as a matter of course could not be authored at all. + + ### What you can write now + + ```ts + validations: [{ + name: 'partner_cap', + type: 'script', + message: 'Partner accounts are capped at 10000.', + condition: "record.account.type == 'partner' && record.amount > 10000", + }] + ``` + + One hop, through any reference-typed field (`lookup`, `master_detail`, `user`, + `tree`). The engine reads the related row before evaluating and binds it in + place of the id, so `record..` resolves. + + ### It is data pinned BEFORE evaluation, not a query from inside CEL + + There is no `os.lookup(...)` / `os.exists` / `os.count` — those stay removed. + The engine statically analyses the predicate, learns exactly which reference + fields it reads through and which related fields it names, and loads those + **before** evaluation. Every registered function stays pure once `now` is + pinned, so `objectstack build` artifacts stay byte-stable. + + The cost is bounded by construction: one hop, only the fields a rule actually + names, one batched read per reference field per write, and nothing at all when + no rule traverses. + + ### Read authority — system, bounded by the projection + + The related row is read under **system authority**. A validation rule's output + is a pass/fail the *system* enforces, not data handed to the caller — which is + why RLS predicates are excluded from this capability altogether. Reading as the + acting user instead made the rule unauthorable for exactly the persona it exists + to constrain: a member with CRUD on the child and no read on the parent faulted + on every write. + + What bounds the elevation is the **projection**: only the + columns the predicate names, intersected with the related object's declared + fields. A column the related object does not declare never enters the query, and + is refused as the authoring fault it is — distinct from a column that exists and + is empty, which evaluates as `null`. + + A related object no organization wall scopes — no tenant column (`sys_user` + behind a `user` field), `tenancy.enabled: false`, or `external` — is bounded by + row as well: for any caller that is not system (a user, a public-form + submitter, a caller with no principal), only a row the caller's own read of that + object returns. A reference to any other row refuses the write as not readable, + whatever that row holds. Under a walled posture (`group` or `isolated`), such a + caller with no active organization gets no related read at all: a rule reading + through a stored reference refuses the write as not found. + + ⚠️ **The accepted cost, stated plainly.** A caller can *infer* a related value + they cannot see by observing which writes are refused. The value itself never + appears — the refusal names the field and the rule, never the value — and the + channel is deliberately no wider than "this rule refused this write". + + ### Two shapes are refused, with a prescription + + Both fault at evaluation today, so neither removes anything that works: + + | Shape | Why | Write instead | + | --- | --- | --- | + | `record.account.type == 'x' && record.account == 'acc_1'` | reading through the relationship resolves `record.account` to the related RECORD, so the id comparison would stop matching — silently | `record.account.id == 'acc_1'` for the value comparison | + | `record.account.owner.email` | a second hop is not loaded | denormalise onto `account`'s object, or read it in a hook | + + A field that is **not** reference-typed is untouched: `record.address.city` on + an object-valued field traverses today and keeps traversing. + + ### `@objectstack/plugin-security` gains `canWriteObject` + + The WRITE admission — the sibling of the existing `canReadObject`, running the + middleware's own arms in the middleware's own order: system bypass; then, before + anything resolves, the ADR-0103 engine-owned write guard and the ADR-0090 D12 + delegated-administration gate, each called as the middleware's own primitive; + then no resolved permission sets, unresolvable posture, the ADR-0066 D3 + `requiredPermissions` capability AND-gate for both principals, the CRUD grant, + the ADR-0090 D10 delegator check, and — when the caller's payload is supplied — + the field-level security WRITE gate over it (`getFieldPermissions`, folded + through the D3 field-capability contract, intersected with the delegator's mask + under D10, then the forbidden-write detection); and last, the ADR-0123 D2 + no-active-organization wall, the same verdict the middleware's step 3.7 throws + on. It exists for doors that must ask + "could this caller perform this write" without running the engine middleware — + the write preview is the first. + + ⭐ What it answers, POSITIVELY — by naming what it RUNS, never a category of the + write decision: the ADR-0103 engine-owned affordance gate, the ADR-0090 D12 + delegated-admin gate, the fail-closed postures (#3545's unresolvable posture and + the D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both + principals, the `allowCreate`/`allowEdit` CRUD grant, the D10 delegator's + independent grant, the step 2.5 FLS write gate over the keys the payload + names, and the ADR-0123 D2 organization wall. It says nothing about any refusal + not in that list. `@objectstack/plugin-security`'s + `can-write-object-admission.test.ts` pins the method's answer equal to the + registered middleware's on its equivalence block's cases, and pins one D12 + UPDATE case as a direction: the method `false`, the middleware `true`. + + ⛔ `true` never means the write will succeed, and ⛔ what follows is not an + enumeration of the distance to success: the middleware refuses both before and + after `next()` for reasons this method is never asked. Nearest to hand are the + remaining pre-resolution gates that run beside the two named above — the + package-managed and system-row write gates, which judge a row's PROVENANCE; the + curated-capability-name and audience-anchor binding refusals, which judge a + payload VALUE; and the ADR-0056 public-form grant, which no caller can present + to this method and which has no extracted primitive to call; the row-level and + post-image refusals — the `using` pre-image, the ADR-0055 controlled-by-parent + master edit, the RLS `check` post-image and the Layer 0 tenant post-image, none + of which this method can judge because it is asked about no ROW; the + payload-VALUE refusals the same caller passes by simply not sending the value — + the masked echo and the `owner_id` forge, which therefore widen the caller class + by nothing; the anti-filter-oracle guard on the caller's own predicate, which + this method is handed none of; the post-`next()` assertion that the insert + `check` seam really ran, which judges an executed write; and, outside the + middleware entirely, `readonlyWhen`, the static `readonly` strip and the + validation rules themselves. + + ### Scope + + Object validation rules (`script` / `cross_field`) — and the system-authority + read is confined to that one seam. The field-level + `requiredWhen` / `readonlyWhen` / option `visibleWhen` predicates fail **open** + and are deliberately not covered here; RLS predicates are out too. Depth is one + hop. The cleanup UPDATE a `set_null` delete issues on a referencing record + resolves no relationship, so a rule there is evaluated as before this release — + against the bare id, where reading through it faults and refuses the cleanup, + and with it the delete. +- 0318faf: feat: the server answers `current_user.can(object, verb)` in an option's `visibleWhen` (#18783) + + A `select` / `multiselect` / `radio` / `checkboxes` option can gate itself on the acting subject's grants: + + ```ts + stage: Field.select({ + label: 'Stage', + options: [ + { value: 'open', label: 'Open' }, + { value: 'escalated', label: 'Escalated', visibleWhen: "current_user.can('crm_account', 'edit')" }, + ], + }), + ``` + + `@objectstack/formula` answers `can` from `EvalContext.permissions` and refuses loudly when none is passed — and until now nothing on the write path passed one. Every authenticated write that picked such an option took the evaluator's fail-open branch: the value was admitted, one `warn` said the predicate "failed to evaluate", and the gate was never enforced for anyone. + + **What changes.** The write path now evaluates the predicate with the subject's effective object permissions — on `insert` (single and batch), by-id `update`, bulk `update`, and the `validate()` preview. A subject whose map withholds the verb is refused with `VALIDATION_FAILED` and a field error `invalid_option` on that field; a subject who holds it is admitted. Options whose `visibleWhen` never calls `can` are unaffected. + + **Where the map comes from — one producer.** + + - `@objectstack/plugin-security` implements `ISecurityService.getEffectiveObjectPermissions` (declared optional in `@objectstack/spec`) and registers the same method on the engine. + - `@objectstack/objectql` gains `registerEffectiveObjectPermissionsResolver(fn)`. The engine asks it at most ONCE per write (an N-row bulk update is one resolution), only when a picked option's predicate calls `can`, never for a write with no acting user, and never keeps the answer past the write. The answer goes through formula's `toEvalPermissions`, so a map that is not the published shape is refused rather than answered from. + - `@objectstack/core` exports `buildEffectiveObjectPermissions`: the most-permissive merge plus the super-user seed, wildcard fold, managed-write clamp and `apiOperations` annotation. `/auth/me/permissions` builds its `objects` slot with it and the new security method returns it, so the console and the server's own `can()` read the same map. The four folds (`foldWildcardSuperUser`, `clampManagedObjectWrites`, `seedSuperUserRestrictedObjects`, `annotateEffectiveApiOperations`) and the `ManagedSchemaLike` / `ApiExposureSchemaLike` types moved from `@objectstack/plugin-hono-server` to `@objectstack/core`; `@objectstack/plugin-hono-server` re-exports them under the same names, so no import changes. The `/auth/me/permissions` response is byte-identical for the same resolved sets (measured on five fixtures against the previous build). + + **Failure stance.** + + - If the security service cannot resolve the map, a write that needs it is refused with the resolution's own error — fail closed. It is never read as "no grants". + - With no security plugin, or an engine older than the seam, there is no permission data. The gate stays unevaluable and the value is admitted with the same `warn` as before, which names the missing input. The security plugin logs one `warn` at start when the engine lacks the seam. + + **Plain-wildcard coverage, closed in this release.** `can()` reads only the per-object entries of the map. Before #20083, `/auth/me/permissions` listed an object for a `'*'` wildcard grant only when that grant carried a super-user bit, so a subject whose access to an object came only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — got `false` from `current_user.can()` for that object, although the data plane admits the write, and was refused on a `can`-gated option. That gap is closed in this same release by #20083 (`.changeset/20083-effective-map-plain-wildcard.md`): `buildEffectiveObjectPermissions` now puts each set's plain `'*'` on the registered public objects that set does not name, so that population's map — and any client that answers `can()` from the same `/auth/me/permissions` map — carries an entry for each object the wildcard covers, with the wildcard's grants, narrowed on a guarded managed object by the same managed-write clamp as every other entry. The map also differed from `PermissionEvaluator.checkObjectPermission` for subjects holding a super-user wildcard: an entry the super-user set itself names narrower read as granted, which is closed in this same release (`.changeset/20136-super-user-fold-per-set.md`). Its missing `transfer` is closed in this same release (`.changeset/20134-super-user-entries-every-bit.md`). + + **No spec key, route or config key is added or removed.** +- 9347c1f: A row-level or sharing-rule predicate comparing a field against a list with `!=` / `==` is refused at the CEL lowering instead of lowering to a filter that widens on driver-mongodb, and driver-mongodb refuses `$ne` with an array comparand (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/driver-mongodb`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-list-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS reads on MongoDB and RLS write checks.** A policy written `record.status != ['closed', 'archived']` (or `!(record.status == [...])`, or `!=` against a `current_user` membership set) lowered to `{ status: { $ne: [...] } }` (or `$not` around a bare-array equality). The RLS `using` clause is composed into the query after the engine's comparand-shape check, and driver-mongodb passed the shape to the server, where it selects every scalar row: the read returned the rows the policy was written to hide. A `check` written `!=` against a membership set admitted every write. + + - `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose comparand is a list (`unsupported`): a list literal, or a `current_user` variable that resolves to an array. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports the literal; a resolved array is refused per request. + - `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403). A CEL-authored `check` gets this 403; the `INVALID_FILTER` / 400 of `matchesFilterCondition` remains for a filter passed to it directly. + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. + - `@objectstack/lint`: the list-literal form is reported (`rls-predicate-unenforceable`, `sharing-rule-unlowerable-condition`). The RLS reference pass probes each kernel-resolved `current_user` key with its runtime type. + - `@objectstack/driver-mongodb`: `translateFilter` refuses `$ne` with an array comparand at any depth, with `INVALID_FILTER` / 400, as driver-sql and driver-memory already do. + - `@objectstack/spec`: the migration registry carries the entry. + + **What to change.** "One of these values" is `record.status in ['open', 'pending']`; "none of these values" is `!(record.status in ['closed', 'archived'])`. In a raw filter, use `$in` / `$nin`. `in`, scalar `==` / `!=`, `null` and field-to-field comparisons are unchanged. + + +- c164186: `matchesFilterCondition` refuses an array comparand under `$ne` and in the equality position (`{ field: [...] }`, `$eq: [...]`) with `INVALID_FILTER` / 400, before any record is judged (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula` and `@objectstack/plugin-security` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `rls-predicate-array-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for row-level write checks.** This evaluator is what `@objectstack/plugin-security` runs against the post-image of an insert or update to enforce a row-level `check`. It compared strictly, and no stored value ever equals an array, so: + + - a `check` written `record.status != ['closed', 'archived']`, or `!=` against a `current_user` membership array, lowered to `{ status: { $ne: [...] } }` and matched **every** post-image; + - a `check` written `!(record.status == ['closed', 'archived'])` lowered to `{ $not: { status: [...] } }` and did the same. + + Every write such a policy was written to refuse was admitted and stored. The positive `record.status == ['open', 'pending']` already refused every write (403). + + The message withholds the field, the operator and the value, because the filter is usually an access policy the caller did not write, and the comparand may be a resolved membership set. + + **What to change.** A `check` or `using` predicate that means "one of these values" or "none of these values" is spelled with `in`: `record.status in ['open', 'pending']`, or `!(record.status in ['closed', 'archived'])`. Those, scalar `!=` / `==`, `null`, `Date` comparands and `{ $field }` references evaluate exactly as before. + + +- 4d7e740: A row-level or sharing-rule predicate whose comparison is handed something other than one value is refused at the CEL lowering or at the write-check evaluator, instead of admitting writes and reads it was written to refuse (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing`, `@objectstack/lint` and `@objectstack/objectql` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS write checks and reads.** Each shape below was measured through the real plugin-security on driver-sql and driver-memory: + + - `!(record.status in [['closed', 'archived']])` (a list nested in an `in` list) admitted and stored every write the `check` was written to refuse, and a `using` read returned every row on driver-memory. + - `current_user.org_user_ids != 'x'` and `current_user.org_user_ids > 'a'` (a membership set on a comparison with no field) folded to "no restriction": every write admitted, every row read, on every driver. + - `record.status > ['m']` compared the list as the string `'m'` on the write check, while the analytics read scope bound the whole list as one SQL parameter. `record.reviewer_id > current_user` compared the whole caller object as a string and admitted and stored every write; in this release the RLS compiler's comparand faces (#20212) already drop that policy, and this change refuses it at the lowering for every caller of the compiler. + - `record.status != record.tags`, its negation `!(record.status == record.tags)`, and the mirror `record.tags != record.status`, with `tags` a `json` field or a `multiple` lookup, admitted and stored every write. + + What changes: + + - `@objectstack/formula`: `compileCelToFilter` refuses, with `unsupported`, a list comparand under every comparison (the ordering operators now included, and on the constant-fold branch, whichever side), the `current_user` root or a key resolving to an object under an ordering operator, and an `in` list whose member is itself a list. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports each literal form; a resolved value is refused per request. `matchesFilterCondition` refuses, with `INVALID_FILTER` / 400, an array under `$gt` / `$gte` / `$lt` / `$lte`, an array member of `$in` / `$nin`, and a `{ $field }` comparison (`$eq`, `$ne` or an ordering operator) whose column holds a list or an object on the record being judged, on either side. The message withholds the field, the operator and the value. + - `@objectstack/plugin-security`: the RLS compiler drops a policy the compiler refuses and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, and `getReadFilter` hands the analytics read scope the deny scope). A `check` comparing a field with a list-holding column is refused 400 and stores nothing. + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. + - `@objectstack/lint`: the literal forms are reported as `rls-predicate-unenforceable`, and an ordering comparison against a membership set through the reference pass. + - `@objectstack/objectql`: a `having` comparison against a `{ $field }` column whose aggregated row holds a list is refused 400 where the row carries the list itself (driver-memory); driver-sql rows carry the stored JSON text and compare as before. + - `@objectstack/spec`: the migration registry carries the entry. + + The stage 2a changeset's sentence that `{ $field }` references evaluate as before no longer holds for a column holding a list or an object: that comparison is now refused. + + **What to change.** "One of these values" is `record.status in ['open', 'pending']`, and "none of these values" is `!(record.status in ['closed', 'archived'])`, with the list flat. An ordering takes one bound (`record.status > 'm'`); a range is two comparisons joined by `&&`. Compare against one key of the caller (`record.reviewer_id > current_user.id`). A field compared with a `json` or `multiple` field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook. In a raw filter, use `$in` / `$nin` with flat lists and one bound per ordering operator. + + Not changed: a field compared with a `json` or `multiple` field still lowers and is not reported at authoring time, because the lowering sees the predicate's text and not the object's field types; driver-memory still answers a `{ $field }` comparison on a read without evaluating the reference. + + +- de091b5: A row-level write check that orders a field against a bound (`>`, `>=`, `<`, `<=`) is refused with `INVALID_FILTER` / 400 when that field holds a list or an object on the record being written, instead of comparing the list's string form and admitting the write (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula` and `@objectstack/plugin-security` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `rls-predicate-stored-list-ordering-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS write checks.** Measured through the real plugin-security on driver-sql and driver-memory: `record.tags > 'a'`, with `tags` a `json` field holding `['m']`, compared `'m' > 'a'` and admitted and stored the write. `record.meta < 'a'` with `meta` holding `{ a: 1 }` compared `'[object Object]' < 'a'` and did the same, and so did a `multiple` lookup. `using` stands in as the check for a policy that declares no `check`, so the same predicate in `using` was enforced the same way on writes. No shipped row-level or sharing-rule predicate orders a field at all. + + What changes: + + - `@objectstack/formula`: `matchesFilterCondition` refuses `$gt` / `$gte` / `$lt` / `$lte` and `$between` on a field whose value on the record is a list or a plain object, whatever the comparand, with the same `INVALID_FILTER` / 400 and the same message as the stage 2d refusals. The refusal is per record: a record whose `json` field holds one scalar is compared as before. `null` and `Date` values are unchanged, and so is every equality against a stored list (`$eq`, `$ne`, implicit equality, `$in`, `$nin`). `$between` is not produced by the CEL lowering, so it reaches this only through a filter passed to `matchesFilterCondition` directly. + - `@objectstack/plugin-security`: a check insert or by-id update whose post-image holds a list or an object in an ordered field is refused 400 and stores nothing. That includes a by-id update that edits another field of a row whose stored `json` column holds a list, because the post-image merges the stored row. + - `@objectstack/spec`: the migration registry carries the entry. The stage 2a entry `rls-predicate-array-comparand-refused` now ends "Scalar != and ==, null, Date comparands, and { $field } references between single-valued columns evaluate exactly as before", which is true since stage 2d. + + Three moves, named: + + 1. **The write check now matches driver-sql's read.** driver-sql refuses every ordering comparison, and `$between`, on a column it stores as JSON text, by declared type (400, #7398). The in-process check now refuses the same predicate on the same row (400). + 2. **driver-memory's read parts from the write check.** driver-memory, a test driver, compares a stored list element by element on a read and keeps returning those rows (`record.tags > 'a'` reads a row holding `['m']`), while the check now refuses writing it. This is declared on #15104, as for stage 2d's `{ $field }` half. + 3. **A list written into a scalar field under an ordering check now answers 400.** `status: ['m']` into a `text` field under `record.status > 'a'`, or `amount: [500]` into a `number` field under `record.amount > 10`, was admitted, and driver-sql stored it as the text `'["m"]'` / `'[500]'`. It is now refused before anything is stored. + + **The explain answer.** `security/explain` evaluates the business RLS predicate in-process on the fetched record, so it now answers `INVALID_FILTER` / 400 where the record holds a list or an object under an ordering predicate (this stage). It already answered 400 for a `{ $field }` comparison against a list-holding column (stage 2d). For both, per operation: + + | explain operation | driver | the enforced operation answers | same as explain's 400? | + |---|---|---|---| + | `read` | driver-sql | 400 `INVALID_FILTER` (the driver's refusal) | yes | + | `update` | driver-memory | 400 `INVALID_FILTER` (the post-image check) | yes | + | `update` | driver-sql | 403 `PERMISSION_DENIED`: the pre-image gate fails closed on the driver's 400 | no — both deny | + | `read` | driver-memory | the rows its element-wise read admits | no — the test driver's read | + + Explain itself is unchanged. + + **What to change.** Order a single-valued column (`record.priority > 2`), or test membership in the list with `in` (`record.status in ['open', 'pending']`). A `json` or `multiple` field has no ordering. + + +- a9fb83e: fix(core,runtime,plugin-dev,plugin-security): a release artifact whose `packages` is `null` is refused as malformed, never read as absent (#19926) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on a value the schema already refuses, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the level). + + `ObjectStackDefinitionSchema.packages` is `z.array(ArtifactPackageSchema).optional()`, and `.optional()` admits `undefined`, not `null`. The schema refused `packages: null` (`invalid_type`), and `composeStacks` refused it with two or more inputs (`STACK_SCHEMA_INVALID`, `status: 422`). The runtime readers below read it as absent instead: a single-package artifact whose own top level is the one package body. Those readers now follow the declaration. An absent `packages` is `undefined` and nothing else; `null` is one of the present, non-array values the rule beside `AssembledPackageBodySchema` calls malformed, like `{}`, `0` or `'x'`, and it is refused with the same envelope: `INVALID_ARTIFACT_PACKAGES`, `status: 422`. No error code is added. + + - **`@objectstack/core`**: `resolveArtifactPackageOrder` refuses `packages: null` where it returned `[artifact]`. The refusal message names the value `null`, not `object`. The resolver's callers that hand it the whole artifact raise the refusal: the kernel `manifest` service's `register()` (`ObjectQLPlugin`) and `@objectstack/verify`'s collection reader for a collection the stack's top level does not carry. + - **`@objectstack/runtime`**: `resolveArtifactCollections` drops `null` from its absent branch, so `AppPlugin`, `createStandaloneStack`, `loadArtifactBundle`'s runtime-module merge and `resolveProjectDatabaseUrl` answer a `packages: null` artifact exactly as they already answer `packages: {}`. `carriedPackageIds`, and `resolveArtifactGrantBinding` for an artifact whose `grantedPermissions` is a record, read the package list through the core resolver and raise its refusal too. + - **`@objectstack/plugin-dev`**: the i18n detector's private absent guard moves in lockstep with the resolver's absent branch, so `devI18nPluginOptions` reaches the resolver and raises its refusal when the `i18n` config (on the stack or its `manifest`), a non-empty `manifest.translations` and a non-empty top-level `translations` do not answer first. `DevPlugin` keeps its posture: it reports the metadata defect on its `error` line and boots on the in-memory i18n fallback. + - **`@objectstack/plugin-security`**: `appSecurityPluginOptions` has no guard of its own and raises the resolver's refusal for `packages: null`. + - **What does not change**: the schema; an absent `packages` (no key, or an explicit `undefined`), which still returns the caller's own object by identity; a well-formed `packages[]`; and `composeStacks` with a single input, which still returns that input by identity. + + No in-repo producer writes `packages: null`, and `os build` and `os validate` refuse it at the schema before any reader runs. For a single-package artifact, leave the `packages` key out. +- 009da14: fix(plugin-security, objectql)!: a row-level `check` now holds for every row a multi-row write stores — an array insert and a predicate (`multi: true`) update (#19950, #19964) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows the set of writes the write gate accepts. A multi-row write that is admitted today can be refused after this change. It ships as `minor` under the launch-window convention, as the using-defaulted check did (#19942). + + A row-level security `check` (declared on the policy, or defaulted from its `using`) is the write-side half of the policy: a row the check refuses is never stored. The write gate enforced it for a single-row insert and a by-id update, but not for the two multi-row write shapes. An **array insert** (`insert(object, [rows])`, which the create-many data route calls) installed no check, so its rows were stored unjudged. A **predicate update** (`update(object, changes, { where, multi: true })`) never judged its new rows. The gate assumed a `using`-scoped `where` covered the write, but a policy that declares only `check` scopes nothing, and a scoped `where` says nothing about the new row in any case. + + Both shapes are now judged row by row. An array insert judges each row on the image the `beforeInsert` chain produced. A predicate update judges each row the write selects on its new image: the matched row merged with the final payload. The engine (`@objectstack/objectql`) supplies those rows through the seam the insert check already uses (`OperationContext.postHookWriteImageCheck`). It runs the judgement on the predicate path over the rows its composed query selects, reusing the matched-row read that path already makes. + + **Writes that are now refused.** Each refusal is the existing row-level CHECK denial, `403 PERMISSION_DENIED`, and nothing is stored. One failing row refuses the whole write. There is no transition switch. + + - **A predicate update under a policy that declares `check`**, when any matched row's new image fails that check, including when the policy has no `using` at all. + - **A predicate update that moves a matched row out of a policy's `using`**, when no applicable policy declares `check`. The `using` is the defaulted check; a by-id update already gives this answer. + - **An array insert** when any row fails the check. This includes every configuration that already refused each single insert, such as a `using` or `check` that does not compile. + - **A predicate update on a host that installs the judgement and never runs it**, for example a custom write executor in place of the engine. It is refused as an insert already is, with an `error` log saying the check was not evaluated. + + **Remedy.** To let a write store a row outside a policy's scope, declare a `check` on that policy that admits it; otherwise fix the data the write carries. + + **What does not change.** + + - A single-row insert is judged exactly as before. A by-id update is not changed by this entry; its judgement on the row it stores is its own entry (#19989). + - A predicate update still touches only the rows its scoped `where` selects. The check refuses a write; it never changes which rows are selected. + - A predicate update or array insert whose rows all pass is admitted as before. + - A system-context write is not gated. +- 560b724: A row-level or sharing-rule predicate comparing with `!=` / `==` against the bare `current_user` root is refused at the CEL lowering instead of lowering against the whole caller context object (#19959). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-variable-root-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS write checks.** A policy written `record.owner_id != current_user` (or `== current_user`, or `!(record.owner_id == current_user)`) named the variable root alone, which resolved to the whole caller context, and lowered to `{ owner_id: { $ne: } }` (or the bare object, or `$not` around it). A strict compare never equals an object, so a `check` so written admitted and stored every insert and by-id update it was written to refuse, a USING-only such policy admitted every insert, and explain reported the read as narrowed with the caller's membership sets echoed in its `readFilter`. A constant comparison such as `current_user != 'guest'` folded to no restriction. + + - `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose operand is the bare variable root (`unsupported`), in both of its modes, so the authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports it before any request. A variable that resolves to an object is refused per request; a `Date` still passes. + - `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, explain answers `denies`). + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is still skipped at bootstrap, now with reason `unsupported` instead of `unresolved-variable`. + - `@objectstack/lint`: the shape is reported as `rls-predicate-unenforceable` on either RLS clause, where it was silent, and as `sharing-rule-unlowerable-condition` on a sharing condition, where it was `sharing-rule-runtime-variable-condition`. + - `@objectstack/spec`: the migration registry carries the entry. + + **What to change.** Compare against the key the predicate means: `record.owner_id != current_user` becomes `record.owner_id != current_user.id` (or `current_user.organization_id`, `current_user.email`); a membership test is `record.owner_id in current_user.org_user_ids`. Scalar keys, `in`, `null`, literals and field-to-field comparisons are unchanged. + + +- 4463966: fix(plugin-security, objectql)!: a by-id update's row-level `check` now holds for the row it stores, after the `beforeUpdate` chain (#19989) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows the set of writes the write gate accepts. A by-id update that is admitted today can be refused after this change. It ships as `minor` under the launch-window convention, as the multi-row check did (#19950). + + A row-level security `check` (declared on the policy, or defaulted from its `using`) is the write-side half of the policy: a row the check refuses is never stored. An insert and a predicate update are judged on the row the driver stores. A by-id update was judged only on the change set as the caller sent it, merged with the stored row, before the `beforeUpdate` chain ran. A value a hook wrote into a checked field after that point was never judged, so the row it produced could be stored outside the policy. + + A by-id update is now also judged on the row it stores: the prior row merged with the final payload, after the `beforeUpdate` chain and both readonly strips, before the statement. The engine (`@objectstack/objectql`) runs that judgement through the seam the insert and predicate update already use (`OperationContext.postHookWriteImageCheck`). The existing judgement of the change set as sent stays, so this change only ever refuses more. + + **Writes that are now refused.** Each refusal is the existing row-level CHECK denial, `403 PERMISSION_DENIED`, and nothing is stored. There is no transition switch. + + - **A by-id update whose `beforeUpdate` chain writes a checked field to a value the check refuses**, including a value derived from a field the caller changed. + - **A by-id update on a host that installs the judgement and never runs it**, for example a custom write executor in place of the engine. It is refused as an insert and a predicate update already are, with an `error` log saying the check was not evaluated. + - **An update whose payload `id` addresses no row while `where.id` addresses one**, under a policy with a `check`. The engine writes the `where.id` row while the gate had judged the payload id. It used to be written and then refused; it is now refused before anything runs. + + **Remedy.** A hook that must store a value the caller's `check` refuses does so in a separate write under a system context, or the policy declares a `check` that admits it. Otherwise fix the data the write carries. For the last case, address the row with one id: `update(object, { id, ...fields })` or `update(object, fields, { where: { id } })`. + + **What does not change.** + + - A by-id update whose hooks leave the checked fields inside the check is admitted as before. + - A change set the check refuses as sent is refused as before, even when a hook would have replaced the refused value. + - Inserts and predicate updates are judged exactly as before. + - A system-context write is not gated. +- 26550c6: fix(plugin-security)!: the SCIM projection tables are row-scoped in every shipped permission set — no principal below platform admin reads a SCIM row another organization provisioned (#20001) + + Clause-②: no (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner and the ADR-0087 disposition below, never by the level). + + The seven `@better-auth/scim` tables carry no tenant column, so the organization + wall is inert on them, and the read that the shipped permission sets grant on + every better-auth-managed object had no row policy behind it for any of the + seven. Any authenticated member could therefore read the users, groups and + memberships every organization's identity provider had provisioned. + + **What a principal can no longer read.** Every shipped set that grants that read + and carries row-level security — `member_default`, `viewer_readonly`, + `organization_admin` and its wall-less variant `organization_admin_no_bypass` — + now declares a row policy on each table: + + - `sys_scim_user`, `sys_scim_subject`, `sys_scim_projection_grant` and + `sys_scim_identity_tombstone`: only the rows whose `user_id` is the caller + (the `
_self` policies); + - `sys_scim_group`, `sys_scim_group_member` and `sys_scim_connection_binding`: + no row at all (the `
_none` policies). + + This binds organization admins as well: an org admin no longer reads their own + organization's SCIM users or groups, only the rows about themselves. An MCP + agent acting for a user is bounded by that user's sets and reads the same. No + organization-scoped read replaces the old one, because none of the seven tables + has a column naming the organization and a row policy cannot follow + `connection_id` to the connection's organization. `admin_full_access` is + unchanged and still reads every row. + + **Remedy.** A deployment that needs a principal below platform admin to read + these tables grants it in a permission set of its own, with a row-level-security + policy on each table that names the rows it may see. Do not reach for a policy + that admits every row: no organization wall bounds these tables, so such a + policy admits every organization's rows. + + Unchanged: SCIM provisioning itself (its reads and writes run through + better-auth's adapter under system context, which no row policy reaches), and + every other managed object. + + +- ed3546f: fix(plugin-security)!: the Layer 0 tenant write wall now holds for the row a write stores, after the `beforeInsert` / `beforeUpdate` chain (#20013) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows the set of writes the write gate accepts. Under a walled tenancy posture (`isolated` or `group`), a write whose `beforeInsert` or `beforeUpdate` hook sets `organization_id` to an organization outside the caller's organization scope is now refused. It is admitted today, and the row is stored in that organization. It ships as `minor` under the launch-window convention, as the row-level `check` changes did (#19950, #19989). + + The Layer 0 tenant wall (ADR-0095 D1, ADR-0105 D5) holds a write's `organization_id` to the same filter the read side uses, so a non-platform user can only place a row in an organization they hold. The wall judged the payload as the caller sent it, before the engine ran the hook chain. A value a hook wrote into `organization_id` after that point was never judged, whether the hook derived it from another field, a parent record or a lookup. + + The wall now also judges the row the engine is about to store, through the seam the row-level `check` already uses (`OperationContext.postHookWriteImageCheck`): an insert's rows once the `beforeInsert` chain has run (every row of an array insert), the one row of a by-id update, and every matched row of a predicate update, each merged with the final payload. It is installed whenever the wall applies to the write, with or without a business `check`. The existing judgement of the payload as sent stays, so this change only ever refuses more. + + **Writes that are now refused.** Each refusal is the wall's existing denial, `403 PERMISSION_DENIED` ("the insert/update would place '…' in another tenant"), and nothing is stored. There is no transition switch. + + - **An insert, a by-id update or a predicate update whose hook chain leaves `organization_id` outside the caller's organization scope**: another organization under `isolated`, one outside the membership set under `group`. For an on-behalf-of write the delegator's scope applies too (ADR-0090 D10). + - **A walled write on a host that installs the judgement and never runs it**, for example a custom write executor in place of the engine. It is refused after the write with an `error` log saying the tenant wall was not evaluated on the stored row, as a write with an uncalled row-level `check` already is. The platform's own permission-set data door (ADR-0094), which executes its writes without the engine and so runs no hook chain, is not affected. + + **Remedy.** A hook that must place a row in another organization does so in a separate write under a system context, which the wall does not gate. Otherwise fix the hook, or the data it derives the organization from, so the stored row stays in the caller's organization scope. + + **What does not change.** + + - A write whose hooks leave `organization_id` in the caller's scope, or leave it alone, is admitted as before. An update that does not touch the column keeps the row's current organization. + - An insert that leaves `organization_id` absent is not judged on it: the platform fills it with the caller's active organization, as before. + - A supplied out-of-scope `organization_id` is refused before anything runs, as before, even when a hook would replace it with an in-scope one. + - A system-context write, a platform administrator on an object whose posture lets them cross the wall, and every write under the `single` posture are not gated by the wall, as before. +- 7766b62: fix(plugin-security)!: no shipped permission set below platform admin reads a row of `sys_verification` or `sys_jwks` — the credential rows those objects declare private (#20027) + + Clause-②: no (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner and the ADR-0087 disposition below, never by the level). + + `sys_verification` (one-time verification and password-reset tokens) and + `sys_jwks` (JWT signing keys) declare `access: { default: 'private' }`, and the + shipped permission sets documented them as denied to every principal below + platform admin. The sets did not hold that: the read they grant on every + better-auth-managed object is an explicit per-object entry, which the `private` + posture does not govern, and neither object had a row policy behind it. + + **What a principal can no longer read.** Every shipped set that grants that read + and carries row-level security — `member_default`, `viewer_readonly`, + `organization_admin` and its wall-less variant `organization_admin_no_bypass` — + now declares a row policy that admits no row on each of the two objects + (`sys_verification_none`, `sys_jwks_none`). That covers the rows the objects + declare private, including a caller's own verification row. An MCP agent acting + for a user is bounded by that user's sets and reads the same. `admin_full_access` + is unchanged and still reads every row. + + **Remedy.** None is expected to be needed: no shipped product surface reads these + tables under a user context. A deployment that genuinely needs a principal below + platform admin to inspect them grants that in a permission set of its own, with + a row-level-security policy that names the rows it may see. + + Unchanged: better-auth's own verification, password-reset and token-signing flows + (they read and write through its adapter under system context, which no row + policy reaches), the owner-scoped reads of the other `private` identity objects + (`sys_device_code`, `sys_oauth_access_token`, `sys_oauth_refresh_token`), and + every other managed object. + + +- 0d3ec47: fix(plugin-security)!: a row-level policy whose compiled filter carries a `null` list member or a `null` ordering bound now fails closed on both clauses, so its read and its write check agree (#20212) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what a row-level policy grants. A policy that returned rows on a read, or admitted a write, can now return no rows and refuse the write with 403. It ships as `minor` under the launch-window convention for accept-set narrowings. + + `RLSCompiler.compileFilter` now runs the platform's two shared comparand faces (`assertListComparandShapes` and `normalizeFilterComparandTypes` from `@objectstack/spec/data`) on every compiled policy filter, for `using` and `check` alike. These are the functions the engine already runs on a caller's own `where`. It runs them before the middleware chain adds the RLS filter to that `where`, so until now a compiled policy filter reached the driver unjudged. A policy they refuse is dropped the way a policy with an unresolved `current_user` variable, or a list under `==`, is already dropped. When no other applicable policy compiles, the clause answers `RLS_DENY_FILTER` and logs one `[RLS] DENY (fail closed)` WARN with `reason: 'refused-comparand'`. + + **Why.** The rulings refuse a `null` member of `$in` / `$nin` and a `null` comparand of `$gt` / `$gte` / `$lt` / `$lte` in every filter, because no two backends agree on what they match. On the RLS path they reached the backend, and the `check` clause of the same policy was evaluated in-process by another matcher. One policy then gave two answers. Measured with rows `open`, `closed` and a NULL status: + + | predicate | `using` read before, SqlDriver / InMemoryDriver | `check` insert `closed` / `open` before | after, both clauses | + | --- | --- | --- | --- | + | `!(record.status in ['open', null])` | the NULL row / the `closed` row | admitted / 403 | no rows, 403 | + | `record.status in ['open', null]` | the `open` row / the `open` and NULL rows | 403 / admitted | no rows, 403 | + | `record.status > null` | no rows / no rows | 403 / 403 | no rows, 403 | + | `record.status <= null`, `record.status in [null]` | no rows / the NULL row | 403 / 403 | no rows, 403 | + + On SqlDriver the first row's read hid the `closed` row that its own write check admitted. PostgreSQL answered as SQLite. + + **What now answers differently.** + + - A read (`find`, `findOne`, `count`) under such a policy returns no rows when no other applicable policy compiles, and logs the WARN. Beside another policy that compiles, this policy no longer contributes rows: the read returns what the other policies grant, with no WARN. + - A `check` (declared, or defaulted from `using`) refuses every insert and update it governs with the row-level CHECK denial, `403 PERMISSION_DENIED`, when no other applicable `check` compiles. + - `explain` reports the RLS layer as `denies` instead of `narrows`. + - Analytics: `getReadFilter` hands the deny sentinel to the analytics faces, which answer zero rows. They previously refused the whole query with `READ_SCOPE_COMPILE_FAILED` / 500. + + **Who is affected.** A deployment whose stored policies carry one of these shapes, for example a policy saved without `os validate`. No policy in this repository does: every `using` / `check` string under `examples/` and `packages/` (outside tests) that names `null` is a null check (`== null`, `!= null`), which is unchanged. + + **Fix.** Test for no value with `== null` and for a value with `!= null`. "One of these, or no value" is `record.status in ['open'] || record.status == null`. "Has a value" is `record.status != null`. `os validate` prints the rewrite for each finding. + + **Unchanged.** A policy whose compiled filter the faces accept compiles to the same filter as before, with the same WARNs. A caller's own `where` carrying these shapes is still refused `INVALID_FILTER` / 400 by the engine. The null checks `record.f == null` / `record.f != null` lower to `$null` and are not refused. + + `@objectstack/lint`: the `rls-predicate-unenforceable` finding for a `null` list member or `null` ordering bound now says what the runtime does: the policy is dropped on every request, with the clause's own fail-closed consequence. It used to say the policy survived and the backend answered. +- aeb0557: fix(security)!: the RLS write check refuses a field-to-field comparison the read refuses — one comparison class, one answer per policy (#20355) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on the row-level write check, shipped as `minor` + under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; + breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the + level). The hand-migration prescription is registered under protocol major 18 as + `rls-predicate-cross-class-field-comparison-refused`, one ADR-0087 D3 entry for the whole + family: the authoring arm `os validate` gained in #20347 and this write-check arm. + + **What changed.** A row-level policy that compares two fields of no shared comparison + class — `record.status != record.amount` (text and a number), `record.status != + record.photo` (text and a file field), `record.status != record.is_open` (text and a + formula field), `record.status != record.meta` (text and a json field) — already had + every read it scopes refused with `INVALID_FILTER` / 400 on the SQL drivers, because + driver-sql compiles a column-to-column comparison only within one class. The write + check did not know the rule: it compared the two raw values in-process, so an insert + or update the policy's `check` judges (or its `using`, standing in as the check) was + admitted and stored whenever that comparison happened to hold. Measured through + plugin-security and ObjectQL on SQLite, sqlite-wasm and PostgreSQL. The write check + now refuses the comparison too, with the read's envelope, `INVALID_FILTER` / 400, for + every insert (single or array), by-id update and predicate update it judges, and + nothing is stored. The same-class comparisons it always compared are compared as + before. The 400 names no column of the policy; the server log names the policy and + both columns. A comparison against a json or `multiple` field is refused by its + declared type now, where it used to be judged by the value each record held. + + **`@objectstack/formula`.** `matchesFilterCondition(record, filter, options?)` takes an + optional third argument: `options.fields`, the object's declared columns (`type` and + `multiple` per field name). Given it, every `{ $field }` comparison between two + declared columns is judged by `crossFieldComparisonVerdict` from + `@objectstack/spec/data` before any record is read, and one the platform defines no + answer for throws `INVALID_FILTER` / 400. Without it the evaluator behaves exactly as + before. Two new exports go with it: `findCrossFieldClassRefusal(filter, fields)`, the + pure judgement, and `crossFieldClassRefusalCarriedBy(error)`, which reads the refused + comparison off the error for a server-side log. + + **`@objectstack/driver-sql`.** `crossFieldComparisonClass` reads the same export + (`crossFieldColumnVerdict`) instead of keeping its own copy of the classification, and + layers above it only its internal type aliases. Every read answers as before. + + **`@objectstack/lint`.** The `rls-predicate-unenforceable` finding for such a + comparison now states the write answer the runtime gives: the in-process write check + refuses it by the same classification and stores nothing. + + **If a policy of yours is refused.** The platform defines no comparison between those + two columns on any path, so the policy never protected a read either. Compare a field + only with a field of the same class — a number with a number, text with text, a + boolean with a boolean, a date with a date, a datetime with a datetime, a time of day + with a time of day — or, if the two columns do hold comparable values, correct the + declaration of the one declared with the wrong type. `os validate` names every such + comparison. +- f6ceddc: A grants resolution with no active organization now applies only the global grants. `resolveUserAuthzGrants` applies a grant scoped to an organization only while that organization is the active tenant, and one rule decides it for all three kinds of grant row it reads: position assignments (`sys_user_position`), permission-set grants (`sys_user_permission_set`) and the organization's own position rows whose bound permission sets it collects (`sys_position`). + + **BREAKING** for a principal acting with no active organization. Before, "no organization" read as "every organization": each organization-scoped grant the user held anywhere applied, with no organization boundary left on it. That is the resolution a session falls back to when it names an organization its owner no longer belongs to, so a member removed from an organization kept the capabilities that organization had granted until someone revoked each grant by hand. Such a principal now holds its global grants and nothing scoped to an organization. + + - **Unchanged:** a principal with an active organization resolves exactly as before, and a global grant (no organization) applies everywhere as before. Platform-admin standing is unchanged: it was only ever derived from the unscoped `admin_full_access` grant or the declared administrator list, never from an organization-scoped grant. + - **If a principal relied on it:** act in the organization. Select it as the active organization, or mint the API key from a session that has it active, or grant the permission set globally (no organization) when it is meant to apply everywhere. + - **No "every organization" mode.** No option asks the resolver for every organization's grants, and nothing falls back to that reading. + - **`@objectstack/plugin-security`:** `buildContextForUser(ql, userId, nowMs?, tenantId?)` takes the organization to resolve the user in. The access explainer (`explainAccessForCaller`) resolves the explained user in the caller's organization, and the delegator behind an on-behalf-of principal is resolved in the live principal's organization, so the delegated intersection counts the delegator's grants where the request actually runs. + + Clause-②: yes (narrowing) + + +- 041d9fd: fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on a published + route — landing in the launch window as `minor` on all four packages (the + lockstep convention: during the window the bump level is not the carrier, this + banner and the disposition above are). Nothing that was already admitted + becomes refused **except** the requests `GET /data/` refuses today for + the same principal, which is the defect. Nothing that was refused becomes + admitted. + + `POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. + + The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. + + **This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. + + - **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. + - **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. + - **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. + - **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. + + The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. +- 2266438: Re-run the seed-ownership claim when the seed settles, and report whether each pass was final. + + `claimSeedOwnership` was reached exactly once per database lifetime, on the pass that promotes the first platform admin, while the platform's own seeder was still writing in the background — an app bundle that overruns `OS_INLINE_SEED_BUDGET_MS` (default 8 s) continues past kernel start rather than block it. Registry order and seed order are unrelated, so every object whose rows landed after that walk stayed `owner_id IS NULL` permanently: nothing re-ran the claim. Ownerless rows are invisible to every `readScope: 'own'` grant, and under `public_read` they read fine and answer 403 on every write at `modifyAllRecords: false` — a granted permission that can never be exercised. + + The claim now also runs on `app:seeded`, the published settle signal for that background continuation, against the same admin and with the same predicates — so it moves ownership for exactly the rows the promotion-time pass missed, and never for a row a human already owns. + + Two additive keys support it, both optional: `bootstrapPlatformAdmin` reports `adminUserId` on the promotion path and on the `already_have_admin` short-circuit (so the re-run reads the one existing holder scan instead of a second copy of it), and both `bootstrapPlatformAdmin` and `claimSeedOwnership` accept a `seedSettlement` snapshot read through the `seed-settlement` contract. No existing key, argument or return shape changed. + + Every claim pass now logs one line whether or not it claimed anything, and says whether its reading was final: a pass taken while a seed source is still writing is reported at `warn` as PROVISIONAL. Previously a pass that matched nothing logged nothing at all, so a boot that permanently orphaned rows and a boot with nothing to do produced identical evidence. +- a016f08: fix(plugin-security)!: the insert-side RLS `check` is evaluated on the row that will be STORED — after `beforeInsert` — instead of on the caller's raw payload (#16608) + + + + **BREAKING** — an accept-set narrowing on the write gate's refusal behaviour. An insert that is admitted today can be refused after this change. + + `check` validates the row a write produces — the PostgreSQL `WITH CHECK` analog. `update` reached that row by merging the caller's pre-image with the change set. `insert` could not: it has no pre-image, and the security middleware runs BEFORE the engine's operation, so its post-image was `opCtx.data` — the caller's payload as it arrived, ahead of `applyFieldDefaults` and ahead of every `beforeInsert` hook. + + A denormalised scoping field is exactly what an RLS predicate compares (ADR-0055: a predicate cannot traverse a lookup) and exactly what an app stamps server-side so a caller cannot choose it. Judging the raw payload therefore inverted the policy in both directions, measured on 17.3.0 with a real engine, a real `SecurityPlugin` and both drivers: + + - **the derived value was not on the image**, so the only way to pass a `check` over it was for the caller to SEND the value the hook exists to make un-sendable. Same identity, same object, same second: the payload carrying the stamped field returned 201, the identical payload leaving it to the hook returned 403 — and the stored row was identical either way. + - **the sent value WAS on the image and was then overwritten**, so an insert naming an in-scope organization while pointing at a parent in ANOTHER organization PASSED the check and stored the parent's organization. That is a row whose stored scope the caller does not hold, and it is why this is a narrowing rather than a widening: today it is admitted, after this change it is refused with nothing stored. + + Ruled 2026-09-07 (maintainer, verbatim 「同意」, director seat, summon #17, decision batch #3). The refused alternative — keep the order and write the contract that a checked field must arrive from the caller, plus an `os validate` rule to police it — institutionalises the contradiction and needs a permanent lint to hold it in place. + + **What changed, mechanically.** `OperationContext` gains `postHookWriteImageCheck` (`@objectstack/objectql`), an optional judgement an enforcement layer installs and `ObjectQL.insert` runs once the `beforeInsert` chain has produced the row — after the post-hook declared-field door, after the two value-changing strips (`stripRuntimeOwnedFields` and the static-`readonly` strip with its `defaultValue` re-default, both moved ahead of it), and before every producer with a side effect (the secret channel, the autonumber, validation, the statement), so a refusal still costs nothing. `@objectstack/plugin-security` installs its compiled `check` filter there for `insert` instead of matching it against `opCtx.data`; this entry leaves `update` unchanged, and the predicate and by-id updates move onto the same seam in their own entries (#19950, #19989). The compiled filter is still built in the middleware, where the caller's permission sets, the ADR-0090 D10 delegator's, the staged membership and the request context are all resolved — only the IMAGE is deferred. A middleware that installed the judgement and finds the seam was never run refuses the write and logs at ERROR: an unjudged write is not an allowed one. + + **Who is affected.** Only objects governed by a permission set that EXPLICITLY declares `check`, on single-row inserts by a non-system caller — the gate's existing scope, unchanged. Two behaviour changes to expect, and they are the two halves of the same correction: an insert that left a hook-stamped field off the payload now succeeds where it used to be refused, and an insert whose hook-stamped field lands outside the caller's scope is now refused where it used to be admitted. Callers that were duplicating the stamp to get past the gate keep working and may stop. + + **Two further behaviour changes the reorder produces, measured on both legs** (the reviewed order and this one), because moving the strips ahead of the seam also moves them ahead of the credential channel: + + - a caller-forged value on an author-declared `readonly` **`secret`** field is now stripped. Before, `encryptSecretFields` ran first and replaced the row's value with a `sys_secret` reference, so the strip's `Object.is` value test compared that reference against the caller's plaintext, read the difference as a hook's write, and KEPT the forgery — measured on 17.3.0's order as stored `token: "secret:sec_1"` with a `sys_secret` row minted. This is a narrowing, and it closes a hole that predates this card. + - an empty string on a `readonly` **`password`** field is stripped instead of answering `VALIDATION_ERROR`. `""` reaches the store on neither order, so the 2026-08-13 empty-credential ruling's guarantee is unchanged; only which refusal a caller sees moves, on a payload a caller was never allowed to send. ⚠️ This is the one direction of the reorder that is not a narrowing, and it is recorded rather than left to be discovered. + + **The invariant this buys, stated to its real edge.** A stored row satisfies the insert `check` on every field the CALLER can steer, whatever the caller sent. Nothing offered any such guarantee before: the check read the payload, and the payload was entirely the caller's. + + ⚠️ It is deliberately not "on every field", and the difference is a boundary rather than a hedge. Four engine-owned passes still run between the judgement and the driver, and each substitutes a platform value for whatever stands on the row: the tenant fill of an ABSENT organization column (`resolveSystemInsertOrganization` plus the driver's `injectTenantOnInsert`), `encryptSecretFields` replacing a `secret` field's plaintext with a `sys_secret` reference, `applyAutonumbers` issuing a record number, and `normalizeMultiValueFields` coercing a declared multi-value field to its stored shape. A policy whose `check` names an autonumber, a `secret` or the tenant column is therefore judging a value the platform is about to replace. None of those four is caller-steerable — which is exactly why the two passes that WERE (`stripRuntimeOwnedFields` and the static-`readonly` strip) moved above the seam instead of being explained away. +- 331a1a2: fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) + + Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** + + **The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: + + | identity path | `crm_account` | `crm_opportunity` | `crm_task` | + |:--|--:|--:|--:| + | API key, `principalKind: human` | 9 | 23 | 45 | + | OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | + + The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. + + **The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. + + **(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. + + ⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. + + **(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. + + **(3)** The Setup page's promise is untouched — it is now true rather than rewritten. + + Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. + + `DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: + + | direction, after release | consumer cost | + |:--|:--| + | ship optional fields, later tighten them to required | a compile break | + | ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | + + The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. +- 1c83ca2: The first-boot `already_have_admin` short-circuit now FINDS an existing platform admin instead of sampling for one, so a tenant's organization-admin count can no longer decide whether a second unscoped `admin_full_access` grant is minted. + + Before this change the holders read was `sys_user_permission_set` with **no `orderBy` and a cap of 50**, and the predicate that actually decides — `!organization_id` — was applied **client-side to whatever 50 rows the driver returned first**. `admin_full_access` is not only the platform-admin set: every *organization-scoped* grant of it writes a row carrying the same `permission_set_id`, so this population grows with the number of **org** admins, not platform admins. A tenant with fifty-odd of them filled the window with rows that all fail the filter, the short-circuit did not fire, a **second** unscoped grant was minted, and `claimSeedOwnership` re-owned the seeded business records to the newly promoted user — silently, because the boot logs a successful promotion exactly as on a genuinely fresh install. Measured on the real better-sqlite3 driver: with 60 organization-scoped grants plus one unscoped human grant, the unordered 50-row window contained 50 organization-scoped rows and not the one that decides. + + That is the guarantee #14348 case D pins — 「Moving an already-granted platform admin is reserved to the maintainer.」 — failing open by row count. + + - **The read asks the driver the narrow question first.** `{ permission_set_id, organization_id: null }`, ordered and bounded. Because it is narrowed server-side, no number of organization-scoped grants can crowd the answer out of a window. + - **A second, ordered and bounded leg still applies the exact predicate.** It runs only when the narrow leg found nobody. This is deliberate rather than redundant: `organization_id: ''` is storable and reads back as `''` on both SQL families, which `!organization_id` counts as **unscoped** and `where: { organization_id: null }` does **not** return — so replacing the client-side predicate with the narrowed read alone would have made this guard fire *less* often and mint the very grant this fixes. Both legs are strictly additive to what the old read could see, so the guard can only fire more often than before, never less. + - **The bound is never silent.** The scan pages 200 rows at a time up to a 5000-row ceiling, and reaching that ceiling without finding an unscoped human holder now WARNS — naming the ceiling, the number of rows examined, and the consequence (promoting from here would mint a second unscoped grant and re-own the seeded records). + - **The answer says how many rows it examined.** `bootstrapPlatformAdmin`'s returned report gains an optional `adminGrantRowsExamined`, counted by row identity across both legs, on every return the guard reaches. A guard that had seen the whole population and one that had seen a truncated slice of it previously returned byte-identical payloads. + - **The ordering is stated to the driver, and it is measured, not assumed.** `tryFind` answers `[]` when a query is refused, and on this guard `[]` reads as "no platform admin exists yet" — which promotes. An order this object could not serve would therefore be a silent relaxation, so `id` ascending was measured honoured through ObjectQL on both SQL driver families against the real declarations. + + Unchanged: an unscoped grant held by the seed identity `usr_system` still never counts, so a database where it was wrongly promoted stays self-healing on restart; the walled postures still mint no grant row and still point a legacy unscoped holder at the config path; and a genuinely fresh install still promotes exactly as before. +- 9b9581b: First-boot platform-admin promotion under the `single` posture now CHOOSES its target instead of sampling one: the candidate read is ordered by the database, and an operator who declared an owner gets that owner — and only once that owner has verified the address. + + Before this change the selection read `sys_user` with **no `orderBy` and a cap of 50** and then sorted that array client-side, so "the oldest authenticable user" actually meant *the oldest authenticable user among whatever 50 rows the driver produced first*. Measured on 113 seeded users with the intended owner inserted first, holding the oldest `created_at` and an id that collates last: the in-memory driver returned it in row 1 and promoted it, while the default sqlite driver returned rows in id order, never saw it at all, and handed the unscoped `admin_full_access` grant — plus, through `claimSeedOwnership`, ownership of every seeded business record — to a seeded job-seeker persona. Same code, same config, same data; the answer changed with the storage driver. + + - **The read is ordered where the driver can see it.** `created_at` ascending with `id` as the tie-breaker (seeded populations routinely share one timestamp). There is deliberately no client-side re-sort left behind: one would re-rank the returned page and keep the guard passing if the ordering were ever lost again. + - **The declared owner is asked first, and must be a VERIFIED holder.** `OS_PLATFORM_OWNER_EMAIL` was imported into this file and read only on the walled branch, so a deployment that had said who its owner is could still have someone else promoted. Under `single` the target is now a row that holds a declared address, is human, can authenticate, and has `email_verified === true` — all four. Requiring verification rather than merely preferring it answers the one direction in which honouring the declaration would otherwise have been a widening: because `sys_user.email` is UNIQUE on the SQL family, an attacker who registers the declared address before the operator does would have been promoted with no way for the real owner to coexist, so an unverified holder is refused instead. + - **A declared owner who cannot sign in, or has not verified, REFUSES.** No silent fall-back to whoever happens to be oldest — that is the outcome this fixes. The pass warns, naming the variable, the address and which of the two is missing (`declared_owner_not_authenticable` / `declared_owner_not_verified`), and promotes nobody. **Accepted cost, stated rather than discovered:** a `single` deployment whose declared owner has not verified their email gets no platform admin at first boot until they do, loudly. Because the pass replays per sign-up while no admin exists, that warning re-emits on each replay until the owner is promotable; it is deliberately not latched, so the condition stays visible in the log a fresh operator is actually reading. + - **Verification landing is a replay trigger again.** `shouldReplayBootstrapFor` admits a `sys_user` update touching `email` / `email_verified` under `single` — but only while an owner is declared, which is the only configuration where such a write can change the answer. With none declared, the trigger set stays exactly as narrow as it was. + - **The cap is replaced, and never silent again.** A 200-row page with a 5000-row scan ceiling, walked oldest-first. Because the page is ordered it holds the rows the age rule actually wants, so truncation can only bite when every one of the oldest 5000 humans is non-authenticable — and reaching the ceiling now WARNS, naming the number examined. + - **The grant's log line records WHY and FROM HOW MANY.** `[security] first user promoted to platform admin: ` keeps its prefix and gains the basis (`declared-owner` / `oldest-authenticable`) and the candidate-pool size, repeated as `basis` / `candidatePoolSize` fields for structured sinks. The returned report carries `basis` too. + + Unchanged: no declaration still means first-user promotion by age (`single` keeps Choice 4A), and that leg has no verification requirement; a user nobody can authenticate as is still never promoted; an existing unscoped grant still short-circuits before any selection runs, so no deployment that already has an administrator can be re-pointed by this. +- 2a79726: feat(plugin-security): a position row can no longer spell an ADR-0068 built-in identity name (#15972) + + `sys_position.name` and `sys_user_position.position` were unconstrained, so a tenant could mint a row spelling any framework-reserved built-in identity name — `platform_admin`, `org_owner`, `org_admin`, `org_member`. PR #15948 closed every in-repo READER that turned such a name into authority; it could not stop the row existing, and a reader is not an invariant: an out-of-repo consumer that reads the NAME instead of the capability rung reopens the hole with nothing mechanical to catch it. + + Both declarations now carry an object-level `validations[]` rule whose CEL list literal is **generated** from `BUILTIN_IDENTITY_NAMES`, the `@objectstack/spec` constant that declares the identities. The set is a closed enumeration — imported, never retyped, and never widened to an `org_*` pattern, so an ordinary tenant position named `org_manager` still writes. Object-level validations are evaluated by the engine on insert, by-id update and multi-row update, so the data API, the seeders and metadata import are all covered by one refusal carrying one code (`VALIDATION_FAILED`). + + Two doors, two shapes, for a reason: + + - **`sys_position`** exempts the platform's own catalog provenance (`managed_by` of `platform`, or its legacy `system` spelling). `bootstrapBuiltinRoles` seeds exactly these four names per organization on purpose, and that catalog is unaffected. A `package`- or tenant-authored row is refused. + - **`sys_user_position`** takes **no** exemption. No writer in any package creates an assignment row spelling a built-in identity name — `platform_admin` standing comes from the unscoped `admin_full_access` grant, the `org_*` trio from `sys_member.role` — so every such row is a name pretending to be an identity. + + Existing rows are not migrated and nothing rewrites them (maintainer ruling: refuse new writes only). The rule is an INVARIANT, so a row that already spells a reserved name is refused on any edit until it is renamed — frozen, not bricked. `scripts/measure-reserved-identity-name-census.mjs` is the read-only census that reports such rows from an operator-supplied export. + + Housekeeping this change drags along, disclosed because a reviewer should not have to discover it: a validation rule's `name` is snake_case by contract, and `scripts/tenant-audit-census.mjs` counts every snake_case `name:` literal in a `*.object.ts` as a "declared object" (it already counts the four `actions[]` names on `sys_position`, so that figure was never a count of objects). The two new rule names move it 298 → 300, so the census artefacts are regenerated with the script's own `--write`. That block regenerates **whole**, so it also refreshes two figures this diff did not cause — `tracked non-test sources scanned` 557 → 562 and `engine-shaped types recognised` 59 → 58 — which are drift accumulated since the block was last measured at `9cefca9a3`. +- b7c792b: fix(plugin-security)!: a row-level security policy that declares no `check` now holds INSERTs and UPDATEs to its `using` (#19942) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows the set of writes the write gate accepts. A write that is admitted today can be refused after this change. It ships as `minor` under the launch-window convention, the same way the insert-side `check` reorder did (#16805). + + The published contract has always said this. `RowLevelSecurityPolicySchema.check` read "defaults to USING clause if not specified" (it now states the default per operation across the applicable policies, #19953), and PostgreSQL treats a policy without `WITH CHECK` the same way. The write gate did not do it. It compiled only the policies that declared `check`, so a policy with only a `using` never checked a write. With `using: "record.status != 'closed'"`, a caller could INSERT a closed row. The row was stored even though the same caller could not read it afterwards. + + **Writes that are now refused.** Each refusal is the existing row-level CHECK denial, `403 PERMISSION_DENIED`, and nothing is stored. There is no transition switch. + + - **Any policy with only a `using`.** A single-row INSERT, or a by-id UPDATE, is refused when its resulting row falls outside the `using` of every applicable write-class policy (`insert`, `update` or `all`) and none of those policies declares a `check`. To let a write move a row outside a policy's scope, declare a `check` on that policy. + - **The platform's `_self` policies.** These have `operation: 'all'` and `using: user_id == current_user.id`. They now refuse a write that sets `user_id` to another user, on the self-service tables a member may write: `sys_user_preference`, and the revoke patch on `sys_api_key`. A member can no longer create a preference row for someone else. A member can no longer re-own their API key by adding `user_id` to a revoke patch. Before this change both writes were admitted, and the second one had `user_id` stripped later. + - **Re-pointing `created_by` under the ownership floor.** This is a by-id UPDATE that changes `created_by` while the floor still applies to that write. The readonly strip used to remove the new value, and the update was admitted. It is now refused with 403. The new row is judged before that strip runs (#16790). + - **A `using` that does not compile.** This applies to an `insert` or `all` policy that has only a `using`. That `using` is now also the insert check, and the policy fails closed: every insert it governs is refused. Before this change the insert was admitted, because no check ran. + + **What does not change.** + + - If any applicable policy declares `check`, only the declared checks decide, exactly as before. A policy with only a `using` alongside them adds nothing to the check. + - The platform's ownership floor (`owner_only_writes`) is part of a defaulted check only when the by-id write gate kept it for that write. A record share at edit depth, a `public_read_write` object, or a covering controlled-by-parent master gate still replaces the floor. Those writes are not refused again on the new row. + - `select` policies never gate a write's new row. + - Bulk updates without a single id are still scoped by the `using` where clause. Their new rows are now checked row by row as well, by the separate multi-row entry (#19950). + - The `modifyAllRecords` bypass on private and platform-global objects still skips the check. +- 7026141: fix(plugin-security)!: an RLS predicate naming an undeclared column now denies in EVERY position and polarity, on the read face and the write face alike (#17042) + + + + **BREAKING** — a fail-open-to-fail-closed narrowing on row-level security. A policy that widened yesterday denies today. Shipped as `minor` under the launch-window convention, the same grading the insert-side `check` post-image narrowing used. + + A predicate naming a column the object does **not declare** could not narrow, and in a **negation-carrying position** it did not deny either — it **widened** the policy to every row inside the tenant wall, and on the write path it **permitted** the write the policy was authored to refuse. + + ⛔ It is **not** a cross-tenant leak. Tenancy is a separate layer and it holds. What was defeated is the narrowing the policy author wrote *inside* the wall — an owner-only or private-record policy silently becoming "every row". + + Two independent sites, each with its own reason, each measured against the same two controls (a real column must still narrow; the *same* phantom column in a **positive** position must still refuse): + + - **Read face.** `extractTargetField` is a **leading-only** `==` / `=` / `in` shape match, so `nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])` and any arm after the first returned `null`; the policy was **kept**, the drop counter never incremented and the deny sentinel never armed. The kept filter then met the settled include-direction ruling — a row that *has* no such column satisfies "column != x". Measured on the matcher: **3 of 3** rows for each negated shape, against **1 of 3** for the real narrowing and **0 of 3** for the same phantom column in a positive position. + - **Write face — the worse one.** `computeWriteCheckFilter` compiled `check` clauses with **no field-existence check at all**, and the ADR-0058 D4 post-image gate evaluates that filter in-process. Measured end to end on both SQL drivers: every negated phantom **permitted** the insert, in both post-image polarities, while a positive phantom refused (by accident of an absent value comparing unequal) — which is why a suite that only ever exercised the positive shape stayed green over the hole. + + **The repair is one seam, not two.** `RLSCompiler.compileFilter` — the single choke point both the read layer and the write gate already pass through — now takes the object's declared-column set and judges every column the policy names on the **compiled** `FilterCondition` tree. That is positional-agnostic by construction: the pushdown compiler lowers `!` to `$not`, `||` to `$or` and `&&` to `$and`, so a column lands as a plain object key whatever position it was authored in, and there is no spelling of negation left for a shape match to miss. Widening the regex instead was rejected: a matcher that must enumerate every spelling of negation is the same "recognises only what it was told about" defect one level over, and it would additionally have broken the ADR-0095 carve-out that *depends* on the regex recognising only the leading shape. A policy dropped this way joins the existing fail-closed path — same deny sentinel, same WARN line — rather than growing a parallel mechanism. + + ⛔ **The matcher's include-direction ruling is untouched.** A row lacking a column *does* satisfy "column != x" for an ordinary user query, and re-semanticing every filter in the repo to fix one caller is not the trade. The defect was that a policy compiler lowered an undeclared column into a filter at all; the matcher now never sees a phantom, and a regression test pins the raw matcher still answering 3 of 3 for the same filter so a later reader can see which half moved. + + **Who is affected.** Only a permission set carrying an RLS policy whose predicate names a column its object does not declare — an authoring mistake `@objectstack/lint` already reports on all of these shapes. For such a policy the object now returns **zero rows** for every holder of the set (read) and refuses every governed insert / update (write), where before a negated spelling returned everything and permitted everything. ⚠️ **An installation relying on such a policy to grant access will lose that access at the upgrade, and that is the intended direction**: what it was "granting" was the absence of enforcement. Correct the column name; the linter names the miss and offers the object's real field list. + + **driver-sql, previously unmeasured, is now measured, and it refines the picture.** On the **read** face `driver-sql` and `driver-sqlite-wasm` never widened — they failed closed by **raising** `INVALID_FILTER` / 400 when the phantom column reached the statement builder, so the read-face defect was driver-dependent (in-process matchers widened; SQL raised). On the **write** face they failed open exactly like every other driver, because the `check` is evaluated in-process and never reaches SQL. After this change both faces answer uniformly on both drivers. `driver-mongodb` remains inferred from the shared ruling rather than measured. + + `@objectstack/lint`'s diagnostic for this miss is corrected in the same change. Its **detection is unchanged** — all the negated shapes were already reported. Its consequence text was stale in one half and misattributed in the other: it described the field miss as having two directions decided by position, and it credited the write leg's fail-closed to a safety net that path never had. It now states one direction for both clauses, and records the older runtime's fail-open write behaviour explicitly so an operator reading it against a deployment that predates this guard is not told the wrong thing. + +### Patch Changes + +- 4efb988: `seed-name-lookup.ts` — the batched seed existence read's OWN failure now reaches the author when no logger was injected, through the one delivery derivation the package already owns (#18570). + + The oracle every declared-metadata seeder consults hoists one `$in` read out of its loop and degrades to the per-item read when that read cannot answer — an outage, or a page proven to be a prefix of the answer. It reported that degradation through a doubly-optional `logger?.warn?.(…)`, which evaluates to NOTHING when the caller injected no sink: the read failed, the pass silently switched to the slow path, and no human was told. + + Measured differentially rather than read off the code, in this package's own `bootstrap-declared-capabilities` control harness: an unreadable-database pass with **no logger** printed exactly **one** author-visible line — the seeder's own end-of-pass summary — while the batched read that failed *first* said nothing. With this change the same pass prints **two**, and that assertion is now the pin (`toHaveLength(1)` → `toHaveLength(2)`, both lines selected by content). + + - **Delivery only.** The wording, the structured meta (`object`, `names`, `rowBudget`, `organization`) and the two named causes — `unreadable` and `truncated` — are axis-specific and stay at the call site, which is the split `seed-refusal-sink.ts` documents. ⛔ No sixth hand-written copy of the rule, and ⛔ no generic refusal sentence. + - **A read that ANSWERED stays silent on every channel**, with or without a sink — the discriminating control that keeps a healthy boot quiet. + - **It also stops a throw.** `logger?.warn?.(…)` guards `null`/`undefined`, never a non-callable `warn`: a host that declared one and shipped something else raised `TypeError: logger?.warn is not a function` *inside* the degradation path, turning a slower read into a failed boot. The site now asks `typeof` — the same question `reportThroughSink` asks — so such a host takes the console arm instead. + - **No exported surface moves.** `seed-name-lookup.ts` is package-private (`src/index.ts` re-exports nothing from it) and `SeedLookupLogger` is unchanged, both members still optional. +- ef256e6: `PermissionEvaluator.checkObjectPermission` and `buildAccessMatrix` now ASK `@objectstack/spec`'s `objectPermissionGrants` instead of restating the super-user fold — one rule, one definition (#18785). + + "Does this effective object permission grant this verb?" had three independent implementations: the spec helper published in 17.4, the enforcement door in `@objectstack/plugin-security`, and the access-matrix snapshot in `@objectstack/lint`. A differential over the full input space — every declared object-permission bit (`allowCreate` / `allowRead` / `allowEdit` / `allowDelete` / `allowTransfer` / `allowExport` / `viewAllRecords` / `modifyAllRecords`) in all three authorable states, 6561 entries by 6 verbs — found **zero** disagreements, so this is a structural convergence and **no behaviour changes**. + + - **No API change, no bit changes meaning.** The read bypass is still `viewAllRecords || modifyAllRecords`, the write bypass is still `modifyAllRecords` alone, `allowCreate` still has no super-user bypass, and `export` is still `grant ∧ read`. + - **The export door keeps its cross-set shape.** `checkObjectPermission('export', …)` still asks `(∃ set granting export) ∧ (∃ set granting read)` across the resolved set list — the same answer the `/me/permissions` most-permissive merge hands the client. Folding it per set would have narrowed the door. + - **Both consumers are pinned to the fold independently of the helper**, so a change to one cell of `objectPermissionGrants` reddens them rather than propagating silently. +- 8f6d831: fix(plugin-security): the `sys_permission_set` duplicate-name refusal carries `UNIQUE_VIOLATION`, and the packaged-set lock answers first (#19307) + + Clause-②: yes + + Two halves of one defect on the data door's insert leg for `sys_permission_set` + (`permission-set-projection.ts`), both measured live on `examples/app-showcase` + with a seeded admin over a cookie session. + + **1. The refusal carried no machine-readable code.** It threw a bare `Error` + with `.status = 409` and no `.code`, and the flat `{ error, code }` responder + invents nothing for a producer that declared nothing, so the client got prose: + + ``` + POST /api/v1/data/sys_permission_set {"name":"dev_local_set"} + → 409 {"error":"[Security] permission set 'dev_local_set' already exists","object":"sys_permission_set"} + ``` + + ADR-0112's 2026-08-17 amendment closed `error.code` at the flat door too, so a + 409 with no code is that contract unhonoured — and a UI that has to branch on + the refusal was pushed back to string-matching. The same request now answers + `409 … "code":"UNIQUE_VIOLATION"`, message byte-identical. + + ⚠️ `UNIQUE_VIOLATION` is REUSED, not minted. `sys_permission_set` declares + `{ fields: ['name'], unique: 'organization' }`, so this very collision already + answers `409 UNIQUE_VIOLATION` when the index catches it instead of this + pre-check; a second spelling would make one condition answer two envelopes + depending only on which layer got there first. The ledger gains a provenance + row for `@objectstack/plugin-security` — the union, its casing and every other + package's rows are unchanged, and no schema shape moves. + + **2. It ran BEFORE the packaged-set lock, so the most likely path answered the + less useful of two true refusals.** A package-declared set has a projected row, + so its name is duplicate AND locked at once. An admin who opened the Clone + dialog on a packaged set and typed the base set's own name — the single most + likely thing to type — got `already exists`, which names no remedy, and never + reached `NOT_OVERRIDABLE`, which names the clone path. The lock now runs first: + + ``` + POST /api/v1/data/sys_permission_set {"name":"showcase_manager"} + → 403 {"error":"[Security] Permission set 'showcase_manager' is declared by package + 'com.example.showcase' and is locked … Choose a different name for your set, or clone + 'showcase_manager' …","code":"NOT_OVERRIDABLE","object":"sys_permission_set"} + ``` + + **What did NOT move**, measured on the same runtime: an ordinary + (non-package-declared) duplicate **whose provenance the lock can resolve** still + answers the duplicate refusal and not `NOT_OVERRIDABLE` — that qualifier is + load-bearing, and the corner below is the case it excludes; an unauthenticated + write on the same resource still answers `401 UNAUTHENTICATED`; and an `update` + targeting a packaged set answers `403 NOT_OVERRIDABLE` exactly as before. + + ⚠️ **One corner moved with the order**: an ordinary duplicate attempted while no + artifact source can answer now takes the lock's fail-closed `unknown` refusal — + `403` `NOT_OVERRIDABLE` (`PackagedPermissionSetProvenanceUnknownError`, "retry + once the metadata layer is readable") — instead of the 409. Both are refusals and + neither writes; it is pinned so the behaviour is declared rather than incidental. + + ⚠️ **And the order has a cost, stated rather than discovered**: the lock's probe + (`protocol.getMetaItemLayered`) used to be evaluated only AFTER the duplicate + check passed, so a duplicate insert never paid for it. It is now evaluated + unconditionally, ahead of that check. Two consequences, both deliberate: every + **duplicate** insert on `sys_permission_set` costs one extra metadata round trip + (the accepted path's cost is unchanged — it always paid this probe), and the + duplicate path is now COUPLED to metadata-layer reachability, where before it + answered from the record alone. That coupling is the mechanism behind the corner + above, and it is the price of putting the refusal that names the remedy first. +- a5afe38: The delegated-administration gate resolves a scope's business-unit anchor **inside the caller's own organization**. In a single-database multi-org posture (ADR-0105 D1 `group` / `isolated`) a unit name shared by two organizations no longer crosses the boundary in either direction (#19775). + + `sys_business_unit.name` carries no uniqueness — the object's only unique index is `(code, organization_id)` — yet the gate looked the anchor up by name alone under a bare `{ isSystem: true }` context, which carries no tenant. The engine threads a tenant to the driver only when `execCtx.tenantId` is defined and `SqlDriver.applyTenantScope` returns early without one, so nothing scoped that read: a `limit: 1` lookup answered whichever id the driver ordered first, and which organization won was an id ordering. Measured on a real engine over a real SQL driver, with two organizations each holding a unit called `sales`, both directions were wrong at once — the delegate **lost its own subtree** (denied inside its own unit) while the gate **approved** a delegated write anchored in the other organization, and `describeDelegableScope` handed that organization's unit ids back to the caller. + + - **What changed**: the anchor read, the descendant walk and the two catalog reads behind `describeDelegableScope` now carry the caller's organization — `organizationId ?? tenantId`, the same spelling the permission-set load already resolves a caller's authority with — and the candidates that come back are reduced to the caller's own rows. Both arms are load-bearing and each was measured to be: the driver's compatibility arm deliberately also returns organization-less rows, and a driver with no tenant scoping at all returns every organization's. + - **Fail closed**: an anchor that resolves to no unit of the caller's own organization now approves nothing, exactly as a misconfigured scope already did. Under a walled posture this also refuses an **organization-less** business unit, which that posture already treats as invalid state; a delegation anchored on one stops resolving and must be re-anchored on a unit the organization owns. + - **`group` posture**: the anchor resolves in the caller's **active** organization, not their whole membership set — the narrower of the two, and the one the caller's permission sets (and therefore the `adminScope` itself) were already loaded in. + - **Unchanged where there is no boundary to cross**: a caller carrying no organization (the `single` posture) keeps the by-name answer it had. + + No exported symbol and no payload key was added: `describeDelegableScope` and `scopesCoverUser` take the caller's context as a new optional argument, and omitting it resolves exactly as before. +- 0e90a8d: The delegated-administration gate resolves a position name **inside the caller's own organization** when it decides whether that position may be self-delegated and which permission sets it distributes. In a single-database multi-org posture (ADR-0105 D1 `group` / `isolated`) a position name shared by two organizations no longer lets one organization's row answer for the other. + + `sys_position` is a per-organization catalog and its `name` carries no installation-wide uniqueness, yet the two position reads behind those decisions looked the row up by name alone, `limit: 1`, under a bare `{ isSystem: true }` context carrying no tenant — so whichever id the driver ordered first answered. Measured on a real engine over a real SQL driver, with the other organization's ids sorting first: a holder could **self-delegate a position their own organization never marked delegatable**, because the other organization's same-named row was; and a delegated administrator could **assign a position whose own bindings hand out a permission set outside their allowlist**, because the other organization's bindings were the ones checked — while positions their own organization bound correctly were refused. + + - **What changed**: both reads now carry the caller's organization (`organizationId ?? tenantId`, the spelling the business-unit anchor read already uses) and keep only the caller's own row out of what comes back. The self-delegation check, the delegated-assignment allowlist and containment checks, and the `assignablePositions` list of `describeDelegableScope` all read the caller's own position. + - **Fail closed**: a position name with no row in the caller's organization is not delegatable and distributes no permission sets — never another organization's row. Under a walled posture an **organization-less** `sys_position` row no longer answers either question, as that posture already treats such a row as invalid state. + - **Unchanged where there is no boundary to cross**: a caller carrying no organization (the `single` posture) keeps the by-name answer it had. + + No exported symbol, payload key, error code or refusal message was added or changed. +- 55cd8d4: fix(plugin-security): `security/explain` computes a record's `update` / `delete` verdict from the by-id write path's own inputs, so `record.visible` matches what the by-id PATCH / DELETE does (#19963) + + Clause-②: no + + `POST /api/v1/security/explain` with `{ object, operation: 'update' | 'delete', recordId }` answered `decision.record.visible: false` (`decidedBy: 'rls'` or `'sharing'`) on rows that the by-id `PATCH` / `DELETE /api/v1/data/{object}/{id}` then admitted for the same caller. It happened on every object whose OWD is private (set explicitly, or left unset). A console that gates Edit on `record.visible` hid Edit and inline edit from users who were allowed to edit. Two inputs differed from the write path: + + - **The platform ownership floor.** The by-id write gate drops `owner_only_writes` / `owner_only_deletes` (`created_by == current_user.id`) when the sharing service answers `allow` for the row. Explain kept the floor, so it excluded every row the caller did not create. That covers a row shared to them with `edit` access, a row they own but did not create, and a row an `org`-depth writer may edit. + - **The write depth.** The write path hands the sharing service's per-record gate (`canEdit` / `canDelete`) the caller's effective write depth. Explain asked the same gate without it, so a caller with `org` or unit write depth was judged owner-only. + + Explain now asks the same floor decision the write gate asks. It also passes the same write depth to the per-record gate. + + Unchanged: + + - Enforcement: the by-id write gate admits and refuses exactly what it did before. Its floor decision moved into one method that both paths call. + - Reads (`operation: 'read'`) and object-level explanations (no `recordId`). + - A caller acting on behalf of another user (`onBehalfOf`): its record-level write explanation uses the same inputs as before. + - Objects whose OWD is `public_read_write` already matched and still do. +- b9e9609: fix(plugin-security): `security/explain` asks the sharing read filter with the caller's read depth, so a record's `read` verdict matches what the caller's `find` returns (#19986) + + Clause-②: no + + `POST /api/v1/security/explain` with `{ object, operation: 'read', recordId }` answered `decision.record.visible: false` (`decidedBy: 'sharing'`) on rows that the same caller's `find` returned. It happened on every object whose OWD is private (set explicitly, or left unset), for a caller whose read depth is wider than `own`. The find path hands the sharing service's read filter the caller's effective read depth. Explain asked the same filter without it, so a caller with `org` read depth was judged owner-only on every row it did not own, and a caller with unit read depth was judged owner-only on rows its unit owns. + + Explain now passes the same read depth, computed the way the find path computes it. A read depth already present on the explained context no longer decides the report. + + Unchanged: + + - Enforcement: `find` admits and refuses exactly what it did before. + - Writes (`update` / `delete`) and object-level explanations (no `recordId`). + - A caller acting on behalf of another user (`onBehalfOf`): its record-level read explanation uses the same inputs as before. + - Objects whose OWD is not private already matched and still do. +- 8e9a425: fix(plugin-security): `security/explain` fails closed when a dependency it shares with enforcement throws, so a request that fails is no longer reported as allowed (#20002) + + Clause-②: no + + `POST /api/v1/security/explain` calls the same functions as the enforcement middleware. Enforcement does not catch a failure in them, so the request fails. The explain engine caught the same failure and turned it into a value that it then read as an answer. So when the sharing service's share store was unavailable, `{ object, operation: 'read', recordId }` answered `decision.record.visible: true` (`decidedBy: 'sharing'`, sharing layer `admitted`), and the caller's `find` for the same row threw. Four call sites had this problem: + + - **The sharing read filter** (the reported case). A failure became `null`, which the record matcher reads as "no filter". So an unshared row, a shared row, and the caller's own row were all reported visible. + - **The sharing service's per-record `update` / `delete` gate.** A failure became "no gate wired", so ownership, a `read` share or the OWD answered a write that the by-id `PATCH` / `DELETE` then failed on. + - **The layered row-level security composition.** A failure became "no tenant wall and no business RLS". The row was reported visible, while `allowed` was `false` because of the same failure. + - **An on-behalf-of delegator whose grants could not be read.** A failure became "no delegation", so the agent's own grants decided alone and `allowed` was `true`. The same `find` answered `503 SERVICE_UNAVAILABLE`. + + Each failure is now reported as it happened. The affected layer's `record.outcome` is `not_evaluated`, with no `rowFilter` and no `matchesRecord`, and its `detail` says the layer could not be evaluated. `record.visible` is `false`, and `decidedBy` names the layer that failed: `sharing`, or `rls` for the composition. For the delegator case, the `principal` and `object_crud` layers deny and `allowed` is `false`. The response has no new keys, and `not_evaluated` is an existing outcome value. + + Unchanged: + + - Enforcement admits and refuses exactly what it did before. + - A dependency that answers is reported exactly as before. That includes a read filter that answers "no restriction", such as an `org`-depth reader's `null`. + - Failures that already failed closed are unchanged: record fetch, share listing, and permission-set resolution for the principal or the delegator. +- d4c897e: fix(plugin-approvals, plugin-security, service-messaging, service-realtime): nine system objects that relied on `titleFormat` declare a title pointer, so their record title is no longer the raw id (#20044) + + Clause-②: no + + ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation, and an explicit `nameField` takes precedence over the render-only `titleFormat`. Nine system objects declared a `titleFormat` and no pointer. When such an object is registered, the registry's designate-only pass picks the first title-eligible field as `nameField`, and for these nine that field is `id`. A `/meta` read serves that pointer as if it had been declared, so a renderer that follows ADR-0079's order showed the raw record id as the record page's title. + + Eight of the titles are composites. Each of those objects now declares `display_title`, a formula field with `returnType: 'text'` over the same columns, and points `nameField` and `displayNameField` at it: + + - `sys_approval_delegation`: `{delegator_id} → {delegate_id}`; + - `sys_position_permission_set`: `{position_id} → {permission_set_id}`; + - `sys_user_permission_set`: `{user_id} → {permission_set_id}`; + - `sys_user_position`: `{user_id} → {position}`; + - `sys_notification_delivery`: `{channel} → {recipient_id}`; + - `sys_notification_preference`: `{user_id} · {topic} · {channel}`; + - `sys_notification_subscription`: `{principal} · {topic}`; + - `sys_presence`: `{user_id} ({status})`. + + `sys_notification_receipt`'s title is the single column `{state}`, so its `nameField` and `displayNameField` now name `state` directly. + + This is the migration the `titleFormat` schema text prescribes: "Migrate a single-field title to nameField, a composite to a formula field designated as nameField". The record title is now the text the `titleFormat` described. Every column these titles read is required, so the formulas carry no null guard. Each formula reads only its own row's columns, never a field of a looked-up record. + + A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads and write responses of the eight objects now carry `display_title`, and the server-side title accessor (`resolveRecordTitle`) returns the title text instead of the raw id. No row scope, permission set or API method changes. + + `titleFormat` stays on all nine objects, unchanged, for renderers that still read it first. The set of fields `$search` scans is unchanged: a formula field is never a search target, and neither was `id`. On `sys_notification_receipt`, `state` was already in the set and now leads it. No search-companion column is provisioned for any of the nine. + + The new `display_title` label and help text are in each package's English bundle. The zh-CN, ja-JP and es-ES bundles carry the generator's English fill for them, recorded in the source-hash companions. +- b940f32: The delegated-admin gate now counts a `sys_user_position` holding only where the runtime grants it. Self-delegation's "you currently hold this position" check (ADR-0091 D3 rule 4) no longer accepts a holding stamped for a different organization — a user can no longer self-delegate a position in an organization where they hold nothing just because they hold a same-named position elsewhere. Organization-less holdings still count, exactly as the runtime authz resolver grants them in every organization. The binding blast-radius check (ADR-0090 D12) likewise counts only the assignments the bound position row reaches — its own organization's plus organization-less ones — so another organization's same-named assignments no longer refuse a binding as outside the subtree or push it over the assignment cap. An organization-less (`single` posture) caller is unchanged. +- f9e16d8: `DELETE /api/v1/data/sys_permission_set/{id}` stops reporting a deletion it did not perform. + + A package-declared permission set cannot be deleted from an environment: its delete is an + ADR-0005 RESET — the overlay tombstones and the record re-projects to the declared body. + That behaviour is unchanged and deliberate. What was wrong is the answer: the door replied + `200 {"object":…,"id":…,"success":true}`, byte-identical to a real deletion, so a caller + that meant to revoke a permission set was told it was gone while it was still enforced, and + a UI fired a success toast and showed the row again on refresh. + + The write-through's delete leg now reports how many of the addressed records actually went, + and `deleteData` maps that onto the already declared `success` key instead of hard-coding + `true`. No key is added to `DeleteDataResponseSchema`. + + On the wire: + + - packaged set — `200 {"success":false}`, the record still present with the same id (was + `success: true`); + - environment-authored set — `200 {"success":true}`, the record really gone (unchanged); + - unknown id — `404 RECORD_NOT_FOUND` (unchanged: zero-removed is deliberately not read as + not-found, because the record is still there to GET). + + The read-back that decides this is fail-closed: a read that cannot answer reports the record + as NOT deleted and warns on the durability channel, because "the read failed" and "the row is + gone" are opposite facts and only the second may claim a deletion. + + Clause-②: no +- fb7d75f: Tell a read that DID NOT ANSWER apart from a read that answered NOTHING at two boot-reconciler seams, so a transient storage fault can no longer withdraw a standing org-admin grant or report an unreadable catalog as an already-canonical one (#15840). + + `reconcileOrgAdminGrant`'s `sys_member` read swallowed a fault into `[]`, and `[]` is what that function reads as "this user is not an admin of this organization" — the input to a DELETE. One transient read fault therefore revoked a sitting admin's standing grant, and the store kept it withdrawn after the fault cleared; only a `debug` line separated that run from a healthy one. That read now reports at `error` and returns `{ action: 'skipped', reason: 'membership_unreadable' }`, performing no write at all for the pair: nothing is granted, so nothing widens, and nothing standing is destroyed. The next `sys_member` write and the `kernel:ready` backfill ask again. + + `normalizeManagedByVocab` swallowed a catalog read fault into `[]` too, so an unreadable catalog and an already-canonical one were byte-identical on both channels — the same `{ positions: 0, permissionSets: 0 }` and zero log lines at any level — while the row that needed healing stayed legacy. A read that does not answer now reports at `error` and refuses the pass instead of attesting counts it could not read. The refusal aborts at the first un-answered read, so it is one line per refused boot rather than the four the report-and-continue shape measured. Its only production consumer already declared the handling: the `kernel:ready` bootstrap catches it, reports it at `warn` as non-fatal, and boot proceeds. + + ⭐ Per-site, not a sweep. A genuine EMPTY read keeps today's behaviour EXACTLY at both seams — a demotion with no membership row still revokes, a membership still grants, an already-canonical catalog still answers `{ positions: 0, permissionSets: 0 }` in silence. `claim-seed-ownership.ts` is untouched: its fault already propagates to a per-predicate handler that reports at `warn` and names the consequence, which is the right disposition already. The plugin's other reads keep their existing best-effort contract, where an unanswered read costs a grant that is not created rather than one that is destroyed. + + No exported symbol, published payload key or spec path changes: `action: 'skipped'` is already in the returned union, `reason` is already free text, and the two logger option types gain an optional `error` method a caller may omit. Healthy-path behaviour is byte-identical; only the fault path moves. +- 470746a: fix(security): resolve `current_user.accessible_org_ids` into the RLS variable bag (#16518) + + `patch` — a bug fix in a released package. No API signature changes, no exported + symbol added, no spec or ADR edit: the contract already promised this, and only + the line that delivers it was missing. + + ## What was wrong + + `packages/spec/src/contracts/rls-membership-resolver.ts` does not merely reserve + the name `accessible_org_ids`. It declares the field's SHAPE (`:53`, + `accessible_org_ids?: string[]`), states at `:35` that the key is CORE-resolved + and not an app resolver, and lists it at `:70` in + `RESERVED_RLS_MEMBERSHIP_KEYS` — so an app's membership resolver is refused when + it tries to supply the set itself. `ExecutionContext.accessible_org_ids` goes + further and names the RLS spelling outright: *"RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`"*. + + `RLSUserContext` declared `id`, `organization_id`, `positions`, `org_user_ids` + and `email`, and nothing copied `accessible_org_ids` out of the execution + context. So the key was reserved on the grounds that core resolves it, and core + did not resolve it — a slot with a declared shape and no filler, which is the + ADR-0049 "declared but unenforced" shape. + + **The cost is the invisible one.** A predicate such as + `employer_org IN (current_user.accessible_org_ids)` compiled to an unresolved + variable, every applicable policy dropped out, and `RLS_DENY_FILTER` returned + **zero rows with no error raised**. Nothing failed. An empty list is + indistinguishable from "this user really has no data", which is how the shape + survived three green static gates and, in the reporting app, left ten policies + across six objects inert — the entire multi-tenant isolation model. + + The failure direction is **closed**: zero rows, never a cross-tenant read. This + is a usability and declared-means-enforced defect on a security surface, not a + leak. + + ## What it does now + + `RLSCompiler.compileFilter` copies `ExecutionContext.accessible_org_ids` into + `RLSUserContext`, following `org_user_ids`' precedent exactly — both are + core-resolved membership sets the runtime **pre-resolves**, precisely so this + compiler never has to issue a subquery. The compiler is unchanged otherwise; it + already handled the value correctly once present. + + The producer already existed and is unconditional: `resolve-authz-context.ts` + types the set as required and `assemble-execution-context.ts` copies it on every + face, in every posture (*"in `single` posture the set is resolved but no wall + consumes it"*). Only the consuming line was missing. + + One consequence worth naming: **reserved now means reserved at the compiler + too.** `stageRlsMembership` screens reserved keys out of a *resolver's* answer, + but a bag already present on the context was spread through unscreened, and + landed in the variable bag because nothing named the field. Now that the kernel + names it, the compiler's own "a membership key never clobbers a named field" + rule covers it and the kernel's value wins. + + ## Measured, end to end + + A rig on real drivers (`driver-sql`, `driver-sqlite-wasm`), six rows across + three organizations, a caller holding membership in two of them: + + | predicate | before | after | + |:--|--:|--:| + | `employer_org IN (current_user.accessible_org_ids)` | **0 of 6** | **4 of 6** — the rows of both orgs | + | same, caller scoped to ONE org | 0 of 6 | 2 of 6 — that org only | + | same, caller with no set / an empty set / an org with no rows | 0 of 6 | 0 of 6 — unchanged, still fails closed | + | a predicate naming a NON-EXISTENT variable | 0 of 6 | 0 of 6 — unchanged (#16119's face, untouched) | + | `org_user_ids`, `organization_id`, `email`, `id`, an app membership key | — | byte-identical | + + An app **could** work around the defect by supplying the same set under its own + unreserved key through `rlsMembership` and rewriting its predicates to + `current_user.my_org_ids`; that reads 4 of 6 on the same rig, before and after. + The workaround costs every app a membership-resolver registration it should not + need and moves every predicate off the documented spelling — and it is no longer + necessary. +- ac24458: security(rls): the RLS compiler refuses `RESERVED_RLS_MEMBERSHIP_KEYS` by name + + A caller-supplied `ExecutionContext.rlsMembership` entry could supply a RESERVED + kernel key — `id`, `organization_id`, `positions`, `org_user_ids`, + `accessible_org_ids`, `email` — whenever the kernel had not resolved a value for + that key on the request. `RLSCompiler.compileFilter` admitted a membership key on + the test `userCtx[key] === undefined` ("did the kernel happen to resolve one"), + not on whether the key is reserved, so an absent kernel value handed the name to + the bag. + + The direction was widening. With the key unresolved, the predicate referencing it + fails CLOSED — it joins the dropped-policy path and the compile returns the deny + sentinel, which yields zero rows. The bag instead produced a satisfiable filter + over caller-chosen values, converting a denial into a match. + + The merge now refuses reserved keys by name, at the one seam both faces pass + through (the read layer compiles `using` there, the ADR-0058 D4 write gate + compiles `check` there). `stageRlsMembership`'s existing screen covers only the + registered resolver's answer, and only when a resolver is registered at all — it + returns at its first line otherwise — so it could not carry this guarantee. + + No behaviour change for non-reserved membership keys, and none when the kernel + did resolve the reserved value: the kernel's value already won, and still does. + A refused key simply stays unresolved, so its policies drop out and fail closed + through the reason vocabulary that already exists. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [7465eeb] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 18dbc3dff6a..6f96c6ab228 100644 --- a/packages/plugins/plugin-security/package.json +++ b/packages/plugins/plugin-security/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-security", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Security Plugin for ObjectStack — RBAC, RLS, and Field-Level Security Runtime", "main": "dist/index.js", diff --git a/packages/plugins/plugin-sharing/CHANGELOG.md b/packages/plugins/plugin-sharing/CHANGELOG.md index bcfab501d62..0c38d94d616 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,719 @@ # @objectstack/plugin-sharing +## 17.5.0 + +### Minor Changes + +- 9347c1f: A row-level or sharing-rule predicate comparing a field against a list with `!=` / `==` is refused at the CEL lowering instead of lowering to a filter that widens on driver-mongodb, and driver-mongodb refuses `$ne` with an array comparand (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/driver-mongodb`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-list-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS reads on MongoDB and RLS write checks.** A policy written `record.status != ['closed', 'archived']` (or `!(record.status == [...])`, or `!=` against a `current_user` membership set) lowered to `{ status: { $ne: [...] } }` (or `$not` around a bare-array equality). The RLS `using` clause is composed into the query after the engine's comparand-shape check, and driver-mongodb passed the shape to the server, where it selects every scalar row: the read returned the rows the policy was written to hide. A `check` written `!=` against a membership set admitted every write. + + - `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose comparand is a list (`unsupported`): a list literal, or a `current_user` variable that resolves to an array. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports the literal; a resolved array is refused per request. + - `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403). A CEL-authored `check` gets this 403; the `INVALID_FILTER` / 400 of `matchesFilterCondition` remains for a filter passed to it directly. + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. + - `@objectstack/lint`: the list-literal form is reported (`rls-predicate-unenforceable`, `sharing-rule-unlowerable-condition`). The RLS reference pass probes each kernel-resolved `current_user` key with its runtime type. + - `@objectstack/driver-mongodb`: `translateFilter` refuses `$ne` with an array comparand at any depth, with `INVALID_FILTER` / 400, as driver-sql and driver-memory already do. + - `@objectstack/spec`: the migration registry carries the entry. + + **What to change.** "One of these values" is `record.status in ['open', 'pending']`; "none of these values" is `!(record.status in ['closed', 'archived'])`. In a raw filter, use `$in` / `$nin`. `in`, scalar `==` / `!=`, `null` and field-to-field comparisons are unchanged. + + +- 4d7e740: A row-level or sharing-rule predicate whose comparison is handed something other than one value is refused at the CEL lowering or at the write-check evaluator, instead of admitting writes and reads it was written to refuse (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing`, `@objectstack/lint` and `@objectstack/objectql` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS write checks and reads.** Each shape below was measured through the real plugin-security on driver-sql and driver-memory: + + - `!(record.status in [['closed', 'archived']])` (a list nested in an `in` list) admitted and stored every write the `check` was written to refuse, and a `using` read returned every row on driver-memory. + - `current_user.org_user_ids != 'x'` and `current_user.org_user_ids > 'a'` (a membership set on a comparison with no field) folded to "no restriction": every write admitted, every row read, on every driver. + - `record.status > ['m']` compared the list as the string `'m'` on the write check, while the analytics read scope bound the whole list as one SQL parameter. `record.reviewer_id > current_user` compared the whole caller object as a string and admitted and stored every write; in this release the RLS compiler's comparand faces (#20212) already drop that policy, and this change refuses it at the lowering for every caller of the compiler. + - `record.status != record.tags`, its negation `!(record.status == record.tags)`, and the mirror `record.tags != record.status`, with `tags` a `json` field or a `multiple` lookup, admitted and stored every write. + + What changes: + + - `@objectstack/formula`: `compileCelToFilter` refuses, with `unsupported`, a list comparand under every comparison (the ordering operators now included, and on the constant-fold branch, whichever side), the `current_user` root or a key resolving to an object under an ordering operator, and an `in` list whose member is itself a list. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports each literal form; a resolved value is refused per request. `matchesFilterCondition` refuses, with `INVALID_FILTER` / 400, an array under `$gt` / `$gte` / `$lt` / `$lte`, an array member of `$in` / `$nin`, and a `{ $field }` comparison (`$eq`, `$ne` or an ordering operator) whose column holds a list or an object on the record being judged, on either side. The message withholds the field, the operator and the value. + - `@objectstack/plugin-security`: the RLS compiler drops a policy the compiler refuses and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, and `getReadFilter` hands the analytics read scope the deny scope). A `check` comparing a field with a list-holding column is refused 400 and stores nothing. + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. + - `@objectstack/lint`: the literal forms are reported as `rls-predicate-unenforceable`, and an ordering comparison against a membership set through the reference pass. + - `@objectstack/objectql`: a `having` comparison against a `{ $field }` column whose aggregated row holds a list is refused 400 where the row carries the list itself (driver-memory); driver-sql rows carry the stored JSON text and compare as before. + - `@objectstack/spec`: the migration registry carries the entry. + + The stage 2a changeset's sentence that `{ $field }` references evaluate as before no longer holds for a column holding a list or an object: that comparison is now refused. + + **What to change.** "One of these values" is `record.status in ['open', 'pending']`, and "none of these values" is `!(record.status in ['closed', 'archived'])`, with the list flat. An ordering takes one bound (`record.status > 'm'`); a range is two comparisons joined by `&&`. Compare against one key of the caller (`record.reviewer_id > current_user.id`). A field compared with a `json` or `multiple` field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook. In a raw filter, use `$in` / `$nin` with flat lists and one bound per ordering operator. + + Not changed: a field compared with a `json` or `multiple` field still lowers and is not reported at authoring time, because the lowering sees the predicate's text and not the object's field types; driver-memory still answers a `{ $field }` comparison on a read without evaluating the reference. + + +- 560b724: A row-level or sharing-rule predicate comparing with `!=` / `==` against the bare `current_user` root is refused at the CEL lowering instead of lowering against the whole caller context object (#19959). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-variable-root-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS write checks.** A policy written `record.owner_id != current_user` (or `== current_user`, or `!(record.owner_id == current_user)`) named the variable root alone, which resolved to the whole caller context, and lowered to `{ owner_id: { $ne: } }` (or the bare object, or `$not` around it). A strict compare never equals an object, so a `check` so written admitted and stored every insert and by-id update it was written to refuse, a USING-only such policy admitted every insert, and explain reported the read as narrowed with the caller's membership sets echoed in its `readFilter`. A constant comparison such as `current_user != 'guest'` folded to no restriction. + + - `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose operand is the bare variable root (`unsupported`), in both of its modes, so the authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports it before any request. A variable that resolves to an object is refused per request; a `Date` still passes. + - `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, explain answers `denies`). + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is still skipped at bootstrap, now with reason `unsupported` instead of `unresolved-variable`. + - `@objectstack/lint`: the shape is reported as `rls-predicate-unenforceable` on either RLS clause, where it was silent, and as `sharing-rule-unlowerable-condition` on a sharing condition, where it was `sharing-rule-runtime-variable-condition`. + - `@objectstack/spec`: the migration registry carries the entry. + + **What to change.** Compare against the key the predicate means: `record.owner_id != current_user` becomes `record.owner_id != current_user.id` (or `current_user.organization_id`, `current_user.email`); a membership test is `record.owner_id in current_user.org_user_ids`. Scalar keys, `in`, `null`, literals and field-to-field comparisons are unchanged. + + + +### Patch Changes + +- 7851fa3: docs(plugin-sharing): the `grantsRefused` subtype comment states the NARROWING, not a spec lag (#15712) + + Two comments in this package described a spec/plugin lag that #14969 ended. + `@objectstack/spec` now declares `grantsRefused?: number` on + `SharingRuleEvaluationResult` itself, so "the six declared fields are unchanged" + and "the contract lives in `@objectstack/spec` and is another lane's to move" + read as if the spec were still behind. A reader reconciling the two would + conclude the spec is missing a key it has. + + No code moves. `SharingRuleReconcilePassResult extends SharingRuleEvaluationResult + { grantsRefused: number }` is a legal covariant narrowing before and after, and + that narrowing is now what the prose says: the spec declares the key OPTIONAL on + purpose — an `ISharingRuleService` implementation that does not count refusals + leaves it ABSENT, and absent is not `0` — while this implementation always counts + them and therefore requires it. The load-bearing paragraph is kept verbatim: + `grantsRefused > 0` is NOT "the pass failed", it is the pass reporting that it met + a record it cannot grant on and CONTINUED. + + What reaches a consumer: doc comments, and only through the published + `dist/index.d.ts` / `dist/index.d.mts`, where the JSDoc on the exported + `SharingRuleReconcilePassResult` ships (705,069 to 705,528 bytes). The + declaration-only projection of that file, comments stripped, is byte-identical + before and after — no exported symbol added or removed, no key changed — and the + JavaScript outputs (`dist/index.js`, `dist/index.mjs`) are untouched, because the + compiler strips comments from them. +- 97f4f8c: `plugin-sharing` recognises the engine's organization refusal through objectql's own published recognizer instead of a locally re-spelled literal, and the `PROVENANCE_WAIVERS` row that excused that local spelling is retired with it (#16160). + + Clause-②: no + + The waiver carried its own expiry in its `reason`: *removed together with the stamp site when objectql publishes a recognizer*. It does, so both halves land here — `check:error-code-provenance` reconciles a waiver in three directions at once (the `registeredUnder` key still lists the code, the waived package still does not, and the scan still finds a site for the pair), so removing either half alone reddens the gate on the other. + + - **`ENGINE_ORGANIZATION_REFUSAL_CODE` is gone.** It was a `constdef` stamp site in `plugin-sharing/src/sharing-rule-service.ts` for a code this package only ever RECOGNISES — `@objectstack/objectql` is the emitter and already carries the row. The per-grant catch now asks `isSystemWriteOrganizationRequiredError(err)`, and the `warn` that reports an absorbed refusal names `SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE`. Both are imported from `@objectstack/objectql`, which exports them for exactly this: a consumer performs the `code` compare without authoring the string, so it acquires no stamp site of its own and cannot drift from what the engine throws. + - **Nothing about the absorbed set moves.** The catch stays as narrow as it was — one engine refusal absorbed, everything else rethrown unchanged — and `plugin-sharing` still emits this code nowhere: the surviving mention is a structured log field on the refusal it just absorbed, not a refusal envelope of its own. + - **No error-code membership moves.** `ERROR_CODE_LEDGER` and `StandardErrorCode` are untouched; `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` stays registered under `@objectstack/objectql` exactly as before. The only ledger change is one `PROVENANCE_WAIVERS` element, 10 waivers → 9, and the gate's site census 339 → 338 with `listed` unchanged at 322. +- 4be0868: `sys_sharing_rule.recipient_id` now declares `dependsOn: ['recipient_type', 'object_name']` — every sibling field its `recipient-picker` widget actually reads. + + The picker reads two siblings, not one: `recipient_type` picks the mode (a record picker over `sys_user` / `sys_team` / `sys_business_unit` / `sys_position`), and for the `field` recipient kind (#15072) it reads `object_name` to offer that object's user-valued columns. The declaration named only the first. The neighbouring `criteria_json` field already declares `dependsOn: ['object_name']` for its own `filter-condition` widget, so the key is live and correctly used a few lines up — the omission was an omission. + + Nothing was broken at runtime: the form renderer hands widgets the WHOLE watched record as `dependentValues` rather than a `dependsOn`-scoped slice, which masked the under-declaration. A renderer that ever scoped it — which is exactly what this key asks for — would drop the object name and degrade the `field` recipient mode to a plain text input **in silence**, with no error anywhere. This is the declaration catching up with what is read, so the scoping change can never be the one that breaks it. + + The same commit corrects the `recipient_id` docblock: the picker no longer "has no mapping for that kind and degrades to its text input" — the pinned console (`.objectui-sha` 87af769e, which includes objectui#10049 / commit 23b99585) offers the shared object's user-valued columns for the `field` kind, using a "holds users" predicate that is a clause-for-clause copy of this plugin's own `fieldHoldsUsers`. + + Authors and stored rows are unaffected: no key is added, removed or renamed, no value is newly accepted or refused, and no wire byte moves. +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [63b6818] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [17005cc] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [922c755] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [4fef271] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [5dba7f3] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [afc3b64] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [2bbb462] +- Updated dependencies [90ff10a] +- Updated dependencies [3bd221d] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [8490127] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [ae0c90c] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [0b866bf] +- Updated dependencies [c839986] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [009da14] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [aa04ea2] +- Updated dependencies [172b4cf] +- Updated dependencies [4463966] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [b373596] +- Updated dependencies [7465eeb] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [a08e059] +- Updated dependencies [fe677ae] +- Updated dependencies [fc646cf] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [949e99b] +- Updated dependencies [16c5a33] +- Updated dependencies [16c5a33] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [16c5a33] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [cfe2387] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [a78f731] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [c74de10] +- Updated dependencies [db74b16] +- Updated dependencies [2b24b8b] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [c5d6b2b] +- Updated dependencies [2d91c9a] +- Updated dependencies [2f122b6] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [4a1df19] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [9801da1] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [b2b6a06] +- Updated dependencies [8538edf] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [92ea760] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [a016f08] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [0f38ab0] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/objectql@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 2e41707313f..5a70180667d 100644 --- a/packages/plugins/plugin-sharing/package.json +++ b/packages/plugins/plugin-sharing/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-sharing", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Record-level sharing for ObjectStack — sys_record_share + middleware that enforces sharingModel + ISharingService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-webhooks/CHANGELOG.md b/packages/plugins/plugin-webhooks/CHANGELOG.md index 5b6784db0f5..a265d098fe3 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,558 @@ # @objectstack/plugin-webhooks +## 17.5.0 + +### Minor Changes + +- 9a0c0b5: fix(plugin-webhooks): a webhook credential stored as cleartext inside `sys_webhook.definition_json` is refused, at the delivery path and at the write door (#10164) + + Clause-②: no (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner and the ADR-0087 disposition below, never by the level). + + **Webhooks that still carry the legacy cleartext shape STOP DELIVERING.** A + `sys_webhook` row whose signing secret or custom header map exists only as a + `secret` / `headers` key inside `definition_json` — with nothing stored in the + encrypted `signing_secret` / `headers_secret` column — used to be delivered from + that cleartext with a `warn`. It is now refused, dated `2026-09-23`: + + - **Delivery path.** The subscription is PARKED, the same fail-closed shape as an + encrypted credential that cannot be recovered: nothing is sent, and every + matching record change is recorded in `sys_http_delivery` as a `dead` row with + 0 attempts, no signature and no headers. Its `error` names the refusal as + `[VALIDATION_ERROR/400]`, and the drop is reported once at `error`, with + `code: 'VALIDATION_ERROR'`, `status: 400`, `field: 'definition_json'` and the + refused `keys` in the log meta. + - **Write door** (when `WebhookOutboxPlugin` is mounted, the standard mount). A + `sys_webhook` insert or update whose `definition_json` carries a `secret` or + `headers` key, whatever its value, is refused before anything is stored + (`VALIDATION_ERROR` / `400`, with `object`, `field` and `keys` on the error). + That covers a raw `PATCH /api/v1/data/sys_webhook`. It also covers a Setup-form + save that echoes back a legacy blob unchanged. Omitting `definition_json`, or + writing one without those keys, is unaffected. The plugin binds this refusal + itself, and it is not exported from the package entry: a host that composes + `AutoEnqueuer` on its own still gets the delivery-path refusal above, but not + this write-door refusal. + + **Fix.** Both remedies need a registered `CryptoProvider` + (`engine.setCryptoProvider` — `LocalCryptoProvider` in dev, KMS/Vault in + production), because writing a `secret`-typed column is itself refused without + one. With a provider registered, either: + + - restart, and the boot sweep `migrateLegacyWebhookSecrets` moves both values into + their encrypted columns and strips them from `definition_json` in one update. The + subscription re-arms at the next refresh. + - or re-author the webhook yourself. Write the key into `signing_secret` and the + header map into `headers_secret` (a JSON object of string values), then remove + both keys from `definition_json`. + + There is no transition path for a deployment that runs with no `CryptoProvider`. + + Unchanged: authoring. `defineWebhook({ secret, headers })` is written exactly as + before, and the boot materializer still routes each value to its encrypted column. + A row whose encrypted columns are set delivers exactly as before. + + + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [a370073] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [690f083] +- Updated dependencies [e7fea46] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [a9096af] +- Updated dependencies [497655f] +- Updated dependencies [4be4e04] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [879b512] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [564ac2f] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [7e6ca17] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [d4c897e] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [7010085] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [e07eecf] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/service-messaging@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index cad3e3500f6..e008c76a680 100644 --- a/packages/plugins/plugin-webhooks/package.json +++ b/packages/plugins/plugin-webhooks/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-webhooks", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Persistent, cluster-aware webhook dispatcher. Durable outbox + per-partition cluster.lock for exactly-once-ish delivery across nodes. See content/docs/concepts/webhook-delivery.mdx.", "type": "module", diff --git a/packages/qa/dogfood/CHANGELOG.md b/packages/qa/dogfood/CHANGELOG.md index adfe5dcf6fb..0a87a7dd560 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,702 @@ # @objectstack/dogfood +## 0.0.45 + +### Patch Changes + +- Updated dependencies [9a0c0b5] +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [c8a006f] +- Updated dependencies [7843663] +- Updated dependencies [08b213e] +- Updated dependencies [7851fa3] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [9fca8eb] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [917b87e] +- Updated dependencies [482d34d] +- Updated dependencies [e526556] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [ee6fbd7] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [4f1a56b] +- Updated dependencies [f39ea95] +- Updated dependencies [f19dbcf] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [a370073] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [4af758d] +- Updated dependencies [86c5052] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [63b6818] +- Updated dependencies [c9246fa] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [5741ff1] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [c54d8d6] +- Updated dependencies [eea7ccc] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [690f083] +- Updated dependencies [e7fea46] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [a9096af] +- Updated dependencies [497655f] +- Updated dependencies [4be4e04] +- Updated dependencies [c5d270a] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [c81e7ff] +- Updated dependencies [17005cc] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [b5cbfef] +- Updated dependencies [d438b3a] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [cf39b83] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [29a1b3d] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [ad067ad] +- Updated dependencies [a6a1de4] +- Updated dependencies [6e4024c] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [922c755] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [877dc03] +- Updated dependencies [879b512] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [21b7c12] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- Updated dependencies [564ac2f] +- Updated dependencies [4efb988] +- Updated dependencies [8015dc8] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [ef256e6] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [4fef271] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [b49728f] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [d7f7e34] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [841a71e] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [4be0868] +- Updated dependencies [5636641] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [2aac821] +- Updated dependencies [48c91e9] +- Updated dependencies [8dba7aa] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [a754563] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [a90272a] +- Updated dependencies [5dba7f3] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [a5afe38] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [afc3b64] +- Updated dependencies [a6a4361] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [0e90a8d] +- Updated dependencies [ecf90b2] +- Updated dependencies [863a775] +- Updated dependencies [44ce049] +- Updated dependencies [2bbb462] +- Updated dependencies [90ff10a] +- Updated dependencies [e9eb224] +- Updated dependencies [3bd221d] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [d1ca874] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [8490127] +- Updated dependencies [14add48] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [ae0c90c] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [0b866bf] +- Updated dependencies [c839986] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [009da14] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [55cd8d4] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [aa04ea2] +- Updated dependencies [e8f163f] +- Updated dependencies [172b4cf] +- Updated dependencies [b9e9609] +- Updated dependencies [4463966] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [60fdaa9] +- Updated dependencies [ab82001] +- Updated dependencies [7b76fff] +- Updated dependencies [26550c6] +- Updated dependencies [8e9a425] +- Updated dependencies [e7f69db] +- Updated dependencies [b373596] +- Updated dependencies [7465eeb] +- Updated dependencies [246314d] +- Updated dependencies [84156c7] +- Updated dependencies [ed3546f] +- Updated dependencies [7e6ca17] +- Updated dependencies [980bc05] +- Updated dependencies [bf37b99] +- Updated dependencies [e0f17a3] +- Updated dependencies [7766b62] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [7ddf396] +- Updated dependencies [226e00c] +- Updated dependencies [8a44ce7] +- Updated dependencies [d4c897e] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [a8bcce6] +- Updated dependencies [7c1039b] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [a08e059] +- Updated dependencies [6780e34] +- Updated dependencies [536f2d5] +- Updated dependencies [fc646cf] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [949e99b] +- Updated dependencies [16c5a33] +- Updated dependencies [16c5a33] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [16c5a33] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [cfe2387] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [0d3ec47] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [a78f731] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [c74de10] +- Updated dependencies [db74b16] +- Updated dependencies [2b24b8b] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [c5d6b2b] +- Updated dependencies [2d91c9a] +- Updated dependencies [2f122b6] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [4a1df19] +- Updated dependencies [aeb0557] +- Updated dependencies [70ce802] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [df3ba16] +- Updated dependencies [de8c973] +- Updated dependencies [50e273f] +- Updated dependencies [c745e2b] +- Updated dependencies [9801da1] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [7d63088] +- Updated dependencies [87c37ae] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [2b53993] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [b2b6a06] +- Updated dependencies [8538edf] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [92ea760] +- Updated dependencies [76ddab7] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [40098a4] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [113050e] +- Updated dependencies [5d12b16] +- Updated dependencies [54b3d1d] +- Updated dependencies [634f23d] +- Updated dependencies [f03f6c7] +- Updated dependencies [374d9d3] +- Updated dependencies [4ef8247] +- Updated dependencies [b8ec127] +- Updated dependencies [ab48938] +- Updated dependencies [cb648cb] +- Updated dependencies [efa2533] +- Updated dependencies [2c87a48] +- Updated dependencies [dd2fd20] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [3c557e2] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [b940f32] +- Updated dependencies [e66da5c] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [7010085] +- Updated dependencies [2266438] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [611795e] +- Updated dependencies [f9e16d8] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [a016f08] +- Updated dependencies [b110578] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [e07eecf] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [3977410] +- Updated dependencies [46cf705] +- Updated dependencies [45c2cf9] +- Updated dependencies [0f38ab0] +- Updated dependencies [cca1dc0] +- Updated dependencies [9540590] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [f572a7e] +- Updated dependencies [ca31ff6] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [f3b28eb] +- Updated dependencies [fd5cff2] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [8d4690b] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [470746a] +- Updated dependencies [b7c792b] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [6ff5b56] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [6465cc0] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [6058cb2] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/plugin-webhooks@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/plugin-approvals@17.5.0 + - @objectstack/mcp@17.5.0 + - @objectstack/plugin-sharing@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/service-analytics@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/service-messaging@17.5.0 + - @objectstack/plugin-audit@17.5.0 + - @objectstack/verify@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/plugin-email@17.5.0 + - @objectstack/service-storage@17.5.0 + - @objectstack/connector-rest@17.5.0 + - @objectstack/connector-openapi@17.5.0 + - @objectstack/connector-mcp@17.5.0 + - @objectstack/trigger-schedule@17.5.0 + - @objectstack/example-crm@4.0.97 + - @objectstack/example-multi-package@0.0.4 + - @objectstack/example-showcase@0.3.19 + - @objectstack/trigger-record-change@17.5.0 + ## 0.0.44 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 5e6059d64e3..bd27c308015 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/dogfood", - "version": "0.0.44", + "version": "0.0.45", "private": true, "license": "Apache-2.0", "description": "Dogfood regression gate — hand-written golden tests that boot real example apps through @objectstack/verify's in-process HTTP stack, pinning historical runtime regressions (#2018 timezone bucketing, #1994 cross-owner RLS, #2004 field fidelity) that static checks miss.", diff --git a/packages/qa/downstream-contract/CHANGELOG.md b/packages/qa/downstream-contract/CHANGELOG.md index 5f44fedc686..4e4235a901d 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,442 @@ # @objectstack/downstream-contract +## 0.0.43 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + ## 0.0.42 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index f2fa9a2790a..90d63d3e4de 100644 --- a/packages/qa/downstream-contract/package.json +++ b/packages/qa/downstream-contract/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/downstream-contract", - "version": "0.0.42", + "version": "0.0.43", "description": "Frozen third-party consumer fixture — a backward-compatibility gate for @objectstack/spec. Authored the way an external project on a published release authors metadata; if a spec change breaks it, that change is breaking (#2035).", "license": "Apache-2.0", "private": true, diff --git a/packages/qa/http-conformance/CHANGELOG.md b/packages/qa/http-conformance/CHANGELOG.md index 38eb98f273c..63eba456e84 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,50 @@ # @objectstack/http-conformance +## 0.1.5 + +### Patch Changes + +- Updated dependencies [0f95f43] +- Updated dependencies [74eaab8] +- Updated dependencies [baf9745] +- Updated dependencies [271d6bb] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [98bd798] +- Updated dependencies [d3a2331] +- Updated dependencies [5ba2ec3] +- Updated dependencies [fe0ae5c] +- Updated dependencies [74832b6] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [0318faf] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [a9fb83e] +- Updated dependencies [e7f69db] +- Updated dependencies [fe677ae] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [e5cf27d] +- Updated dependencies [615c468] +- Updated dependencies [89f87f2] +- Updated dependencies [3062e50] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [71629a1] +- Updated dependencies [07150b3] + - @objectstack/core@17.5.0 + ## 0.1.4 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 29391864de7..9a6653da3c5 100644 --- a/packages/qa/http-conformance/package.json +++ b/packages/qa/http-conformance/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/http-conformance", - "version": "0.1.4", + "version": "0.1.5", "private": true, "license": "Apache-2.0", "description": "HTTP transport-port conformance gate (ADR-0076 D11/OQ#10, #2462) — a zero-dependency node:http reference implementation of IHttpServer plus a cross-adapter suite that boots the dispatcher bridge and REST generator on it AND on plugin-hono-server, pinning that the port stays free of framework-isms. Not published; validation instrument, not a product server.", diff --git a/packages/rest/CHANGELOG.md b/packages/rest/CHANGELOG.md index 5358eec3d37..b785b874c41 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,2266 @@ # @objectstack/rest +## 17.5.0 + +### Minor Changes + +- 2b08a72: fix(runtime): a repeated `?version=` on `GET /packages/:id` is refused `400 VALIDATION_ERROR` in the repo's one message, and `@objectstack/rest` publishes the rule that owns it (#17672) + + `GET /api/v1/packages/:id?version=a&version=b` answered **`404`**, with a second + sentence written at that door. This repo already had a landed answer for exactly + that condition on exactly that route — `400 VALIDATION_ERROR` in the ADR-0112 + nested body (#6307) — and one implementation of it, `refuseRepeatedQueryParams` + / `repeatedQueryParamMessage` in `packages/rest/src/query-multiplicity.ts`, + whose header is the authority on the rule. + + Driven before the change, one host, three refusals: + + ``` + GET /packages/com.acme.crm?version=a&version=b -> 404 RESOURCE_NOT_FOUND + GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + ``` + + A client branching on the answer could not tell "your request named the + parameter twice" from the two genuine not-founds. After: + + ``` + GET /packages/com.acme.crm?version=a&version=b -> 400 VALIDATION_ERROR + GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + ``` + + The body is the dispatcher's declared envelope — + `{ success: false, error: { code: 'VALIDATION_ERROR', message, httpStatus: 400 } }` + — with `VALIDATION_ERROR` derived by `buildApiError` from + `standardErrorCodeForHttpStatus(400)`, the standard catalog's member for 400. + ⛔ Nothing in `packages/spec` moves. + + **What was actually blocking this was reachability, not judgement.** + `@objectstack/rest` declares exactly one export subpath and that module was not + on it, so #17668 could neither call the rule nor (correctly) copy it, and + shipped the `404` with its own sentence instead. The barrel now publishes + `repeatedQueryParamMessage` and `refuseRepeatedQueryParams`, and the dispatcher + domain calls the message function — so the sentence a caller is told for a + repeated parameter is the same one on every door that carries the rule, ⛔ never + a second copy that drifts. + + ⚠️ The two published symbols are not interchangeable across a package boundary, + and the barrel entry says so. `repeatedQueryParamMessage` is the portable half: + a pure function of two primitives. `refuseRepeatedQueryParams` writes the bare + ADR-0112 body onto a `res`, which suits handlers of that shape and ⛔ not a + runtime dispatcher domain — measured, its body fails that surface's + `BaseResponseSchema` with `success is missing, must be a boolean`. + + **Not a breaking change, measured rather than assumed.** The `404` it replaces + was introduced by #17668 (`1a25f4a8d`), which is not an ancestor of + `@objectstack/runtime@17.4.0` (exit 1; two control commits from that tag's own + history answer exit 0 on the same predicate, in a checkout + `--is-shallow-repository` reports `false`). It has never been published, so no + released consumer can have branched on it. Everything else about the door is + unchanged: `?version=` and `?version=latest` still serve the + installed row, an absent version and an unknown id still answer `404`, and a + one-element array is still one occurrence. + + Also corrected, on the module that owns the rule: its header said the + dispatcher's `/packages` domain "reads no `version`" — load-bearing prose, + since it is part of why the rule needs only one home. That stopped being true + when #17668 landed. The paragraph now states what is true, which is that the one + home did not move and now serves two doors. +- 6e4024c: `security explain` refuses an object name that does not exist instead of reporting `denies` — a typo is no longer indistinguishable from a permission decision (#18253). + + `GET/POST /api/v1/security/explain?object=leave_requst` used to walk all nine layers for a name nobody declared and answer `200` with `allowed: false` and `object_crud: 'denies'` — the byte-identical pair a **real** denial answers. Only the layer prose differed (#10401/#10424), and no client branches on prose, so the tool an administrator opens to ask "why can this person see this record" answered confidently about a record that does not exist. + + It now answers `404` with `error.code: 'OBJECT_NOT_FOUND'`. + + - **The engine decides, the door maps.** `explainAccess` throws `ExplainObjectNotFoundError` (plugin-security `errors.ts`), so every caller of `ISecurityService.explain` gets the refusal, not only the HTTP one; the REST route turns it into the status. A judgement made at the door would have been loud in one caller and silent in the other. + - **Nothing was newly minted.** `OBJECT_NOT_FOUND` at 404 is what this platform already answers for an unregistered object name (`mapDataError`, `packages/rest/src/error-response.ts`) and is a `StandardErrorCode` member, so no ledger row and no `packages/spec` change carries it. The body is emitted through the `/security/explain` family's one refusal emitter (#8073), so it is the ADR-0112 D5 envelope by construction. + - ⚠️ **Only one of the three unresolved causes moved.** An `unpublished_draft` declaration EXISTS (its remedy is "publish it") and a `metadata_unavailable` read did not answer, so both keep today's `denies` explanation — asserting absence there would state as fact the half the condition made unknowable. + - **Callers that branch on the verdict.** A client that treated `allowed: false` as "denied" for a misspelled object now meets a `404` refusal instead of a `200` decision. That is the point of the change, and it is the only wire movement: a resolvable object's report is byte-identical. +- df1b275: fix(rest)!: `GET /meta/:type/:name` answers absence in ONE envelope, whichever arm produced it (#18402) + + + + Clause-②: no + + The contract surface (`packages/spec`) is not in this diff; no authorable key, no closed-set member, no published export and no registry entry moves. + + ## What was wrong + + #18066 gave this route ONE absence emitter and reached it from the two conditions that RETURN nothing. The conditions that THROW one were left on the classification door, which renders the flat envelope — a string `error` beside a top-level `code`. So `body.error.code` — the accessor #8013 settled on and objectui#4252 reads — was `undefined` on exactly those, and **which envelope a caller had to parse for an absence was decided by two things it cannot see**: + + - `metadata.enableCache`, which **defaults to `true`**. The cached arm's `getMetaItemCached` throws `metadataItemNotFoundError` on a falsy `item`; the uncached arm resolves item-less and returns. + - which protocol implementation is mounted. The in-repo `metadata-protocol` resolves item-less from `getMetaItem`; a protocol that throws the miss reached the same flat door. + + Re-measured on `origin/main` at `551139bb7` rather than copied from the report — the same absent `view`, driven through both arms: + + | arm | status | body | + |:--|--:|:--| + | uncached, item-less return | 404 | `{"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}}` | + | cached, producer throws | 404 | `{"error":"Metadata item view/no_such_view not found","code":"RESOURCE_NOT_FOUND"}` | + + Same route, same status, same code, two envelopes — and the flat one echoed the type and the name where the emitter says one fixed sentence. + + ## What it does now + + Both arms reach `sendMetaItemAbsent`. The route's absence answer is one body: + + ``` + 404 {"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}} + ``` + + ⭐ This **strengthens** the ADR-0045 §3 property rather than merely preserving it. The unpublished app and the service-gated one already answered through the emitter, so an absence that kept the thrown dialect was a response pair that told them apart — by envelope shape, and by the producer's prose. Byte-identity across all of them is now pinned on the SERIALIZED body, not on object equality. + + ## **BREAKING** — the default wire answer moves for non-`app` types + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` (the lockstep convention: `major` is refused by `check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087 disposition above). + + What breaks: on `GET /meta/:type/:name`, the **absence** refusal moves from the flat top-level `code` to the nested `error.code`. ⚠️ For every type that does **not** bypass the cache — `object`, `view`, `flow`, `page` and the rest — this is the **default** answer, not a minority path: `metadata.enableCache` defaults to `true`, so those types took the cached arm and the cached arm threw. Measured in this repo against a real booted app: the showcase declares no `enableCache`, and its dogfood pin on `GET /meta/object/:name` was reading the flat `body.code` — a real consumer, in-tree, depending on the flat shape for exactly this refusal. + + Only `app` (and `dashboard`, `doc`, `book`, `?state=draft`, `?preview=draft`, `?package=`) bypassed the cache and already answered the nested shape. + + **The remedy is one accessor.** Read `body.error.code` instead of `body.code` on this route's 404. Nothing else about the refusal moves: the status is still `404`, the code is still `RESOURCE_NOT_FOUND`, and the message is the emitter's fixed sentence rather than the producer's. `ObjectStackClient` normalizes both envelopes already, so SDK callers are unaffected. + + ## ⛔ What it deliberately does NOT do + + - **It is not "every 404 is absence."** `NO_DRAFT` is a 404 on this same route — the Studio designer's `?state=draft` probe — and it says the item **is** there and its draft is not. Folding it in would tell a designer the object does not exist: #5532's flattening, reintroduced by the repair for a sibling of it. A producer-declared code the ADR-0112 ledger does not know keeps its `declaredCode` for the same reason, and a producer that declared NO code gets none invented for it. + - **It does not converge the flat dialect itself.** That envelope POSITION is the live ratchet **#9559** owns repo-wide (`check:route-envelope`); converting two of `sendDeclaredFault`'s four emissions here would mint a new divergence — the same audience refusal answering two shapes depending on which ROUTE served it. +- a675ad4: The remaining raw `FieldSchema.reference` readers now **REFUSE** a carrier they cannot read, instead of answering "no target" (#18550). The previous release routed the arbiter (`referenceCarrierOf`) and the lint target readers; these were the measured residue of the same ruling — every reader, not just the arbiter. + + `FieldSchema.reference` is `z.string().optional()`, so `ObjectSchema.safeParse` refuses an object- or array-valued carrier at the contract door. These reads are the other door: the one a value reaches only when it never went through parse — a hand-built fixture, a raw `registerObject`, a stored row rehydrated past its schema. + + **`@objectstack/objectql`** — both of the delete cascade's carrier reads (`planCascadeAtomicity` and `cascadeDeleteRelations`). This is the one with a measurable runtime consequence, and it is why the level is not `patch`: + + ``` + before acct=1 task=1 + delete RESOLVED true <- success reported to the caller + after acct=0 task=1 <- an ORPHANED master_detail row + ``` + + An unreadable carrier made the relation invisible to the cascade, so the parent was deleted, the detail row stayed, and the caller was told the delete succeeded — no `restrict` refusal, no `set_null`, nothing logged. It now refuses before any row is touched. + + **`@objectstack/rest`** — the public-form lookup picker's field-def fallback. The field def is also hoisted out of the metadata fetch's `catch {}`, so an unreadable carrier is no longer reported as `LOOKUP_TARGET_MISSING`: "no target is declared" and "the declared target cannot be read" want different fixes from whoever owns the metadata. + + **`@objectstack/metadata-protocol`** — the seed dependency graph, which also retires an `as string` cast that asserted exactly what its truthiness guard had not checked. + + **`@objectstack/lint`** — the four remaining target readers: `masterDetailCount` (`validate-expressions`), the `displayField` consumer edge (`validate-field-consumers`), the field and action-param targets (`validate-object-references`), and `masterOf` (`validate-sharing-rule-enforceability`). + + **`@objectstack/verify`** — `relationTarget`, which no longer degrades an unreadable carrier to the generic "has no `reference` target" an object with no relationship metadata at all receives. + + `null`, `undefined` and `''` are ABSENCE, not a wrong shape, and still answer `undefined` at every one of these sites — a field is allowed to name no target, and `StrictField` declares `reference` nullable. Each site's absence answer is pinned alongside its refusal. + + Upgrading: nothing conformant changes. A non-string `reference` could not be authored, stored or parsed before this release either; what changes is that one now fails loudly at the read instead of being read as an absent target. If a test asserted the old silence, assert the refusal instead. +- 95ab93f: fix(rest): four published doors refuse a `?limit=` they cannot read with `400 VALIDATION_FAILED`, instead of substituting, clamping or dropping it and answering `200` (#20061, #20062) + + Clause-②: no (narrowing) + + **BREAKING**: shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA). The banner and the ADR-0087 + disposition below carry the breaking-ness, not the level. + + `GET /data/import/jobs`, `GET /data/:object/export`, `GET /meta/:type/:name/history` + and `GET /search` read `?limit=` with a bare `Number()`. That coercion does not + fail. It invents a value, and the door served it with a `200`: + - `?limit=0` on the import-job history answered the 50-row default against a + declaration of `min(1).max(200)`; + - `?limit=abc` on the export downloaded one row; + - on the metadata history it returned the whole change log; + - on search it removed the overall cap. + + Since `@objectstack/client` sends `limit` exactly as the caller wrote it, the door + is the only place such a value can be refused. + + Each door now reads the parameter against its own declaration: + - **`GET /data/import/jobs`** reads `limit` and `offset` through + `ListImportJobsRequestSchema` (`limit` `int().min(1).max(200)`, default 50; + `offset` `int().min(0)`, default 0). + - **`GET /meta/:type/:name/history`** reads `limit` through + `HistoryMetaItemRequestSchema.limit` (`z.number()`, so any finite number, as + before). + - **`GET /data/:object/export`** and **`GET /search`** declare no request schema. + There `limit` must be a whole number. Their range handling is unchanged: the + export floor of 1 and cap of 50000, and search's `[1, 100]` clamp. + + A value outside the declaration answers `400` with the data surface's existing + envelope, `{ error, code: 'VALIDATION_FAILED', fields }`. `fields[0].field` names + the parameter. `fields[0].code` is the ADR-0114 member for the failed constraint: + `invalid_type` for a value that is not a number (or not a whole one, where one is + required), `min_value` or `max_value` for one outside a declared bound. The + service is never called. + + What changes, per door (every row answered `200` before): + + | door | request | answered before | answers now | + |:--|:--|:--|:--| + | `GET /data/import/jobs` | `?limit=0`, `?limit=-3` | 50 rows / 1 row | `400`, `min_value` | + | `GET /data/import/jobs` | `?limit=201`, `?limit=500` | 200 rows | `400`, `max_value` | + | `GET /data/import/jobs` | `?limit=abc`, `?limit=1.5`, `?limit=Infinity` | 50 / 1.5 / 200 rows | `400`, `invalid_type` | + | `GET /data/import/jobs` | `?offset=-1`, `?offset=abc`, `?offset=1.5` | offset 0 / 0 / 1.5 | `400`, `min_value` / `invalid_type` | + | `GET /data/:object/export` | `?limit=abc`, `?limit=` (empty) | a one-row export | `400`, `invalid_type` | + | `GET /data/:object/export` | `?limit=1.5`, `?limit=Infinity` | limit 1.5 / capped to 50000 | `400`, `invalid_type` | + | `GET /meta/:type/:name/history` | `?limit=abc`, `?limit=Infinity` | the whole change log | `400`, `invalid_type` | + | `GET /meta/:type/:name/history` | `?limit=` (empty) | zero events | `400`, `invalid_type` | + | `GET /search` | `?limit=abc` | no overall cap | `400`, `invalid_type` | + | `GET /search` | `?limit=1.5`, `?limit=Infinity` | a cap of 1.5 / 100 | `400`, `invalid_type` | + + A blank value such as `?limit=%20` is refused on every door. + + **Unchanged:** + - An absent `limit` keeps each door's default: 50 jobs, a 10000-row export, the + full history, search's 20. + - An empty `?limit=` on the import-job history and on search still means absent, + as it always did there. + - Every conforming value reaches the service exactly as before, including the + ranges no card here takes a position on: export `?limit=0` still exports one + row, search `?limit=500` is still clamped to 100, and history still forwards + `0` or `1.5` because its declaration admits them. + + **Fix for a caller that now gets the `400`:** send `limit` as a whole number, within + the declared range where the door declares one (`1`–`200` for import jobs), or + omit it to get the door's default. + + +- 8d1f7ab: feat!: retire the saved-report stack — `sys_saved_report` / `sys_report_schedule`, `/api/v1/reports`, `client.reports`, `IReportService`, the `reports` capability and `@objectstack/plugin-reports` (#20102) + + **BREAKING** — the saved-report stack is removed whole, with no deprecation window + (maintainer ruling 2026-09-25, 「A. 退役」). It persisted a raw object query + (`object_name` + `{ filter, fields, orderBy, limit, groupBy }`) with a render format + and an owner, and could e-mail it on a schedule. Measured on the main branch of this + repository, objectui and cloud before removal: zero callers of the routes, the SDK + namespace or the service contract outside their own tests, and no app declaring the + capability. + + **NOT affected: the `report` metadata kind.** `ReportSchema`, `defineReport`, + `/meta/report`, datasets and the analytics service are unchanged. The two shared the + word "report" and nothing else. + + FROM → TO, per surface: + + - `requires: ['reports']` → **refused** by `defineStack` (`STACK_CAPABILITY_UNKNOWN`, + 422) with the prescription "requires: 'reports' was removed in @objectstack/spec + 17.5.0 … Delete the token." Fix: delete the token. `os serve` on an older artifact + that still carries it warns with the same prescription and ignores it; `os validate` + and `os build` over a plain-object config (no `defineStack` call, so no parse-time + vocabulary check) report it as a non-fatal capability advisory carrying the same + prescription, never "check for a typo". The token is + gone from `PLATFORM_CAPABILITY_TOKENS` and `PLATFORM_CAPABILITY_PROVIDERS`; the new + `RETIRED_PLATFORM_CAPABILITY_GUIDANCE` (`@objectstack/spec/kernel`) carries the + prescription. + - `IReportService`, `SavedReport`, `ReportSchedule`, `ReportQuery`, `ReportFormat`, + `ReportRunResult`, `SaveReportInput`, `ScheduleReportInput` + (`@objectstack/spec/contracts`) → removed, no replacement export. Fix: delete the + import. + - `SysSavedReport`, `SysReportSchedule` (`@objectstack/platform-objects/audit`) and + the names `sys_saved_report` / `sys_report_schedule` in + `PLATFORM_PROVIDED_OBJECT_NAMES` → removed. A stack referencing either name is now + flagged as a probable typo instead of resolving. + - `GET|POST /api/v1/reports`, `GET|DELETE /api/v1/reports/:id`, + `POST /api/v1/reports/:id/run`, `POST /api/v1/reports/:id/schedule`, + `GET /api/v1/reports/:id/schedules`, `DELETE /api/v1/reports/schedules/:scheduleId` + → unmounted: each answers the standard unmatched-route `404`, byte-identical to a + path that never existed. Their nine error codes (`REPORTS_LIST_FAILED`, + `REPORT_DELETE_FAILED`, `REPORT_GET_FAILED`, `REPORT_NOT_FOUND`, + `REPORT_RUN_FAILED`, `REPORT_SAVE_FAILED`, `REPORT_SCHEDULE_FAILED`, + `SCHEDULES_LIST_FAILED`, `SCHEDULE_DELETE_FAILED`) leave `ERROR_CODE_LEDGER` with + their only emitter. + - `client.reports.*` (`list`, `save`, `get`, `delete`, `run`, `schedule`, + `listSchedules`, `unschedule`) → removed. Fix: delete the call. A report is `report` + metadata, read through `meta.*` and queried through `analytics.*`; a saved ad-hoc + object query is a ListView on that object. + - `RestServer`'s constructor keeps the position of the retired saved-report provider, + typed `undefined`, so no later positional argument re-binds. Pass `undefined` there; + passing a provider is a compile error. + - `@objectstack/plugin-reports` → no longer built or published from this repository, + and `@objectstack/cli` no longer depends on it or mounts it. Fix: remove the + dependency. There is no successor package and no scheduled-delivery replacement. + + **Existing databases.** `sys_saved_report` / `sys_report_schedule` tables in a deployed + database are left in place, untouched — no backfill, no reaper, no drop — under the + repository's convention for a retired platform object: the platform never drops a + table that metadata stops declaring, and `os migrate plan` lists such a table in its + informational unmanaged-tables section so an operator can decide. + + `@objectstack/metadata-protocol` (patch): the `INVALID_SORT` hint for a sort node + spelled `{ field, direction }` no longer names the retired saved-report contract as + the source of that vocabulary; it names the better-auth adapter's `sortBy`, which + still uses it. Code and status are unchanged. + + Breaking ships as `minor` per the launch-window convention + (`scripts/check-changeset-no-major.mjs`). + + **Clause-②: yes (narrowing)** — a published capability token, a service contract and + its types, two platform objects, eight routes, nine registered error codes and an SDK + namespace are removed; nothing previously refused is now accepted. + + +- 329ea2e: fix(rest): the remaining numeric query reads refuse a value they cannot read with `400 VALIDATION_FAILED`, instead of dropping it or handing on `NaN` and answering `200` (#20139) + + Clause-②: no (narrowing) + + **BREAKING**: shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA). The banner and the ADR-0087 + disposition below carry the breaking-ness, not the level. + + Five published doors still read a numeric query parameter with a bare `Number()`. + That coercion does not fail. It invents `NaN` or `0`, and the door dropped it or + served it with a `200`: + - `GET /meta/:type/:name/history?sinceSeq=abc` read the change log from the start; + - `GET /meta/:type/:name/audit?limit=abc` served the producer's default 100 events; + - `GET /meta/:type/:name/diff?from=abc` diffed a different pair of versions; + - `GET /search?perObject=abc` removed the per-object cap; + - `GET /approvals/requests?limit=abc` served the unpaged 500-row window instead of a page. + + Each door now reads the parameter the way `?limit=` on import jobs, export, history and + search already does: + - **`GET /meta/:type/:name/history`** reads `sinceSeq` through + `HistoryMetaItemRequestSchema.sinceSeq` (`z.number()`, so any finite number, as before). + - **`GET /meta/:type/:name/audit`** reads `limit` through + `AuditMetaItemRequestSchema.limit` (`z.number()`; the implementation's `[1, 500]` + clamp is unchanged). + - **`GET /meta/:type/:name/diff`** (`from` / `to`, and their `fromVersion` / + `toVersion` spellings), **`GET /search`** (`perObject`) and + **`GET /approvals/requests`** (`limit` / `offset`) declare no request schema. There the + value must be a whole number. Each service's own range handling is unchanged. + + A value the door cannot read answers `400` with the data surface's existing envelope, + `{ error, code: 'VALIDATION_FAILED', fields }`. `fields[0].field` names the parameter as + the caller spelled it, and `fields[0].code` is `invalid_type`. The service is never + called. On `GET /approvals/requests` this is a `400`, not the route's + `500 APPROVAL_REQUEST_LIST_FAILED`. + + What changes, per door (every row answered `200` before): + + | door | request | answered before | answers now | + |:--|:--|:--|:--| + | `GET /meta/:type/:name/history` | `?sinceSeq=abc`, `?sinceSeq=Infinity` | the change log from the start | `400`, `invalid_type` | + | `GET /meta/:type/:name/history` | `?sinceSeq=` (empty) | `sinceSeq: 0` applied as a cursor | `400`, `invalid_type` | + | `GET /meta/:type/:name/audit` | `?limit=abc`, `?limit=Infinity` | the default 100 events | `400`, `invalid_type` | + | `GET /meta/:type/:name/audit` | `?limit=` (empty) | one event | `400`, `invalid_type` | + | `GET /meta/:type/:name/diff` | `?from=abc`, `?from=Infinity` | the version before `to`, diffed instead | `400`, `invalid_type` | + | `GET /meta/:type/:name/diff` | `?to=abc` | the current body, diffed instead | `400`, `invalid_type` | + | `GET /meta/:type/:name/diff` | `?from=1.5`, `?to=2.5` | a diff against a version that cannot exist | `400`, `invalid_type` | + | `GET /search` | `?perObject=abc` | no per-object cap | `400`, `invalid_type` | + | `GET /search` | `?perObject=1.5`, `?perObject=Infinity` | a cap of 1.5 / clamped to 25 | `400`, `invalid_type` | + | `GET /approvals/requests` | `?limit=abc`, `?limit=Infinity` | the unpaged 500-row list, no `total` | `400`, `invalid_type` | + | `GET /approvals/requests` | `?limit=` (empty) | a one-row page | `400`, `invalid_type` | + | `GET /approvals/requests` | `?offset=abc` | the first page | `400`, `invalid_type` | + | `GET /approvals/requests` | `?offset=` (empty) | the service's 50-row paged mode | `400`, `invalid_type` | + | `GET /approvals/requests` | `?limit=1.5`, `?offset=1.5` | 1.5 handed to the engine | `400`, `invalid_type` | + + A blank value such as `?sinceSeq=%20` is refused on every one of these doors. + + **Unchanged:** + - An absent parameter keeps each door's default: the history log from the start, the + audit trail's 100 events, previous-vs-current on `/diff`, search's 5 per object, and the + unpaged approvals list. + - An empty `?perObject=`, `?from=` or `?to=` still means absent, as it always did there. + - Every conforming value reaches the service exactly as before, including the ranges no + card here takes a position on: history still forwards `sinceSeq=0` or `1.5`, audit still + forwards `limit=0` or `900` to its own clamp, `/diff` still forwards `from=0`, search + `perObject=50` is still clamped to 25, and approvals `limit=0` / `offset=-1` still reach + the service's own clamp. + - `GET /data/:object/export?page=` is unchanged. It sets only the export's chunk size, and + no value of it changes the rows exported. + - `POST /meta/:type/:name/rollback` `toVersion` is unchanged. It already refused an + unreadable value with `400 INVALID_REQUEST`. + + **Fix for a caller that now gets the `400`:** send the parameter as a number (a whole + number on `/diff`, search and approvals), or omit it to get the door's default. + + +- 443b2f4: feat(spec,rest,lint): an import mapping target may name a declared part of a compound field (`mailing_address.street`), and the importer assembles the parts into one value (#20149) + + Clause-②: yes + + **What was missing.** A mapping could write each source column to one flat + field only, so nothing could build an `address` value from the separate + street / city / state / postal code / country columns a spreadsheet carries. + A dotted target such as `mailing_address.street` named no field and was + refused at `objectstack validate`, on the dry run and on the commit. + + **What changes.** + + - `@objectstack/spec`: `ImportFieldMappingSchema.target` declares the part + path. A target may name `field.part` when `field` is a declared field whose + stored value schema is a closed object of optional strings (today: + `address`), and `part` is a key that schema declares: `street`, `city`, + `state`, `postalCode`, `country`, `countryCode`, `formatted`. The part names + are read from the value schema, never listed by hand. The one verdict, + `judgeImportMappingTarget`, answers the new `{ kind: 'part', field, part }`; + `indexImportMappingTargets` carries each compound field's parts on + `parts`; `unknownImportMappingTargets` gives each refused target a `reason` + (`unknown` or `collides`) and, for a dotted target, what its `head` names. + `location` is not compound for import: its parts are required numbers, so a + value assembled from text cells would be the wrong type. + - `@objectstack/rest`: `applyMappingToRows` assembles every part target of a + row into one value under the field's key, before the engine sees the row, + whatever transform produced the part (`none`, `map`, `constant`, `join`, + each element of a `split`). A blank part cell (empty, whitespace or a + `nullValues` token) is left out, string parts are trimmed under + `trimWhitespace`, and a row whose parts are all blank leaves the field + unset, as a blank flat cell does. On an update the assembled value replaces + the stored one. The dry run and the commit judge the same assembled row. + - `@objectstack/lint`: `mapping-target-field-unknown` accepts a declared part + and reports what stays refused, naming the legal parts each time. + + **Still refused, at `objectstack validate`, on the dry run and on the commit + (`400 INVALID_FIELD`, before any row):** + + - a part the value does not declare (`mailing_address.stret`); the refusal + lists the declared parts; + - a dotted path on a field with no parts (`full_name.first`). A dotted target + never traverses a reference (`account.name`): map the column to the + reference field with transform `lookup`; + - a mapping that writes a field both whole and by part (`mailing_address` and + `mailing_address.street`): one row carries one value for the field. Map it + whole or by its parts, not both. + + **What to do.** Nothing, unless you want the capability: point each address + column at `field.part`, for example `{ source: 'Zip', target: + 'mailing_address.postalCode' }`. +- 7e7fab7: fix(spec,rest,lint): an import mapping target that names no field is refused on the dry run, on the commit and at `objectstack validate` alike (#20150) + + Clause-②: yes (narrowing) + + + + **BREAKING** in the accept-set sense only, landing in the launch window as + `minor`: the import route and `objectstack validate` now refuse a mapping they + used to pass, and every such mapping already failed on the commit. + + **What was wrong.** `ImportFieldMappingSchema.target` is declared as "Target + object field(s)", and nothing held a mapping to it. A mapping whose target named + no field of its `targetObject` (measured with `mailing_address.street` on an + object whose address field is `mailing_address`): + + - passed `objectstack validate`, `os lint` and `os build` with no diagnostic; + - answered `ok` for every row on `POST /api/v1/data/:object/import` with + `dryRun: true`; + - then failed every row on the commit with `INVALID_FIELD` ("Unknown field + 'mailing_address.street' on object '…'"). + + The dry run promised what the commit refused. + + **What changes.** + + - `@objectstack/spec` exports ONE verdict on what a target may name, beside the + schema it judges: `unknownImportMappingTargets(fieldMapping, objectDef)`, with + `indexImportMappingTargets`, `judgeImportMappingTarget`, + `importMappingEntryTargets` and `IMPORT_TARGET_ALWAYS_ADDRESSABLE_COLUMNS` + (from `@objectstack/spec/data`). A target may name a declared field, a column + the platform provisions on that object (`resolveInjectedSystemColumns`), or one + of `id` / `created_at` / `updated_at`, which the engine's write door admits on + every object. An object with no readable, non-empty field map is not judged. + - `@objectstack/rest`: `prepareImportRequest` refuses a named mapping (`mappingName`) + with a target that names no field, before any row, with `400 INVALID_FIELD` — + the code the commit's per-row refusal already carried. The dry run and the + commit give the same answer, and so does the async import-job route. + - `@objectstack/lint`: the reference-integrity suite (`os validate`, `os lint`, + `os build`) gains `validateMappingTargetFields`, rule id + `mapping-target-field-unknown` (`MAPPING_TARGET_FIELD_UNKNOWN`), severity + `error`, located at `mappings[i].fieldMapping[j].target`. It asks the same + spec verdict, so it never refuses a target the import door accepts. + + **What to do.** Point each reported target at a field the object declares. An + array target (`split`) is judged element by element. +- 2bcd5cf: fix(runtime): the dispatcher's `/meta` item reads ask the same per-caller read gate `RestServer` asks, and `@objectstack/rest` publishes it (#20193) + + Clause-②: yes + + `GET /meta/:type/:name` and `GET /meta/:type/:name/published` have two + implementations: `RestServer`, and the runtime dispatcher's `/meta` domain. On a + host that mounts only the `${prefix}/*` catch-all (`@objectstack/hono`'s + `createHonoApp`, the documented embed shape, and any adapter built on the public + `HttpDispatcher` API), the dispatcher is the only one that answers. Its item read + and its `/published` read applied **no** per-caller read gate. An authenticated + member who does not hold `crm_admin` got these answers through `dispatch()`: + + ``` + request before after (= RestServer) + GET /meta/doc/crm_admin_runbook 200 + the gated body 403 PERMISSION_DENIED + GET /meta/doc/crm_admin_runbook/published 200 + the gated body 403 PERMISSION_DENIED + GET /meta/book/admin_guide (set-gated) 200 + the book 403 PERMISSION_DENIED + GET /meta/app/crm (and /published) 200, every entry 200, pruned + GET /meta/app/payroll (app-level perms) 200 403 PERMISSION_DENIED + GET /meta/app/launchpad (unpublished) 200 404, the same body as a missing name + GET /meta/dashboard/ops 200, every widget 200, minus the widget whose service is off + GET /meta/object/invoice/published 200, every field 200, the ADR-0106 mask applied + ``` + + Holders are still served in full. Object reads through the plain item read are + masked as before. An anonymous caller still gets `401 UNAUTHENTICATED`. + + **One gate, not two.** `RestServer`'s gate moved unchanged into + `packages/rest/src/meta-item-read-gate.ts`. That covers the ADR-0046 §6.7 docs + audience, the app nav filter (`requiredPermissions`, the ADR-0045 §3 publish gate + and the docs-audience entry arm), the ADR-0057 D10 service gates and the #7912 + servability gate. Both transports now call it. Each transport supplies only its + own I/O (the caller, the protocol's list read, the security service and a + service probe) and writes the gate's data verdict in its own envelope. There is + no second audience resolver in `packages/runtime`. `RestServer` keeps its + private helper names as delegates, so its own answers are byte-for-byte + unchanged. + + A gate input that cannot be read is answered as that fault, never as the + document. This covers a books or doc-list read that throws, and a host whose + protocol has no list read at all (fail closed, ADR-0049). + + **`@objectstack/rest`'s published export surface widens**, and that is why this + changeset declares `Clause-②: yes`. Its only export subpath (`.`) gains one value + and five types: + + - `createMetaItemReadGate(sources, metaType, name, documents, policy)`: the gate + itself; + - `MetaItemReadGateSources`: the I/O a caller supplies (the caller, a metadata + list read, the security service, a service probe, a prune-log set); + - `MetaItemReadVerdict` and `MetaItemReadRefusal`: the data verdict (`serve`, or + `refuse` with `absent` / `app-permission` / `docs-audience`); + - `MetaReadGateCaller`: the slice of the execution context the gate reads; + - `MetaReadGatePolicy`: `arms` and `app`, how a door runs the gate. + + They are public because the runtime dispatcher's `/meta` domain in + `@objectstack/runtime` consumes this one gate. `@objectstack/rest` cannot import + the runtime, so the shared decision has to live here and travel as an export, + the way `repeatedQueryParamMessage` does. A caller outside the platform does not + need them. + + Nothing is removed or renamed, and no authorable key moves. The dispatcher's + refusals are not the widening: they pull a second transport back to the gate + the contract already declares (ADR-0046 §6.7, ADR-0045 §3, `apps.mdx`), which is + why `@objectstack/runtime` stays a `patch`. +- cc40033: fix(runtime): the dispatcher's `/meta/:type` list prunes what `RestServer`'s list prunes, through one list gate that `@objectstack/rest` publishes (#20237) + + Clause-②: yes + + `GET /meta/:type` has two implementations: `RestServer`, and the runtime + dispatcher's `/meta` domain. On a host that mounts only the `${prefix}/*` + catch-all (`@objectstack/hono`'s `createHonoApp`, the documented embed shape, + and any adapter built on the public `HttpDispatcher` API), the dispatcher is the + only one that answers. Its list branch applied **no** per-caller filter. An + authenticated member who does not hold `crm_admin` got these answers through + `dispatch()`: + + ``` + request before after (= RestServer) + GET /meta/doc?include=content the set-gated doc listed WITH its body the doc left out + GET /meta/book the set-gated book listed the book left out + GET /meta/app an app whose requiredPermissions the that app left out; the other app + member lacks, and an ungated app with pruned of its gated entry + its requiredPermissions-gated entry + GET /meta/dashboard (anyone) every widget minus the widget whose service is off + ``` + + The plural spellings (`/meta/docs`, `/meta/books`, `/meta/apps`) answer the + same. Holders are still listed everything in full. Object lists are masked as + before. An anonymous caller still gets `401 UNAUTHENTICATED`. + + **One gate, not two.** `RestServer`'s list filters moved unchanged into + `createMetaListReadGate`, beside the item gate in + `packages/rest/src/meta-item-read-gate.ts`. It covers the ADR-0046 §6.7 doc + and book audience prunes, the app nav filter (`requiredPermissions`, the + ADR-0045 §3 publish gate and the docs-audience entry arm) and the ADR-0057 + D10 dashboard widget gate. `RestServer`'s list route and the dispatcher's list + branch both call it, over the same ports the item gate takes, and each rewraps + the pruned items in its own list envelope. There is no second audience + resolver in `packages/runtime`. `RestServer`'s list answers are unchanged. + + Every exit of the dispatcher's list branch now runs the gate and the ADR-0106 + object mask: the protocol list and the two fallbacks, the runtime metadata + service's list and the ObjectQL registry. The fallbacks used to serve + unmasked object schemas as well as ungated docs, books and apps. A gate input + that cannot be read (a doc list's books read throws) is answered as that fault, + never as the unfiltered list. + + **`@objectstack/rest`'s published export surface widens**, and that is why this + changeset declares `Clause-②: yes`. Its only export subpath (`.`) gains one + value: + + - `createMetaListReadGate(sources, metaType)`: the list gate. It takes the + same `MetaItemReadGateSources` the item gate takes, and it answers a judge + from a list's items to the items this caller may be served. + + It is public because the runtime dispatcher's `/meta` domain in + `@objectstack/runtime` consumes this one gate. `@objectstack/rest` cannot import + the runtime, so the shared decision has to live here and travel as an export, + the way `createMetaItemReadGate` does. A caller outside the platform does not + need it. + + Nothing is removed or renamed from the package's exports, and no authorable key + moves. The dispatcher's prunes are not the widening: they pull a second + transport back to the rules the contract already declares (ADR-0046 §6.7, + ADR-0045 §3, `apps.mdx`'s `requiredPermissions` row), which is why + `@objectstack/runtime` stays a `patch`. +- 80153f5: feat(spec,rest)!: the served OpenAPI `info` carries the publisher's `api.documentation` identity; `api.documentation.version` retired (#20294) + + Clause-②: yes (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, + never by the level). The breaking half is one key: `api.documentation.version`. + + `RestServerConfig.api.documentation` (`RestApiConfigSchema`) declared nine + members, and `RestServer` parsed them, copied them into its config — and never + read them back. Measured before this change, with every member authored: both + doors that serve the OpenAPI document (`{apiPath}/openapi.json` and its + environment-scoped twin) answered the bundled artifact's `info` unchanged, 0 of 9 + honoured. ADR-0049 enforce-or-remove, split by who owns each field: + + - **Enforced — the publisher's identity.** `title`, `description`, + `termsOfService`, `contact` (`name` / `url` / `email`) and `license` (`name` / + `url`) now overlay the served `info` on both doors. A member you leave unset + keeps the bundled value, and a config with nothing authored — no block, + `documentation: {}` — serves `info` byte-identical to + `@objectstack/spec/openapi.json`, exactly as before. `contact` and `license` + replace the bundled object **whole**: `license: { name: 'MIT' }` serves + `{ name: 'MIT' }` with no URL, never MIT at the bundled Apache-2.0 URL, and a + partial `contact` never keeps ObjectStack's name or URL. + - **Retired — `documentation.version`.** The served `info.version` is the + protocol version, the version of the `@objectstack/spec` package that generated + the document, with no configured override: an earlier ruling made it equal the + published artifact's so an integrator can read which protocol version they are + talking to. A publisher-set version would give the field a third meaning, so + the key is now refused. + + ``` + FROM new RestServer(server, protocol, { api: { documentation: { title: 'Acme Orders API', version: '2.3.0' } } }) + -> constructed; GET /api/v1/openapi.json served info.title 'ObjectStack REST API' + and info.version = the spec version — both authored values ignored + TO -> throws: REST API configuration is invalid: `api` does not satisfy + `RestApiConfigSchema` … + - api.documentation.version: `api.documentation.version` was removed in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … Delete the key. To publish + your app's own release number, write it into `api.documentation.description`, … + + FROM new RestServer(server, protocol, { api: { documentation: { title: 'Acme Orders API' } } }) + -> GET /api/v1/openapi.json: info.title 'ObjectStack REST API' + TO -> GET /api/v1/openapi.json: info.title 'Acme Orders API' (and on the environment-scoped door) + + FROM RestApiConfigSchema.parse({ documentation: { description: 'd' } }).documentation + -> { title: 'ObjectStack API', description: 'd' } // a default no document ever served + TO -> { description: 'd' } + ``` + + **Fix.** `api.documentation.version` → delete the key. The served + `info.version` is always the protocol version; to publish your app's own release + number, write it into `api.documentation.description`. `tsc` refuses the key at + the authoring site (its input type is `never`), and `RestServer` construction and + the REST plugin's `start` refuse it with that prescription. + + **What else changes.** `documentation.title` is `.optional()` instead of + `.default('ObjectStack API')`: that default was materialized into every present + block and never served, so the parsed block now carries exactly what was + authored (the parsed `title` is typed `string | undefined` now). `api.version` (the route identifier) and the runtime version still + never reach `info.version`. A host that authors none of these keys — every + CLI-started deployment, since `os serve` forwards only `enableProjectScoping` + and `projectResolution` — serves the same document as before. + + ### The kit + + - **Schema.** The eight identity members carry describes naming the served + `info` field; `version` is a `retiredKey()` tombstone inside the live + `documentation` block (a non-strict `z.object()`, so a bare deletion would have + stripped it in silence), next to the `enabled` tombstone. + - **REST server.** `registerOpenApiEndpoints` builds `info` through a pure + helper that returns a NEW object — the cached artifact's own `info` is never + written — and the same handler serves both doors. + - **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains + `api/RestApiConfig:documentation.version`; the D3 entry + `rest-api-documentation-version-retired` carries the prescription to + `os migrate meta` and the upgrade guide. No D2 conversion: a `RestServerConfig` + is plugin TS configuration, never a stack collection member or a stored row. + - **Ledger and docs.** `liveness/rest_api.json`: the eight identity leaves and + the `contact` / `license` containers flip to `live` with the overlay as + evidence; the `version` row stays `dead` with a REMOVED note. The generated + `state-counts.md` moves `rest_api` from 12 live / 12 dead to 20 / 4; the + `rest-server` reference page is regenerated. + + +- 26daf0b: feat(spec,rest)!: retire `api.responseFormat` and `api.documentation.enabled` — parsed, defaulted, and read by nothing (#20295) + + Clause-②: no (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, + never by the level). + + Two keys of `RestServerConfig.api` (`RestApiConfigSchema`) were accepted, given + defaults and copied into the REST server's config by `normalizeConfig` — and no + site ever read them back. `responseFormat` (`envelope`, `includeMetadata`, + `includePagination`) toggled nothing: `envelope: false` unwrapped no response. + `documentation.enabled` was a second on/off switch for the OpenAPI document that + nothing consulted: `api.enableOpenApi` decides that mount. Measured before + removal, each against a lit control on the same instrument: no reader in this + repo's packages, and no author in objectui at its pinned commit or in cloud. + ADR-0049 enforce-or-remove; the verdict is RETIRE, by the maintainer's criterion — + mainstream data APIs keep a fixed response envelope that no administrator toggles + server-wide, and the OpenAPI switch already exists and is enforced. + + ``` + FROM new RestServer(server, protocol, { api: { responseFormat: { envelope: false } } }) + -> constructed; `envelope: false` changed nothing + TO -> throws: REST API configuration is invalid: `api` does not satisfy + `RestApiConfigSchema` … + - api.responseFormat: `api.responseFormat` was removed in @objectstack/spec 17.5.0 + (ADR-0049 enforce-or-remove) — … Delete the key. Response shapes are fixed, … + + FROM RestApiConfigSchema.parse({ documentation: { enabled: false, title: 'My API' } }) + -> { documentation: { enabled: false, title: 'My API' }, … } // served the document anyway + TO -> ZodError { code: 'invalid_type', path: ['documentation', 'enabled'], + message: '`api.documentation.enabled` was removed in @objectstack/spec 17.5.0 (ADR-0049 + enforce-or-remove) — … Delete the key; `api.enableOpenApi: false` is the switch …' } + ``` + + **Fix.** `api.responseFormat` → delete the key; response shapes are fixed, so + there is nothing to configure. `api.documentation.enabled` → delete the key; to + serve no OpenAPI document, set `api.enableOpenApi: false` (it leaves + `GET /openapi.json` and `GET /docs` unmounted). `tsc` refuses both keys at the + authoring site (their input type is `never`). + + **What does not change.** Every live key of the `api` block parses exactly as + before, including the rest of `documentation` (`title`, `description`, + `version`, `termsOfService`, `contact`, `license` — a separate decision). A + config without the two keys mounts the same REST surface: neither key ever + reached it. A `documentation` block no longer grows an `enabled: true` default. + + ### The retirement kit + + - **Schema.** `RestApiConfigSchema` and its inline `documentation` object are + non-strict `z.object()`s, so each key is a `retiredKey()` tombstone carrying its + prescription (a bare deletion would have stripped it in silence). + `responseFormat` retires whole — its three members were its only members. + - **REST server.** `normalizeConfig` runs the tombstones (the `crud.patterns` + posture, not `requireAuth`'s warn-and-ignore), so a config carrying either key + now fails `RestServer` construction and the REST plugin's `start` with the + prescription, and the normalized config no longer carries or re-defaults them. + - **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `api/RestApiConfig:responseFormat` + and `api/RestApiConfig:documentation.enabled`. No D2 conversion: a + `RestServerConfig` is plugin TS configuration, never a stack collection member or + a stored row. The family's D3 entry, `rest-api-config-dead-keys-retired`, carries + the prescription to `os migrate meta` and the upgrade guide. + - **Ledger and docs.** `liveness/rest_api.json` keeps both rows `dead` with a + REMOVED note (`responseFormat`'s three child rows collapse into its one row); + the generated `state-counts.md` moves `rest_api` from 14 to 12 dead; the + reference page for `rest-server` is regenerated. + + +- 95f729a: fix(rest, runtime): the runtime dispatcher's `/meta` reads answer what `RestServer`'s answer — one list chain, one `public`-audience predicate, and the `?state=draft` read (#20320) + + Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains five value exports (`createMetaListAnswer`, `translateMetaList`, `metaRequestLocale`, `isPublicAudienceRead`, `STORED_VERSION_DOOR_POLICY`) and six type exports (`MetaListAnswer`, `MetaListAnswerSources`, `MetaListRequest`, `MetaListTranslationSources`, `MetaPublicReadRoute`, `MetaRequestHttp`), so its published surface grows; nothing it exported before is removed, renamed or narrowed. `@objectstack/runtime` publishes no new surface and stays a `patch`. + + A host that mounts only the `${prefix}/*` catch-all (`createHonoApp`, and any + adapter written on the public `HttpDispatcher` API) serves `/meta` through the + runtime dispatcher. Its reads now give the same answers as `RestServer`'s + `GET /meta/:type` and `GET /meta/:type/:name`. Until now a dispatcher-only host + answered: + + - **`GET /meta/app?id=crm`** — every app the caller may see, not `[crm]`. An + `?id=` that matches nothing listed every app instead of an empty list. + - **`GET /meta/view?object=lead`** — every view, not the lead views sorted for + the switcher. + - **`GET /meta/docs`** (the plural spelling) — every doc WITH its body. The + content slim compared the raw segment, so it ran only for `/meta/doc`. + - **any doc list** — each doc with its `translations` map and in no locale. + `RestServer` collapses each doc to the request's locale. + - **every translatable list** (`app`, `view`, `object`, `page`, `dashboard`, + `action`, `dataset`) — untranslated labels, whatever `Accept-Language` or + `?locale=` asked for, and no `Vary: Accept-Language` header. + - **`GET /meta/api`** — every stored `api` declaration, including ones the + endpoint matcher does not serve (their routes answer 404). + - **an anonymous `GET` of a `public` book or doc** (list or item) — + `401 UNAUTHENTICATED`. `RestServer` serves it (ADR-0046 §6.7). + - **`GET /meta/:type/:name?state=draft` from a caller who may read drafts** — + the ACTIVE item. It should be the pending draft, whole for a caller who may + save the app and pruned per caller for everyone else, or `404 NO_DRAFT` when + nothing is pending. A caller who may not read drafts is still answered the + plain read, byte for byte. + + **What changed.** The list route's whole post-read chain moved out of + `RestServer` into `createMetaListAnswer` in `@objectstack/rest`, unchanged. That + chain is the `api` served-set face, the per-caller list gate, `?id=`, + `?object=`, the doc locale collapse and content slim, the transport's own + object mask, and the translation. Every exit of the dispatcher's list branch + now hands its answer to that same function. The anonymous gates on both + transports ask one exported predicate, `isPublicAudienceRead`. It admits only + `GET` reads of book and doc, so every other type keeps the anonymous deny, and + the §6.7 audience gate still refuses `org` and `{ permissionSet }` audiences. + The dispatcher's `?state=draft` read runs the exported + `STORED_VERSION_DOOR_POLICY`, the constant `RestServer`'s draft branch runs. + + `RestServer`'s own answers are unchanged: the move is a refactor on that side, + and every existing REST test passes unedited. + + New exports from `@objectstack/rest`: `createMetaListAnswer`, + `translateMetaList`, `metaRequestLocale`, `isPublicAudienceRead`, + `STORED_VERSION_DOOR_POLICY` and their types. Nothing is removed or renamed. +- 5c7aa46: fix(rest, runtime): the runtime dispatcher's `/meta` doors scope a caller to the organization `RestServer` scopes them to, and its item read, book tree and list answer what `RestServer`'s answer (#20408) + + Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains seven value exports (`createMetaItemAnswer`, `createMetaBookTreeAnswer`, `metaCallerOrganizationId`, `metaReadOrganizationId`, `projectMetaObjectSchema`, `refuseUnknownMetaListType`, `translateMetaEnvelope`) and five type exports (`MetaItemAnswer`, `MetaItemAnswerSources`, `MetaItemRequest`, `MetaBookTreeAnswer`, `MetaBookTreeSources`), and `MetaListAnswer` gains an optional `cacheControl`. `MetaListAnswerSources`, new in this same release with `createMetaListAnswer`, takes the transport's object-schema masker (`resolveObjectMasker`) instead of a whole-mask port, so the chain decides the cache posture for both transports. Nothing any published version exported is removed, renamed or narrowed. `@objectstack/runtime` publishes no new surface and stays a `patch`. + + A host that mounts only the `${prefix}/*` catch-all (`createHonoApp`, and any + adapter written on the public `HttpDispatcher` API) serves `/meta` through the + runtime dispatcher. Until now, on such a host: + + - **A member removed from an organization kept its metadata partition.** The + dispatcher's `/meta` doors took the organization from the session's + `activeOrganizationId` as stored. Under a wall-enforcing tenancy posture the + identity resolver DROPS a claim naming an organization the caller no longer + belongs to, and `RestServer` reads that vetted value. The dispatcher did not, + so for the rest of the session the removed member was served that + organization's org-scoped overlays (`view`, `dashboard`, `report`, + `translation`, `email_template`) by the item read, the list, `/published` and + `?state=draft`, listed its pending drafts on `GET /meta/_drafts`, and had a + `PUT /meta/:type/:name` land in its partition. Every `/meta` door here now reads + the vetted organization on the execution context, the value `RestServer` reads. + - **`GET /meta/:type/:name` answered a different body.** Nothing was translated + whatever `Accept-Language` or `?locale=` asked for. A doc kept its whole + `translations` map, in no locale. An object schema came with no + `sortability`. The answer had no `Vary: Accept-Language`. + - **`?preview=DRAFT`** (any casing but lower) from a builder read the published + world on the item read and the list. `RestServer` compares it + case-insensitively. + - **`GET /meta/object/:name?preview=draft`** from a builder answered the ACTIVE + schema, never the pending draft. + - **`GET /meta/totally_invented_type`** answered `200 {"items": []}`. `RestServer` + refuses a segment that names no metadata type with `400 INVALID_REQUEST`. + - **`GET /meta/book/:name/tree`** was no route: `404 ROUTE_NOT_FOUND` to a signed-in + reader and `401` to an anonymous reader of a `public` book (ADR-0046 §6.7). + - **An object schema served under an undetermined field visibility** (ADR-0106 + D6 tier 2: served unmasked) carried no `Cache-Control`. `RestServer` answers + `private, no-store`. This was true of the list, the item read, `/published` and + the legacy one-segment object read. + + **What changed.** Everything `RestServer`'s `GET /meta/:type/:name` does after + the store read moved, unchanged, into `createMetaItemAnswer`: absence, the item + gate, the doc locale collapse, the object mask and its cache posture, and the + body (the translation and `sortability`, `translateMetaEnvelope`). The book-tree + route's whole answer moved into `createMetaBookTreeAnswer`, and the list's + unknown-type refusal into `refuseUnknownMetaListType`. The list chain now + applies the object mask itself (`projectMetaObjectSchema`) and reports the cache + posture. The dispatcher's `/meta` domain calls each of these, and takes its + organization from `metaCallerOrganizationId` / `metaReadOrganizationId`, which + `RestServer`'s list and item reads ask too. + + `RestServer`'s own answers are unchanged: the move is a refactor on that side, + and every existing REST test passes unedited. +- 9449512: fix(rest, runtime): the runtime dispatcher serves the layered view, `GET /meta/:type/:name/layers` and the deprecated `?layers=` flag, as `RestServer` serves it (#20478) + + Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains three value exports (`createMetaLayeredAnswer`, `wantsMetaItemLayers`, `metaItemLayersDeprecationHeaders`) and two type exports (`MetaLayeredAnswer`, `MetaLayeredRequest`). Nothing any published version exported is removed, renamed or narrowed. `@objectstack/runtime` publishes no new surface and stays a `patch`. + + A host that mounts only the `${prefix}/*` catch-all (`createHonoApp`, and any + adapter written on the public `HttpDispatcher` API) serves `/meta` through the + runtime dispatcher. Until now, on such a host: + + - **`GET /meta/:type/:name?layers=true` answered the plain read.** The body was + `{ type, name, item }` with a `200`, so a client reading `code`, `overlay` or + `effective` read `undefined`. There was no `Deprecation` header and no `Link` + to the successor. An author (a caller the item's save door admits) was served + the app pruned, where the layered view serves them every layer whole. + - **`GET /meta/:type/:name/layers` was no route.** It answered a located + `404 ROUTE_NOT_FOUND`. + + Both spellings now answer what `RestServer` answers: the three layers, each + judged by the per-caller read gate under the stored-version doors' policy + (whole for a caller who may save the item, pruned as the plain read prunes it + for everyone else), each projected through the object-schema field mask, and + `private, no-store` when the caller's field visibility could not be determined. + The read is scoped to the caller's vetted organization and to `?package=`. The + flag's answers, refusals included, carry `Deprecation: true`, and a `Link` to + `/layers` built from the request's own URL (every `createHonoApp` request + carries one; a host that hands `dispatch()` no URL gets `Deprecation` alone). The route answers `501 NOT_IMPLEMENTED` where the protocol has no + layered read, and the flag is then the plain read, on both transports. + + **What changed.** Everything `RestServer`'s layered helper does after the store + read moved, unchanged, into `createMetaLayeredAnswer`, and the flag's parse and + headers into `wantsMetaItemLayers` and `metaItemLayersDeprecationHeaders`. The + dispatcher's `/meta` domain calls all three. `RestServer`'s own answers are + unchanged: every existing REST test passes unedited. +- fb194c7: fix(rest): `POST /api/v1/data/:object/import` reads a comma in a number cell only as a thousands group, and refuses every other comma instead of storing a different number (#20497) + + Clause-②: no (narrowing) + + **BREAKING for callers of the import door.** A cell for a numeric field + (`number`, `currency`, `percent` and the other numeric value types) that + carries a comma is now admitted only when the comma groups thousands: 1 to 3 + leading digits, then groups of exactly three digits, and only before any `.` + (`1,000`, `12,345.67`, `-1,234,567.89`). Any other + comma makes the cell that row's `invalid_number` error, the same code the + plain write doors (create, update, batch) already answer for the cell. The + reader used to strip every comma before parsing, so each of these was stored + as a DIFFERENT number while the import reported `ok 1, errors 0`. For each + shape, change the cell FROM the refused spelling TO the one that says what you + mean: + + - **A decimal comma.** FROM `3,14`, `1,5`, `0,5`, `1.000,5` (stored as `314`, + `15`, `5`, `1.0005`) TO a `.` decimal point with no grouping, or with comma + grouping: `3.14`, `1.5`, `0.5`, `1000.5` or `1,000.5`. A file exported with a + decimal-comma locale has to be converted before import. No locale is + guessed: `1,500` is always one thousand five hundred. + - **A comma that does not group thousands.** FROM `1,2,3`, `1,23`, `1,0000`, + `1234,567`, `,123`, `1,000,` or a comma after the `.` (`12,345.6,7`), each + stored with its commas removed, TO the number with no separators, or with + well-formed thousands grouping. + - **Grouping by twos (`12,34,567`, `1,00,000`).** FROM that grouping TO + `1234567` / `100000`, or `1,234,567` / `100,000`. These used to import as + the number they denote. They are refused now because the reader cannot tell + a two-digit group from a decimal comma, and it no longer guesses. + + **What is not affected.** Every cell without a comma reads exactly as before. + That is measured over the platform numeric grammar's 41 case rows: the only + row whose import reading changed is `1.000,5`. A well-formed thousands grouping + reads as before, with or without a leading currency symbol, a trailing `%`, a + sign or accounting parentheses (`$1,000`, `1,234%`, `(1,234)`). A JSON number, + and an xlsx cell Excel stores as a number, never pass through this reading. + The dry run answers the same verdicts as the real write. A refused cell fails + only its own row, and the rest of the batch imports as before. + + **If you are refused.** The row's result carries `code: 'invalid_number'` and + quotes the cell, so the file can be corrected and re-imported. + + +- 94c9302: `POST /analytics/dataset/query` parses its `selection` at the door, the way its two siblings already do + + The route checked one thing about the body it forwards — that + `selection.measures` was a non-empty array — and forwarded everything else + unexamined. `/analytics/query` and `/analytics/sql` Zod-parse their body at + the entry and lift a malformed member to a 400 before the service is reached, + so a client met two postures on one family depending on which door it knocked + on, and a malformed member of `selection` travelled into `dataset-executor` to + be answered by whatever the face behind it happened to do with it. + + ⚠️ **A 400 is newly reachable.** Requests that previously slipped through are + now refused. Two shapes: + + - A `timeDimensions[].dateRange` outside the closed preset vocabulary answers + `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` — the same code, status and wording + the sibling door has answered for the identical condition since the + vocabulary closed. Measured on the tree before this change, the literal + string `not a range at all` reached the executor under an ordinary `200`. + - Anything else malformed answers `400 VALIDATION_FAILED` with + `details.fields[]`, each entry naming the member as `selection.`. + + **What is NOT newly refused, deliberately.** `selection` is a + `DatasetSelection`, which is *not* the `AnalyticsQuery` the siblings parse: it + carries no `cube`, and `runtimeFilter`, `dateGranularity`, `compareTo` and + `totals` are members of its own. Reusing the sibling schema would have refused + every real dashboard widget. What the door parses is the projection of the + seven members whose declaration on `DatasetSelection` *is* the `AnalyticsQuery` + member of the same name — `dimensions`, `measures`, `timeDimensions` (declared + there by reference), `order`, `limit`, `offset`, `timezone` — so the refusal + set is exactly what the published interface already declared. The four + dataset-only members are projected away before the parse and keep reaching the + executor untouched. + + Validation-only: the caller's `selection` object is what `queryDataset` + receives, by identity, never a parse output. +- ab56ea3: refactor(rest)!: `ImportProtocolLike` declares the request each of its three required members receives, instead of `args: any` (#16952) + + The exported extension point `runImport` accepts a protocol through now states its own contract. + + **FROM** — every required member erased its parameter, so the interface declared nothing about the request it would hand an implementor: + + ```ts + export interface ImportProtocolLike { + findData(args: any): Promise; + createData(args: any): Promise; + updateData(args: any): Promise; + } + ``` + + **TO** — each member names the declared spec request, wrapped in the server-scoped envelope the runner adds (`ImportProtocolRequest`, exported alongside): + + ```ts + export type ImportProtocolRequest = R & { context?: any; environmentId?: string }; + + export interface ImportProtocolLike { + findData(args: ImportProtocolRequest): Promise; + createData(args: ImportProtocolRequest): Promise; + updateData(args: ImportProtocolRequest): Promise; + } + ``` + + **Why this is breaking-ish, and released as `minor`.** This is a narrowing of a published surface: an implementor that compiles today may stop compiling. Nothing about the values the runner sends changes — the request objects are byte-for-byte the ones #16638 already made canonical — so no runtime behaviour moves. What changes is that the compiler now holds an implementor to the same `QuerySchema` the runner is held to: `where` / `limit` / `offset` / `fields` / `orderBy` / `expand` are declared, and the wire spellings `$filter` / `$top` are not. + + **Migration for implementors.** If your `findData` / `createData` / `updateData` reads a wire alias, it will now fail to compile — that diagnostic is the point of this change, and the fix is to read the canonical key: + + ```ts + // before — compiles, and silently degrades to match-everything when `$filter` is absent + async findData(args: any) { + const where = args?.query?.$filter ?? {}; + const limit = args?.query?.$top ?? 2; + } + + // after — drop your own annotation and let the declaration type the parameter + async findData(args) { + const where = args.query!.where; + const limit = args.query!.limit; + } + ``` + + ⛔ An implementor that keeps an explicit `args: any` annotation of its own opts back out: the annotation wins over the contextual type, and the contract reaches nothing. Leave the parameter unannotated, or name `ImportProtocolRequest` explicitly. + + ⚠️ The `?? {}` shape in the "before" is the mechanism that made a dialect mismatch silent rather than loud: an unrecognised query does not throw, it degrades into a filter that constrains nothing, so a duplicate probe stops discriminating and an upsert updates the wrong record. Prefer a read that throws. + + +- 9ca49eb: `import-runner.ts` builds its three server-side `findData` requests in the CANONICAL QueryAST, and the helper that carried them is typed against the declared contract instead of `any`. + + `FindDataRequestSchema` declares `query: QuerySchema.optional()`, and `QuerySchema` declares `where` / `limit` / `offset` / `fields` / `orderBy` / `expand` — it declares neither `$filter` nor `$top`. The normalizer's own table calls those two "the wire-only spellings no schema declares". The reference resolver, the duplicate probe and the id recheck each built a literal in that undeclared dialect, and nothing reddened because the helper they went through took `query: any`: the literals were type-checked by nothing at all, so the undeclared keys cost no diagnostic. Reverting one of them to `$filter` now costs `TS2353 … '$filter' does not exist in type 'QueryInput'`; on the pre-change file the identical revert cost zero errors. + + - **The three literals.** `$filter` → `where`, `$top` → `limit`, plus the `object` the declared query requires. No behaviour change on the two `rest-server.ts` call paths (`POST /data/:object/import` and the async import-job worker), which hand `runImport` the real `ObjectStackProtocolImplementation`: that normalizer folds `$filter` onto `where` and `$top` onto `limit` by the spec's own `RPC_QUERY_ALIAS_SLOTS`, moving the value verbatim, so both dialects reach `engine.find` as the same option bag. + - **The erasure vehicle.** `findArgsBase` now takes a `FindDataRequest` rather than a bare `any` query, so the request-level `object` is compiled too and the `object: ''` placeholder every caller had to override is gone. This is the durable half: rewriting the literals while leaving the parameter `any` would leave the next author in this file with no diagnostic at all. + - **The pin.** `rest-server-canonical-query-ast.test.ts` censuses the PACKAGE rather than one file. `import-runner.ts` has no HTTP door — every query in it is server-built — so its census rejects a wire spelling anywhere in the file, not only inside a `query:` slot. That whole-file rule is the one that finds this class: these three literals were arguments to a helper and were never in a `query:` slot to begin with. + + ⚠️ Implementor-visible: `ImportProtocolLike` is exported, its `findData(args: any)` never declared which dialect the runner sends, and the runner now sends the canonical one. An implementation that reads `args.query.$filter` / `args.query.$top` directly — rather than through the protocol normalizer — receives `undefined` and must be updated to read `where` / `limit`. +- 3644fad: **BREAKING (runtime behaviour on a published route).** The public-form lookup-picker route + `GET /forms/:slug/lookup/:field` resolves its target object from the canonical field key + `reference` alone. The three tolerant fallback arms it used to read after it — the + `referenceTo`, `target` and `options.objectName` spellings — are deleted. + + Effect on the wire: a stored object-metadata row whose lookup field carries one of those + three spellings and no `reference` used to answer `200` with rows from the aliased object; it + now answers `500 LOOKUP_TARGET_MISSING`, and the data engine is never called. A field + carrying `reference` is unaffected, including a partially-migrated row carrying a legacy + spelling beside it. `publicPicker.object` on the form is still the explicit override and is + still read first. + + No migration is prescribed, and none is owed. `FieldSchema` is a `strictObject` that refuses + `relatedTo`, `referenceTo`, `target`, `targetObject` and `lookupObject` by name, answering + with a rename hint naming the canonical key, so no authoring path can produce such a row; a + census across both trees found no producer and no relation field carrying any of them, with + positive controls; and the maintainer ruled on 2026-09-09 that no deployment holds rows to + preserve. The spec spelling is the contract, and a stored row spelling the target the old way + is a producer defect rather than a dialect this route accommodates. + + +- 777d0c2: fix(rest,runtime): a sandboxed body that crashed now answers the sanitised 500 at the bulk REST door and at `/api/v1/actions`, instead of a declared 4xx or a 400 carrying the crash text (#17273) + + + + **BREAKING** — the answer two published doors give moves for existing inputs. No + export, signature or declared type changes; what changes is the response an + existing call observes, and a client branching on `error.code` or on the status + for the affected shape now falls to its 5xx path instead of its refusal path. + Shipped as `minor` under the launch-window convention (`major` is refused while + the fixed group versions in lockstep), so this banner — not the level — is the + breaking-ness signal. + + **What changes for an operator.** #15071 ruled that a crash inside a sandboxed + hook or action body is a FAULT, not the refusal a declared code names, and + converged the single-record `/api/v1/data` door on it. Two doors that door does + not decide kept the old answer, and both are closed here. Measured, driven end + to end: + + The bulk / metadata / UI routes — everything reporting through + `handleRouteError` / `sendThrownError` — for a crash that declared a 4xx: + + ``` + FROM 409 {"error":"hook 'guard' threw: TypeError: ctx.input.title.trim is not a function", + "code":"DELETE_RESTRICTED","object":"account"} + TO 500 {"error":"Internal server error","code":"INTERNAL_ERROR"} + ``` + + `POST /api/v1/actions/:object/:action`, for a body that really crashed inside + QuickJS (`return ctx.input.title.trim();` with a numeric `title`): + + ``` + FROM 400 {"success":false,"error":{"code":"VALIDATION_ERROR", + "message":"TypeError: not a function","httpStatus":400}} + TO 500 {"success":false,"error":{"code":"INTERNAL_ERROR", + "message":"Internal server error","httpStatus":500}} + ``` + + and, when that crash also declared a status of its own, `409 DELETE_RESTRICTED` + with the same `TypeError:` message becomes the same sanitised 500. + + The full ` '' threw: …` wrapper still reaches the server log on both + paths, so nothing an operator diagnoses with is lost. + + **The `/actions` answer was also contradicting its own published page.** The + error catalog states for this very route that "a `TypeError` / a + `ReferenceError` / a driver's own error class is a crash (500)", and this module's + header says `did it reject or crash? reject → 400; crash → 500`. The door said + 400. The code now matches the page; the page is unchanged. + + **What does NOT change.** An ordinary sandboxed REFUSAL — a body that throws a + business error and does not crash — is untouched at both doors: same status, + same code, same sentence, same structured fields. A refusal whose text merely + mentions a native error name ("Import failed with a TypeError in row 4") is + still a refusal, because the name list is anchored. Non-sandbox producers are + untouched. The 5xx passthrough arm's unconditional prose-drop is not narrowed: + the fault terminal withholds prose too. + + **Why.** A declared code, and a declared status, are the author's statement + about a failure mode they handled; a crash is not that mode. Answering one with + a business status shipped an internal, stack-shaped sentence to an end user and + told the client the wrong thing about what happened. #15071's own residue note + said closing it meant moving a status a passthrough decided — that is what this + does, deliberately and in the shrinking direction: the wire loses the crash + text and the producer's code, and gains nothing. + + **If you were relying on the old answer,** the affected shape is a sandboxed + hook or action body that FAULTS (`TypeError`, `ReferenceError`, a driver's own + class). It now surfaces as a 5xx to clients, retry policies and alerting rather + than as a 4xx — which is the point of the change. +- cf6e0a1: fix(rest): a hook that crashes after declaring a code now answers 500 UNCLASSIFIED_FAULT instead of the declared status with the crash text (#15071) + + + + **BREAKING** — the answer this published door gives moves for existing inputs. + No export, signature or declared type changes; what changes is the response an + existing call observes, and a client branching on `error.code` for the affected + shape now falls to its 5xx path instead of its refusal path. Shipped as `minor` + under the launch-window convention (`major` is refused while the fixed group + versions in lockstep), so this banner — not the level — is the breaking-ness + signal. + + **What changes for an operator.** A sandboxed hook or action body that declared a + refusal code and then CRASHED — `throw`-ing nothing, but hitting a bug on a later + line — used to answer the single-record `/api/v1/data` routes with the code's own + business status and the QuickJS debug sentence as the client-facing message, for + example `409 DELETE_RESTRICTED · "hook 'guard' threw: TypeError: x is not a + function"`. It now answers `500 UNCLASSIFIED_FAULT` with the sanitised message + and no crash text, which is what the same crash carrying no declared code has + always answered. The full wrapper still reaches the server log through the + existing `[REST] Unhandled error` / withheld-fault path, so nothing an operator + diagnoses with is lost. + + **What does NOT change.** An ordinary declared refusal — a hook that throws a + business error carrying a code and does not crash — is untouched: same status, + same code, same sentence, same structured fields. So is every non-sandbox + producer of those codes, and so is the `developerMessage` channel, which keeps + the rule it already had for a fault. + + **Why.** A declared code is the author's statement about the failure mode they + handled; a crash is not that mode. Answering one with a business status shipped + an internal, stack-shaped sentence to an end user and told the client the wrong + thing about what happened, while the door one branch down already sanitised the + identical crash. Maintainer ruling, 2026-09-04, decision batch #27, on #15071. + + **If you were relying on the old answer,** the affected shape is a hook that + declares one of the classification's ten code-gated refusals and then faults: it + now surfaces as a 5xx to clients and retry policies rather than as a 4xx. That is + the point of the change — the crash was never the refusal the code named. + +### Patch Changes + +- 3a5eaea: `packages/rest`'s fault logging gains a **declared level seam**, `OS_REST_LOG`, with the **shipped default unchanged**. At the default — and an unset or unrecognised value *is* the default — a reported fault still prints the whole `Error`: message, `cause` chain and stack frames, exactly as before. ⛔ No wire byte moves, no published payload gains a key, and no existing log line changes shape. + + What is new is that the loud/quiet choice is now **declared and machine-read** instead of implicit in whether an author happened to pass `error` or `error.message`: + + - **`OS_REST_LOG`** accepts `debug` / `info` / `warn` / `error` / `silent` — deliberately the same vocabulary and the same `'info'` default as `@objectstack/objectql`'s `OS_REGISTRY_LOG`, so the two are one logging contract with two populations rather than a second ad-hoc environment variable. Documented for operators in this package's README. + - **`scripts/check-rest-log-declared.mjs`** enforces it: the seam is located by its environment read (never a hardcoded path), the vocabulary is read from `REST_LOG_LEVELS` rather than copied, the two seams' vocabularies are held equal, a harness declaration must name a level the seam actually recognises — an unrecognised one resolves to the default *silently* — and every inline vitest project must carry its own declaration, because a root-level `env` is inert for project runs. + - **The shipped default is gated, not just documented.** Lowering `REST_LOG_DEFAULT_LEVEL` to `error` or `silent` is a finding, because at those levels this package stops reporting faults it is the only reporter of. + + **Why the default does not move.** Measured on one green `packages/rest` run: 2,095 indented `at ` frame lines, 36.7% of captured output, 100% of them arriving through this one shim. They are not dead weight. When a 5xx is withheld from the client, the log is the only copy of the driver text, and that text lives on `error.cause` — printed only because a whole `Error` object, not a summary, reaches `console.error`. Four assertions across `rest-5xx-message-sanitization.test.ts` and `rest-expected-error-logging.test.ts` pin that by asserting the **identity** of the error that arrives, one of them carrying an explicit do-not-delete warning aimed at exactly this repair. + + Operators: nothing to do. A deployment that wants the REST layer quieter can now say so — `OS_REST_LOG=error` drops the warning half, `silent` drops both — but doing so discards diagnostics that have no second copy anywhere, and the README says so at the seam. +- 2d81e39: docs(rest): the `'platform'` virtual-id docblock names the live `/environments/` URL family (#15858) + + `RestServer`'s `environmentId === 'platform'` docblock described the reserved virtual id as being addressed *"through the regular project URL shape (`/projects/platform/...`)"* — the spelling ADR-0006 v4's second addendum (D2, executed 2026-08-28) retired with **no alias and no grace period**. It now reads *"through the regular environment URL shape (`/environments/platform/...`)"*. + + **The prefix is corrected rather than the paragraph retired, because the shape is live.** The fork this card opened — *"if the shape is live the sentence needs its prefix corrected, and if it is not, the paragraph may want retiring"* — was decided by a cross-repo reading: the host enables environment scoping precisely so `/api/v1/environments/platform/...` resolves to the control-plane protocol, its kernel resolver returns no per-environment kernel for that id, and a live test drives `routePath: '/environments/platform/meta'`. Framework-side, `resolveProtocol` still short-circuits `environmentId === 'platform'` to the control-plane protocol. Every behavioural claim in the paragraph is true today; only the URL spelling and the phrase "the regular project URL shape" were not. + + What reaches a consumer of this package: the docblock ships inside `dist/index.d.ts` and `dist/index.d.cts` (and the bundles), so `projects/platform` no longer appears anywhere in the published artifact. **No behaviour moves** — comment-only, and the file is line-count neutral at 13,877 lines before and after. + + ⚠️ Two things deliberately left alone, both measured rather than overlooked: + + - The sibling site that calls `/projects/:environmentId` **"the retired spelling"** is *correct* — it documents the repair that landed under #16538. Harmonising the two would make the right one wrong. + - The same paragraph's *"It is NOT a row in the projects **table**"* is about a table, not a URL. That is a different question — it turns on what the control-plane row is called today — and it is not guessed into this edit. +- 5ce3705: `DatasetSelectionSchema` — the ADR-0021 dataset selection is a Zod declaration now, and `POST /api/v1/analytics/dataset/query` parses the whole selection against it (#17551). + + `DatasetSelection` was a TypeScript **interface** with no Zod schema anywhere in the repo. PR #17548 doored that route, but only over the **seven** members the selection shares with `AnalyticsQuery`; the other **four** — `runtimeFilter`, `dateGranularity`, `compareTo`, `totals` — were declared in TypeScript, published in the api-surface, and enforced by nothing on the wire. The measured consequence is #17550: `compareTo: { kind: 'nonsense' }` came back as a previous-period comparison under an ordinary **200**, a number a dashboard renders and a person reads as fact. + + - **One declaration, in `packages/spec`.** `DatasetSelectionSchema`, `DatasetCompareToSchema` and `DatasetTotalsSchema` are authored in `api/analytics.zod.ts`, beside the `AnalyticsQueryRequestSchema` the sibling routes parse. `@objectstack/spec/contracts` now **re-exports** the `DatasetSelection` and `DatasetCompareTo` types from that schema instead of declaring interfaces of its own — the same move `AnalyticsQuery` made in #4538, taken here before a mirror could drift. + - **A transcription, not a new contract.** The seven shared members are read straight off `AnalyticsQuerySchema.shape`, so the claim that the two agree is structural rather than a hand-written list; the four dataset-only members are the already-published TypeScript members made executable. No member is added and nothing the interface permitted is refused. + - **Refusals carry a prescription.** An unrecognised `compareTo.kind` answers the sentence `datasetCompareKindRefusalMessage` builds — what arrived, the two windows the executor implements, what to do — and `@objectstack/service-analytics`' `shiftRange` now raises that same sentence with its own origin clause, so one condition keeps one wording. An unknown key is named, echoed and pointed at the canonical spelling (`where` → `runtimeFilter`, `granularity` → `dateGranularity`), and the retired `{ offset }` arm and the pre-#5011 bare-string form each carry their rewrite. + - ⚠️ **What narrows on the wire**, so an upgrading caller can look for it: a selection member whose value the published interface never permitted now answers `400 VALIDATION_FAILED` with `details.fields[]` instead of travelling into the executor. Measured against the sibling route spelling for spelling, `runtimeFilter` now behaves exactly as `/analytics/query`'s `where` does — three structurally-malformed filter spellings (`{ $or: 'x' }`, an `$or` branch that is not a filter object, `{ $not: 5 }`) are refused at the schema on both routes, and the four semantic ones (`{ stage: {} }`, `{ amount: { $between: [10] } }`, `{ $nor: […] }`, `{ $or: [] }`) still pass both and are answered deeper. The dataset route was the looser of the two; it is not any more. + - **No valid selection changes.** Every in-repo specimen and all five `@object-ui` call sites that build a selection today still pass, pinned in both packages; the route still forwards the caller's object to the service by identity, never a parse output, and the schema carries no default or transform that could override the engine's own timezone resolution chain. +- 75237a9: fix(spec)!: `timeDimensions[].dateRange`'s array arm is exactly two string bounds, and each refusal ORIGIN gets a true sentence (#17598; ruling A, decision batch #117 item 3) + + + + **BREAKING** accept-set narrowing at `timeDimensions[].dateRange` — shipped as + `minor` under this repo's launch-window convention for breaking changes + (`scripts/check-changeset-no-major.mjs`), above the `patch` floor the `fix` + commit type sets, and the same grade the one comparable precedent took: the + STRING-arm closing on this same schema is #16041, and it shipped + `"@objectstack/spec": minor` (`packages/spec/CHANGELOG.md` 17.4.0, under Minor + Changes). ⚠️ Its driver half #16322 declares `"@objectstack/spec": patch`, but + that entry is — in that changeset's own words — "a `PROVENANCE_WAIVERS` row + only", not an accept-set narrowing, so it is not a grade this one is measured + against. The maintainer + ruling calls it a "major changeset"; under the launch window that phrase maps to + the protocol MAJOR the migration registers against (18), not to the changeset's + bump level, which `scripts/check-changeset-no-major.mjs` reserves. The semantic + prescription is registered under protocol major 18 as + `analytics-date-range-array-two-bounds-required`. + + ### What changed + + `AnalyticsDateRangeSchema`'s array arm was `z.array(z.string())` with **no length + constraint**, so `['2026-01-01']`, `[]` and `['a', 'b', 'c']` were schema-valid. + It is now `z.tuple([z.string(), z.string()])` — a tuple rather than a length + refinement, so the arity is stated to the author's compiler before any parse runs. + Preset names, two-bound windows and an absent `dateRange` parse byte-identically + to before. + + `analyticsDateRangeRefusalMessage(input)` becomes + `analyticsDateRangeRefusalMessage(input, origin)`, where `origin` is `'schema'` or + `'runtime'` and is **required** — there is deliberately no default. + + ### Migration: FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `dateRange: ['2026-01-20']` | `dateRange: ['2026-01-20', '2026-01-20']` — a single day is that day as both bounds, the shape the shipped #16322 table already prescribes | + | `dateRange: []` | no conversion. An empty array names no window: write the two bounds the widget was meant to show, or omit `dateRange` (it is optional, and absent means the query is not time-bounded) | + | `dateRange: ['a', 'b', 'c']` | no conversion. Decide which two bounds you meant and write them | + | `analyticsDateRangeRefusalMessage(value)` | `analyticsDateRangeRefusalMessage(value, 'schema')` at a parse door, `…(value, 'runtime')` past one | + + `os migrate meta --from 17` emits the first three as a structured TODO rather than + rewriting them: rewriting a one-element array to the same day twice at load would + be the platform deciding, silently, that the author meant one day rather than a + window whose end they forgot, and for the other two shapes there is nothing to + decide from. + + ### Why it is not a new class of breakage + + Since PR #17593 all four analytics faces (`ObjectQLStrategy`, `NativeSQLStrategy`, + the draft-preview evaluator, `DatasetExecutor.runCompare`) already refused anything + that is not exactly two bounds with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`, so + every stored range this narrowing refuses was **already failing at query time**. + The contract door was looser than every reader behind it; this moves the refusal + to authoring time and states it accurately. Blast radius is the WIDGET, not the + page: a stored dashboard carrying a now-refused range loses that widget with the + refusal shown and still loads. + + ### The wording half + + The shared sentence ended `"Refused at the schema"` and described every refused + array as `"received an array with a non-string bound"`. For a one-element window + refused by a face **both clauses were false** — every bound present is a string, + and it was refused past the schema, not at it — which is why + `@objectstack/service-analytics` had to overwrite the message rather than reuse it, + leaving one condition with two wordings. The origin is now a parameter and the + `received …` clause names the arity and the bad bound separately, so the sentence + is true for each origin both before and after the arm narrows. + + The same rule reaches the WIRE. Narrowing the arm to a tuple gave the union a + second voice: its arm answers `Too small: expected array to have >=2 items` for + the very arity the prescription just prescribed, and the ADR-0114 union + expansion emitted both as `fields[]` entries on `POST /analytics/query` and + `POST /analytics/dataset/query`. `fieldsFromZodIssues` (`@objectstack/types`), + the one mapper both doors report through, now drops the branch issues that land + at the union's OWN path for this refusal — recognised structurally through + `isAnalyticsDateRangeRefusalIssue`, never by message prose. A refusal that names + a DEEPER position keeps it: `dateRange: ['2026-01-01', 3]` still reports + `timeDimensions.0.dateRange.1`, because WHICH bound is not a string is a + location the prescription does not carry. Every other union expands exactly as + before. Client-visible effect: one `fields[]` entry for an arity refusal instead + of two, with the prescriptive one kept. +- 758ac40: refactor(types): one `isNativeErrorName` reader, so three doors cannot disagree about what a crash is (#17681) + + The predicate that decides whether a sandboxed body's `throw` is a business + REFUSAL (4xx, the author's words relayed) or a CRASH (5xx, the words withheld) + had **three byte-identical copies** — measured, one distinct 74-character regex + literal across three packages: + + | copy | package | its stated reason for being a copy | + |:--|:--|:--| + | `isScriptFaultMessage` | `@objectstack/rest` (`error-response.ts`, #7543) | the original | + | `isScriptCrash` | `@objectstack/objectql` (`hook-withheld-readonly-fault.ts`) | this package must not depend on `@objectstack/rest` for a regex | + | `sandboxRefusalMessage` | `@objectstack/runtime` (`sandbox/quickjs-runner.ts`, #17265) | rest declares one export subpath and re-exports nothing from `error-response` | + + ⭐ **Every reason is a statement about reaching `@objectstack/rest`, and none of + them survives moving the rule.** `@objectstack/types` now owns + `isNativeErrorName` — the name list, the `^` anchor, and the deliberate absence + of a bare `Error:`. All three packages already depend on it and it depends on + none of them, so this fold **adds zero dependency edges** and cannot cycle. + + ⚠️ The hazard was never style. One copy learning a new native error name and the + others not means the same throw is a refusal at one door and a crash at the + next — a crash message **leaked** at one boundary and **withheld** at another. + #16013's argument for extracting exactly this class applies verbatim: the + classification is the part nobody may get wrong, so one *tested* helper is worth + more than N correct copies that must each stay correct forever. + + ⛔ **No behaviour changes at any door, per case.** This is a pure refactor and + the three WRAPPERS are deliberately NOT folded, because they are not the same + shape and merging them would move a door's answer: + + - rest asks a trimmed message and answers a boolean; + - objectql asks **two** slots — `err.name` **or** `err.innerMessage.trim()` — + because a code hook and a sandboxed body carry the native name in different + places; + - runtime asks the trimmed inner message and answers the **message**, not a + boolean. + + What the three share is the predicate, so the predicate is what moved. Each call + site keeps its own slot choice and its own trimming, and `isNativeErrorName` + deliberately does **not** trim for its callers — a contract pinned in its test. + + **Shipped rather than `skip-changeset`**, measured on a real build: all four + packages publish `files[]: ["dist", …]`, and the built `dist` of each carries + the new call — `@objectstack/types` 4 files, `@objectstack/objectql` 4, + `@objectstack/rest` 3, `@objectstack/runtime` 2 — with `looksLikeInternalErrorLeak` + scoring 4 in `types/dist` as the lit control and a nonexistent symbol scoring 0. + The retired copies are gone from the artifacts too: the regex literal scores + **0** in `rest/dist`, `objectql/dist` and `runtime/dist`, and **2** in + `types/dist` (the ESM and CJS bundles). + + `@objectstack/types` takes **minor**: a new export is a purely additive widening + of a published surface, which is at least minor whatever the commit type says. + The three consumers take `patch` — their artifacts change, their behaviour does + not. +- 4d2008c: `GET /api/v1/meta/:type/:name` answers `404 RESOURCE_NOT_FOUND` for a name with nothing behind it, instead of `200` carrying the declared envelope minus its `item` member (#18066). + + Measured on a real server (`examples/app-showcase`, API 17.4.0, four absent names, all identical): + + ``` + GET /api/v1/meta/app/no_such_app_xyz + 200 {"type":"app","name":"no_such_app_xyz","lock":"none","editable":true,"deletable":true,"resettable":false} + ``` + + Two declarations in this repository already said otherwise, and this restores what they declare rather than deciding anything new. `GetMetaItemResponseSchema` — the route's own `responseSchema` — makes `item` a required member; parsing the body above against it fails `invalid_type` / `expected: 'nonoptional'` at `item`. And the **cached** arm of this same route has always answered this condition with `404 RESOURCE_NOT_FOUND`, because `getMetaItemCached` throws on a falsy `item`. Which arm a request took was deciding whether absence was an error at all — `app`, `dashboard`, `doc`, `book`, `?state=draft`, `?preview=draft`, `?package=` and every `enableCache: false` deployment are diverted around the cache. + + - **Every type is affected, not only `app`.** The fall-through sat in the shared tail of the uncached arm, below the per-type gates. The report measured `app` because that type bypasses the cache structurally; a `?state=draft` or `?package=` read of any type reached the same 200. + - ⚠️ **The break was at `JSON.stringify`, not in the producer.** `metadata-protocol`'s `getMetaItem` returns `{ type, name, item: undefined, lock, … }` for a miss — `item` is *present* holding `undefined`, which `z.unknown()` admits — so the returned object conforms and only the serialized body does not. A conformance probe written against the object rather than the wire bytes reports agreement. + - **The permission denial is unchanged.** `403 PERMISSION_DENIED` for an app that exists and whose `requiredPermissions` the session lacks answers exactly as before: the new check is ordered ahead of every gate, and those gates are reachable only by a document that exists, so an absent name can never be converted into a denial. Enumerating app names through the 403 stays impossible. + - **It also closes an enumeration hole in the other direction.** ADR-0045 §3 makes an unpublished app *externally unobservable*, and an unpublished app answered this 404 while a nonexistent name answered the 200 — so the pair of responses reported which app names exist-but-are-unpublished. Both absence answers now come from one emitter and are byte-identical. + - **An unreadable metadata store is still `503`, never this 404.** That distinction is a producer-side throw and never reaches the new check. + + ⚠️ **For callers**: a probe that read "the call did not throw" as "this name resolves" now sees the 404 it should always have seen. A caller that read the item-less 200 as a create-vs-edit signal must read the status instead. The console side was already corrected independently (objectui#9262 reads both dialects as absence), so no first-party consumer depends on the old shape. +- 5941246: fix(rest): `POST /api/v1/batch` answers the same thing for a wired-and-failing engine on every wiring — 503, the answer this slot's two other consumers already give (#18559) + + `objectQLProvider` has three consumers in `packages/rest/src/rest-server.ts`. Two reach the + seam through `wiredEngineOrLoud`, which keeps "no engine is wired" and "the engine WAS wired + and could not be resolved" as two facts. The cross-object batch door read the field directly, + so a rejection escaped the read, missed the adjacent `501 NOT_IMPLEMENTED` arm (it tests + `!ql || typeof ql.transaction !== 'function'`, which a rejection never reaches) and landed in + the handler's generic outer `catch`. + + ⛔ **Not a re-collapse and not a regression.** The two facts always differed on the wire, so + the decidable test #14251 tightened was already satisfied at this consumer. What was wrong is + that they differed *through a catch-all that knows nothing about this seam*. + + **What moves, measured on a real `RestServer` over a real `ObjectKernel`, driven at the door:** + + | wiring, engine wired and FAILING | before | after | + |:--|:--|:--| + | single-kernel (the composition the open core boots) | 503 `SERVICE_UNAVAILABLE` | 503 — unchanged | + | multi-kernel (a `kernelManager` is wired) | **500 `INTERNAL_ERROR`** | **503 `SERVICE_UNAVAILABLE`** | + + ⭐ The single-kernel row is why this is a de-divergence rather than a new wire ruling: there + `computeExecCtx` resolves the engine through its own `wiredEngineOrLoud` branch and raises + before the batch handler's engine line runs, so this door already answered 503. The 500 was + reachable only where that gate's kernel branch absorbs by design and hands the engine question + down. An operator got one of two answers for one fact depending on which composition was + running — and 500 and 503 are not synonyms to a client: one says "I am broken", the other says + "I am temporarily unavailable, retry". + + **Unchanged, and pinned:** both ABSENCE shapes still answer `501 NOT_IMPLEMENTED` on both + wirings — no provider wired at all, and a provider that RESOLVES `undefined`, which is the + seam contract declaring absence rather than failing. The fault MESSAGE is still withheld + (`Internal server error`); only the status and the machine code move. `SERVICE_UNAVAILABLE` is + an existing `StandardErrorCode` already emitted by the sibling `/meta/object/:name/state/:field` + door for this same fact — no new code, no new payload key, no new export. +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- 2b321a4: Four consumers of the implicit-reference-target contract resolve a reference field's target through `referenceTargetOf` instead of the materialized `reference` carrier, so a `{ type: 'user' }` field authored without one seeds, serves, and lints as the fully specified metadata the spec says it is (#19289). + + `IMPLICIT_REFERENCE_TARGETS` (`@objectstack/spec/data`) says a `user` field's target is "a CONSTANT OF THE TYPE, so `reference` on a `user` field materializes that constant; it does not supply it. Metadata authored without it (hand-written JSON, an AI author, a Studio form) is **fully specified, not under-specified**." Two arbiters answer two different questions — `referenceCarrierOf` what the carrier says, `referenceTargetOf` what the field points at — and for `user` only the second matches that text. #18550 standardized a population of readers on the first, which is correct wherever a site's own type gate excludes `user` and wrong wherever it does not. This is the census of that population: 17 carrier call sites judged one by one, four repaired. + + Clause-②: no + + Not a widening. It deletes a mistaken refusal of metadata the published contract already declares complete, which the charter files as `no` — 「删已发布契约文本本就否定的误拒本身是 `no`」. No key, alias or spelling is newly accepted anywhere: the target comes from the spec's own constant, never from a second way of writing it. + + - **`@objectstack/rest` — the loud one.** A `publicPicker` on a spec-complete `{ type: 'user' }` field answered `500 LOOKUP_TARGET_MISSING`, so opening a reference picker on a "responsible person" column returned an error page. It now answers `200` over `sys_user`. ⛔ This is not a re-widening of #12920's narrowing: a stored def spelling the target `referenceTo` / `target` / `options.objectName` still resolves nothing and still answers `500`, pinned in both directions. + - **`@objectstack/metadata-protocol` — the silent one, and the one that stored a wrong value.** A seed row's `{ type: 'user' }` field contributed no `dependsOn` edge and never reached `references`, so its natural key was written **verbatim** into a column that holds a record id — the dangling reference `buildDependencyGraph`'s own docblock names as the cause of broken parent joins. ⚠️ Upgrading seed authors: such a field now takes the same path the explicit `reference: 'sys_user'` spelling always took, which includes the failure path — a natural key that resolves to no `sys_user` row now DROPS the whole record, counted, reported and logged at `error`, where it was previously written verbatim. Seed `sys_user` before the referencing object, enable `multiPass`, or fix the key. + - **`@objectstack/lint` — the widest.** `object-graph`'s field slice fed `resolveFieldPath`, whose `RELATIONSHIP_FIELD_TYPES` admits `user`; a carrier-less one answered `hop-untargeted`, which `isUnjudgeable` treats as "the graph could not answer". Every rule in the package that resolves a field path therefore stopped judging any path through such a field, reporting nothing. `validate-field-consumers` separately dropped the `displayField` consumer edge onto `sys_user`, so a field that column displays was reported consumed by nobody. + - **Nothing else widens.** `user` is the only member of `IMPLICIT_REFERENCE_TARGETS`, so a `lookup` / `master_detail` / `tree` whose author-chosen target is absent still names nothing, exactly as before — pinned at every repaired site. + - **The unreadable-carrier behaviour is unchanged.** `referenceTargetOf` reads the carrier through `referenceCarrierOf` **before** it judges the type, so #13053/#18550's `TypeError` on an object- or array-valued `reference` still fires everywhere it fired before. The implicit target is not a fallback that swallows it. + - **No authoring change.** Metadata that already spells `reference: 'sys_user'` resolves to the same target it always did; nobody has to restate the constant, and nobody has to stop restating it. +- 95fb417: **The declared `zod` floor moves from `^4.4.3` to `^4.6.1`**, because on zod below 4.6.1 the three standard error formatters — `z.treeifyError()`, `error.format()` and `error.flatten()` — cannot render a refusal these packages actually emit (#19581). + + Clause-②: no + + **What breaks below the new floor.** All three formatters walked an issue's `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming a member of `Object.prototype` was answered by the prototype and no node was ever created. Two different failures follow: + + | path shape | what happened on `^4.4.3` | + |:---|:---| + | terminal element (`['assignments','__proto__']`, `['x','toString']`) | the inherited member is adopted as the node, then `node._errors.push(...)` runs on it — `TypeError: Cannot read properties of undefined (reading 'push')` | + | non-terminal element (`['__proto__', …]`) | the walk continues **into** `Object.prototype` and writes the next segment onto it — the message is silently dropped from the returned tree and the process gains a global prototype key | + + **Why it reached this platform's consumers.** `@objectstack/spec` refuses a `__proto__` key on its open-key authoring surfaces, and that refusal's issue path is `['assignments','__proto__']` — precisely the terminal shape. Anything that formatted one of these refusals for display crashed on it, and the crash was in the formatter, not in the guard. The guards themselves are unchanged and still necessary: 4.6.1 still drops a `__proto__` key from `z.record()` and `.catchall()` output, which is what they exist to refuse. + + **What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. + + `@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. + + ## The second half the floor move needs: an unknown key refuses TERMINALLY again + + From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no + longer aborts the shape that raised it. Two things follow, and both were + measured on this package with the same bodies on 4.4.3 and 4.6.1: + + 1. **A closed shape's own refinements now run after the refusal**, adding a + second complaint that contradicts the first. + 2. **A union containing that shape loses its envelope.** zod's + `handleUnionResults` returns a single non-aborted member's issues + *unwrapped* instead of raising `invalid_union`, so the union's message + becomes whichever branch zod judged closest. + + At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong + branch's prescription. Writing `type: 'page'` on a ViewItem answered: + + ``` + Unrecognized key(s) on this view container: `viewKind`, `config`. + • `viewKind` belongs to a single VIEW, not to the container. Wrap it: … + ``` + + — naming neither `page` nor its removal. It now answers, as it did before: + + ``` + config.type: 'page' was removed from the list-view `type` enum in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … + ``` + + **What an upgrading consumer must do.** Nothing. No key or value changed + status: everything this package accepted before it accepts now, and everything + it refused it still refuses. What changed is which of several competing + complaints an author reads, and that a refusal behind a union is again + reported as `invalid_union` with its branches, which is what `z.treeifyError()` + and this package's own `formatZodError` expand. + + ⚠️ A closed shape declared with a bare `z.object(…).strict()` or + `z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT + get this and will still collapse its union. Build closed authoring shapes with + `strictObject`, or re-declare an existing one through `closedObject`. +- bc80e16: fix(rest): `GET /meta/app` leaves out a `type: 'doc'` navigation entry the caller may not read (#19790) + + `DocNavItemSchema` declares that a `doc` entry the member may not read is not + rendered, and that a `book` entry is not rendered for a member with no readable + page in it. Until now only a renderer could honour that. The server's app-nav + filter pruned on `requiredPermissions`, `requiresService` and object servability, + so every member of the app got the entry. That included its label and the gated + book or doc name, however the book was gated. + + The filter now applies the docs audience (ADR-0046 §6.7) on both the list route + (`GET /meta/app`) and the by-name route (`GET /meta/app/:name`), in the top-level + navigation, inside `children` and inside `areas[].navigation`: + + - **`doc`**: the entry is dropped when the doc's effective audience does not + admit the caller. This is the answer `GET /meta/doc/:name` gives. + - **`book`**: the entry is dropped when the book's own audience does not admit + the caller, or when none of its pages is readable. A book's pages are the docs + its groups claim. The *Uncategorized* group that the book tree adds does not + count. + - **`book` + `doc`**: the entry is dropped when either of those checks fails. + - An app emptied by the prune is still served, as it is today when + `requiredPermissions` empties one. An emptied `group` or area collapses, as + with every other gate. + + The verdicts come from the same resolution that `/meta/doc`, `/meta/doc/:name`, + `/meta/book` and `/meta/book/:name/tree` now share. What those reads return is + unchanged. + + **Fails closed.** If the book or doc list read throws while `/meta/app` is being + answered, every `doc` entry is left out of that one response and a warning is + logged. The rest of the navigation is still served. If the caller's + permission-set holdings cannot be resolved, set-gated entries are dropped, as + set-gated content already is. + + **Cost.** An app list with no `doc` entry performs no extra read. Otherwise each + request adds one `book` list read, one permission-set resolution when some book + is set-gated, and one `doc` list read when a set-gated book exists or an entry + names only a book. Each of these happens once per request, however many apps are + listed. Nothing is cached across requests. +- 9401b84: **The docs reads fail closed when a gate input cannot be read.** `GET /api/v1/meta/doc/:name` and `GET /api/v1/meta/doc` decide whether a caller may read a doc from the environment's books and, on the single read, the doc corpus those books claim over. A thrown read of either was treated as an empty list — and to the audience resolver an empty book list means "no `{ permissionSet }` book anywhere" (every doc readable by any signed-in member), and an empty corpus means "no book claims this doc" (so its audience is `org`). A metadata-store fault on those reads therefore served a permission-set-gated doc, **body included**, to a signed-in member who does not hold the set, and listed it for them. + + Now the fault is answered as the fault it is, through the route's error door — the same answer `GET /api/v1/meta/book/:name/tree` has always given when its own book read fails, so the three docs reads answer one fault one way. With `@objectstack/metadata-protocol` that is `503` / `SERVICE_UNAVAILABLE`: retry once the metadata store is reachable. While the store is failing, no doc is served or listed, because without the books the gate cannot tell which docs a gated book claims. Healthy reads answer exactly as before (`200` to a holder, `403` / `PERMISSION_DENIED` to a non-holder, `401` / `UNAUTHENTICATED` to an anonymous caller). + + Nothing to change in your metadata or your clients. A client that treated a `200` from these reads during a store outage as authoritative now sees the outage instead. +- 585c9af: **The read doors beside `GET /api/v1/meta/:type/:name` now apply the plain read's per-caller gates to docs, books and object schemas.** The plain read withholds a document per caller in several ways. On `doc` and `book` it applies the documentation audience: a `{ permissionSet }`-gated doc or book is `403 PERMISSION_DENIED` to a non-holder and `401 UNAUTHENTICATED` to an anonymous caller. On `app` it applies the navigation filter: an app whose `requiredPermissions` the caller lacks is `403`, an unpublished app is `404` to a non-builder, and gated entries are left out. On `object` schemas it applies the field mask. It also applies the optional-service widget gate on `dashboard`. The doors beside it served the same stored document with none of those gates: + + - `GET …/:name/layers`, and the deprecated `GET …/:name?layers=true`, served every layer. This exposed a gated doc's or book's body to any signed-in member. Through `?layers=true`, which sits on the route anonymous callers may reach for public docs, it also exposed any doc or book to a caller who was not signed in. + - `GET …/:name/published` served a gated doc's or book's body, a gated or unpublished app whole, a dashboard's widgets bound to an optional service this deployment lacks, and an object schema's unreadable fields. + - `GET …/:name/diff` served both compared versions' values, including a doc's content, an app's navigation and an object's fields. + - `GET …/:name/history` and `GET …/:name/audit` served the change log and audit trail of a doc, book or app the caller may not open. + + What each door answers now: + + - `/published` answers exactly what the plain read answers the same caller, for every type: the same refusal, or the same pruned or masked document. That includes the dashboard widget gate: a widget bound to an optional service this deployment does not register is left out of `/published`, as it is from the plain read. + - `/layers`, `?layers=true` and `/diff` refuse a `doc`, `book` or `app` the plain read refuses whole, with the same status and code. For an app, that means one whose `requiredPermissions` the caller lacks (`403`) or an unpublished app to a non-builder (`404`). They mask object fields as the plain read does. `/diff` of a `doc`, `book` or `app` with nothing behind the name answers `404 RESOURCE_NOT_FOUND`, as the plain read does. They do not apply the dashboard widget gate or any other per-deployment gate: they show the stored version, which is what an author edits. + - For an app the caller may open, `/layers`, `?layers=true` and `/diff` answer by who is asking. A caller who may save the app (the one `PUT /meta/app/:name` admits) receives the full stored version, including the navigation entries that `requiredPermissions` or the documentation audience withhold from them: Studio's designer saves back what it loads, so a pruned load would delete those entries. Every other caller receives the app without the entries withheld from them, left out as the plain read leaves them out, on every layer and on both sides of a diff. The plain read and `/published` prune for every caller, authors included, except the plain read's `?state=draft`: it serves the pending draft, a stored version, and answers as these three doors do. An app the plain read refuses whole is refused on these doors to an author too. + - `/history` and `/audit` answer the plain read's refusal when the plain read refuses the doc, book or app whole. Otherwise they serve the events, which carry no document body. + + Types no per-caller gate judges, such as `view` and `flow`, are unchanged on every door. `dashboard` is unchanged on every door except `/published`. A caller the plain read serves in full gets the same answers as before. A client reading these doors as a caller the plain read restricts, including an integration reading `?layers=true` anonymously, now receives the plain read's answer, except that a caller who may save an app reads it whole on `/layers`, `?layers=true` and `/diff`. To read a gated doc or book through any of these doors, hold the permission set its book names. +- 2dccb7d: **The stored-version doors of an app answer by who is asking: whoever may save the app reads it whole, everyone else reads it pruned.** `GET /api/v1/meta/app/:name/layers`, the deprecated `?layers=true` and `…/diff` serve the versions Studio's designer loads and saves back. Until now they served an app the caller may open as stored to every such caller, including the navigation entries that `requiredPermissions` or the documentation audience withhold from them, which exposed those entries' names and targets to members the plain read hides them from. + + - A caller who may save the app receives the full stored version on these three doors, so a designer that saves back what it loaded keeps every entry. "May save" is exactly what `PUT /meta/app/:name` admits that caller: a system context or `manage_metadata`. `manage_org_presentation` does not save apps, so it does not qualify. + - Every other caller who may open the app receives it without the entries `requiredPermissions` or the documentation audience withhold from them, left out as the plain read leaves them out: on each layer, and in the values on both sides of a diff. A diff keeps all of its entries; only their values are pruned. + - Unchanged: the plain read (its `?preview=draft` included) and `/published` still prune for every caller, authors included. The plain read's `?state=draft` is the exception: it serves the pending draft, a stored version, and answers as these three doors do. An app the plain read refuses whole (an app-level `requiredPermissions` the caller lacks, or an unpublished app to a caller without Studio or Setup access) is still refused on every door, to an author too. `/history`, `/audit`, and the doc, book and dashboard answers do not change. + + A client that reads these doors as a non-author now receives fewer navigation entries. To read an app's full stored version there, read it as a caller the app's save door admits. + + For code that runs the shared read gate: `MetaReadGatePolicy.app` is `'gate'` or `'author-exempt'`, and a door that passes `'author-exempt'` supplies the caller's save verdict as `MetaReadGateCaller.mayWriteItem`. +- a36a691: **`GET /api/v1/meta/:type/:name?state=draft` now serves the pending draft as a stored version: whoever may save an app reads its draft whole, and nothing is left out because a service is off in this deployment.** Studio's app editors build their edit baseline by merging this draft over the layered view (`…/layers`) and save the result back as a draft. The draft read used to run the rendered read's gates, so it left out the navigation entries an author may not open. For every caller it also left out an entry, an app or a dashboard widget bound to an optional service this deployment does not register. The pruned draft replaced the whole navigation in the merge, and the author's next draft save deleted those entries without any error. + + - A caller who may save the app (the one `PUT /api/v1/meta/app/:name` admits: a system context or `manage_metadata`) receives the stored draft whole, including the entries that `requiredPermissions` or the documentation audience withhold from them. This is the answer `/layers`, `?layers=true` and `/diff` already give that caller. + - Every other caller who may open the app receives the draft without the entries `requiredPermissions` or the documentation audience withhold from them, as before. + - No caller has anything left out of a draft by a per-deployment gate any more: an app, a navigation entry or a dashboard widget whose `requiresService` names a service this deployment lacks is part of the stored draft, as on `/layers`. + - Unchanged: an app the plain read refuses whole (an app-level `requiredPermissions` the caller lacks, or an unpublished app to a caller without Studio or Setup access) is still refused on the draft read, to an author too. A read with no pending draft still answers `404 NO_DRAFT`. The rendered reads, meaning the plain read without `?state=draft`, its `?preview=draft` preview and `/published`, still prune for every caller, authors included. Docs, books and object schemas answer as before. + + A client that reads `?state=draft` as a caller who may save the app now receives every entry of the stored draft. A client that reads it as any caller now also receives the entries and widgets bound to an optional service this deployment lacks. +- 5049a3c: **Pending metadata drafts are now served only to a caller with an authoring capability — the check `GET /api/v1/meta/_drafts` already made.** A pending draft is unpublished authoring work. Until now, every door that reads one, other than `/meta/_drafts`, served it to any signed-in caller who could open the item. The draft access that the `previewDrafts` / `state` request declarations, ADR-0106 D4 and ADR-0037 described as admin-gated upstream is now gated. + + Clause-②: no + + - **The doors:** `GET /api/v1/meta/:type/:name?state=draft` and `?preview=draft`, `GET /api/v1/meta/:type?preview=draft`, and `POST /api/v1/analytics/dataset/query` with `previewDrafts: true` or `?preview=draft` on `RestServer`, plus the runtime dispatcher's `/meta` item and list `?preview=draft`. + - **Who may read drafts:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the same predicate `/meta/_drafts` asks, not a second rule. + - **Everyone else gets the read as if the draft switch were absent.** They receive the published version, pruned for them as the plain read prunes it. For a name that has nothing published, they receive that door's own absence answer: `404` on the item read, `404 NOT_FOUND` for a dataset by name, and the item simply missing from a list. The answer is byte-identical to the plain read, so it does not reveal whether a draft exists. For example, `?state=draft` on an app with no pending draft answers such a caller with the published app, not `404 NO_DRAFT`. A dataset preview run by such a caller uses live rows, never a pending seed draft's rows. + - **Unchanged:** callers with an authoring capability read exactly what they read before. Whoever may save an app reads its `?state=draft` whole, and everyone else pruned per caller. `/meta/_drafts` still answers `403` to a caller without the capability, because it lists drafts and has no published answer to fall back to. `/diff` and `/history` are not changed by this release. +- 7fa3e3e: **`GET /api/v1/meta/:type/:name/diff` and `GET /api/v1/meta/:type/:name/history` are now authoring doors: a caller without an authoring capability is refused, as `GET /api/v1/meta/_drafts` refuses.** Before this release, any signed-in caller who could open an item could read its version diff and its change history. Both doors read the metadata version log, which records a draft save exactly as it records a published save. So a member could read an item's unpublished draft through `/diff`, either by naming the draft save's version in `from`/`to` or through the default range once a draft was pending. Through `/history`, the same member could read the draft-save events. This follows the maintainer's ruling on #20378 (letter B, comment 5865708652), which pulls both doors back into the declared contract: draft and preview reads are admin-gated upstream (ADR-0106 D4). It narrows the earlier ruling that let every caller who may open an app read `/diff` pruned, for these two doors only. + + Clause-②: no + + - **Who may read them:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the predicate `/meta/_drafts` and every draft switch already ask, not a second rule. + - **Everyone else:** `403` with code `FORBIDDEN`, in the same nested `error` envelope `/meta/_drafts` answers. The refusal is decided on the caller before the query is parsed and before any item or version is read. So it is the same answer for an item that exists, one that does not, and one that exists only as a draft, and it carries no item name, version or event. The message names the door, not drafts. + - **Unchanged:** callers with an authoring capability read both doors exactly as before, per-caller pruning included: on `/diff`, whoever may save an app reads both sides whole, and any other admitted caller reads them pruned. `/layers` and the deprecated `?layers=true` read the active row, so they keep answering every caller who may open the app with the pruned plain-read answer. `/audit` is unchanged. + + A client that read `/diff` or `/history` as a member now receives `403 FORBIDDEN`. To read them, call as a caller holding one of the three capabilities above. +- 8e02859: **`GET /api/v1/meta/:type/:name/audit` is now an authoring door: a caller without an authoring capability is refused, as `/diff`, `/history` and `GET /api/v1/meta/_drafts` refuse.** Before this release, any signed-in caller who could open an item could read its protection-audit trail. Every save appends a row to that trail, a draft save included, and the row carries `note: "draft"`, the actor and the time. So a member could learn that an item had unpublished authoring work, who saved it and when. For an item that had never been published, where the plain read answers `404`, the member could learn that it existed at all. This carries the maintainer's ruling on #20378 (letter B, comment 5865708652) to this door, as triage graded on #20441: draft and preview reads are admin-gated upstream (ADR-0106 D4), and the audit trail, like the version log, has no published-only answer to fall back to. + + Clause-②: no + + - **Who may read it:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the predicate `/meta/_drafts`, `/diff`, `/history` and every draft switch already ask, not a second rule. + - **Everyone else:** `403` with code `FORBIDDEN`, in the same nested `error` envelope `/meta/_drafts` answers. The refusal is decided on the caller before the protocol is resolved, before the query is parsed and before any event is read. So it is the same answer for an item that exists, one that does not, and one that exists only as a draft, and it carries no event, actor or item name. The message names the door, not drafts. + - **Unchanged:** callers with an authoring capability read the trail exactly as before, including the per-caller refusal of an item the plain read refuses them and the organization scope of the read. + + A client that read `/audit` (`client.meta.getAudit`) as a member now receives `403 FORBIDDEN`. To read it, call as a caller holding one of the three capabilities above. +- 397572e: fix(metadata-protocol): `GET /meta/:type/:name/diff` with no `from` compares against the nearest earlier version whose body differs, so the default diff right after a publish shows what the publish changed (#20451) + + Clause-②: no — no key, export, route, parameter or response field moves; only which version the default `from` side names. + + Every draft save appends a `sys_metadata_history` row, and publishing the draft appends the same body again as the next row. The default `from` side was the history row immediately before the `to` side, so right after a publish it was the draft save the publish came from, and the default diff answered "no changes". The change the publish carried was reachable only by naming `?from=`. + + - **Now:** with no `from`, `diffMetaItem` walks back from the `to` side over the history rows it already reads and takes the nearest earlier row whose body differs, by the diff's own equality (all three buckets empty means equal). A body-less row, a delete's, compares as an empty body, so the walk stops on it and the answer names the deletion. With no earlier row that differs, the `from` side is absent: `fromVersion: null`, everything added. + - **Measured on the real REST stack**, before → after: + + | history | default range before | default range now | + |:--|:--|:--| + | v1 active, v2 draft save, v3 publish | `2 → 3`, no changes | `1 → 3`, the change the publish carried | + | the same with a v4 draft pending | `2 → 3`, no changes | `1 → 3` | + | create, delete, draft save, publish | `3 → 4`, no changes | `2 → 4`, everything added | + | create, delete, active recreate | `2 → 3`, everything added | unchanged | + | a new item draft-saved, then published | `1 → 2`, no changes | `null → 2`, everything added | + | a single version | `null → 1`, everything added | unchanged | + + - **Unchanged:** an explicit `?from=` / `?to=` names exactly its versions (`?from=2&to=3` over the first row still answers "no changes"); the default `to` side is the active version; the response shape; the one history read, with no cap. The walk compares the stored bodies before redaction, as the diff itself does, so a credential-only change still stops it and its values are still not served. + - `@objectstack/rest`: the route's OpenAPI summary states the new default. +- b43a814: **The layered view, `GET /api/v1/meta/:type/:name/layers` and the deprecated `?layers=true` flag, now answers a name with nothing behind it with the plain read's `404 RESOURCE_NOT_FOUND`, the answer it already gave a member for an unpublished app.** Before this release, a name with no layer behind it answered `200` with `code`, `overlay` and `effective` all `null`. A member asking for an unpublished app got `404`, so the difference between the two answers told the member which unpublished apps exist. ADR-0045 §3 declares a hidden app externally unobservable on every surface, and the plain read already kept that promise. This follows triage's grade on #20507. + + Clause-②: no + + - **What changed:** `createMetaLayeredAnswer`, the one chain both transports call after the store read (`RestServer` and the runtime dispatcher's `/meta` domain), answers a layered read with no layer present as the name's absence, before the per-caller gate runs. Each transport writes that absence in its own envelope, the one it already uses for an unpublished app: `RestServer`'s nested `{ error: { code: "RESOURCE_NOT_FOUND", message } }`, and the dispatcher's `404` error envelope. The flag's `Deprecation` and `Link` headers still ride that answer. + - **Who it applies to:** every caller. The plain read answers an absent name `404` whoever asks, and so does the layered view now. A builder (`studio.access` or `setup.access`) is still served an unpublished app on both spellings. A `?package=` scope that leaves no layer behind the name is that name's absence too. + - **Unchanged:** a name with any layer behind it is judged and served exactly as before. An item whose code layer is scoped away by `?package=` but whose overlay row answers is still served, with `code: null`. + + A client that read `/layers` for a name that has never been published, and took a `200` with every layer `null` as "not saved yet", now receives `404`. Treat that `404` as the same answer. Studio's metadata client already maps a `404` from this route to every layer `null`, so the designer's "open an item that exists only as a draft" path is unchanged. +- 1378ec7: fix(rest): the environment-scoped `?layers=true` answer's successor `Link` names the path the request used, not the route template (#20508) + + Clause-②: no + + On `RestServer`'s environment-scoped mount (`api.enableProjectScoping`), + `GET /api/v1/environments/env_1/meta/view/lead_all?layers=true` answered its + `Deprecation` header with a successor `Link` naming + `/api/v1/environments/:environmentId/meta/view/lead_all/layers`: the route + template, with a literal `:environmentId` in it. A client that followed the + header requested that path. The `Link` now names + `/api/v1/environments/env_1/meta/view/lead_all/layers`. + + `RestServer` builds the `Link` from the request's own path, read the way the + runtime dispatcher reads its request URL, so both transports name the successor + the same way. The unscoped mount's `Link` is unchanged for every name that needs + no percent-encoding. A percent-encoded name now stays encoded in the `Link` + (`lead%20all`, where the header used to carry a raw space), because the path is + parsed as a URL path instead of being assembled from decoded route parameters. + A request that carries no path of its own is still answered `Deprecation: true`, + and names no successor. The body, the status and the `Deprecation` header are + unchanged on both mounts. +- c1d54db: feat(spec): a metadata-form repeater's row properties have a name — `DashboardHeaderAction` fields carry a JSON Schema `title`, and `resolveMetadataFormSchemaTitles` overlays a bundle's `metadataForms..fields..label` onto a derived JSON Schema (#16458) + + ## What was wrong + + The Studio property panel renders `dashboard.header.actions[]` as a table whose + column headers read `items.properties[k].title ?? k` from the JSON Schema + derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields + (`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback + arm ran for every locale, English included, and the maker saw machine keys. + Nothing could localise them either: the only channel, `resolveMetadataFormLabels`, + decorates the `FormFieldSpec` tree, which the table never reads. And the platform + catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared + no children under the composite, so `os i18n extract` emitted no + `header.showTitle` / `header.showDescription` / `header.actions` key and the + console shipped a private overlay for exactly those three. + + ## What changed + + - **`@objectstack/spec`** — `DashboardHeaderActionSchema`'s four fields author + `.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the + derived JSON Schema names each column. New export + `resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in + `@objectstack/spec/system`: every `metadataForms..fields..label` + at any locale of the chain becomes the `title` of the node the path addresses, + stepping through an array's `items` so a repeater ROW property is addressed + as `.` (`header.actions.label`) — the same path the + extractor emits. Pure; returns the input object itself when nothing applies. + `dashboardForm` enumerates the `header` composite's children + (`showTitle`, `showDescription`, `actions` with its four row properties) with + labels equal to the schema titles, pinned equal in `dashboard.test.ts`. + The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx` + → "Metadata authoring forms". + - **`@objectstack/rest`** — `GET /api/v1/meta` localises each entry's derived + `schema` beside its `form`, through that overlay. + - **`@objectstack/platform-objects`** — the four generated `metadata-forms` + catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`, + `ja-JP` and `es-ES`. + + Additive: no key removed, no accept set changed, no parsed output moved. + + `DashboardSchema.columns` deliberately still declares no `.default(12)`, and + the reason is stronger than the one #16458 assumed. The card reasoned that the + renderer already falls back to 12, which would make `.default(12)` + behaviour-preserving. Measured at objectui `origin/main` + (`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a + `columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4` + yields 12 and everything else yields **4** — and the next line switches the + whole layout on that value (`hasExplicitColumns = schema.columns != null || + inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the + default would therefore both retire the inference and flip every auto-flow + dashboard into the positioned grid. A default that silently materialises a key + is expensive to take back, so the round stopped at the declared condition and + left the key alone; see #16458. +- c3ebe4a: A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. + + `ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. + + The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. + + **What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. + + **What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. + + **For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. +- a900841: fix(rest): `/discovery` no longer contradicts itself — `services.*.route` follows the mounted paths, like `routes.*` already did (#16674) + + The `/discovery` document states each service's address twice: once in `routes.X` (the flat convenience map) and once in `services.Y.route` (the per-slot entry). The REST discovery handler rewrote only the first half to the paths this server actually mounts, so any deployment that moved a prefix received a document that disagreed with itself — and the `services` half pointed at a path with nothing mounted on it. + + Measured on a boot with `crud: { dataPrefix: '/objects' }`, reading `GET /api/v1/discovery`: + + - before — `routes.data` = `/api/v1/objects` (the mounted path), `services.data.route` = `/api/v1/data` (unmounted) + - after — both answer `/api/v1/objects` + + The same split opened on four keys at once for an `apiPath` deployment: `data`, `metadata`, `ui` and `auth`. All four now follow the mount. `services.*.route` is written as a projection of the finished `routes` map, so the two halves cannot state different answers whatever a future substitution does to `routes`. + + **A default deployment's document does not move by a byte.** With `crud.dataPrefix` at its `/data` default and `metadata.prefix` at `/meta`, the values the correction writes are the values that were already there; only a deployment that had moved a prefix sees a change, and there the old value addressed nothing. Route-less slots (`cache`, `queue`, `job`, and an in-process `realtime` bus) never gain a route, and no advertisement is withdrawn. + + If you have been reading `services.data.route` on a moved-prefix deployment and compensating for it — by re-deriving the path from `routes.data`, or by hard-coding the prefix — that workaround can go: the field now answers the mounted path directly. +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- e77a23f: Attach four TSDoc blocks to the declarations they describe. + + TSDoc binds a block by position, so a block can end up describing a declaration + it does not document, or none at all. Four had: three in + `packages/rest/src/rest-server.ts` (the `resolveProtocol` paragraph stacked above + `resolveHostnameCached`'s own block, the exported `RestServer` class overview + orphaned by the `RestEnvRegistry` block, and the `registerSharingEndpoints` route + table orphaned by the analytics block) and one in + `packages/runtime/src/http-dispatcher.ts`, where the block above + `resolveActiveOrganizationId` still described `resolveCallerUserId`, a sibling + deleted with the multi-tenant `/cloud` control plane. + + No runtime behaviour changes and no API surface moves. This is a `patch` rather + than `skip-changeset` because the block text was measured to ship: each of the + four appears in the published `dist/index.d.ts` and `dist/index.d.cts` of its + package, both of which are inside `files: ["dist", ...]`. Anyone reading + `@objectstack/rest` or `@objectstack/runtime` declarations in an editor was being + shown a description of the wrong function. + + Clause-②: no +- dfb42c5: fix(rest): `GET /meta/object/:name/state/:field` tells a wired-and-failing engine apart from an absent one (#15405) + + `objectQLProvider` has two consumers in `rest-server.ts`. #13476 repaired one of them — the `computeExecCtx` authorization-input seam — by reaching the provider through `wiredEngineOrLoud`, which keeps "no engine is wired" and "the engine was wired and could not be resolved" as two facts instead of one `undefined`. This route, the slot's second consumer, reached it through `.catch(() => undefined)` and converted every rejection straight back into the `undefined` a never-registered engine produces, three lines before the answer is chosen. So a wired-and-failing engine and a never-registered one both answered `404 NOT_FOUND · "Object not found"` — a diagnostic route lying about the cause during exactly the incident it would be consulted in. + + That line was newly load-bearing rather than long-broken: before #13904 the shipped provider was `try { … } catch { return undefined; }` and could not reject at all, so the `.catch` was dead code. #13904 made the provider re-raise precisely so a consumer could see the outage, and this consumer caught it back. + + **What moves.** On this route only, an engine that is wired and fails to resolve now answers `503 SERVICE_UNAVAILABLE` instead of `404 NOT_FOUND` — the same answer its sibling seam and the package door (#13476) already give for the same fault. No accept set widens and no new wire code is minted: `SERVICE_UNAVAILABLE` is an existing `StandardErrorCode` member, reached through the existing `AuthzStoreUnavailableError`. + + **What does not move.** An engine that was never wired, and a provider that resolves `undefined` (the seam contract declaring absence rather than failing), both keep the `404 NOT_FOUND` they answered before — that is the supported no-data-plane composition. A healthy engine asked about an object that genuinely does not exist still answers `404 NOT_FOUND`; a healthy engine asked about an object that exists is still served. + + **Reachability, stated rather than implied.** Every `/meta` route sits behind the anonymous-deny gate, and that gate resolves the same engine first. Where it takes its provider branch (a single-kernel boot such as `pnpm dev:crm`) a broken engine already raised there, before this route's line ran — so nothing changes for those deployments. The collapse was reachable where a resolvable kernel supplies auth and the separately-wired `objectQLProvider` is broken, which is the multi-kernel wiring, and that is where the new answer lands. + + `POST /email/send` carried the other retired `.catch(() => undefined)` in the same file and moves to `seamOrUndefined`. Its answer is deliberately unchanged at `501 NOT_IMPLEMENTED`; what changes is that a host wiring a **non-`async`** provider — which the seam's declared type cannot prevent — now reaches that same 501 instead of throwing past a `.catch` that did not exist yet and landing in the handler's own `500 EMAIL_SEND_FAILED`. Not reachable from the shipped wiring, where both providers are declared `async`; repaired because it is the same spelling at an embedder-reachable seam. +- 2e8e118: Documentation only: seven in-source prose sites that still stated the superseded readonly-on-INSERT contract as live now state the ruled one. + + The 2026-09-03 maintainer ruling (option C, #14147) put the static `readonly` strip inside `engine.insert` under the same `isSystem` gate as `engine.update`, and deleted the metadata-protocol create-ingress copy. Comments and test headers written before that ruling still said, in the present tense, that a non-system INSERT is exempt from the static strip, or that the strip lives at the DataProtocol create ingress. Each now states the ruled contract, and the superseded sentence is kept only as history, marked as superseded. + + No behaviour changes and no test was deleted, skipped or re-scoped — the diff is comments only. It is a `patch` rather than `skip-changeset` because it was measured to publish: `@objectstack/objectql`'s comment edit moves source line numbers, so `dist/{index,core}.{js,mjs}.map` change, and `@objectstack/rest` inlines that same objectql source into its bundle, so `dist/index.{js,cjs}.map` change with it. Every emitted `.js` / `.mjs` / `.cjs` and every `.d.ts` / `.d.mts` / `.d.cts` is byte-identical before and after, and all six maps ship inside the published tarballs. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/observability@17.5.0 + - @objectstack/service-package@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/rest/package.json b/packages/rest/package.json index 5885f560b75..e2640ca86bb 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack REST API Server - automatic REST endpoint generation from protocol", "type": "module", diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index 8f7444b387d..438a6808027 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,2740 @@ # @objectstack/runtime +## 17.5.0 + +### Minor Changes + +- 7d0f911: **BREAKING for action handlers** — `ActionEngineFacade.find` takes the engine's query ENVELOPE; the bare-filter parameter shape is withdrawn (#15124) + + Clause-②: yes (narrowing) + + `ctx.engine.find(object, query)` now takes `EngineQueryOptions` — the same + options bag `IDataEngine.find` and ObjectQL's own `engine.find` take, named by + identity rather than restated. **One platform, one query shape.** + + ### Migration — FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `ctx.engine.find('task', { status: 'open' })` | `ctx.engine.find('task', { where: { status: 'open' } })` | + | `ctx.engine.find('task', { amount: { $gt: 100 } })` | `ctx.engine.find('task', { where: { amount: { $gt: 100 } } })` | + | `ctx.engine.find('task', {})` | unchanged — an empty envelope is still the unfiltered read | + + The rewrite is lossless and mechanical: the filter moves under `where`, verbatim. + `tsc --noEmit` over your handlers finds every unmigrated call — see below. + + ### Why the shape was withdrawn rather than the bar closed + + Until now this parameter was the `where` HALF of a query while every other + `find` on the platform took the whole envelope, and the runtime wrapped what it + was given. That made the most natural spelling the wrong one, silently: an + author who passed the engine's own envelope reached the engine as + `{ where: { where: … } }` — a filter on a field named `where` — which matches no + row and resolves to `[]` with **no error at all**. A handler that made the + mistake ran to completion over zero rows for as long as it shipped, and its own + hand-written test double, written to the same belief, passed every assertion. + Because an empty `{}` skipped the wrap, one unfiltered read kept working under + either belief, so a dead handler looked partially alive. + + Refusing `where` at the top level instead — intersecting the old parameter with + `{ where?: never }` — was rejected: it asserts a vocabulary fact the spec + declares nowhere, reserving the field name `where` across every customer's data + model to buy one parameter's compile-time check. Aligning the parameter removes + the ambiguity at its root and reserves nothing. + + ### What the new declaration refuses, measured + + If your handler is typed with the published `ActionHandlerContext`, a bare filter + no longer type-checks on **either** path you can reach it by: + + - an object literal (`{ status: 'completed' }`) fails the excess-property check — + a field name is not an envelope key; + - a filter held in a `FilterCondition` variable fails **TS2559** — every envelope + key is optional, so a bag of field names has no property in common with it. + + The envelope's own keys are typed too: `where: 'a = b'`, `fields: 'id,subject'` + and `limit: '50'` are each refused. + + **If your handler is NOT typed with it** — a handler in an `objectstack.config.js` + / `.mjs`, one annotated with your own copy of the context type, or a `(ctx: any)` + handler — nothing above reaches you, so the facade refuses the withdrawn shape at + **runtime** instead, before the engine, with the same prescription: + + ``` + find('task') was given a key 'status' the query envelope does not carry. + ctx.engine.find(object, query) takes the engine QUERY ENVELOPE, not a bare + filter — move the filter under `where`: find(object, { where: { … } }). + Envelope keys: context, cursor, distinct, expand, fields, limit, offset, + orderBy, search, searchFields, top, where. + ``` + + ⚠️ **That refusal matters most for a filter whose value is `null`.** The engine's + own unknown-option check exempts a `null` value, because on an option bag a + `null` is a withdrawal. On a filter it is the "rows with no X" idiom, so + `{ deleted_at: null }` would have been dropped unexecuted and the read would have + widened to **every row** — including the ones you were excluding — with no error + at all. It is refused instead. + + ### What this opens + + `fields`, `orderBy`, `limit`, `offset` and `expand` are reachable from an action + handler for the first time — under the old parameter there was nowhere to carry + them. A caller-supplied `context` is **ignored**: this facade is trusted and + context-less by design, and the runtime stamps its own elevated + `ExecutionContext` last. Do not write one — it reads as authorization and is + none. + + ### Checking a migrated handler + + Do not settle for "it still resolves". A handler that had been passing the + envelope was returning `[]` on **every** call, so a suite written against the + mistake passes and the row count is the only witness. Re-run each migrated + handler against seeded data and assert it returns the rows its filter selects. + + +- 08b213e: feat(mcp): `resume_run` continues a flow run that paused on a screen, behind the same gates as `run_action` (#15705) + + Clause-②: yes + + **What changed.** `run_action` on a flow action whose flow stops on a `screen` node answers `status: "paused"` with a `runId` and the `screen` to fill in. Until now nothing on the MCP surface could submit that screen, so the run stayed parked: an agent could start such an action but never finish it. The new MCP tool `resume_run({ runId, values?, confirm? })` submits the screen's field values (keyed by the names in `screen.fields`) and the run continues. It answers with `run_action`'s envelope, `{ ok, action, objectName, recordId?, result }`. A run that pauses on its next screen comes back paused again, so a multi-screen wizard is walked by calling `resume_run` once per screen. + + **Which runs it continues, and no others.** The runtime's bridge admits a call only where `run_action` would admit starting the same flow on the same record for this caller now: + + - **Only the caller's own run.** The run's trigger identity must be the caller. Another user's run, an unknown id and a finished run all answer the same `404 RESOURCE_NOT_FOUND`. A resumed run continues under the identity of the user who started it, so only that user may continue it. + - **`run_action`'s gates, with `run_action`'s helpers.** A `type: 'flow'` action whose `target` is the run's flow, on the run's object, must be AI-exposed (`ai.exposed`), must pass the caller's `requiredPermissions` and must not be switched off (`ACTION_DISABLED`, `409`). An action flagged `ai.requiresConfirmation` needs `confirm: true` on the resume too (`ACTION_CONFIRMATION_REQUIRED`, `428`), because the flow's writes happen after the screen. The exposure and permission refusals answer `403 PERMISSION_DENIED`. So does a run that no flow action targets. + - **The subject record is read again as the caller.** A record the caller can no longer read is refused `404 RECORD_NOT_FOUND`, which is how `run_action` refuses it. + - **Screen pauses only.** A run waiting on anything else (a timer `wait`, an approval) is refused `409 RESOURCE_CONFLICT` and left as it is. + + Every refusal happens before the engine is asked, so the run stays parked. The engine's own answers (a screen submission missing a required field, a concurrent resume, a run that resumed and then failed) reach the caller with the code, status, message and `details` that `POST /api/v1/automation/:name/runs/:runId/resume` gives for the same result. The two doors now share one classification of the engine's answer. It was moved out of the REST route unchanged, and the route's answers are byte-identical. + + **For hosts.** `McpActionBridge` gains an OPTIONAL member, `resumeRun(runId, { values?, confirm? })`. A bridge that implements it gets `resume_run` beside `run_action`, under the same `actions:execute` OAuth scope, on both the HTTP and the stdio transport. A bridge without it is unchanged and does not list the tool. `run_action`'s description names `resume_run` only where it is registered. `@objectstack/runtime`'s MCP bridge implements the member. +- 1a25f4a: fix(runtime): `GET /api/v1/packages/:id` honours `?version=` instead of silently ignoring it (#17416) + + The route accepted a `?version=` query parameter and the only surface serving it + never read the parameter. A caller asking for a version that is not installed + was answered `200` with the **installed** row, and nothing in the status, + headers or body distinguished that from a version-scoped read that actually + happened. + + The parameter is not hypothetical traffic: `ScopedEnvironmentClient.packages.get` + (`@objectstack/client`) declares `version?: string` and appends it, so the SDK + has been sending a parameter the runtime dropped. The handler that honoured it + — the REST registrar's twin of this route — was removed with the duplicate + response shape, and the dispatcher's `/packages` domain never had that read to + inherit. + + ``` + FROM GET /api/v1/packages/com.acme.crm?version=99.0.0 (1.0.0 installed) + -> 200 { data: { manifest: { version: "1.0.0" }, … } } + + TO GET /api/v1/packages/com.acme.crm?version=99.0.0 + -> 404 { error: { message: "Package 'com.acme.crm' version '99.0.0' not + found — installed version is '1.0.0'" } } + ``` + + **What does not change.** The unversioned read is untouched, down to the row and + the writability verdict it stamps — pinned as the lit control beside the new + assertions, because a green on only the scoped path would also pass with the + ordinary read broken. `?version=` naming the installed version is served + exactly as the unversioned read is, and so is `?version=latest`: the deleted + handler read `requested.value || 'latest'` and its store resolved `latest` to + the newest row, so "no version" and "`latest`" named one request there and name + one request here. An id the registry does not hold keeps its existing 404 + wording whether or not `?version=` rode along — a package that is not installed + cannot be at the wrong version. + + **This is request-side only.** The response shape is not touched, so the route + still answers with exactly one body shape; comparison is exact string equality + on the version, the same predicate the durable package store uses (`AND version + = ?`), so the two answers to "is this package at version v" cannot drift into + semver-range semantics at one of them. + + A repeated `?version=a&version=b` is no longer resolved by silently choosing + one — it is answered with a refusal naming what was seen. The repo's one rule + for a repeated single-valued parameter answers `400 VALIDATION_ERROR` and is + the right end state for this door too; it is not restated here, because the + helper that owns that rule and its message is not exported from + `@objectstack/rest`. +- 182bbde: the resume door's `repairable` is answered by the engine on the exits that stamp no status — `IAutomationService` declares the read-only `inspectConsumedSuspension` (#17541) + + Clause-②: yes (widening) + + The resume route's `400 FLOW_FAILED` details computed `repairable` as the single + expression `status === 'stranded'`. That word is stamped on exactly one exit — + the run that consumed its OWN pause and then threw downstream. The subflow + DELEGATION exit stamps nothing on purpose: a caller resumes the PARENT, the + signal is forwarded down, the child strands, and the parent frame answers + `{ success: false, error, durationMs }`, because nothing re-arms an ancestor by + resuming it and stamping `'stranded'` there would send an operator to retry a + recovery that cannot succeed. + + Since the nested-chain restore landed, that parent's consumed pause IS + journalled and one `restoreConsumedSuspension(parentRunId)` re-arms the whole + chain leaf-first. So the wire answered `repairable: false` about a run the + operator verb WILL repair, and a client written exactly as the reference page + instructs closed it as terminal. Measured through the HTTP route, before and + after, on the same parked delegation: + + ```json + before 400 { "error": { "code": "FLOW_FAILED", + "details": { "runId": "run_…", "repairable": false } } } + after 400 { "error": { "code": "FLOW_FAILED", + "details": { "runId": "run_…", "repairable": true } } } + ``` + + …while at that same instant the engine answered + `inspectConsumedSuspension(runId) → { repairable: true, witness: 'journal' }` + and `restoreConsumedSuspension(runId) → { restored: true, chain: [child, parent] }`. + + **`@objectstack/spec` — additive, `minor`.** `IAutomationService` declares the + optional read-only member `inspectConsumedSuspension(runId)`, which + `AutomationEngine` already implements publicly: would the restore verb have a + consumed suspension to put back for this run? It re-arms nothing and reads the + same two witnesses that verb reads, so what it calls repairable IS what that + verb restores. The declared result is deliberately narrower than the + implementation's, the way `restoreConsumedSuspension`'s already is — `reason` is + typed as the string the implementation answers, not as an enumeration this + contract would have to keep in step, and the engine's wider type satisfies it + under `implements`. `ResumeFailureDetailsSchema.repairable`'s `.describe()` is + rewritten to the truth and the generated reference page regenerated with it. No + key is added, renamed or retired on any wire schema. + + **`@objectstack/runtime` — the door.** On a `400 FLOW_FAILED` whose result + carries a `status`, that stamp still decides, and the engine is not consulted at + all. On a result that carries none, the door asks the declared member and relays + its `repairable`. Both ways of not getting an answer are FAIL-CLOSED: a service + that declares no inspection member answers `false` exactly as it did before, and + an inspection that REJECTS (a store it could not read) answers `false` and says + so once at `warn` — an unreadable store is UNKNOWN, not "nothing to restore", + and it is never allowed to replace the `400` the caller asked for with a `500`. + + ⛔ The fence is untouched: a cascade-failed ancestor is still never STAMPED + `'stranded'`. Its repairability is carried by the journal and REPORTED by the + inspection, which is exactly why the door asks instead of reading a word. ⛔ And + no new `AutomationResult.status` member is minted for this exit — there is + nothing new for a client to learn, and `details.repairable` is the member a + client was already told to branch on. +- 2b6a207: fix(runtime): the API root is the discovery route, under a second spelling — a gated session's `GET ${prefix}/` reaches discovery again (#17625) + + `HttpDispatcher.dispatch()` strips one trailing slash, so both root spellings it + accepts collapsed onto the empty string: `${prefix}/` arrives as `/` and + `${prefix}` arrives as `` (the MSW / base-URL-stripped form). Only the discovery + branch at the foot of the method knew that empty string meant the API root. The + ADR-0069 authentication-policy gate, which runs far above it, did not. + + That disagreement was invisible while `isAuthGateAllowlisted` answered `true` + for a falsy path. objectstack#7898 made the predicate fail-closed at the source + — exemption is now something a path EARNS by naming an allow-listed route — and + the bare-root discovery request started answering 403 for a session carrying an + `authGate` posture (expired password, required MFA): + + ``` + FROM GET ${prefix}/ (session with user.authGate) -> 200 discovery document + TO GET ${prefix}/ (session with user.authGate) -> 403 PASSWORD_EXPIRED // regression + NOW GET ${prefix}/ (session with user.authGate) -> 200 discovery document + ``` + + **Normalising the root to `/` is measured insufficient and is not what landed.** + `isAuthGateAllowlisted('/')` is `false` — a segment-less path matches no + `ALLOW_ROUTES` entry — and the discovery branch tests `/discovery` or the empty + string, neither of which `/` satisfies. `'' -> '/'` therefore relocates the 403 + rather than removing it. Both legs are pinned upstream in + `packages/core/src/security/auth-gate.test.ts` ("does not exempt the dispatcher + bare-root `cleanPath` — step 2 is #17625"). + + The root is canonicalised to `/discovery` instead — the route it has always + served — read from one constant by both the canonicalisation and the branch that + serves it, so the two cannot drift into a third disagreement about what the + empty path means. + + **⛔ No allow-list was widened and `packages/core` is untouched.** The only input + whose gate answer moves is the API root, and it gains exactly the exemption + `/discovery` already carried, by BEING that route — no new information is + reachable, since `/discovery` was already exempt and already outside the + project-membership skip check. A caller that reaches the gate with no path at + all is still refused at the predicate, and the pathless case stays declared + where it lives (`shouldDenyAnonymous`) rather than re-derived at this seam. + + **What does NOT change.** `${prefix}` with no trailing slash keeps serving the + same document; the named `/discovery` route is untouched; the + environment-scoped root `${prefix}/environments/` keeps its own answer, + which matched no allow-listed route before objectstack#7898 either. `//` strips + to `/`, not to the empty string, so it is not the root and is not canonicalised. + + **Why `minor` on a change whose commit type is `fix`.** The two are independent + and the floor is mechanical, not editorial: this PR's clause ② is declared + affirmative, and the maintainer's ruling of 2026-09-04 (decision batch #35, on + objectstack#15294) puts an affirmative clause ② on a package whose + `packages/**/src/**` the diff moves at AT LEAST `minor` — *the commit type may + raise a bump but never lower it below what the act requires*, written out under + "WHICH LEVEL" in the `Check Changeset` step of + `.github/workflows/pr-automation.yml`. ⛔ So the reading that this is "a 403 that + should be a 200, therefore a patch" is an argument about INTENT and does not + reach the level: the act re-admits an input class the merged tree refuses, on an + authorisation surface, and that is what the level grades. The commit type stays + `fix(runtime)`, because the type describes the act and the level prices it. + + **ADR-0087 disposition: no ledger entry is owed and no marker is required.** + This changeset declares no breaking change, which is the only condition under + which `check:adr-0087-registration` demands a disposition marker. On the + substance: no ADR-0087 shape surface moved — the diff touches one + `packages/runtime` transport file and its sibling test, no `*.zod.ts`, no + `packages/spec/**`, no `packages/spec/src/contracts/**` entry and no object + definition — so `objectstack migrate meta` has nothing to reach, and no + authorable metadata key, accept set or stored shape changes. Nor is this an + ADR-0087 conversion-layer entry: nothing lenient is being accepted from a + metadata producer. One transport's two spellings of its own route are being + reconciled to the route's own name, which is the opposite direction — a dialect + removed, not tolerated. +- 156792e: The package-install request contract now names the door that actually serves it, declares the two body forms that door accepts, and the door honours `enableOnInstall` instead of ignoring it (#18058). + + `PackageInstallRequestSchema` was declared, published and bound to `POST /api/v1/packages/install` — a path the composed runtime mounts nowhere: the dispatcher answers `handled=false` and `@objectstack/rest`'s registrar mounts only `POST /api/v1/packages/publish`. Meanwhile `POST /api/v1/packages`, the door that answers `201`, had no declared request contract at all, so the read contract was strictly more truthful than the write contract producing the rows it describes. + + Clause-②: yes (widening) + + **What moved on the published surface** + + - `PackageApiContracts.installPackage.path` — `'/api/v1/packages/install'` → `'/api/v1/packages'`. A caller that read the constant to build a URL was building one nothing serves; a caller that hard-coded the old string gets a `404` today and should send `POST /api/v1/packages`. The method (`POST`) is unchanged and is what distinguishes this entry from `listPackages`. + - `PackageApiContracts.installPackage.input` — `PackageInstallRequestSchema` → the new `PackageInstallBodySchema`. The wrapped schema is still exported and still parses the wrapped form; the new export is a union that also parses a bare manifest. + - `PackageInstallRequestSchema` gains **`overwrite?: boolean`**. This is a declaration of behaviour that already shipped: the door reads `overwrite` from the body (or `?overwrite=true`) to opt back in to replacing an already-installed id instead of answering `409 Conflict`, the first-party SDK sends it, and no schema declared it — so any parse at that door would have silently stripped it and turned a deliberate re-install into a conflict. + - **`PackageInstallBodySchema`** / `PackageInstallBody` / `PackageInstallBodyParsed` are new. The door reads `body.manifest || body`, and first-party callers really do post a bare manifest as the whole body, so the contract declares both forms as a union — every parse is a full parse of one coherent form, never a tolerant shape. The two branches are disjoint, but only the BARE one is CLOSED: `PackageInstallRequestSchema` is a plain `z.object`, so an unknown key on the wrapped form is DROPPED (`{ manifest, bogus: 1 }` parses and `bogus` is gone) while the same key on a bare manifest is refused by name. That asymmetry matches the door, which reads four keys off the wrapper and ignores the rest — closing the wrapped branch would refuse bodies the door answers `201` to. The bare form carries no install options: `settings`, `enableOnInstall` and `overwrite` are not manifest keys and the manifest surface is closed, so a bare-form caller reaches `overwrite` through the query string alone. + + **What moved at the runtime** + + `POST /api/v1/packages` now honours `enableOnInstall: false` in the wrapped body: the package installs `disabled`, through the same registry flip and durable state write `PATCH /packages/:id/disable` uses, so a restart does not re-enable what the caller switched off. `true` and absent install enabled, which is the declared default. Previously the key was declared in three schemas, sent by the SDK, and read by no handler at all. + + The durable write happens on **both** arms, not just the disable. `POST /packages` is a create that an already-installed id reaches through `overwrite`, and `DELETE /packages/:id` does not clear this record either, so an install could answer `201` with `enabled: true` while the state file still listed the id as disabled — and `SchemaRegistry.installPackage` reads that file at boot, re-installing the package DISABLED one restart later with nothing red in between. The mirror of that risk is why the write follows the ROW this door returned rather than the request's intent: `SchemaRegistry.installPackage` lands an id in the boot-seeded `initialDisabledPackageIds` DISABLED whatever the request says, and `enableOnInstall` defaults to `true`, so persisting the request would clear an operator's earlier disable off disk on the SDK's default call while the row being served says `enabled: false`. A flag-absent install of a seeded id therefore answers `enabled: false` and records it disabled — wire, registry and disk agree, and the next boot reads the same. Every install now persists the state it returned. + + **What the declaration does NOT cover — the measured residual** + + This is a subset description of the live door, deliberately, and it is recorded rather than implied. Measured through `HttpDispatcher.handlePackages`, the door also answers `201` to: a manifest missing `type` and/or `version` (both of the runtime's own door drives post one); unknown keys on either form (refused by name on the bare branch, dropped on the wrapped one, `201` either way); a string-typed `enableOnInstall` / `overwrite`, which is compared against `true`/`false`/`'true'` and therefore treated as absent — `enableOnInstall: 'false'` installs ENABLED; and install options spelled on the bare form, which are ignored. In the opposite direction the door answers `400` to a whitespace-only `id` this declaration admits. `ManifestSchema` is not relaxed to close any of that. + + **Documentation** + + `packages/client`'s README install example could not parse against the manifest contract — no `id`, no `type`, and a `label` key the closed manifest surface refuses by name — and the live door answered it `400 Package id is required`. It is now a manifest that parses, and the example names the `overwrite` opt-in beside it. +- 74832b6: **Breaking (shipped as `minor` under the launch-window convention).** Under a **walled** tenancy posture (`group` / `isolated`), a legacy unscoped `admin_full_access` grant row no longer confers `PLATFORM_ADMIN`; platform standing there is derived from `OS_PLATFORM_OWNER_EMAIL` and from nothing else. The migration pointer that announced this since 17.3.0 is retired with it: `reportLegacyPlatformAdminGrant` and `resetLegacyPlatformAdminGrantReport` are **removed from `@objectstack/core`'s published entry** (#18336, #11663 leg L5). + + ⚠️ **The `single` posture is untouched, deliberately.** Its zero-config first-user promotion still mints that row and that row still confers `PLATFORM_ADMIN` — a development environment started for a moment cannot be asked to declare an administrator first. Choice 4A (#11974) rules that promotion correct, and the maintainer's 2026-09-08 ruling on #16682 is verbatim: 「retiring the walled write must not retire the `single` one」. The `single` half's disposition is #11979's. ADR-0131 D5, as amended 2026-09-17 (#18413), is the governing record. + + **What a walled deployment must do.** Declare each administrator's **verified** address in `OS_PLATFORM_OWNER_EMAIL` (comma-separated for several) before upgrading. A walled rig that upgrades with the variable undeclared and an unscoped grant row still in place has **zero** platform administrators; the bootstrap now says so **at error**, naming the variable, the row and its holder — L4 used to skip that line for exactly this rig, on the ground that the deprecation pointer carried the remedy instead, and both halves of that arrangement have now expired. + + - **17.3.0 opened the window, this closes it.** L4 (17.3.0) stopped the walled bootstrap from ever *writing* the row and started the once-per-process pointer; L5 stops the walled derivation from *reading* it. The window was time-boxed and loud by design (#11663 P5). + - **The retirement takes the ANCHOR, not the ROW.** Nothing here writes, deletes or re-owns any grant row — a walled holder keeps the `admin_full_access` permission set they hold, and loses only platform-admin *standing*: the rung and the built-in `platform_admin` position. That row's ownership is ADR-0131 C3's, on the v18 line. + - **No new query.** The posture gate reads the environment, never the engine, so the recorded query multiset is identical under both of its answers — measured, not asserted. Under a wall the guard's grade-1 scan is skipped outright, so that path issues one read fewer. + - **`@objectstack/plugin-auth` moves with it, at TWO readers.** `ensureDefaultOrganization`'s step-2 legacy fallback is keyed on the same expression: under a wall it no longer answers「which user is the platform admin?」from the oldest unscoped grant, so the account it would have bound as the Default Organization's `owner` — and handed the org's seeded rows to — is no longer selected. ⛔ That reader does not merely count the population, it **confers** on it, which is why it is keyed here rather than sequenced. Its bootstrap-trigger predicate retires the matching `sys_user_permission_set`-insert arm under a wall with it (cost only; the `sys_user` arms are untouched, and on a walled rig the declared owner's verifying update is the only write that ever grows the population). And: + - **`@objectstack/plugin-auth`'s break-glass guard moves with it.** `last-admin-guard.ts` enumerates the administrator population from the SAME anchor, and its contract is to answer the same question the derivation answers. Its grade-1 (grant-anchored) enumeration is now keyed on the identical expression, so under a wall the guard no longer counts a holder the derivation does not recognise. Consequence on a walled rig: a write that would end the last **config**-anchored administrator's standing is now REFUSED where it was permitted, and a write that removes the now-inert grant row is no longer refused as though it removed the last administrator. Under `single` the guard is unchanged. Its two zero-population refusals also gained a walled clause, because「restore the `admin_full_access` row」stopped being a remedy that ends the emptiness there. + - **`@objectstack/organizations`' walled bootstrap moves with it.** That package wraps `ensureDefaultOrganization` and is the runtime that actually performs the default-organization bootstrap on a walled deployment (plugin-auth's own wiring skips it there). With the helper's legacy fallback keyed off, a walled rig carrying a legacy grant row **no longer** has a Default Organization created for that holder, and that holder is no longer bound as its `owner`; the bootstrap waits for a declared administrator to verify instead. ⚠️ Named because the behaviour an operator gets **from this package** moves — its own source does not change, and the pin re-authored inside it is not the reason. + - **Why `@objectstack/runtime` and `@objectstack/plugin-hono-server` are named.** Neither package's own source changes. Both carry `export * from '@objectstack/core'` (`runtime/src/index.ts`, `plugin-hono-server/src/adapter.ts`) and their built `.d.ts` carry that statement, so the two removed names leave their published surfaces too. All publishable packages sit in one Changesets `fixed` group, so naming them moves no version — it is named so the tombstone reaches the CHANGELOG an upgrading consumer of THOSE packages greps. Precedent is mixed (a core-only declaration exists); this follows the `ApiRegistry` precedent, which named every package the removal reached. + + +- fc91239: feat(spec)!: the canon for "the version of a package or plugin" is SemVer 2.0.0 — nine carriers, one grammar + + Clause-②: yes (narrowing) + + + + **BREAKING** — four published accept sets converge on one, and the fringe each + of them carried outside SemVer 2.0.0 is refused. The widening half needs no + action from anyone; the narrowing half is listed per carrier below, with its + FROM → TO. + + One concept was judged by four different grammars across ten carriers in two + repositories, and the strictest refused `2.0.0-beta.1` — the exact string a + sibling declaration documented as an example of itself. The disagreement was + observable between doors on the same resource, not merely between schema files: + `os plugin build` refused a prerelease the publish door accepted, the Studio + form refused it twice over, the `PATCH` door answered `400`, and the install + door parsed nothing at all. An earlier change collapsed the eight regex literals + onto three exported constants, which removed the drift but not the disagreement. + + `@objectstack/spec/kernel` now exports ONE grammar — + `SEMVER_2_0_0_VERSION_PATTERN`, semver.org's own published expression — and + every carrier references it. + + ## What every author gains, with no edit + + Prerelease and build suffixes are accepted on the five carriers that demanded a + bare three-segment core, so `2.0.0-beta.1`, `17.0.0-rc.5`, `1.0.0+20230101` and + `1.0.0-rc.1+exp.sha.5114f85` now pass a key that refused all of them. Identifiers + are case-preserving everywhere, as the standard requires. This repository cuts + prereleases of its own packages while the key describing a package could not + express one; that ends here. + + ``` + FROM ManifestSchema.parse({ id: 'com.acme.crm', version: '2.0.0-beta.1', … }) + -> throws // and `os plugin build` exits 1 + + TO ManifestSchema.parse({ id: 'com.acme.crm', version: '2.0.0-beta.1', … }) + -> parses + ``` + + ## What stops being accepted, per carrier + + Eight strings, all of them forms SemVer 2.0.0 forbids and none of them a valid + prerelease. What they have in common is that no precedence order exists for any + of them — `dependency-resolver.ts` can place none in an order — so a package + versioned this way could be published and never compared against its own + successor. + + ``` + FROM version: '01.1.1' TO version: '1.1.1' // §2 no leading zero in + FROM version: '1.01.1' TO version: '1.1.1' // a numeric identifier + FROM version: '1.1.01' TO version: '1.1.1' + FROM version: '1.0.0-0123' TO version: '1.0.0-123' // §9 no leading zero in a + // numeric prerelease id + FROM version: '1.0.0-alpha..1' TO version: '1.0.0-alpha.1' // §9 no empty + FROM version: '1.0.0-alpha..' TO version: '1.0.0-alpha' // identifier + FROM version: '1.0.0-.' TO version: '1.0.0' + FROM version: '1.0.0+.' TO version: '1.0.0' // §10 no empty build id + ``` + + ⛔ Each repair above is one defensible reading and not the only one, which is + why they ship as ADR-0087 D3 semantic TODOs rather than as mechanical D2 + conversions: a version is how a release is addressed, so rewriting one + re-points whatever already resolved the old string. Run + `objectstack migrate meta --from ` for the per-site list. + + Per carrier: + + - `ManifestSchema.version` and its three sibling declarations + (`MetadataPluginManifestSchema`, `PluginRegistryEntrySchema`, + `PluginMetadataSchema`), plus the `PATCH /api/v1/packages/:id` door: gain the + whole prerelease and build space; lose a leading zero in the numeric core. + - `PluginSchema.version` and the plugin boot path in `@objectstack/core`: lose + those eight and **nothing else**. ⭐ Every valid prerelease and build form the + loader accepts today it still accepts, which is what keeps the widen-never- + narrow ruling on that path honoured rather than reversed; both halves of that + bound are pinned in `plugin.test.ts` and `plugin-loader.test.ts`. + - `PackageVersionSchema.version`: gains case-preserving identifiers + (`1.0.0-Beta.1`, `1.0.0+Build.5`), which the boot path has always accepted and + this key alone refused; loses the same eight. + - `PackageManifestSchema.version`: was a bare `z.string()` constraining nothing, + so it is the one carrier where the grammar is entirely new. `latest`, + `v1.0.0`, `1.0`, the empty string and `2.0.0-beta.1extra!` were accepted and + frozen into a published manifest snapshot; each is refused now. A dist-tag + becomes the version it pointed at, a `v`-prefix drops, a two-segment string + gains its patch. + + ## The prose moved with the grammar + + Every `.describe()` names SemVer 2.0.0 and the nine generated reference-doc rows + follow; the `PATCH` door's refusal says so; `manifest.test.ts`'s + 「should enforce semantic versioning」 case stops listing `1.0.0-beta` among the + invalid versions. `PluginLoader.isSemverShapedVersion` becomes `isSemverVersion` + — a predicate named for a standard it does not implement gets misused by the + next caller whatever its docblock says, and the name is true now. + + Three exported constants are retired, each replaced by the one canon: + + ``` + FROM import { MAJOR_MINOR_PATCH_VERSION_PATTERN } from '@objectstack/spec/kernel' + FROM import { SEMVER_SHAPED_VERSION_PATTERN } from '@objectstack/spec/kernel' + FROM import { SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN } from '@objectstack/spec/kernel' + TO import { SEMVER_2_0_0_VERSION_PATTERN } from '@objectstack/spec/kernel' + ``` + + ⛔ They are not interchangeable with what they replaced — each named an accept + set that no longer exists, which is why they are retired rather than aliased. A + consumer that referenced one to REPRODUCE a verdict gets the canon's verdict + now; one that referenced it to match a foreign grammar owns that grammar itself. + + The accept set is pinned witness by witness in `version-grammar.test.ts`: move a + cell there and you have moved a published accept set on nine carriers at once, + in one visible edit. +- 13d5294: fix(runtime): `POST /api/v1/packages` parses the manifest's `version` leg instead of installing anything it is handed (#19120) + + Clause-②: no (narrowing) + + **BREAKING for callers of the install door** — a manifest with no `version`, or + one whose `version` does not match the declared semantic grammar, is now refused + `400` / `VALIDATION_ERROR`. It used to install and answer `201`. + + The accept set only shrinks back to what the published declaration has always + said. `PackageInstallRequestSchema` binds `manifest: ManifestSchema`, and + `ManifestSchema` declares `version` required with a semantic grammar. The door + parsed nothing at all: `const manifest = body.manifest || body` went straight to + `installPackage`, with an id check as the only gate on the way. That is + «declared ≠ enforced» on a published API contract — and because the install + landed silently, an author could install metadata the platform's own CLI build + step (`os plugin build`) would have refused outright. + + The gate asks the declaration **by reference** — `ManifestSchema.shape.version` + — rather than keeping a copy of the grammar. The version-grammar canon is an + open question on its own card; whichever way it is settled, this door follows it + with no further edit. + + **What is not affected.** Boot-time and in-process installs reach + `SchemaRegistry.installPackage` / `ObjectQL.registerApp` directly and never pass + through this branch, so nothing about how a package is loaded from disk or + registered by a plugin changes. A well-formed manifest installs exactly as + before, on both body forms (wrapped and bare) and on both install limbs (the + protocol primitive and the bare-registry fallback). + + **Scope — the `version` leg alone.** The declaration's own docblock records five + classes this door answers `201` to while the schema refuses them. This change + closes one: `version`. A missing `type`, unknown keys on either body form, a + string-typed `enableOnInstall` / `overwrite`, and install options spelled on the + bare form are each left exactly as they were — measured after the change, all + four still answer `201`. Each is its own reading and its own card. + + **If you are refused.** Give the manifest the `version` the schema has always + required — `version: "1.0.0"`, three dot-separated numbers. The refusal names + the key and shows the shape, so the prescription arrives with the `400` rather + than in a changelog. + + +- c02fa12: fix(runtime): `POST /api/v1/packages` parses the whole body through `PackageInstallBodySchema` instead of reading it key by key (#19328) + + Clause-②: no (narrowing) + + **BREAKING for callers of the install door.** The door now enforces the + WHOLE install-body declaration. A body that `PackageInstallBodySchema` refuses + is refused with `400` / `VALIDATION_ERROR` and installs nothing, whatever the + reason. Every shape below used to answer `201`, and two of the install options + used to be honoured. The list names the shapes a caller is most likely to have + sent, and ends with one bullet for everything else the declaration states. For + each one, change what you send FROM the refused shape TO the declared one: + + - **A manifest with no `type`, in either body form.** FROM + `{ "manifest": { "id": …, "name": …, "version": … } }` (or the same manifest + sent bare) TO the same manifest with a declared `type`: one of `app`, + `plugin`, `ui`, `driver`, `server`, `theme`, `agent`, `objectql`, `module`, + `gateway` or `adapter`, e.g. `"type": "app"`. It used to install anyway. + - **A manifest with no `name`, in either body form.** `name` is the manifest's + other required key besides `id`, `version` and `type`. FROM + `{ "manifest": { "id": …, "version": …, "type": … } }` (or the same manifest + sent bare) TO the same manifest with `"name": "…"`, the human-readable + package name. It used to install anyway, because the door parsed only the + `id` and `version` legs. + - **An unknown key inside the manifest, or on a bare body.** Example: a + transposed `namesapce`. FROM a manifest carrying the undeclared key TO the + manifest with that key removed, or spelled as the declared key it was meant + to be (`namespace`). The refusal names the key. The key used to be STORED + with the package. + - **A string-typed `enableOnInstall` or `overwrite`.** FROM + `"enableOnInstall": "false"` / `"overwrite": "true"` TO JSON booleans, + `"enableOnInstall": false` / `"overwrite": true`. `'false'` used to install a + fresh package ENABLED, which is the opposite of what the caller asked for. + `'true'` for `overwrite` was read as absent, which answered `409` on an + installed id. + - **Install options spelled on the BARE form (`enableOnInstall`, `overwrite`, + `settings`).** FROM `{ "id": …, …, "overwrite": true }` TO the wrapped form, + `{ "manifest": { "id": …, … }, "enableOnInstall": …, "overwrite": …, + "settings": … }`. `overwrite` may also go on the query string instead + (`?overwrite=true`), which a bare body may keep using. Before this change the + door handled these key by key: `enableOnInstall` was ignored, but + **`overwrite: true` and `settings` were HONOURED** (an installed id was + overwritten, and the settings reached the install). All three were also + stored as manifest keys. They are refused now. This is the part of the + change that removes behaviour a caller could have been relying on. + - **Any other constraint `ManifestSchema` declares.** Each one is now enforced + at this door. Before, only the `id` and `version` legs were, and the manifest + was stored as sent. The constraints include: + - the `namespace` grammar: 2–20 characters, starting with a lowercase + letter, with only lowercase letters, digits and underscores; + - the closed value sets of `scope` (`cloud`, `system`, `project`), + `runtime` and `packaging`; + - the declared type of a key (for example a non-string `description`, or a + non-string version in `dependencies`); + - the retired manifest keys `configuration`, `capabilities`, `extensions` and + `loading`, which the declaration keeps only as named refusals; + - unknown keys inside the nested blocks the declaration closes. These are + `contributes` (and its `kinds[]`), `data[]` (each entry is a `SeedSchema` + seed), `navigationContributions[]` (and their items), `engine`, `engines`, + and the structured form of `permissions`. + + FROM the off-declaration value TO the value the declaration states. The + refusal names the path. + + The accept set only shrinks back to what the published declaration has always + said. `PackageInstallBodySchema` in `@objectstack/spec` is a union of two + branches: the wrapped request, or a bare manifest as the whole body. + `ManifestSchema` declares the manifest's required keys (`id`, `name`, + `version`, `type`) and the grammar or value set of the rest. It closes the + manifest, and the nested blocks listed above, against unknown keys. + The wrapped request types `enableOnInstall` and `overwrite` as booleans. Its + docblock says a bare manifest carries no install options: «a caller that needs + an option sends the wrapped form». Until now, the door parsed only two legs of + the manifest (`id` and `version`) and read every other key positionally off + the raw body. That is «declared ≠ enforced» on a published API contract. Now + the door parses the body once through the declared union and reads + `overwrite`, `settings` and `enableOnInstall` off the parsed request. Nothing + in `@objectstack/spec` moves, and neither branch of the union is relaxed. + + **What is not affected.** + + - A well-formed body installs exactly as before, in both forms and on both + install limbs (the protocol primitive and the bare-registry fallback). + - The first-party SDK (`client.packages.install(m, { enableOnInstall, + overwrite, settings })`) already sends the wrapped form with JSON booleans. + Studio's create-package dialog sends `{ manifest }` with a declared `type`. + - The manifest is stored as sent, with no parse-time defaults added. + - The refusals for a missing or malformed `id` and `version` keep their own + sentences and still come first. + - Every request-shape refusal is still answered ahead of the duplicate-id + `409`. + - Boot-time and in-process installs reach `SchemaRegistry.installPackage` / + `ObjectQL.registerApp` directly and never pass through this door. + + **Still accepted, because the declaration accepts it.** An unknown key at the + top level of the wrapped form (`{ manifest, bogus }`) is dropped, not refused. + `PackageInstallRequestSchema` is declared in strip mode, and the door now does + exactly what the declaration says. + + **If you are refused.** The refusal names the path of each failed constraint, + and the form it read the body as. A misplaced install option also gets the wrapped form (and, for + `overwrite`, `?overwrite=true`) spelled out. So the prescription arrives with + the `400` rather than in a changelog. + + +- 0b4022b: feat(automation): `GET /automation/:name/runs` retires `cursor` and computes `hasMore` (#19543) + + This door declared a pagination parameter it never spent and then reported, as a + literal, that there was nothing more to fetch. Both halves are closed here, per + the maintainer-approved ruling of 2026-09-21 (decision batch #204 item 2, + letter C of three). + + **BREAKING** — `cursor` no longer parses on `ListRunsRequestSchema`, its slot + is gone from `IAutomationService.listRuns`, and `@objectstack/client` no longer + declares or sends it on any of the three run-list surfaces + (`automation.runs.list`, `automation.listRuns`, + `client.environment(id).automation.listRuns`). It was declared on the wire, + *validated* at the boundary, forwarded into the service contract, appended by + the SDK, and read by no implementation. No emit site has ever written the + response half `nextCursor`, and the only ordering this door has is a required + but non-unique `startedAt` timestamp that nothing ever minted a resume point + from — so a caller looping "until the cursor runs out" re-read the first and + only window forever, with no error. + + ``` + FROM ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' }) + -> { name: 'f', limit: 20, cursor: 'n_007' } // forwarded, then dropped + + TO ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' }) + -> throws: '`cursor` was removed from GET /api/v1/automation/:name/runs in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) …' + ``` + + `cursor` is a `retiredKey()` tombstone rather than a deletion: the request + schema is not `.strict()`, so a bare deletion would have made Zod silently strip + whatever a generated client kept sending — a clean parse and a parameter that + never takes effect, which is this defect re-created one layer down (ADR-0104). + Writing the key is now a `tsc` error and a parse error carrying the + prescription. + + **The SDK is retired in the same stroke, and that is what makes the sentence + above true.** Retiring the key in the schema alone would have left the one + generated client this repo ships typing it `string` and sending it into a route + that no longer reads it — the exact ADR-0104 shape the tombstone exists to + prevent, re-created one layer down, for the channel most callers actually reach + this door through. So the option is gone from all three surfaces and no + `?cursor=` is appended on any of them; an untyped caller cannot smuggle it past + the retired schema either, which is pinned. Same call as when #6361 retired the + notifications `cursor`: the client dropped the option and recorded the removal + in its docblock. + + ``` + FROM client.automation.runs.list('f', { limit: 5, cursor: 'abc' }) + -> GET …/automation/f/runs?limit=5&cursor=abc // the key is dropped server-side + + TO client.automation.runs.list('f', { limit: 5 }) + -> GET …/automation/f/runs?limit=5 + // `{ cursor }` is now a TS2353 excess-property error; widen `limit` + // (1..100) and read `hasMore` instead. + ``` + + **⛔ `limit` is NOT retired, and its `.default(20)` stays.** The sibling + `/packages` door retired *its* `limit` alongside `cursor` (#17667) because + nothing read it. That does not transfer, and the ruling says so explicitly: here + `limit` is read end to end — the HTTP boundary enforces the declared `1..100` + range read off the schema itself, the service takes it as an option, and the + engine spends it as the run store's history window. Retiring it would have been + a regression, not a narrowing. + + **`hasMore` is now computed, and this is a behaviour change callers can see.** + The door shipped `{ runs, hasMore: false }` with the `false` written as a + literal, beside a list the engine had already cut with `.slice(0, limit)`. A + caller asking for one row of a thousand was handed one row and told that was all + of them. A request whose window is shorter than the matching run set now + receives `hasMore: true` where it previously received `false`; a caller that + read `false` as "this is the whole history" was always wrong and is now told so. + `nextCursor` stays absent — nothing mints one. + + Read the new `false` with **one qualification**: unfiltered it is exact, but + under `?status=` it means "no further match inside the window that was scanned" + rather than "none exists", because the durable history source has no status slot + and the window is taken before the filter is applied. Pushing the filter down is + a `RunStore` contract change this card did not scope. The published + `RunListResult.hasMore` docblock and the response schema's own description both + carry that qualification, so a consumer meets it where they meet the field. + + **How truncation is established, because the obvious signal is wrong.** + `runs.length === limit` cannot tell a flow holding exactly `limit` runs from one + holding ten thousand; the two windows are byte-identical. So + `AutomationEngine` over-reads its history source by exactly one row and compares + the merged, filtered, ordered set against the caller's window. + `RunStore.listHistory`'s signature is deliberately unchanged — over-reading is + expressible in the `limit` it already takes. + + **New:** `IAutomationService.listRunsPage`, an optional member returning + `{ runs, hasMore }` (the shape `IExportService.listExportJobs` already uses, + minus the cursor nothing mints), plus the exported `RunListResult`. The engine + implements it and `listRuns` is its `runs` half, so there is one implementation + and no second copy to rot. A deployment whose automation service does not + implement it answers `501` naming the member, never a `200` carrying a guessed + `hasMore`. + + **One strictness regression, stated because it reverses a recorded decision.** + `?cursor=a&cursor=b` used to answer `400 VALIDATION_FAILED` and now answers + `200` with the key ignored, like any other unrecognised query name. #7300 + validated the key rather than deciding it, so that a future cursor + implementation would not be the one to discover the type was unenforced; this + ruling decides it instead — there will be no cursor implementation on this + door — so the refusal would be validating a key the contract no longer has. + This route declares no closed query-parameter set, so an unrecognised name has + never been refused here on its own account. + + Clause-②: yes + + +- 0862063: fix(runtime): `GET /api/v1/packages` honours the declared `enabled` query parameter (#19394) + + Clause-②: no (narrowing) + + **BREAKING for callers of the packages list door** — `?enabled=` is now read. + A request that supplies it gets a filtered list instead of the whole one, and a + value the declared type does not admit is refused `400` / `VALIDATION_FAILED` + instead of being dropped. Both used to answer `200` with every installed + package. + + The accept set only shrinks back to what the published declaration has always + said. `ListInstalledPackagesRequestSchema` declares + `enabled: z.boolean().optional()` and the serving door never read the key, so a + caller filtering an installed-package list by `enabled` was handed the + unfiltered list with no refusal and no warning — «declared ≠ enforced» in the + silent direction, which nothing in the status, the headers or the body + distinguishes from a request served as asked. + + **The semantics are the declaration's, not a plausible reading of it.** Absent + means NO filter, and it stays reachable: `.optional()` carries no `.default()`, + so an absent key is an absent key and never collapses into `false`. An explicit + `enabled=false` is a filter and selects the disabled rows only — so "omitted" + and "false" are two different requests, which is the distinction the first-party + SDK already spells (`client.packages.list({ enabled })` sends the key only when + it is not `undefined`). `enabled` is read off the row's own `enabled` state, not + off `status`; the two are independent keys on `InstalledPackageSchema` and a row + carrying no `enabled` at all counts as enabled, which is that schema's declared + `.default(true)`. + + The coercion is the repo's one parser for a query parameter declared + `z.boolean()` (`parseBooleanParam`), the same one `GET /api/v1/notifications` + reads its identically-declared `read` with — so the wire has exactly the two + spellings the type has, and a third (`enabled=1`, `enabled=yes`, an empty + `enabled=`) is refused rather than guessed. A repeated `?enabled=a&enabled=b` is + refused with the sentence this door already uses for a repeated `?version=`. + + **What is not affected.** `status` and `type` filter exactly as before, an + unmatched filter still selects nothing rather than erroring, and `hasMore` stays + the constant `false` it became when the request-side pagination keys were + retired. Boot-time and in-process installs never reach this branch. + + **If you are refused.** Send `enabled=true` or `enabled=false`, the two + spellings the schema has always declared, or drop the key to get every row back. + + +- f9977c1: fix(runtime): `POST /api/v1/packages` parses the manifest's `id` leg instead of reading it positionally (#19417) + + Clause-②: no (narrowing) + + **BREAKING for callers of the install door** — a manifest whose `id` is not + reverse-domain notation is now refused `400` / `VALIDATION_ERROR`. It used to + install and answer `201`. + + The accept set only shrinks back to what the published declaration has always + said. `MANIFEST_ID_PATTERN` is declared once in + `packages/spec/src/kernel/manifest.zod.ts` and referenced by both faces of one + identity — `ManifestSchema.id`, what an author writes, and + `PackageSchema.manifestId`, what the registry stores and publishes by. The door + read `manifest.id` POSITIONALLY (`typeof manifest?.id === 'string' ? + manifest.id.trim() : ''`) and parsed nothing, so `id: 'pkg-a'` installed and + answered `201` while `defineStack()`, `os build`, `os validate` and the publish + face all refused the same id. The author was handed a package that could never + be rebuilt or published. That is «declared ≠ enforced» on a published API + contract — and nothing in `packages/spec` moves for it: the declaration was + already right. + + The gate asks the declaration **by reference** — `ManifestSchema.shape.id` — + rather than keeping a copy of the grammar, exactly as the `version` leg beside + it does, so a future move of the reverse-domain rule reaches this door with no + further edit. The sentence the caller reads is the declaration's own + (`manifestIdRefusal`), **surfaced rather than reworded**: it names the key, + echoes the value, lists the two examples, and carries a suggestion arm that + verifies its candidate against the pattern before offering it. Posting + `id: 'pkg-a'` now answers, in the response envelope's `error.message`: + + ```text + Invalid package id 'pkg-a' on `manifest.id`. Expected reverse-domain notation + ('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments + of letters, digits and inner hyphens; a segment may not open with a hyphen; + underscores are not admitted. Did you mean 'com.example.pkg-a'? + ``` + + **What is not affected.** Boot-time and in-process installs reach + `SchemaRegistry.installPackage` / `ObjectQL.registerApp` directly and never pass + through this branch, so nothing about how a package is loaded from disk or + registered by a plugin changes. A conforming manifest installs exactly as + before, on both body forms (wrapped and bare) and on both install limbs (the + protocol primitive and the bare-registry fallback). + + **The `Package id is required` sentence does NOT move.** `''` fails + `MANIFEST_ID_PATTERN` too, so where this gate sits decides whether a published + message changes or only the accept set does. It is ordered AFTER the existing + `!pkgId` check: an absent, empty, whitespace-only or non-string `id` still + answers `400 Package id is required`, never the schema's sentence — which on + that input is the one case where the suggestion arm has nothing to offer. + Measured both directions. It is ordered BEFORE the `version` gate for the + mirror-image reason: that refusal's sentence names the id it prescribes for, and + prescribing a `version` repair for an id that can never be legal sends the + author round twice. + + **Scope — the `id` leg alone.** The declaration's residual docblock records the + classes this door answers `201` to while `PackageInstallBodySchema` refuses + them. This change closes one: `id`. A missing `type`, unknown keys on either + body form, a string-typed `enableOnInstall` / `overwrite`, and install options + spelled on the bare form are each left exactly as they were — measured after the + change, all seven spellings of those four classes still answer `201`, against a + `pkg-a` control that answers `400` and a conforming control that answers `201`. + Each is its own reading and its own card. Closing them is the one call this + handler still pointedly does not make, `PackageInstallBodySchema.safeParse(body)`. + + **If you are refused.** Give the manifest an id in reverse-domain notation — + lowercase dot-separated segments, hyphens allowed inside a segment, underscores + not. The refusal names the key, echoes what you wrote and, where a mechanical + repair exists, offers one it has already checked against the rule, so the + prescription arrives with the `400` rather than in a changelog. + + +- 3875ae6: feat!: retire the `GET /api/v1/automation` flow list in favour of `GET /api/v1/meta/flow`; `ListAiConversationsResponse` declares `hasMore` (#19543) + + **BREAKING** — two sibling list doors that declared paging nobody honoured. + + **The flow list is retired, with no alias and no transition window** (maintainer + ruling: 「退役,统一走 /meta/flow」). Its contract described a capability no build + ever delivered: the request declared `status`, `type`, `limit` (default 50) and + `cursor`, and the route read none of them; the response declared `FlowSummary` + rows with `total`, `nextCursor` and `hasMore`, and the route answered bare flow + names beside a literal `hasMore: false`. Measured before removal on the main branch + of this repository and cloud, and on objectui at its pinned commit and at main: + zero callers of the route or of `client.automation.list` outside their own tests, + while the Console flow-runs page and the Setup packaged-automation page already + read `GET /api/v1/meta/flow`. + + FROM → TO, per surface: + + - `GET /api/v1/automation` (and its environment-scoped twin) → no longer mounted + for `GET`. `POST /api/v1/automation` (create a flow) still lives at that path, so + on the default Hono host a `GET` there answers the host's standard method + mismatch — `405 METHOD_NOT_ALLOWED` with `Allow: POST` — the same answer any + POST-only path gets. A transport that forwards every automation path to the + dispatcher (the `@objectstack/hono` catch-all) is told the domain does not handle + it and answers its own not-found `404`. Fix: read `GET /api/v1/meta/flow`; + flows are metadata (ADR-0106), and it answers full definitions, so map each item + to its `name` if you only need names. Per-flow runtime enablement and trigger + binding is `GET /api/v1/automation/_status`, unchanged. + - `client.automation.list` (`@objectstack/client`) → removed; calling it is a + compile error. Fix: `client.meta.getItems('flow')`, or + `client.automation.getRuntimeStatus()` for the enabled/bound state. + - `ListFlowsRequestSchema`, `ListFlowsResponseSchema`, `FlowSummarySchema` and the + types `ListFlowsRequest`, `ListFlowsRequestParsed`, `ListFlowsResponse`, + `ListFlowsResponseParsed`, `FlowSummary` (`@objectstack/spec/api`) → removed, + no replacement export (TS2305 on import). Fix: delete the import; the flow + definition type is `Flow` from `@objectstack/spec/automation`. + - `AutomationApiContracts.listFlows` → removed; the map has eight entries, none of + them a `GET` at the bare path. Every other automation route is unchanged. + + **`ListAiConversationsResponseSchema` gains a required `hasMore`** (the spec half + of the same card; the server half is objectstack-ai/cloud#2426). The list is + declared **newest first** and pages by keyset: `cursor` is the `id` of the last + conversation the caller already holds, and `hasMore` says whether another page + follows. `hasMore` is required rather than optional so a server that does not + compute it is off-contract instead of silently spec-valid; no `nextCursor` is + declared, because the next cursor is the last conversation's id, already on the + page. Who notices: code that constructs a `ListAiConversationsResponse` must now + set `hasMore`, and a response parsed with the schema is refused without it. + `client.ai.conversations.list()` is unchanged — it still resolves to the + conversation array. + + Breaking ships as `minor` per the launch-window convention + (`scripts/check-changeset-no-major.mjs`). + + **Clause-②: yes (narrowing)** — the conversation list's response surface gains a + declared `hasMore`; a route, an SDK method, three published schemas with their five + types and a contract entry are removed, and a conversation-list response without + `hasMore` is now refused. + + +- 90ff10a: `AutomationContext` declares `callerParamKeys?: string[]` — the flow doors say which `params` keys the caller supplied, and a `screen` node reads that instead of inferring it (#19846). + + Clause-②: yes + + A screen whose fields the run's caller already supplied continues without pausing (#15787). Deciding "did the caller supply this field?" from the params bag was an inference: the bag a flow receives also holds the subject record's columns and the launched row's id, which the door seeds itself. Two constructions still skipped a screen that should have paused — both a non-default `recordIdField` with a `recordIdParam` naming a key the record lacks, on an object-less action whose record has a `recordId` column, or on an object-bound action whose record shadows `recordId` and the `Id` alias. Maintainer ruling on #15705, verbatim: 「15705同意」. + + **What the key says.** The keys of the caller's own `params`, recorded before the door seeds anything. An empty array means the caller supplied nothing; an absent key means the producer does not say. + + **Who fills it.** The action door (`dispatchFlowAction`: `POST /api/v1/actions/...` and MCP `run_action`) and the trigger door (`buildAutomationContext`: `POST /api/v1/automation/:name/trigger`, the legacy `POST /api/v1/automation/trigger/:name`, and a declarative `type: 'flow'` endpoint). Record-change, time-relative and webhook triggers, and code calling `execute` directly, leave it absent; the schedule trigger, whose run has no caller, states an empty list (#19900). `subflow` and `map` nodes drop the parent's list from the child run's context, because it describes the parent's bag. + + **What the screen does with it.** When the key is present, a field is caller-supplied when its name is in the list and `params` holds a value for it; the inference is not consulted. A present value that is not an array names nothing, so the screen pauses. When the key is absent, the inference from #15787 applies unchanged. + + **Accepted cost, precisely:** both doors leave out of that list the keys they use to carry the launched row's id — `recordId`, the camelCase `Id` alias, and on the action door the action's own `recordIdParam` — even when the caller's bag names them, because a client that mirrors the row id into `params.recordId` is addressing the row, not answering the screen. On a run started through either door, a field named like one of those keys is therefore not caller-supplied: a required such field is collected interactively, and an optional one does not count as answering the screen. + + **What moves for a headless caller, through either door:** + + - the two constructions above pause instead of skipping; + - a field whose value equals a column of the subject record, or equals the row id, now counts as supplied when the caller named it — the inference could not tell those from the seeds and paused; + - a field named like the action's `recordIdParam` no longer counts as supplied when the caller sent that key with a value other than the row id — the inference counted it; the door now treats that key as the row-id channel. + + Nothing moves for an implementation of `IAutomationService`: the key is optional, and a context without it keeps its prior meaning. +- a9fb83e: fix(core,runtime,plugin-dev,plugin-security): a release artifact whose `packages` is `null` is refused as malformed, never read as absent (#19926) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on a value the schema already refuses, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the level). + + `ObjectStackDefinitionSchema.packages` is `z.array(ArtifactPackageSchema).optional()`, and `.optional()` admits `undefined`, not `null`. The schema refused `packages: null` (`invalid_type`), and `composeStacks` refused it with two or more inputs (`STACK_SCHEMA_INVALID`, `status: 422`). The runtime readers below read it as absent instead: a single-package artifact whose own top level is the one package body. Those readers now follow the declaration. An absent `packages` is `undefined` and nothing else; `null` is one of the present, non-array values the rule beside `AssembledPackageBodySchema` calls malformed, like `{}`, `0` or `'x'`, and it is refused with the same envelope: `INVALID_ARTIFACT_PACKAGES`, `status: 422`. No error code is added. + + - **`@objectstack/core`**: `resolveArtifactPackageOrder` refuses `packages: null` where it returned `[artifact]`. The refusal message names the value `null`, not `object`. The resolver's callers that hand it the whole artifact raise the refusal: the kernel `manifest` service's `register()` (`ObjectQLPlugin`) and `@objectstack/verify`'s collection reader for a collection the stack's top level does not carry. + - **`@objectstack/runtime`**: `resolveArtifactCollections` drops `null` from its absent branch, so `AppPlugin`, `createStandaloneStack`, `loadArtifactBundle`'s runtime-module merge and `resolveProjectDatabaseUrl` answer a `packages: null` artifact exactly as they already answer `packages: {}`. `carriedPackageIds`, and `resolveArtifactGrantBinding` for an artifact whose `grantedPermissions` is a record, read the package list through the core resolver and raise its refusal too. + - **`@objectstack/plugin-dev`**: the i18n detector's private absent guard moves in lockstep with the resolver's absent branch, so `devI18nPluginOptions` reaches the resolver and raises its refusal when the `i18n` config (on the stack or its `manifest`), a non-empty `manifest.translations` and a non-empty top-level `translations` do not answer first. `DevPlugin` keeps its posture: it reports the metadata defect on its `error` line and boots on the in-memory i18n fallback. + - **`@objectstack/plugin-security`**: `appSecurityPluginOptions` has no guard of its own and raises the resolver's refusal for `packages: null`. + - **What does not change**: the schema; an absent `packages` (no key, or an explicit `undefined`), which still returns the caller's own object by identity; a well-formed `packages[]`; and `composeStacks` with a single input, which still returns that input by identity. + + No in-repo producer writes `packages: null`, and `os build` and `os validate` refuse it at the schema before any reader runs. For a single-package artifact, leave the `packages` key out. +- 76ddab7: fix(runtime,mcp): `action.ai.requiresConfirmation` is ENFORCED at the AI-facing action door — an unconfirmed call is refused, and `run_action` grows the `confirm` member that satisfies it (#15942) + + **Behaviour change — read this if any of your actions declare `ai.requiresConfirmation: true`.** An AI-facing invocation of such an action (`invokeBusinessAction`, reached from the MCP `run_action` tool) is now REFUSED unless the request carries the confirmation member. A call that succeeded before starts answering `428 ACTION_CONFIRMATION_REQUIRED`, and nothing dispatches: the action body does not run, and the subject record is not even read. + + FROM → TO, for a caller of a gated action: + + ``` + run_action({ actionName: 'archive_lead', recordId: 'lead_1' }) // was: ran + run_action({ actionName: 'archive_lead', recordId: 'lead_1', confirm: true }) // now: required + ``` + + The refusal is machine-readable so the retry is mechanical rather than guessed — `error.details` carries `{ actionName, objectName?, confirmationMember }`, and `confirmationMember` echoes the member's exact spelling (`AI_ACTION_CONFIRMATION_MEMBER`, `@objectstack/spec/contracts`). The `run_action` tool schema advertises `confirm` as an optional boolean, so an agent discovers the retry from the tool definition rather than from prose. + + **What is NOT gated**, because this narrows a published accept set and the narrowing is deliberately as small as the author's own declaration: + + - Only the DECLARED flag gates. `ai.requiresConfirmation: true`, set by the action's author, and nothing else. The wider `list_actions` heuristic — `mode: 'delete'` / `variant: 'danger'` on an action whose author declared nothing — still reports `requiresConfirmation: true` to advise a client, and still does NOT refuse. An explicit `ai.requiresConfirmation: false` never refuses. + - Only the boolean `true` confirms. `'true'`, `1` and `false` are not attestations. + - Only the AI-facing doors. The enforced set is the doors that enforce `ai.exposed` — today `invokeBusinessAction` via MCP `run_action`. REST `/actions` is not `ai.exposed`-gated and sits outside this gate. + - `list_actions` is unchanged. + + **A gate, not a queue.** Nothing is parked, nothing is held for an operator, and there is no resume path: a refused call simply did not run, and the caller confirms with its human and retries. And `confirm: true` is an unverifiable caller claim — an agent that always sends it bypasses the gate. The gate makes FORGETTING loud; it does not prove a human. + + Why it is worth the break: the flag was read once and consumed once, to fill a field of the `list_actions` summary. It stopped nothing. That is the failure ADR-0049 retired `tool.requiresConfirmation` for — "a SAFETY flag that is merely accepted is false compliance" — reappearing on the very key the retirement's own ledger entry told authors to move to. The contract this implements landed in `@objectstack/spec` first (#16293). +- ea4d164: Bind an environment artifact's install-time GRANTED permission set to the packages that artifact materializes. + + `EnvironmentArtifactSchema.grantedPermissions` — the consented `{ services, hooks, network, fs }` set the control plane compiles onto the artifact at install-consent time (ADR-0025 §3.5 step 2 / F4) — now reaches `PluginPermissionEnforcer.registerGrantedPermissions` at materialize time, one call per consent record, keyed by the plugin manifest `id`. `AppPlugin.init()` performs the binding, so it happens on every path that turns an artifact into a kernel plugin without either caller changing a line, and the enforcer holding the result is readable as `AppPlugin.permissionEnforcer` (with `AppPlugin.grantBinding` recording what bound). + + Absent, `{}` and a consented entry stay three distinct states. An artifact carrying no `grantedPermissions` key allocates no enforcer and registers nothing, so a package with no consent record loads exactly as it did; a per-plugin `{}` is a consent record that consented to nothing and registers a bag that denies every service, hook, host and path. A consent record naming a package the artifact does not carry is reported at `warn` rather than passing in silence. + + Fixed alongside, because without it the binding was unreachable: the `{ schemaVersion, metadata }` envelope unwrap in `loadArtifactBundle` handed the kernel `metadata` alone and dropped every key standing beside it, so an envelope artifact reached the kernel with `grantedPermissions` stripped. The loss was silent and indistinguishable from the legitimate absent reading. The unwrap now carries the key across when the envelope declares it, `{}` included, and never invents one. + + New exports from `@objectstack/runtime`: `registerArtifactGrantedPermissions`, `resolveArtifactGrantBinding`, `carriedPackageIds`, `ArtifactGrantBinding`. + + This is the registration half. Access-time enforcement runs through `SecurePluginContext`, which no production path constructs; that seam is ADR-0025 install-flow work and is unchanged here. +- 331a1a2: fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) + + Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** + + **The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: + + | identity path | `crm_account` | `crm_opportunity` | `crm_task` | + |:--|--:|--:|--:| + | API key, `principalKind: human` | 9 | 23 | 45 | + | OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | + + The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. + + **The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. + + **(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. + + ⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. + + **(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. + + **(3)** The Setup page's promise is untouched — it is now true rather than rewritten. + + Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. + + `DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: + + | direction, after release | consumer cost | + |:--|:--| + | ship optional fields, later tighten them to required | a compile break | + | ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | + + The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. +- e6965dd: **`AppPlugin` now names the manifest-stage `permissions` value its ADR-0057 security registrar cannot read, instead of dropping it in silence.** + + The registrar flattens the manifest under the stack's own collections (`{ ...manifest, ...collections }`), so `manifest.permissions` is read whenever the stack declares no `permissions` collection of its own. That key is the ADR-0025 §3.2 capability grant a package *requests* — a flat list of permission strings, or `{ services, hooks, network, fs }` — while the registrar wants ADR-0090 `PermissionSet[]`. Both arms were skipped with nothing logged: the structured arm is not an array, so the whole value never entered the loop; every member of the flat list carries no `name`, so all of them were dropped. An author who wrote `manifest: { permissions: ['sales_rep'] }` meaning a permission set got no set registered, no `sys_audience_binding_suggestion`, and no line anywhere saying why — the "absence must be loud" rule in AGENTS.md → Route & surface ownership §3. + + It now warns once per boot, naming the field, how many entries were lost, both readings of the key, and where permission sets belong (`defineStack({ permissions: [ … ] })`). The report is written per `SECURITY_FIELDS` entry, so a hand-built bundle carrying `positions` / `capabilities` / `sharingRules` on its manifest is named too. + + **Nothing else moves.** Which items register is byte-for-byte unchanged — the registrar is deliberately *not* made tolerant of the grant reading (widening the key was rejected by name, #14242 road C, maintainer 2026-09-02). The line is `warn`, not `error`: nothing here claimed to persist anything. It stays silent on every shape where nothing was lost — a stack declaring its own `permissions` collection, a manifest with no such key, a manifest whose entries the registrar really can read, and the `securityMetadataRegistrar: 'artifact-door'` composition that owns the route. +- 777d0c2: fix(rest,runtime): a sandboxed body that crashed now answers the sanitised 500 at the bulk REST door and at `/api/v1/actions`, instead of a declared 4xx or a 400 carrying the crash text (#17273) + + + + **BREAKING** — the answer two published doors give moves for existing inputs. No + export, signature or declared type changes; what changes is the response an + existing call observes, and a client branching on `error.code` or on the status + for the affected shape now falls to its 5xx path instead of its refusal path. + Shipped as `minor` under the launch-window convention (`major` is refused while + the fixed group versions in lockstep), so this banner — not the level — is the + breaking-ness signal. + + **What changes for an operator.** #15071 ruled that a crash inside a sandboxed + hook or action body is a FAULT, not the refusal a declared code names, and + converged the single-record `/api/v1/data` door on it. Two doors that door does + not decide kept the old answer, and both are closed here. Measured, driven end + to end: + + The bulk / metadata / UI routes — everything reporting through + `handleRouteError` / `sendThrownError` — for a crash that declared a 4xx: + + ``` + FROM 409 {"error":"hook 'guard' threw: TypeError: ctx.input.title.trim is not a function", + "code":"DELETE_RESTRICTED","object":"account"} + TO 500 {"error":"Internal server error","code":"INTERNAL_ERROR"} + ``` + + `POST /api/v1/actions/:object/:action`, for a body that really crashed inside + QuickJS (`return ctx.input.title.trim();` with a numeric `title`): + + ``` + FROM 400 {"success":false,"error":{"code":"VALIDATION_ERROR", + "message":"TypeError: not a function","httpStatus":400}} + TO 500 {"success":false,"error":{"code":"INTERNAL_ERROR", + "message":"Internal server error","httpStatus":500}} + ``` + + and, when that crash also declared a status of its own, `409 DELETE_RESTRICTED` + with the same `TypeError:` message becomes the same sanitised 500. + + The full ` '' threw: …` wrapper still reaches the server log on both + paths, so nothing an operator diagnoses with is lost. + + **The `/actions` answer was also contradicting its own published page.** The + error catalog states for this very route that "a `TypeError` / a + `ReferenceError` / a driver's own error class is a crash (500)", and this module's + header says `did it reject or crash? reject → 400; crash → 500`. The door said + 400. The code now matches the page; the page is unchanged. + + **What does NOT change.** An ordinary sandboxed REFUSAL — a body that throws a + business error and does not crash — is untouched at both doors: same status, + same code, same sentence, same structured fields. A refusal whose text merely + mentions a native error name ("Import failed with a TypeError in row 4") is + still a refusal, because the name list is anchored. Non-sandbox producers are + untouched. The 5xx passthrough arm's unconditional prose-drop is not narrowed: + the fault terminal withholds prose too. + + **Why.** A declared code, and a declared status, are the author's statement + about a failure mode they handled; a crash is not that mode. Answering one with + a business status shipped an internal, stack-shaped sentence to an end user and + told the client the wrong thing about what happened. #15071's own residue note + said closing it meant moving a status a passthrough decided — that is what this + does, deliberately and in the shrinking direction: the wire loses the crash + text and the producer's code, and gains nothing. + + **If you were relying on the old answer,** the affected shape is a sandboxed + hook or action body that FAULTS (`TypeError`, `ReferenceError`, a driver's own + class). It now surfaces as a 5xx to clients, retry policies and alerting rather + than as a 4xx — which is the point of the change. +- f04be62: feat(types,triggers,service-automation,runtime,cli,spec,lint)!: package-authored scheduled work is a deployment decision — `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, off by default everywhere (#17396) + + + + Maintainer ruling, 2026-09-12, verbatim, untranslated: + + > schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,有没有可能定义为一个环境变量,根据环境变量控制? + + > 如果多租户暂时只接禁用定时任务,完整的考虑一下影响面。 + + > group 默认也关,云端每库一租户全局默认关 + + **A new deployment variable, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, decides whether this deployment runs PACKAGE-AUTHORED scheduled work at all** — time-triggered flows (`type: 'schedule'` with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. It is read at boot beside `resolveTenancyPosture` and is ⛔ **not** a metadata concept and ⛔ **not** a new spec key: whether a clock-driven workload is affordable is a fact about the deployment — its database, its tenants, its budget — that no package author can know, and a metadata key would ask them to. + + **OFF by default, in every posture and in every kernel.** Unset means off; `true` / `1` / `on` / `yes` (case-insensitive) means on. ⛔ Deliberately not the opt-out `!== 'false'` shape `OS_MULTI_ORG_ENABLED` uses, which reads a typo as "on" — here that would arm exactly the workload an operator meant to refuse. + + ⛔ **Platform-internal scheduled work is NOT gated** and runs either way: approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill. The boundary is **authored by a package**, not "runs on the job service" — the platform's own maintenance is part of the runtime a deployment asked for. + + **BREAKING**, in two directions, and both land inside the same launch window as #16659 / PR #17334, so no published version ever saw the rule this narrows. + + 1. **A NARROWING, and it is the one to plan for.** A deployment that upgrades and does nothing runs **no** packaged time-triggered flow and **no** packaged `defineJob`. Anything that was firing from a package stops. ⇒ Set `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` if you depend on it. Nothing detects the shape for you at authoring time, by design — but nothing is silent either: every such flow is listed in `getTriggerBindingAudit()` and the `os dev` / `os start` startup summary with a DISTINCT reason, **disabled by deployment policy**, ⛔ never as "binding failed"; the packaged-job loop says so once per app at `info` with the count; and `os doctor` prints the effective value in both states. + 2. **A WIDENING of what binds.** With the switch on and tenancy posture `single`, a time-triggered flow that declares **no** `config.organization` now binds and runs — under #16659 alone it was refused. That posture holds exactly one organization (a second is refused), so the run carries **no** organization and every tenant-scoped insert beneath it resolves that one the way a single-organization install always did; a `timeRelative` sweep there runs **unscoped**. ⛔ Nothing is invented: the key is OMITTED, never filled from the install, the platform organization, or the swept record's own `organization_id`. + + **Under a walled posture (`group` / `isolated`) the 2026-09-08 ruling on #16659 stands unchanged**: a time-triggered flow declares `config.organization` or it is not armed, there is no fan-out, and no organization is ever chosen for it. `group` is walled here for a measured reason rather than by analogy — `resolveSystemWriteOrganization` refuses an organization-less system insert under any wall and `TenancyService.defaultOrgId()` answers `null` (ADR-0093 D3), so an organization-less group-wide sweep could read the whole group while every row it inserts is refused. Which organization such a sweep's inserts belong to is not yet decided; until it is, `group` behaves as walled. + + **`flow-schedule-organization-missing` is DELETED** from `@objectstack/lint` (the id and its exported constant, `FLOW_SCHEDULE_ORGANIZATION_MISSING`; both are unreleased — they were introduced by the still-unconsumed #16659 changeset in this same window, so no consumer can be holding either). The rule family's criterion is *is this stack enough to know the flow is dead?*, and the honest answer here is no: the deployment switch and the tenancy posture decide it, and neither is in any stack. A finding that is false for the default deployment is noise. ⛔ The near-miss diagnostic did **not** go with it — `describeMissingScheduleOrganization` and its `organizationId` / `tenantId` / … scan still fire at BIND, the one door that can read both facts, and only where the key is actually required. + + **ADR-0087 semantic entry 18 (`schedule-flow-acting-organization-required`) is REWRITTEN, not added.** Its acceptance criteria required every time-triggered flow to declare; that is no longer the rule. It now prescribes the two decisions in order — decide the switch, then declare per organization under a wall — and records that `os lint` reporting nothing is the criterion being met rather than a check that was skipped. The unconsumed `.changeset/schedule-trigger-acting-organization.md` carries a banner saying the same, so a reader of either one cannot get the narrower half alone. + + **Where the switch is read, and where it is not.** Both triggers gate at `start()`, ahead of the descriptor and the declaration, so an operator on a deployment that was never going to run a flow is not sent to fix a descriptor nothing would have read. `AutomationEngine.activateFlowTrigger` reads the same resolver and does not call `start()` at all when it is off — that is what keeps the audit's reason precise, since a refusal arriving as a THROW can only be reported through the catch that says "Failed to bind". Neither read is cached: the resolver reads `process.env` live, so a host that rebinds after the environment changes sees the value current at the bind. The scope is `schedule` and `time_relative` only — `record_change` and `api` are fired by a caller that already exists and already carries an identity, and a kind added to `FlowTriggerKind` later is OUTSIDE the switch until someone decides otherwise, because a new capability that disappears on arrival is the worse default. +- 4280055: fix(runtime): mount the scoped `/api/v1/environments/:id/packages*` door, and reconcile the package read/delete responses to their declared schemas (#16781) + + **The door.** `mountPackagesRoute` mounted `/packages*` at the unscoped prefix only, while automation / actions / ai each registered a scoped variant twenty lines away. On a host composed as `@objectstack/plugin-hono-server` + this plugin with `enableProjectScoping: true` and **without** `@objectstack/hono`'s `createHonoApp`, that left `GET /api/v1/environments/:id/packages`, `GET …/packages/:id` and `DELETE …/packages/:id` answered by the transport's own `notFound` — a bare 404 on routes `content/docs/api/environment-routing.mdx` documents. The domain has resolved scoped package paths since #15859; nothing mounted one. + + `mountPackagesRoute` is now wrapped in a `base`-taking `registerPackageRoutes(base)`, exactly like its three siblings, and called a second time with the scoped base. **The same handler, no second implementation.** The unscoped mounts keep their registration position and their unconditional mounting, so the change is purely additive: no route that answered before stops answering. + + **The wire.** Two responses gained the key their own declared schema requires (contract review of #16628, finding F2). Both additions are **additive** — no key left either payload: + + - `GET /packages` now sends **`hasMore`** (`ListInstalledPackagesResponseSchema`). It is `false`: this door applies its `status` / `type` / `enabled` filters and returns every remaining row, reading no `limit` and no `cursor`, so there is no next page to announce. + - `DELETE /packages/:id` now sends **`packageId`** (`UninstallPackageApiResponseSchema`). `registryRemoved` and `persisted` stay on the wire unchanged. + + A client that reads only the keys it read before is unaffected; a client parsing either payload against the published schema stops being refused. + + The `DELETE /packages/:id` route-ledger row now carries `responseSchema: 'UninstallPackageApiResponseSchema'`, backed by new conformance coverage that drives the real handler. `GET /packages` is deliberately left blank: its rows are the ASSEMBLED package body, while `InstalledPackageSchema` wraps the AUTHORING-stage `ManifestSchema` — the #14242 stage mismatch, which no `@objectstack/spec/api` export declares yet. Both directions of that boundary are pinned, so the row becomes fillable against a red test rather than a guess. +- de1a611: `AppPlugin` now supplies `SeedLoaderConfig.locale`, so the `Seed.locale` axis takes effect on the default boot path. + + The locale filter axis landed complete on the consumer side: the loader reads `Seed.locale`, composes it with `env` by conjunction, and names every dataset it drops. What it never had was a **producer** — no first-party call site passed `config.locale`, so `filterByLocale` returned its input on its first line and `dataset.locale` was never read at all. Authoring the key changed nothing. That is the same shape `Seed.env` spent releases in before framework#4704. + + - **The locale is resolved from the app's own `i18n.defaultLocale`** — the same envelope key, read the same way `loadTranslations` already reads it for `setDefaultLocale` — and threaded into all three `SeedLoaderRequest`s `AppPlugin` builds: the inline boot seed, the per-org replayer registered for tenant provisioning, and the dev hot-reload seeder. + - **An app that declares no locale sends no `locale` key at all**, rather than an `'en'` default. Absence is the loader's unrestricted spelling, so a stack that never opted in keeps loading every dataset exactly as before; defaulting would have turned a wiring change into a data change, silently dropping a `locale: ['zh-CN']` dataset on every stack without an `i18n` block. A blank or non-string `defaultLocale` is treated as absence for the same reason. + - **Resolved at the call sites, not inside `load()`.** The sibling `env` axis resolves itself in the loader off an ambient `NODE_ENV`; a locale has no ambient source, and the only layer that knows which locale a stack runs in is the app config the loader is never handed. So this axis needs a real producer, which is what this change is. + + `SeedLoaderService#warnOnUnresolvedLocaleScope` **stays**. It is not a signpost for an unwired state that has now gone away: three of this repo's six seed-request builders are publish/install-time paths that are handed no stack config and still pass no locale, embedding hosts build their own requests, and a stack may declare no `i18n` block at all. Every one of those still reaches `load()` with locale-scoped datasets and no `config.locale`, and the warning is what keeps that loud instead of silently inert. + + The liveness ledger row `seed.locale` moves `experimental` → `live` with a `producer` pointer naming this wiring, and records which call sites supply the locale and which do not rather than claiming the frontier away. + + ⚠️ **Release-note reconciliation, for whoever compiles this release.** The sibling changeset `seed-locale-axis.md` (from the PR that landed the consumer half) states in the present tense that no first-party call site supplies `config.locale`, that the axis is inert on the default boot path, and that the liveness ledger records `seed.locale` as `experimental`. All three sentences describe the state that changeset shipped into, and **this change ends all three**. If both land in one release, the notes must read them in order — or fold them into one entry — rather than publishing the earlier state as current. ⛔ That sibling changeset is deliberately not edited here: it accurately records what its own PR did, and release notes are compiled centrally. + + ⛔ Out of scope, unchanged: rows already written under a different locale stay resident. Every seed is an `upsert` and the loader only writes, so switching a stack's locale on a non-empty database does not remove the other market's rows. + +### Patch Changes + +- 7f62536: A **declared capability absence** — a 5xx answered because the deployment did not install an optional service — is now reported **once per route per process at `warn`**, naming the missing service, instead of one `error` line per request. Every other 5xx keeps the per-request `error` line #14310 shipped. + + Measured before the change, on a stock showcase boot: `GET /api/v1/ai/*` (the cloud-only AI service's declared `501 NOT_IMPLEMENTED`) printed one `error`-level line per request, and Studio opens it unprompted. A deployment that is working exactly as configured was training the channel built to mean "an operator must look" into noise — which is the failure mode `--log-level`-watching operators learn as "skim the errors". + + - **What counts as an absence** is the envelope the door composed: a producer-declared 5xx (`declaresServerFault` — the repo's existing declared-5xx predicate) whose ADR-0112 `code` is `NOT_IMPLEMENTED` or `SERVICE_UNAVAILABLE`. Nothing is invented to recognise one; the code the producer already declared *is* the declaration. + - **The predicate is applied inside the shared funnel** (`logServerFault`, `@objectstack/types`), not at each door, so `sendError`'s nested-envelope exit and the runtime dispatcher read one answer by construction. A door cannot opt in, opt out, or drift. + - **The dedupe key is (route, process).** A restart reports again, and a second, different route reports on its own — deliberately not a global "first N", which is the shape that hides the second route. A door that supplies no route coordinates is demoted to `warn` but never suppressed: an un-keyed bucket is that same hiding shape. + - **A thrown 5xx keeps its `error` line even when it declared `501`.** The thrown exit hands the funnel the throw and no envelope `code`, so it is not recognised as an absence — fail-loud for the half that carries a stack. + + ⛔ **No wire byte moves.** Status, `code`, `message` and body shape are unchanged at both doors; this changes a log level and a count. The response bytes are pinned in `packages/runtime/src/declared-capability-absence-warn-once.test.ts`, and that block runs green on the pre-change tree too, which is what makes it a before/after measurement rather than a claim. + + Operators who were alerting on `[5xx]` at `error` level for an uninstalled optional service will now see one `warn` line per route per process instead. The line says so in its own text: `(declared capability absence — reported once per route per process)`. +- fdeeea0: A release artifact whose `packages` is present but is not an array (`{}`, `0`, `'x'`) is now refused by the runtime's collection reader too, as `INVALID_ARTIFACT_PACKAGES` (ADR-0112, `status: 422`) (#15293). + + Clause-②: no + + `packages` is declared as an array of package entries (`ObjectStackDefinitionSchema.packages: z.array(ArtifactPackageSchema).optional()`), and the rule is now written down once, beside `AssembledPackageBodySchema` in `@objectstack/spec`: an absent `packages` means a single-package artifact, and any other non-array value is malformed and refused. `resolveArtifactPackageOrder` in `@objectstack/core` already refused it, and so did the i18n detector in `@objectstack/plugin-dev` and the default-permission-set reader in `@objectstack/plugin-security`. + + - **What changes**: `AppPlugin` reads its collections in `start()`, and `start()` now raises the same refusal `init()` already raised through the kernel's `manifest` service. Under `os dev`, `DevPlugin`'s child-`start()` loop logs it on its `error` line, where before the app started on its top-level collections alone. `createStandaloneStack` now refuses such an artifact while it builds the stack. Before, the refusal came later, when the app registered with the `manifest` service. `loadArtifactBundle`'s runtime-module merge reports it through its existing `warn` line and skips the merge, as it already does for a malformed `packages[]` entry. `resolveProjectDatabaseUrl` no longer reads a default datasource out of such an artifact: it declines, as it already does for any artifact it cannot read, and moves on to the next rung (the unified default database). The boot that loads the artifact then refuses it. + - **What does not change**: an absent `packages` still returns the caller's own object by identity. A well-formed `packages[]` resolves exactly as before. `packages: null` is not absent: it is malformed, and it is refused the same way (#19926). + - **Fix**: remove the `packages` key for a single-package artifact, or make it an array of `{ manifest: … }` entries. +- 4af758d: refactor(runtime,mcp): the last two admission doors classify the `tenancy` rejection through the shared `classifyAdmissionTenancyPosture` (#17114) + + `@objectstack/core`'s `classifyAdmissionTenancyPosture` is the one place the + #13906 decision 1 option A classification lives: a branded "never registered" + rejection is the supported no-tenancy composition and answers a quiet + `undefined`, while every other rejection becomes + `AuthzStoreUnavailableError('tenancy', err)` — ADR-0112 `SERVICE_UNAVAILABLE` / + 503 — because the posture is an authorization INPUT and admission was never + decided. + + Two admission doors were still hand-writing that classification, out of the + declared scope of the fold that extracted it: + + - `@objectstack/runtime`'s `resolveExecutionContext` — the REST/dispatcher + entry-point identity resolver; + - `@objectstack/mcp`'s `resolveStdioTenancyPosture` — the stdio door's **async + kernel** leg. + + Both now call the shared function. ⛔ **No behaviour changes at either door.** + Tenancy posture decides which rows a caller may see, so a divergence between + copies would be two answers to "whose data is this", and the copies are the + stale ones by construction — the shared version is the one that will be + maintained. + + **The resolution stayed at each seam, deliberately.** The extractable part is + the classification, not the resolution: each door keeps its own accessor guard + and hands its own former accessor expression in as the thunk, so the helper + never learns *how* a seam reaches the service. A helper that owned the wiring + too would be wrong for one seam or grow a flag per seam. + + **One neighbouring leg is deliberately NOT folded.** The stdio door's **sync** + fallback is taken only on a `KernelBase`-shaped host with no `getServiceAsync`, + whose accessor reports its one possible fault — nothing registered under that + name — **unbranded**. Routing it through the shared classification would mint a + 503 outage out of a supported composition, so its bare `catch` remains that + seam's recorded decision. A test arm now fails if that leg is ever folded. + + Shipped rather than `skip-changeset`: both packages publish `files[]: ["dist"]`, + and the built `dist` of each carries the new call (2 files each, measured after + a real build, with a symbol known-absent scoring 0 and + `isServiceNotRegisteredError` scoring 4 in `runtime/dist` as the lit control). + `@objectstack/mcp`'s `dist` no longer mentions `isServiceNotRegisteredError` at + all. +- bdb247d: `@objectstack/spec/kernel` exports `SEED_WRITE_EXECUTION_CONTEXT`, the one spelling of the seed-write posture every seeder now reads + + The execution context a seed write must use — `isSystem`, `skipTriggers`, + `seedReplay` — had **no exported form**, so every seeder held a private copy of + it and nothing held the copies equal. There were three on `main`: + `SeedLoaderService.SEED_OPTIONS` (`@objectstack/metadata-protocol`), + `SEED_WRITE_OPTIONS` (`@objectstack/runtime`'s `AppPlugin`, whose own docblock + already recorded that it "mirrors" the first) and `SEED_CONTEXT` + (`@objectstack/verify`'s fixture writer, which spelled it a third time + specifically because the runtime kept its copy module-private). + + **Why a shared constant rather than three accurate copies.** `skipTriggers` is + what suppresses "on create" automation for seed rows, and `isSystem` alone does + **not** suppress dispatch. A seed path that lost that flag once seeded with + automation live while the main path had it suppressed — a self-trigger loop that + wedged first boot (#3760). A constant whose divergence re-opens a boot-wedging + defect is a kernel semantic, not a local detail. + + **What is exported, and what deliberately is not.** The **inner** + `ExecutionContext` value, and nothing wrapped around it: + + ```ts + import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; + + await ql.insert(object, rows, { context: SEED_WRITE_EXECUTION_CONTEXT }); + ``` + + The `{ context: … }` options bag stays at the call site. It is what all three + sites ultimately hand to `insert`, but it is an options envelope rather than the + posture: its type differs per engine method, so freezing one bag onto the + protocol surface would serve `insert` and no other operation, and it is + precisely the convenience bundle this export is not. + + ⛔ **No behaviour change.** The value is byte-identical to all three previous + copies, the three flags keep their existing meanings, and no seed path changes + what it writes or how. The three former copies now read this export, so the two + option bags are `{ context: SEED_WRITE_EXECUTION_CONTEXT }` and the `verify` + context is the export itself. + + **Additive, so `minor` on `@objectstack/spec`**: one new name on the existing + `./kernel` entry point, no existing export removed, renamed or narrowed. The + three consumers take `patch` — their published `dist` changes (an import edge, + and the constant now resolves through `@objectstack/spec/kernel`) while their + own public surfaces do not move. +- cea85fd: A sandboxed hook's business refusal reached through a script action answers 4xx, not `500 INTERNAL_ERROR` + + `POST /api/v1/actions/:object/:action` answered **`500 INTERNAL_ERROR`** when a + `beforeUpdate` hook refused a state transition for a business reason and the + refusal travelled out through the action body's `ctx.api` write. The same refusal + has answered **`400`**, with the hook's sentence verbatim, on `/data` since + objectstack#11588. A 500 tells every client "the platform broke", so a + well-behaved one retries, alerts or pages for a guard that will never say yes. + + **Where the producer was.** Not in the action route's classifier — that read the + shape it was handed correctly, and both sides of the line it pins (`a deliberate + REJECTION is a 400` / `an unexpected FAULT is a 500`) are unchanged. The refusal + arrived already stripped of every mark that says "a body reported this on + purpose", one VM hop earlier: `hostErrorToVm` marked **every** `SandboxError` + crossing into the action body's VM as the sandbox's OWN fault (objectstack#4431) + on an `instanceof` test — and a nested sandboxed hook's refusal *is* a + `SandboxError`, wrapped by the same runner one level down. The pump branch that + reads that marker then discarded `innerMessage`, `code`, `status` and `fields`, + and the classifier read the missing business message as a crash. + + **What changed.** The marker now asks the question the `/data` door asks — + `sandboxBusinessMessage`, objectstack#11588 — instead of testing the error's + class. Both of that predicate's conditions travel, because both are load-bearing: + a capability denial carries no business message and stays a fault, and a nested + body that **crashed** carries `TypeError: …` and stays a fault too. + + **No status was picked for this route.** It matches what `/data` already answers + for the same producer: the status the body declared, or `400` when it declared + none. A refusal that declares `{ status: 409, code: 'RECORD_LOCKED' }` now + reaches the caller as `409 RECORD_LOCKED` instead of losing both. + + **The sentence a caller receives is byte-identical to what the 500 carried** — + this moves the status, not the prose. The flattened `SandboxError: ` name prefix + is stripped on the rejection path by the same helper the fault path already used. + + No authorable key, accept set or export surface moves; no consumer needs a + change. Clients branching on 5xx to decide whether to retry will stop retrying + these refusals. +- 310760d: `ActionEngineFacade.delete` refuses a nullish id instead of silently skipping it + + **Who this is for: untyped hosts.** A JS host, or a `registerAction` handler + whose context slot is still `(ctx: any)`, can hand `ctx.engine.delete()` a + nullish id — `delete('todo_task', null)`, or an array with a hole in it. Until + now the arm dropped that element on the floor: nothing refused it, nothing + warned, and the call **resolved as though the row had been deleted**. A silent + no-op on a destructive verb is the one failure an untyped caller has no way to + detect, which is why it is worth a line in your changelog rather than a shrug. + + **What changes.** Every id now reaches the engine as written, and the engine's + own delete-dispatch predicate refuses a `where.id` that is not a truthy scalar: + the call rejects with `Delete requires an ID or options.multi=true` where it + used to resolve in silence. In the array form the refusal stops the loop where + the declared member doc already said a failure stops it — ids before the + nullish element are deleted, ids after it are untouched. + + **If a host was leaning on the old behaviour**, filter before you call: + + ```js + const ids = candidates.filter((id) => id != null); + if (ids.length > 0) await ctx.engine.delete('todo_task', ids); + // `delete nothing` is the EMPTY ARRAY (it resolves, deleting nothing) — + // never a null id. An empty array is contract; a nullish id never was. + ``` + + ⛔ **No declaration moves, and this is not a correction of the `string | string[]` + widening that shipped just before it.** That declaration is accurate: it takes a + single id or an array of them, and under it **no typed caller could ever reach + the skipped branch** — the accept set it publishes has never admitted nullish. + The array form, its per-row semantics, its ordering and its empty-array case are + all unchanged and pinned as controls. What moves is only the runtime's + undeclared tolerance for a value three separate statements already excluded: the + published type, the member's own doc comment, and the spec-side pin that reads + «"delete nothing" is the EMPTY ARRAY, never a null id». +- 2b08a72: fix(runtime): a repeated `?version=` on `GET /packages/:id` is refused `400 VALIDATION_ERROR` in the repo's one message, and `@objectstack/rest` publishes the rule that owns it (#17672) + + `GET /api/v1/packages/:id?version=a&version=b` answered **`404`**, with a second + sentence written at that door. This repo already had a landed answer for exactly + that condition on exactly that route — `400 VALIDATION_ERROR` in the ADR-0112 + nested body (#6307) — and one implementation of it, `refuseRepeatedQueryParams` + / `repeatedQueryParamMessage` in `packages/rest/src/query-multiplicity.ts`, + whose header is the authority on the rule. + + Driven before the change, one host, three refusals: + + ``` + GET /packages/com.acme.crm?version=a&version=b -> 404 RESOURCE_NOT_FOUND + GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + ``` + + A client branching on the answer could not tell "your request named the + parameter twice" from the two genuine not-founds. After: + + ``` + GET /packages/com.acme.crm?version=a&version=b -> 400 VALIDATION_ERROR + GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + ``` + + The body is the dispatcher's declared envelope — + `{ success: false, error: { code: 'VALIDATION_ERROR', message, httpStatus: 400 } }` + — with `VALIDATION_ERROR` derived by `buildApiError` from + `standardErrorCodeForHttpStatus(400)`, the standard catalog's member for 400. + ⛔ Nothing in `packages/spec` moves. + + **What was actually blocking this was reachability, not judgement.** + `@objectstack/rest` declares exactly one export subpath and that module was not + on it, so #17668 could neither call the rule nor (correctly) copy it, and + shipped the `404` with its own sentence instead. The barrel now publishes + `repeatedQueryParamMessage` and `refuseRepeatedQueryParams`, and the dispatcher + domain calls the message function — so the sentence a caller is told for a + repeated parameter is the same one on every door that carries the rule, ⛔ never + a second copy that drifts. + + ⚠️ The two published symbols are not interchangeable across a package boundary, + and the barrel entry says so. `repeatedQueryParamMessage` is the portable half: + a pure function of two primitives. `refuseRepeatedQueryParams` writes the bare + ADR-0112 body onto a `res`, which suits handlers of that shape and ⛔ not a + runtime dispatcher domain — measured, its body fails that surface's + `BaseResponseSchema` with `success is missing, must be a boolean`. + + **Not a breaking change, measured rather than assumed.** The `404` it replaces + was introduced by #17668 (`1a25f4a8d`), which is not an ancestor of + `@objectstack/runtime@17.4.0` (exit 1; two control commits from that tag's own + history answer exit 0 on the same predicate, in a checkout + `--is-shallow-repository` reports `false`). It has never been published, so no + released consumer can have branched on it. Everything else about the door is + unchanged: `?version=` and `?version=latest` still serve the + installed row, an absent version and an unknown id still answer `404`, and a + one-element array is still one occurrence. + + Also corrected, on the module that owns the rule: its header said the + dispatcher's `/packages` domain "reads no `version`" — load-bearing prose, + since it is part of why the rule needs only one home. That stopped being true + when #17668 landed. The paragraph now states what is true, which is that the one + home did not move and now serves two doors. +- 758ac40: refactor(types): one `isNativeErrorName` reader, so three doors cannot disagree about what a crash is (#17681) + + The predicate that decides whether a sandboxed body's `throw` is a business + REFUSAL (4xx, the author's words relayed) or a CRASH (5xx, the words withheld) + had **three byte-identical copies** — measured, one distinct 74-character regex + literal across three packages: + + | copy | package | its stated reason for being a copy | + |:--|:--|:--| + | `isScriptFaultMessage` | `@objectstack/rest` (`error-response.ts`, #7543) | the original | + | `isScriptCrash` | `@objectstack/objectql` (`hook-withheld-readonly-fault.ts`) | this package must not depend on `@objectstack/rest` for a regex | + | `sandboxRefusalMessage` | `@objectstack/runtime` (`sandbox/quickjs-runner.ts`, #17265) | rest declares one export subpath and re-exports nothing from `error-response` | + + ⭐ **Every reason is a statement about reaching `@objectstack/rest`, and none of + them survives moving the rule.** `@objectstack/types` now owns + `isNativeErrorName` — the name list, the `^` anchor, and the deliberate absence + of a bare `Error:`. All three packages already depend on it and it depends on + none of them, so this fold **adds zero dependency edges** and cannot cycle. + + ⚠️ The hazard was never style. One copy learning a new native error name and the + others not means the same throw is a refusal at one door and a crash at the + next — a crash message **leaked** at one boundary and **withheld** at another. + #16013's argument for extracting exactly this class applies verbatim: the + classification is the part nobody may get wrong, so one *tested* helper is worth + more than N correct copies that must each stay correct forever. + + ⛔ **No behaviour changes at any door, per case.** This is a pure refactor and + the three WRAPPERS are deliberately NOT folded, because they are not the same + shape and merging them would move a door's answer: + + - rest asks a trimmed message and answers a boolean; + - objectql asks **two** slots — `err.name` **or** `err.innerMessage.trim()` — + because a code hook and a sandboxed body carry the native name in different + places; + - runtime asks the trimmed inner message and answers the **message**, not a + boolean. + + What the three share is the predicate, so the predicate is what moved. Each call + site keeps its own slot choice and its own trimming, and `isNativeErrorName` + deliberately does **not** trim for its callers — a contract pinned in its test. + + **Shipped rather than `skip-changeset`**, measured on a real build: all four + packages publish `files[]: ["dist", …]`, and the built `dist` of each carries + the new call — `@objectstack/types` 4 files, `@objectstack/objectql` 4, + `@objectstack/rest` 3, `@objectstack/runtime` 2 — with `looksLikeInternalErrorLeak` + scoring 4 in `types/dist` as the lit control and a nonexistent symbol scoring 0. + The retired copies are gone from the artifacts too: the regex literal scores + **0** in `rest/dist`, `objectql/dist` and `runtime/dist`, and **2** in + `types/dist` (the ESM and CJS bundles). + + `@objectstack/types` takes **minor**: a new export is a purely additive widening + of a published surface, which is at least minor whatever the commit type says. + The three consumers take `patch` — their artifacts change, their behaviour does + not. +- c17ff70: The dispatcher's `/meta` domain answers `GET /meta/:type/:name` for a name with nothing behind it with `404 RESOURCE_NOT_FOUND` on its generic `:type/:name` branch, instead of announcing the miss as a `200` (#18401). + + **Clause-②: no** — no schema key moves, no accept set widens or narrows, no export changes, and no error code is minted: the refusal reuses the branch's own existing `deps.error('Not found', 404)`, whose code `standardErrorCodeForHttpStatus` already derives. + + `protocol.getMetaItem` answers a miss with the protection envelope wrapped around an absent item — `{ type, name, item: undefined, lock, editable, deletable, resettable }`, because `resolveLockState(undefined, false)` is unconditional — never with `undefined`. The generic branch returned that straight through, and `JSON.stringify` at the transport then dropped the `item` member, so a caller was handed a `200` whose body is the declared `GetMetaItemResponseSchema` envelope **minus its required member**. + + - **The branch disagreed with its own sibling.** The `object` branch of the same function already refused that exact shape and answered `404`, so one function answered "does absence mean success?" both ways, decided by which type you asked for. The generic branch now runs the same hit test. + - **A miss still falls through, it is not a hard refusal.** An item-less protocol answer hands the read on to the `MetadataService` resolver exactly as the object branch hands its own on to the ObjectQL registry; only a read that no resolver can satisfy reaches the `404`. + - **No new refusal dialect.** The fall-through ends at the branch's own pre-existing `404`, the ADR-0112 nested `{ success:false, error:{ code, message, httpStatus } }` this file already speaks — so the separate question of how this route spells its refusals is untouched. + - **What a caller observes**: a name with no item behind it. A request that was previously answered `200` with an item-less body is now answered `404`; a request that resolves to a real item is byte-identical to before, protection envelope included. +- 74327d3: fix(runtime): a NON-sandboxed crash at `/api/v1/actions` no longer ships its native error message verbatim (#18540) + + Clause-②: no + + A plain `TypeError` thrown by an in-process registered action handler answered + `500 INTERNAL_ERROR` carrying the native sentence on the wire: + + ``` + {"success":false,"error":{"code":"INTERNAL_ERROR", + "message":"Cannot read properties of undefined (reading 'id')","httpStatus":500}} + ``` + + The identical crash through the `/data` door answered `"Internal server error"` + (#7543 / #15071). One repository, two doors, one already meeting the contract. + + **The status was already right; what leaked was the sentence.** No status code, + no `error.code` and no envelope key moves — reaching this branch already proves + the throw declared no `status`/`statusCode` (the branch above serves those) and + is not a `ValidationError`, so the resolver's status was the 500 fallback and its + code was the status-derived `INTERNAL_ERROR`. Only `error.message` changes. + + **Why neither existing guard caught it.** #17273's crash terminal is keyed on the + SANDBOX — `isNativeErrorName` read over the `innerMessage` the QuickJS runner + fills — and this face never crosses a VM boundary, so nothing sets `innerMessage` + and that terminal never fires. *A predicate that classifies by HOW a crash + arrived is structurally blind to crashes that did not arrive that way, while + looking exhaustive.* The other guard, the dispatcher's 5xx withhold, is gated on + `looksLikeInternalErrorLeak`, which recognises DRIVER DUMPS and reads FALSE for + stack-shaped prose. + + **The structural difference, which is the fix.** The `/data` door is default-DENY: + `classifyDataError` ends in an unconditional `UNCLASSIFIED_FAULT()`, and its + `looksLikeInternalErrorLeak` limb only picks `DATABASE_ERROR` over + `INTERNAL_ERROR` — that limb is not what sanitises. The actions door's + `unexpectedFault` exit relayed `err.message` and was therefore default-ALLOW: + prose shipped unless a heuristic recognised it. That exit is this door's + unclassified-fault terminal, so it now answers the terminal's envelope — + `INTERNAL_ERROR_MESSAGE`, through the same `deps.error` seam #17273's terminal + uses. + + ⛔ `looksLikeInternalErrorLeak` is NOT re-pointed at stack-shaped prose. It guards + a different question at every other boundary, and widening it would change what + each of them withholds. + + **Measured population.** Driven through the real `HttpDispatcher.handleActions` + door against `mapDataError` on the same throws: seven shapes leaked at `/actions` + and were already sanitised at `/data` — `TypeError`, `ReferenceError`, + `RangeError`, `SyntaxError`, a driver class whose prose the heuristic does not + recognise (this one shipped a server **filesystem path**), a sandbox timeout and + a sandbox capability denial. All seven now answer the same sentence at both + doors. Two controls are unchanged in both directions: a deliberate rejection + keeps its `400` and its own words, and a crash that DECLARED its own status keeps + that status and that sentence. + + **Who is affected.** Any caller reading `error.message` off a `500` from + `/api/v1/actions` to tell one crash from another. That text was never a contract + — it is the thrown error's own prose — and the full text still reaches the + operator: the `console.error` on the line above keeps it, the same + "the client does not read it, the log keeps it" split `rest` already draws. +- 2cac363: feat(spec): one declaration per version grammar — eight regex carriers of "the version of a package or plugin" now reference three exported constants + + Clause-②: yes (widening) + + **No accept set moves, and that is the whole point of this change.** Eight sites + spelled a version regex out as a literal of their own. Five of those spellings + were byte-identical to each other, two more were byte-identical to each other, + and the eighth stood alone — three accept sets written eight times, growing on + their own: three of the eight were published schema declarations with no parse + caller at all, added by authors who copied a neighbour's literal. Each site now + references the constant carrying the pattern it already enforced, byte for byte. + A ninth in-repo carrier of the same concept spelled no regex at all: + `PackageManifestSchema.version` is a bare `z.string()`, and it stays one here. + + `@objectstack/spec/kernel` gains three exported patterns: + + - `MAJOR_MINOR_PATCH_VERSION_PATTERN` — three numeric segments and nothing + else. Referenced by `ManifestSchema.version`, + `MetadataPluginManifestSchema.version`, `PluginRegistryEntrySchema.version`, + `PluginMetadataSchema.version`, and the `PATCH /api/v1/packages/:id` door in + `@objectstack/runtime`. + - `SEMVER_SHAPED_VERSION_PATTERN` — `major.minor.patch` with an optional + `-prerelease` and an optional `+build` suffix, identifiers in either ASCII + case. Referenced by `PluginSchema.version` and by + `PluginLoader.isSemverShapedVersion` in `@objectstack/core`. Those two + converged on one spelling under the widen-never-narrow ruling and were held + equal by hand until now; they reference one declaration and can no longer + drift apart. + - `SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN` — the same with the suffix + identifiers restricted to lowercase ASCII. Referenced by + `PackageVersionSchema.version`. + + ⛔ **The three are not interchangeable** — they are three different accept sets, + and referencing the wrong one moves a published accept set. None of the three is + a SemVer 2.0.0 conformance check and none is named as one: two accept forms + SemVer forbids (leading zeroes in the numeric core, empty and leading-zero + identifiers), one refuses forms it requires. For ordering or precedence, + `dependency-resolver.ts` in `@objectstack/core` is still the module to extend. + + **Nothing an author can write changes.** Every regex is byte-identical to the + literal it replaces — verified per carrier by sha256 over the extracted literal + — and every existing suite passes unedited. Those two together are the + neutrality proof, and they are the whole of it. `PackageManifestSchema.version` + keeps its bare `z.string()`; it is deliberately untouched here. No `.describe()` + text, refusal message or JSON Schema `pattern` moves. Regenerating the spec's + artifacts moved `api-surface/kernel.json` and `export-origins/kernel.json` and + nothing else, each gaining the three constant names — ⛔ read that as a check + that nothing unexpected regenerated, never as evidence about the accept set: the + artifacts that stayed byte-unchanged do not record a `.regex()` pattern in the + first place. A new pin, + `src/kernel/version-grammar.test.ts`, records each grammar's verdict on twelve + witness strings so the next deliberate move to any of them is one visible edit + to one matrix. +- 4fef271: Installing a package no longer reverts an operator's most recent enable/disable. The install contract is now 「缺省 = 保持,有旗 = 设置」: an install that was not asked to move the lifecycle state does not move it (#18877). + + `SchemaRegistry.initialDisabledPackageIds` is a boot hydration input — filled once, before any registration, from the durable disable file, and never updated by `enablePackage` / `disablePackage`. It was nevertheless consulted by every `installPackage` call, so once an id was in the boot seed set, every re-install within that boot re-landed it DISABLED whatever the operator had most recently done. Since the durable write started following the row the door returns (#18752), that stopped being memory-only: + + ```text + boot 1 operator disables the package → disk lists the id + boot 2 seeded from disk; the package installs disabled + PATCH /packages/:id/enable → 200, registry true, disk CLEARED + install(m, { overwrite: true }) (no flag) → the seed still listed the id + → row disabled, disk written DISABLED + boot 3 the operator's enable is gone, with no error anywhere + ``` + + Reachable with nothing exotic: disable → restart → enable in Studio → an SDK upgrade with `overwrite`. + + - **`installPackage` reads the ROW first.** An existing row keeps its own `enabled`, `status` and `statusChangedAt`; the boot seed decides only for an id that has no row yet (boot hydration and a genuinely fresh install). A fresh id the seed never named still lands enabled, the declared default. + - **`enableOnInstall` now sets the state in BOTH directions.** `true` ⇒ `enablePackage`, `false` ⇒ `disablePackage`, and an ABSENT flag makes no lifecycle call at all — previously only `false` was read, and the `true` case was carried by the re-install restamping every row enabled. The bare (unwrapped) body form still honours nothing: no schema declares the key there. + - **`DELETE /packages/:id` clears both records.** The id leaves the boot seed set with its row, and its durable disable entry is cleared, so the next install of that id is a fresh install. Previously the durable record was immortal — a delete left a disable behind that named a package that no longer existed. + - ⚠️ **Behaviour change for a flag-absent re-install of an EXISTING row.** It used to return the package to the declared default (enabled); it now preserves what the row says. An upgrade flow that relied on a re-install to clear a disable must now send `enableOnInstall: true` — the same key, the same door, now honoured in that direction. A fresh install is unaffected. + + Maintainer decision batch #157 item 5, letter C. Item 4 of that ruling re-rules the #18058 F1 pin 「flag-absent re-install clears the durable disable」 to 「preserves」; F1b stands unchanged. + + Clause-②: no +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- 95fb417: **The declared `zod` floor moves from `^4.4.3` to `^4.6.1`**, because on zod below 4.6.1 the three standard error formatters — `z.treeifyError()`, `error.format()` and `error.flatten()` — cannot render a refusal these packages actually emit (#19581). + + Clause-②: no + + **What breaks below the new floor.** All three formatters walked an issue's `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming a member of `Object.prototype` was answered by the prototype and no node was ever created. Two different failures follow: + + | path shape | what happened on `^4.4.3` | + |:---|:---| + | terminal element (`['assignments','__proto__']`, `['x','toString']`) | the inherited member is adopted as the node, then `node._errors.push(...)` runs on it — `TypeError: Cannot read properties of undefined (reading 'push')` | + | non-terminal element (`['__proto__', …]`) | the walk continues **into** `Object.prototype` and writes the next segment onto it — the message is silently dropped from the returned tree and the process gains a global prototype key | + + **Why it reached this platform's consumers.** `@objectstack/spec` refuses a `__proto__` key on its open-key authoring surfaces, and that refusal's issue path is `['assignments','__proto__']` — precisely the terminal shape. Anything that formatted one of these refusals for display crashed on it, and the crash was in the formatter, not in the guard. The guards themselves are unchanged and still necessary: 4.6.1 still drops a `__proto__` key from `z.record()` and `.catchall()` output, which is what they exist to refuse. + + **What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. + + `@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. + + ## The second half the floor move needs: an unknown key refuses TERMINALLY again + + From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no + longer aborts the shape that raised it. Two things follow, and both were + measured on this package with the same bodies on 4.4.3 and 4.6.1: + + 1. **A closed shape's own refinements now run after the refusal**, adding a + second complaint that contradicts the first. + 2. **A union containing that shape loses its envelope.** zod's + `handleUnionResults` returns a single non-aborted member's issues + *unwrapped* instead of raising `invalid_union`, so the union's message + becomes whichever branch zod judged closest. + + At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong + branch's prescription. Writing `type: 'page'` on a ViewItem answered: + + ``` + Unrecognized key(s) on this view container: `viewKind`, `config`. + • `viewKind` belongs to a single VIEW, not to the container. Wrap it: … + ``` + + — naming neither `page` nor its removal. It now answers, as it did before: + + ``` + config.type: 'page' was removed from the list-view `type` enum in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … + ``` + + **What an upgrading consumer must do.** Nothing. No key or value changed + status: everything this package accepted before it accepts now, and everything + it refused it still refuses. What changed is which of several competing + complaints an author reads, and that a refusal behind a union is again + reported as `invalid_union` with its branches, which is what `z.treeifyError()` + and this package's own `formatZodError` expand. + + ⚠️ A closed shape declared with a bare `z.object(…).strict()` or + `z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT + get this and will still collapse its union. Build closed authoring shapes with + `strictObject`, or re-declare an existing one through `closedObject`. +- 3fd3a4f: A seed dataset declared **once** on an **additive** multi-package artifact (one that carries its collections both flattened at the top level and under `packages[]`) is now registered once per boot. Before this, it reached the shared `seed-datasets` registry twice, so a `mode: 'insert'` dataset wrote its rows twice on every boot and again on every per-organization replay. + + The cause was the identity check that stops the two halves of an additive artifact from being read twice. A dataset has no `name`, so it is matched on its whole value. Boot registration stamps the ADR-0010 envelope (`_packageId`, `_provenance`) onto the package body's copy in place, and `AppPlugin` reads its collections after that. In a compiled `objectstack.json` the two halves are separate objects, so the stamped copy and the unstamped top-level copy no longer compared equal. The value comparison now leaves out the registration envelope. The key set comes from `MetadataProtectionFields` and is not listed by hand, so every nameless collection item is matched on what its author wrote. + + Artifacts in this shape are still produced and loaded: + + - Every multi-package artifact built before the emitter stopped writing the flattened copy has this shape. `os dev` without `--compile`, `os start` and `--artifact` / `OS_ARTIFACT_PATH` boot it as is after an upgrade. + - The current `composeStacks(…, { manifest: 'preserve' })` still emits this shape when the package bodies do not reproduce the flattened copy. Two examples are a standalone action bound to a sibling package's object, and an input with no `manifest`. + + Nothing an author writes changes. An artifact whose datasets are all `upsert` ends up with the same rows as before; each dataset is simply no longer applied twice. +- b7b6cdd: fix(runtime): the run-lifecycle refusal names only a remedy the deployment's tenancy posture honours (#19874) + + `POST /api/v1/automation/:name/runs/:runId/cancel` and + `…/restore-suspension` admit only a caller with platform-operator standing + (the `PLATFORM_ADMIN` rung). When they refuse, the message says how to get that + standing. It used to say "the unscoped `admin_full_access` grant" on every + deployment. Under the walled postures (`group` / `isolated`) that grant no + longer confers platform-admin standing, so an operator who followed the advice + was still refused, with no sign of why. + + The sentence now depends on the requested tenancy posture, read the same way + the platform-admin derivation reads it: + + - **`group` / `isolated`**: standing comes only from the deployment's declared + platform administrators, meaning an account whose verified email address is + listed in `OS_PLATFORM_OWNER_EMAIL`. + - **`single`**: standing comes from an unscoped `admin_full_access` grant or + from `OS_PLATFORM_OWNER_EMAIL`. Both work on that posture today. + - **A posture that cannot be read**: the message names only + `OS_PLATFORM_OWNER_EMAIL`, which every posture honours. + + Nothing else changes. The same callers are admitted and refused, and a refusal + is still `403 PERMISSION_DENIED`. The last sentence still points a refused + caller at `POST /automation/:name/runs/:runId/resume`. +- b81da66: fix(runtime): `POST /automation/:name/runs/:runId/resume` checks WHO is resuming — the run's own starter, or the `sys_automation_run` read grant (#19987) + + Clause-②: no + + **The defect.** The resume route checked the body and then resumed. It never asked who the caller was. An authenticated user holding another user's run id could continue that user's paused run, and the run then went on under the identity STORED on the run: its data nodes ran as the user who started it, with the values the other user submitted. The screen read on the same pause, `GET /automation/:name/runs/:runId/screen`, already refused that caller. + + **The fix.** The route now asks the screen read's own question, through the same function: the caller must be the user who started the run (`getRun(runId).trigger.userId`), OR hold read access to `sys_automation_run` (the operator override), OR be a system context. Anyone else gets `403 PERMISSION_DENIED`, the screen read's code and status, and the run is not touched: nothing reaches the engine, so the pause stays parked for the person it is waiting on. The message states the screen read's requirement word for word under this route's own verb: "Resuming a paused run requires being the identity that triggered the run, or read access to 'sys_automation_run'." + + **What stays the same.** + + - The user who started a run resumes it exactly as before, with no grant needed, and still does while the permission subsystem is down. + - The suspended node's own gate (`resumeAuthority`) is unchanged and still answers behind this one. An `approval` pause is still refused on this route for every caller; approvers decide through the approvals API, which never uses this route. + - Body checks come first, as before, so every `400` for a malformed body is unchanged for every caller. + - For the starter, a `sys_automation_run` reader and a system context, every engine answer is unchanged, including the `404` for an unknown or finished run. + + **Who needs to act.** A caller that resumes runs it did NOT start (for example an integration that feeds a `wait` node's signal, or a support tool) now needs read access to `sys_automation_run`. Without it, it gets the `403` above. Such a caller also gets that `403`, not the old `404`, for a run id that does not resolve. The check fails closed there because a run that is still paused can read as "not found" while the run store is degraded. +- fa00ebf: fix(runtime): a self-hosted restart reads the kernel's own `sys_metadata` back into the registry, so objects authored at runtime keep serving + + An object created and published at runtime on a self-hosted install (Studio, + `PUT /api/v1/meta/object/…`, `POST /api/v1/packages//publish-drafts`) + answered `404 OBJECT_NOT_FOUND` on the data API after the next restart, while + its `sys_metadata` row was still there and + `GET /api/v1/meta/object//published` still served it. Every `os dev` / `os serve` / `os start` boot was affected. + + **Cause.** `createStandaloneStack` stamps `environmentId: 'env_local'` on every + boot (or whatever `OS_ENVIRONMENT_ID` names), and `ObjectQLPlugin.start()` read + any environment id as "a per-project kernel whose metadata comes from an + artifact or a control-plane proxy", so it skipped reading `sys_metadata` at + boot. It logged `Project kernel — skipping sys_metadata hydration (metadata + sourced from artifact)`, which was false on this composition. This is the same + deduction that `runPlatformMigrations` was declared out of. + + **Fix: a declaration, not a deduction.** `createStandaloneStack` now declares + `hydrateMetadataFromDb: true` to `ObjectQLPlugin`, where it used to be deduced + from the environment-id stamp. The plugin option's own caution holds for every + boot this function builds, for two reasons: + + - the registry is per-instance: the function constructs a fresh + `ObjectQLPlugin`, which builds its own `ObjectQL` and `SchemaRegistry`; + - `sys_metadata` is on the kernel's own driver: it declares no datasource, so it + routes to the one `default` datasource the function composes, and every + database driver kind the function dispatches is a direct driver, never a + control-plane proxy. + + What an upgraded install sees at boot: + + - every env-wide `sys_metadata` row (`organization_id` NULL), any metadata type, + is registered again. Org-scoped rows are still served on demand and are not + read at boot (ADR-0005); + - a stored row that cannot register now says so at boot, on lines that were + never reached here before: `[Protocol] [metadata_field_type_refused] …` at + `error`, `[Protocol] Failed to hydrate /: …` and + `[Protocol] [metadata_spec_invalid] …` at `warn`. The same boot also reports + org-scoped rows of types that are not per-org overridable, on one aggregated + line. Each line names its remedy; + - the one-shot `os migrate *` / `os meta *` commands hydrate too, so a plan or + a scan covers runtime-authored objects the way the serving boot registers + them. The read itself writes nothing, and a deferred-DDL boot + (`os migrate plan`, `os migrate duplicates`) still defers the tables of what + it read. +- 2bcd5cf: fix(runtime): the dispatcher's `/meta` item reads ask the same per-caller read gate `RestServer` asks, and `@objectstack/rest` publishes it (#20193) + + Clause-②: yes + + `GET /meta/:type/:name` and `GET /meta/:type/:name/published` have two + implementations: `RestServer`, and the runtime dispatcher's `/meta` domain. On a + host that mounts only the `${prefix}/*` catch-all (`@objectstack/hono`'s + `createHonoApp`, the documented embed shape, and any adapter built on the public + `HttpDispatcher` API), the dispatcher is the only one that answers. Its item read + and its `/published` read applied **no** per-caller read gate. An authenticated + member who does not hold `crm_admin` got these answers through `dispatch()`: + + ``` + request before after (= RestServer) + GET /meta/doc/crm_admin_runbook 200 + the gated body 403 PERMISSION_DENIED + GET /meta/doc/crm_admin_runbook/published 200 + the gated body 403 PERMISSION_DENIED + GET /meta/book/admin_guide (set-gated) 200 + the book 403 PERMISSION_DENIED + GET /meta/app/crm (and /published) 200, every entry 200, pruned + GET /meta/app/payroll (app-level perms) 200 403 PERMISSION_DENIED + GET /meta/app/launchpad (unpublished) 200 404, the same body as a missing name + GET /meta/dashboard/ops 200, every widget 200, minus the widget whose service is off + GET /meta/object/invoice/published 200, every field 200, the ADR-0106 mask applied + ``` + + Holders are still served in full. Object reads through the plain item read are + masked as before. An anonymous caller still gets `401 UNAUTHENTICATED`. + + **One gate, not two.** `RestServer`'s gate moved unchanged into + `packages/rest/src/meta-item-read-gate.ts`. That covers the ADR-0046 §6.7 docs + audience, the app nav filter (`requiredPermissions`, the ADR-0045 §3 publish gate + and the docs-audience entry arm), the ADR-0057 D10 service gates and the #7912 + servability gate. Both transports now call it. Each transport supplies only its + own I/O (the caller, the protocol's list read, the security service and a + service probe) and writes the gate's data verdict in its own envelope. There is + no second audience resolver in `packages/runtime`. `RestServer` keeps its + private helper names as delegates, so its own answers are byte-for-byte + unchanged. + + A gate input that cannot be read is answered as that fault, never as the + document. This covers a books or doc-list read that throws, and a host whose + protocol has no list read at all (fail closed, ADR-0049). + + **`@objectstack/rest`'s published export surface widens**, and that is why this + changeset declares `Clause-②: yes`. Its only export subpath (`.`) gains one value + and five types: + + - `createMetaItemReadGate(sources, metaType, name, documents, policy)`: the gate + itself; + - `MetaItemReadGateSources`: the I/O a caller supplies (the caller, a metadata + list read, the security service, a service probe, a prune-log set); + - `MetaItemReadVerdict` and `MetaItemReadRefusal`: the data verdict (`serve`, or + `refuse` with `absent` / `app-permission` / `docs-audience`); + - `MetaReadGateCaller`: the slice of the execution context the gate reads; + - `MetaReadGatePolicy`: `arms` and `app`, how a door runs the gate. + + They are public because the runtime dispatcher's `/meta` domain in + `@objectstack/runtime` consumes this one gate. `@objectstack/rest` cannot import + the runtime, so the shared decision has to live here and travel as an export, + the way `repeatedQueryParamMessage` does. A caller outside the platform does not + need them. + + Nothing is removed or renamed, and no authorable key moves. The dispatcher's + refusals are not the widening: they pull a second transport back to the gate + the contract already declares (ADR-0046 §6.7, ADR-0045 §3, `apps.mdx`), which is + why `@objectstack/runtime` stays a `patch`. +- cc40033: fix(runtime): the dispatcher's `/meta/:type` list prunes what `RestServer`'s list prunes, through one list gate that `@objectstack/rest` publishes (#20237) + + Clause-②: yes + + `GET /meta/:type` has two implementations: `RestServer`, and the runtime + dispatcher's `/meta` domain. On a host that mounts only the `${prefix}/*` + catch-all (`@objectstack/hono`'s `createHonoApp`, the documented embed shape, + and any adapter built on the public `HttpDispatcher` API), the dispatcher is the + only one that answers. Its list branch applied **no** per-caller filter. An + authenticated member who does not hold `crm_admin` got these answers through + `dispatch()`: + + ``` + request before after (= RestServer) + GET /meta/doc?include=content the set-gated doc listed WITH its body the doc left out + GET /meta/book the set-gated book listed the book left out + GET /meta/app an app whose requiredPermissions the that app left out; the other app + member lacks, and an ungated app with pruned of its gated entry + its requiredPermissions-gated entry + GET /meta/dashboard (anyone) every widget minus the widget whose service is off + ``` + + The plural spellings (`/meta/docs`, `/meta/books`, `/meta/apps`) answer the + same. Holders are still listed everything in full. Object lists are masked as + before. An anonymous caller still gets `401 UNAUTHENTICATED`. + + **One gate, not two.** `RestServer`'s list filters moved unchanged into + `createMetaListReadGate`, beside the item gate in + `packages/rest/src/meta-item-read-gate.ts`. It covers the ADR-0046 §6.7 doc + and book audience prunes, the app nav filter (`requiredPermissions`, the + ADR-0045 §3 publish gate and the docs-audience entry arm) and the ADR-0057 + D10 dashboard widget gate. `RestServer`'s list route and the dispatcher's list + branch both call it, over the same ports the item gate takes, and each rewraps + the pruned items in its own list envelope. There is no second audience + resolver in `packages/runtime`. `RestServer`'s list answers are unchanged. + + Every exit of the dispatcher's list branch now runs the gate and the ADR-0106 + object mask: the protocol list and the two fallbacks, the runtime metadata + service's list and the ObjectQL registry. The fallbacks used to serve + unmasked object schemas as well as ungated docs, books and apps. A gate input + that cannot be read (a doc list's books read throws) is answered as that fault, + never as the unfiltered list. + + **`@objectstack/rest`'s published export surface widens**, and that is why this + changeset declares `Clause-②: yes`. Its only export subpath (`.`) gains one + value: + + - `createMetaListReadGate(sources, metaType)`: the list gate. It takes the + same `MetaItemReadGateSources` the item gate takes, and it answers a judge + from a list's items to the items this caller may be served. + + It is public because the runtime dispatcher's `/meta` domain in + `@objectstack/runtime` consumes this one gate. `@objectstack/rest` cannot import + the runtime, so the shared decision has to live here and travel as an export, + the way `createMetaItemReadGate` does. A caller outside the platform does not + need it. + + Nothing is removed or renamed from the package's exports, and no authorable key + moves. The dispatcher's prunes are not the widening: they pull a second + transport back to the rules the contract already declares (ADR-0046 §6.7, + ADR-0045 §3, `apps.mdx`'s `requiredPermissions` row), which is why + `@objectstack/runtime` stays a `patch`. +- 95f729a: fix(rest, runtime): the runtime dispatcher's `/meta` reads answer what `RestServer`'s answer — one list chain, one `public`-audience predicate, and the `?state=draft` read (#20320) + + Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains five value exports (`createMetaListAnswer`, `translateMetaList`, `metaRequestLocale`, `isPublicAudienceRead`, `STORED_VERSION_DOOR_POLICY`) and six type exports (`MetaListAnswer`, `MetaListAnswerSources`, `MetaListRequest`, `MetaListTranslationSources`, `MetaPublicReadRoute`, `MetaRequestHttp`), so its published surface grows; nothing it exported before is removed, renamed or narrowed. `@objectstack/runtime` publishes no new surface and stays a `patch`. + + A host that mounts only the `${prefix}/*` catch-all (`createHonoApp`, and any + adapter written on the public `HttpDispatcher` API) serves `/meta` through the + runtime dispatcher. Its reads now give the same answers as `RestServer`'s + `GET /meta/:type` and `GET /meta/:type/:name`. Until now a dispatcher-only host + answered: + + - **`GET /meta/app?id=crm`** — every app the caller may see, not `[crm]`. An + `?id=` that matches nothing listed every app instead of an empty list. + - **`GET /meta/view?object=lead`** — every view, not the lead views sorted for + the switcher. + - **`GET /meta/docs`** (the plural spelling) — every doc WITH its body. The + content slim compared the raw segment, so it ran only for `/meta/doc`. + - **any doc list** — each doc with its `translations` map and in no locale. + `RestServer` collapses each doc to the request's locale. + - **every translatable list** (`app`, `view`, `object`, `page`, `dashboard`, + `action`, `dataset`) — untranslated labels, whatever `Accept-Language` or + `?locale=` asked for, and no `Vary: Accept-Language` header. + - **`GET /meta/api`** — every stored `api` declaration, including ones the + endpoint matcher does not serve (their routes answer 404). + - **an anonymous `GET` of a `public` book or doc** (list or item) — + `401 UNAUTHENTICATED`. `RestServer` serves it (ADR-0046 §6.7). + - **`GET /meta/:type/:name?state=draft` from a caller who may read drafts** — + the ACTIVE item. It should be the pending draft, whole for a caller who may + save the app and pruned per caller for everyone else, or `404 NO_DRAFT` when + nothing is pending. A caller who may not read drafts is still answered the + plain read, byte for byte. + + **What changed.** The list route's whole post-read chain moved out of + `RestServer` into `createMetaListAnswer` in `@objectstack/rest`, unchanged. That + chain is the `api` served-set face, the per-caller list gate, `?id=`, + `?object=`, the doc locale collapse and content slim, the transport's own + object mask, and the translation. Every exit of the dispatcher's list branch + now hands its answer to that same function. The anonymous gates on both + transports ask one exported predicate, `isPublicAudienceRead`. It admits only + `GET` reads of book and doc, so every other type keeps the anonymous deny, and + the §6.7 audience gate still refuses `org` and `{ permissionSet }` audiences. + The dispatcher's `?state=draft` read runs the exported + `STORED_VERSION_DOOR_POLICY`, the constant `RestServer`'s draft branch runs. + + `RestServer`'s own answers are unchanged: the move is a refactor on that side, + and every existing REST test passes unedited. + + New exports from `@objectstack/rest`: `createMetaListAnswer`, + `translateMetaList`, `metaRequestLocale`, `isPublicAudienceRead`, + `STORED_VERSION_DOOR_POLICY` and their types. Nothing is removed or renamed. +- 5049a3c: **Pending metadata drafts are now served only to a caller with an authoring capability — the check `GET /api/v1/meta/_drafts` already made.** A pending draft is unpublished authoring work. Until now, every door that reads one, other than `/meta/_drafts`, served it to any signed-in caller who could open the item. The draft access that the `previewDrafts` / `state` request declarations, ADR-0106 D4 and ADR-0037 described as admin-gated upstream is now gated. + + Clause-②: no + + - **The doors:** `GET /api/v1/meta/:type/:name?state=draft` and `?preview=draft`, `GET /api/v1/meta/:type?preview=draft`, and `POST /api/v1/analytics/dataset/query` with `previewDrafts: true` or `?preview=draft` on `RestServer`, plus the runtime dispatcher's `/meta` item and list `?preview=draft`. + - **Who may read drafts:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the same predicate `/meta/_drafts` asks, not a second rule. + - **Everyone else gets the read as if the draft switch were absent.** They receive the published version, pruned for them as the plain read prunes it. For a name that has nothing published, they receive that door's own absence answer: `404` on the item read, `404 NOT_FOUND` for a dataset by name, and the item simply missing from a list. The answer is byte-identical to the plain read, so it does not reveal whether a draft exists. For example, `?state=draft` on an app with no pending draft answers such a caller with the published app, not `404 NO_DRAFT`. A dataset preview run by such a caller uses live rows, never a pending seed draft's rows. + - **Unchanged:** callers with an authoring capability read exactly what they read before. Whoever may save an app reads its `?state=draft` whole, and everyone else pruned per caller. `/meta/_drafts` still answers `403` to a caller without the capability, because it lists drafts and has no published answer to fall back to. `/diff` and `/history` are not changed by this release. +- 5c7aa46: fix(rest, runtime): the runtime dispatcher's `/meta` doors scope a caller to the organization `RestServer` scopes them to, and its item read, book tree and list answer what `RestServer`'s answer (#20408) + + Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains seven value exports (`createMetaItemAnswer`, `createMetaBookTreeAnswer`, `metaCallerOrganizationId`, `metaReadOrganizationId`, `projectMetaObjectSchema`, `refuseUnknownMetaListType`, `translateMetaEnvelope`) and five type exports (`MetaItemAnswer`, `MetaItemAnswerSources`, `MetaItemRequest`, `MetaBookTreeAnswer`, `MetaBookTreeSources`), and `MetaListAnswer` gains an optional `cacheControl`. `MetaListAnswerSources`, new in this same release with `createMetaListAnswer`, takes the transport's object-schema masker (`resolveObjectMasker`) instead of a whole-mask port, so the chain decides the cache posture for both transports. Nothing any published version exported is removed, renamed or narrowed. `@objectstack/runtime` publishes no new surface and stays a `patch`. + + A host that mounts only the `${prefix}/*` catch-all (`createHonoApp`, and any + adapter written on the public `HttpDispatcher` API) serves `/meta` through the + runtime dispatcher. Until now, on such a host: + + - **A member removed from an organization kept its metadata partition.** The + dispatcher's `/meta` doors took the organization from the session's + `activeOrganizationId` as stored. Under a wall-enforcing tenancy posture the + identity resolver DROPS a claim naming an organization the caller no longer + belongs to, and `RestServer` reads that vetted value. The dispatcher did not, + so for the rest of the session the removed member was served that + organization's org-scoped overlays (`view`, `dashboard`, `report`, + `translation`, `email_template`) by the item read, the list, `/published` and + `?state=draft`, listed its pending drafts on `GET /meta/_drafts`, and had a + `PUT /meta/:type/:name` land in its partition. Every `/meta` door here now reads + the vetted organization on the execution context, the value `RestServer` reads. + - **`GET /meta/:type/:name` answered a different body.** Nothing was translated + whatever `Accept-Language` or `?locale=` asked for. A doc kept its whole + `translations` map, in no locale. An object schema came with no + `sortability`. The answer had no `Vary: Accept-Language`. + - **`?preview=DRAFT`** (any casing but lower) from a builder read the published + world on the item read and the list. `RestServer` compares it + case-insensitively. + - **`GET /meta/object/:name?preview=draft`** from a builder answered the ACTIVE + schema, never the pending draft. + - **`GET /meta/totally_invented_type`** answered `200 {"items": []}`. `RestServer` + refuses a segment that names no metadata type with `400 INVALID_REQUEST`. + - **`GET /meta/book/:name/tree`** was no route: `404 ROUTE_NOT_FOUND` to a signed-in + reader and `401` to an anonymous reader of a `public` book (ADR-0046 §6.7). + - **An object schema served under an undetermined field visibility** (ADR-0106 + D6 tier 2: served unmasked) carried no `Cache-Control`. `RestServer` answers + `private, no-store`. This was true of the list, the item read, `/published` and + the legacy one-segment object read. + + **What changed.** Everything `RestServer`'s `GET /meta/:type/:name` does after + the store read moved, unchanged, into `createMetaItemAnswer`: absence, the item + gate, the doc locale collapse, the object mask and its cache posture, and the + body (the translation and `sortability`, `translateMetaEnvelope`). The book-tree + route's whole answer moved into `createMetaBookTreeAnswer`, and the list's + unknown-type refusal into `refuseUnknownMetaListType`. The list chain now + applies the object mask itself (`projectMetaObjectSchema`) and reports the cache + posture. The dispatcher's `/meta` domain calls each of these, and takes its + organization from `metaCallerOrganizationId` / `metaReadOrganizationId`, which + `RestServer`'s list and item reads ask too. + + `RestServer`'s own answers are unchanged: the move is a refactor on that side, + and every existing REST test passes unedited. +- 45f428d: fix(runtime): the dispatcher's `/packages` doors scope a caller to the organization the identity step vetted, not the session's stored claim (#20477) + + Clause-②: no + + The runtime dispatcher's nine organization-scoped `/packages` doors took the caller's organization from the auth session's `activeOrganizationId` as stored. Under a wall-enforcing tenancy posture (`isolated` or `group`), the identity step drops that claim when no membership backs it any more, and the request resolves with no active organization (the maintainer's ruling B on #15409). The doors never saw the drop. So a member removed from an organization kept that organization's packages for the rest of the session: its commit history and whole-package export were served to them, and their publish-drafts, discard-drafts, commit revert, rollback, adopt-orphans, duplicate and uninstall ran inside it. + + The doors now read the vetted organization on the request's execution context, the value `RestServer` scopes by and the dispatcher's `/meta` doors already read. The fix is in the one source the nine doors share (`HttpDispatcher`'s `resolveActiveOrganizationId`), so every door changes together, on both HTTP entries (the `createHonoApp` catch-all and the dispatcher plugin's explicit package routes). + + - **A removed member whose session still names the organization they left:** every door is handed no organization. Reads and writes reach only the env-wide package state, as for any session with no active organization. An uninstall is refused `400 TENANT_SCOPE_REQUIRED` and deletes nothing. The left organization's rows are neither read nor written. + - **A caller authenticated by an API key:** the doors now use the organization the key is bound to. The session read found no session for a key, so these doors used to get no organization for it. + - **Unchanged:** a current member reaches their own organization exactly as before, a member who switched to an organization they belong to reaches that one, and an anonymous caller is refused `401` before any package operation. Single-posture deployments are unchanged, because no claim is dropped there. +- 9449512: fix(rest, runtime): the runtime dispatcher serves the layered view, `GET /meta/:type/:name/layers` and the deprecated `?layers=` flag, as `RestServer` serves it (#20478) + + Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains three value exports (`createMetaLayeredAnswer`, `wantsMetaItemLayers`, `metaItemLayersDeprecationHeaders`) and two type exports (`MetaLayeredAnswer`, `MetaLayeredRequest`). Nothing any published version exported is removed, renamed or narrowed. `@objectstack/runtime` publishes no new surface and stays a `patch`. + + A host that mounts only the `${prefix}/*` catch-all (`createHonoApp`, and any + adapter written on the public `HttpDispatcher` API) serves `/meta` through the + runtime dispatcher. Until now, on such a host: + + - **`GET /meta/:type/:name?layers=true` answered the plain read.** The body was + `{ type, name, item }` with a `200`, so a client reading `code`, `overlay` or + `effective` read `undefined`. There was no `Deprecation` header and no `Link` + to the successor. An author (a caller the item's save door admits) was served + the app pruned, where the layered view serves them every layer whole. + - **`GET /meta/:type/:name/layers` was no route.** It answered a located + `404 ROUTE_NOT_FOUND`. + + Both spellings now answer what `RestServer` answers: the three layers, each + judged by the per-caller read gate under the stored-version doors' policy + (whole for a caller who may save the item, pruned as the plain read prunes it + for everyone else), each projected through the object-schema field mask, and + `private, no-store` when the caller's field visibility could not be determined. + The read is scoped to the caller's vetted organization and to `?package=`. The + flag's answers, refusals included, carry `Deprecation: true`, and a `Link` to + `/layers` built from the request's own URL (every `createHonoApp` request + carries one; a host that hands `dispatch()` no URL gets `Deprecation` alone). The route answers `501 NOT_IMPLEMENTED` where the protocol has no + layered read, and the flag is then the plain read, on both transports. + + **What changed.** Everything `RestServer`'s layered helper does after the store + read moved, unchanged, into `createMetaLayeredAnswer`, and the flag's parse and + headers into `wantsMetaItemLayers` and `metaItemLayersDeprecationHeaders`. The + dispatcher's `/meta` domain calls all three. `RestServer`'s own answers are + unchanged: every existing REST test passes unedited. +- d1c01ff: fix(runtime): `DELETE /packages/:id` refuses an uninstall that names no organization before it touches the running registry (#20492) + + Clause-②: no + + A caller holding `manage_metadata` with no active organization — a member removed from an organization whose session still names it, or a caller who never selected one — sent `DELETE /api/v1/packages/:id` and was answered `400 TENANT_SCOPE_REQUIRED`. The dispatcher had already run the registry uninstall by then, so the package and every object it registers had left the running process for everyone it serves, while its stored rows still said it was installed. The state lasted until a restart re-seeded the registry. + + The door now asks the persisted delete's organization-scope question first, from the same organization value it hands `deletePackage`, and only when a persisted delete will run. The same refusal (`400 TENANT_SCOPE_REQUIRED`) now arrives before anything changes: the package stays served, listed and registered, and its stored rows are untouched. The refusal's message names what an HTTP caller can do, which is to select an organization they are a member of and retry. + + - **Unchanged:** a caller acting in an organization uninstalls exactly as before. A read-only package is still refused `422 WRITABLE_PACKAGE_REQUIRED` first. A host with no persisted delete (no `deletePackage` on its `protocol` service) still uninstalls from the registry alone, because there is no refusal to mirror there. The protocol keeps its own refusal as a second line. + + - **`@objectstack/spec`:** `PROVENANCE_WAIVERS` (the error-code ledger) gains one entry: `@objectstack/runtime` stamps `TENANT_SCOPE_REQUIRED`, which stays registered under `@objectstack/metadata-protocol`. The door mirrors `deletePackage`'s refusal and does not emit a second vocabulary. The registered code union and `ErrorCode` are unchanged. +- c3ebe4a: A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. + + `ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. + + The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. + + **What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. + + **What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. + + **For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. +- e77a23f: Attach four TSDoc blocks to the declarations they describe. + + TSDoc binds a block by position, so a block can end up describing a declaration + it does not document, or none at all. Four had: three in + `packages/rest/src/rest-server.ts` (the `resolveProtocol` paragraph stacked above + `resolveHostnameCached`'s own block, the exported `RestServer` class overview + orphaned by the `RestEnvRegistry` block, and the `registerSharingEndpoints` route + table orphaned by the analytics block) and one in + `packages/runtime/src/http-dispatcher.ts`, where the block above + `resolveActiveOrganizationId` still described `resolveCallerUserId`, a sibling + deleted with the multi-tenant `/cloud` control plane. + + No runtime behaviour changes and no API surface moves. This is a `patch` rather + than `skip-changeset` because the block text was measured to ship: each of the + four appears in the published `dist/index.d.ts` and `dist/index.d.cts` of its + package, both of which are inside `files: ["dist", ...]`. Anyone reading + `@objectstack/rest` or `@objectstack/runtime` declarations in an editor was being + shown a description of the wrong function. + + Clause-②: no +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [2d81e39] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [ee6fbd7] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [4f1a56b] +- Updated dependencies [f39ea95] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [dc709b2] +- Updated dependencies [04333d0] +- Updated dependencies [07f93e0] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [63b6818] +- Updated dependencies [32be735] +- Updated dependencies [c9246fa] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [69b5059] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [c54d8d6] +- Updated dependencies [eea7ccc] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e743fb5] +- Updated dependencies [d46deba] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [7e74af3] +- Updated dependencies [497655f] +- Updated dependencies [7c2c5ae] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [fade3da] +- Updated dependencies [758ac40] +- Updated dependencies [be5c602] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [9ccc417] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [17005cc] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [b5cbfef] +- Updated dependencies [d438b3a] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [4d2008c] +- Updated dependencies [abb01f1] +- Updated dependencies [cf39b83] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [6e4024c] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [922c755] +- Updated dependencies [74832b6] +- Updated dependencies [df1b275] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [877dc03] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [21b7c12] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- Updated dependencies [5941246] +- Updated dependencies [4efb988] +- Updated dependencies [8015dc8] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [62bce5c] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [ef256e6] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [4fef271] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [e3b3cdd] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [58644ad] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [482d584] +- Updated dependencies [2b321a4] +- Updated dependencies [0870fb5] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [cdc1ae0] +- Updated dependencies [5c5b67f] +- Updated dependencies [2306a75] +- Updated dependencies [3f9e2ea] +- Updated dependencies [a251aaa] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [2aac821] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [a754563] +- Updated dependencies [4112752] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [71ef221] +- Updated dependencies [a90272a] +- Updated dependencies [5dba7f3] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [a5afe38] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [bc80e16] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [afc3b64] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [0e90a8d] +- Updated dependencies [ecf90b2] +- Updated dependencies [e07843b] +- Updated dependencies [2bbb462] +- Updated dependencies [1f89ba0] +- Updated dependencies [1f0b341] +- Updated dependencies [90ff10a] +- Updated dependencies [e9eb224] +- Updated dependencies [3bd221d] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [467fa76] +- Updated dependencies [3bd28e2] +- Updated dependencies [d1ca874] +- Updated dependencies [beac798] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [8490127] +- Updated dependencies [6aa3188] +- Updated dependencies [0142415] +- Updated dependencies [a0920b4] +- Updated dependencies [ae7a35a] +- Updated dependencies [ae0c90c] +- Updated dependencies [9bfbacb] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [0b866bf] +- Updated dependencies [c839986] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [009da14] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [55cd8d4] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [aa04ea2] +- Updated dependencies [61609ed] +- Updated dependencies [172b4cf] +- Updated dependencies [fc6ddb8] +- Updated dependencies [b9e9609] +- Updated dependencies [4463966] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [9d81af7] +- Updated dependencies [26550c6] +- Updated dependencies [8e9a425] +- Updated dependencies [e7f69db] +- Updated dependencies [b373596] +- Updated dependencies [7465eeb] +- Updated dependencies [84156c7] +- Updated dependencies [ed3546f] +- Updated dependencies [3557f85] +- Updated dependencies [57c2b73] +- Updated dependencies [f09d412] +- Updated dependencies [adbbc5d] +- Updated dependencies [e0f17a3] +- Updated dependencies [7766b62] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8d76c2d] +- Updated dependencies [8a44ce7] +- Updated dependencies [d4c897e] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [2491729] +- Updated dependencies [84880f9] +- Updated dependencies [95ab93f] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [a08e059] +- Updated dependencies [fe677ae] +- Updated dependencies [55daf89] +- Updated dependencies [fc646cf] +- Updated dependencies [586934e] +- Updated dependencies [8d1f7ab] +- Updated dependencies [e01d347] +- Updated dependencies [dbddf02] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [949e99b] +- Updated dependencies [16c5a33] +- Updated dependencies [16c5a33] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [16c5a33] +- Updated dependencies [9401b84] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [329ea2e] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [cfe2387] +- Updated dependencies [cfe2387] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [585c9af] +- Updated dependencies [2dccb7d] +- Updated dependencies [4df101c] +- Updated dependencies [1207baf] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [2bcd5cf] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [0d3ec47] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [cc40033] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [a78f731] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [d3958ba] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [a36a691] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [c74de10] +- Updated dependencies [db74b16] +- Updated dependencies [2b24b8b] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [95f729a] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [c5d6b2b] +- Updated dependencies [2d91c9a] +- Updated dependencies [2f122b6] +- Updated dependencies [15bf186] +- Updated dependencies [b285508] +- Updated dependencies [5049a3c] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [4a1df19] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [7fa3e3e] +- Updated dependencies [de8c973] +- Updated dependencies [9801da1] +- Updated dependencies [fc0db22] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [8cdbe0c] +- Updated dependencies [5c7aa46] +- Updated dependencies [7d63088] +- Updated dependencies [87c37ae] +- Updated dependencies [2304b16] +- Updated dependencies [3e8b492] +- Updated dependencies [5b674f5] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [8e02859] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [397572e] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [9449512] +- Updated dependencies [b2b6a06] +- Updated dependencies [8538edf] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [fb194c7] +- Updated dependencies [b057434] +- Updated dependencies [b43a814] +- Updated dependencies [1378ec7] +- Updated dependencies [f6ceddc] +- Updated dependencies [92ea760] +- Updated dependencies [40626bd] +- Updated dependencies [e2c55ed] +- Updated dependencies [4c42fd1] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [374d9d3] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [2c87a48] +- Updated dependencies [dd2fd20] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [b940f32] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [bdea10a] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [2266438] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [e77a23f] +- Updated dependencies [f9e16d8] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [a016f08] +- Updated dependencies [b110578] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [96451ec] +- Updated dependencies [45c2cf9] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [dfb42c5] +- Updated dependencies [29d00cc] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [b3f7fdc] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [470746a] +- Updated dependencies [b7c792b] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [4062aef] +- Updated dependencies [777d0c2] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [7173d7d] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [43e17b8] +- Updated dependencies [5cdb0db] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-protocol@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/driver-turso@17.5.0 + - @objectstack/observability@17.5.0 + - @objectstack/service-i18n@17.5.0 + - @objectstack/driver-sqlite-wasm@17.5.0 + - @objectstack/service-datasource@17.5.0 + - @objectstack/service-cluster@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 17779c9e707..da420dfeced 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack Core Runtime & Query Engine", "type": "module", diff --git a/packages/sdui-parser/CHANGELOG.md b/packages/sdui-parser/CHANGELOG.md index 8b049eb7ebd..088dfcd161e 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,101 @@ # @objectstack/sdui-parser +## 17.5.0 + +### Minor Changes + +- 2e0401a: Retire the zero-writer `binding: 'field'` arm from all three of this copy's declarations, so the save gate states the same one-word vocabulary the renderer already does (#16583) + + objectui retired the same arm from its copy of this package: the maintainer + ruling of 2026-09-07 on objectui#6950 (director decision batch #69) took the + serializer's input boundary, objectui#8315 took the two faces in `types.ts`, + both citing enforce-or-remove on a zero-writer measurement. That ruling names + coordinates in objectui only, and nothing propagates a retirement across the + two copies of `packages/sdui-parser` — so this one kept the arm on all three + declarations while the renderer that ships beside it no longer has it. This is + that port, measured here rather than inherited. + + - `RegistryConfigLike.inputs[].binding` — now `'object'` + - `ManifestInput.binding` — now `'object'` + - `ValidationResult.bindings[].kind` — now `'object'` + + **Breaking for TypeScript consumers, deliberately, and compile-time only.** A + registry config, a hand-written `Manifest` literal or a `bindings[]` entry that + spells `'field'` is now a `tsc` error. Runtime behaviour does not move: types + are erased, this package runs no validator over a `Manifest` it is handed, and + `validateTree` still forwards whatever the manifest says. A pin in + `src/__tests__/binding-field-retired.test.ts` states that limit outright, so the + narrowing is not mistaken for a runtime rejection, and it goes red in both + directions — a `@ts-expect-error` that stops being needed is itself `ts(2578)`, + so widening any of the three declarations back fails the package typecheck on + the very line that documents the retirement. + + **Nothing measured has to be rewritten, and the key was never author-writable + here.** `binding` is not a spec key, has no Zod schema and no stored + representation; it reaches this package only through the structural + `RegistryConfigLike` boundary, which exists so the package can be fed + objectui's `ComponentRegistry.getAllConfigs()` without depending on it. Four + readings on this tree, each with its control: `binding: 'field'` has zero + writers in this repository against a firing `binding: 'object'` control of 2 + (both under `packages/sdui-parser/src/__tests__/`); the tracked + `sdui.manifest.json` — the only manifest this repo produces — carries zero + `binding` keys across all 339 of its inputs; nothing outside the package reads + `binding` or `bindings[].kind` at all, the package's single importer + (`@objectstack/lint`'s `validate-jsx-pages.ts`) destructuring `{ diagnostics }` + only; and no arm of the vocabulary is branched on anywhere, so no consumer + loses a case it was handling. + + **Why the reader face is narrowed too.** The counter-argument — producer to + reader is a subset relation, so a permissive reader is not wrong — was answered + rather than assumed away. `ManifestInput` is not a pure reader face + (`manifestFromConfigs` returns it), and `bindings[].kind` is a pure **producer** + face where the relation inverts: a wider union there accepts nothing extra, it + obliges every consumer to handle an arm this package cannot emit. The two are + coupled by `validateTree`'s `kind: input.binding` assignment, so narrowing one + alone would need a cast at the only conversion site — the lenient consumer-side + fallback Prime Directive #12 bans. The reasoning now lives on the declarations + themselves, where a later reader lands. + + The reopen route is the ruling's own: a measured need for field bindings is + filed as a widening with the vocabulary decided then, not pre-declared here for + a producer that does not exist. + + +- fbc12be: The save gate now stamps `inert-quick-add` and `member-type-mismatch`, the two diagnostics that existed only in objectui's copy of this parser — so a page no longer saves clean here and renders with a different verdict there (#17645). + + The two copies of this parser owe each other one thing: byte agreement on the accepted grammar and on diagnostic codes. This copy runs the **save gate** and objectui's runs the **renderer**, so a code on one side only is a dialect — the author gets one reading when they save and another when the page draws, which is surface-dependent and therefore reaches them as intermittent. Measured at the ported revision: objectui stamped 26 codes, this copy stamped 24, and the missing two were exactly these. + + - **`inert-quick-add`** (warning) — `quickAdd` on `` reaches no control. The Quick Add button is gated on **both** `quickAdd` and an `onQuickAdd` handler, and `onQuickAdd` takes a function, which no page on this tier can write (this tier parses, it never executes) and which the board substitutes none of its own for. It **replaces** the `unknown-prop` this copy used to emit for the key, which was false against the contract: `ComponentPropsMap['object-kanban']` publishes `quickAdd`, so an author who checked the spec found the warning contradicted and kept a key that will never do anything. Asked ahead of the declaration lookup on purpose — the claim is about the render path, so declaring the key must not silently disarm it. A falsy value and an unevaluated braced expression are deliberately untouched. + - **`member-type-mismatch`** (warning; `error` when an `enum` arm is present) — the coarse type check one level down, over the member kind an input declares. This brings the `ManifestInput.of` key and its three readers with it: the validator, the serializer's canonicalization, and the codegen's element type. `of: 'string'` on an array input now types the members `string[]` in the generated `.d.ts` instead of `unknown[]`, and a member no declared arm accepts draws **one** diagnostic naming every offending position rather than one per member. + + **Nothing published changes shape for an input that declares no `of`.** The key is absent-means-undeclared: the validator checks no member, the codegen emits the unnarrowed element type, and `manifestFromConfigs` publishes no `of` at all, so an entry written before the key existed serializes byte-identically. Measured on the tracked `sdui.manifest.json`: 0 of 339 inputs declare `of`, and the artefact regenerates to the same sha256 across this change. + + ⚠️ **Both new codes are diagnostics, not a new red gate.** Each is a warning, so `compile().ok` — the save gate's pass/fail — is unchanged, and a page that saves today still saves. Escalating an inert authored key to `error` is a separate question and belongs at the save gate, not here. + +### Patch Changes + +- 338feda: fix(sdui-parser): `not-a-container` is decided by the declared `children` input, not `isContainer`. This matches the renderer's copy of the parser (#19969) + + The save gate's `validateTree` now warns `not-a-container` on a child list only when the component's manifest entry declares no input named `children`. It never reads `isContainer`, which now means layout containment only, and it has no fallback to it. This is a port of objectui#9910 (objectui `5ea623ea`), which is already inside the pinned console build. Saving and rendering now reach the same verdict on every page again. + + Against the served `sdui.manifest.json`, five of its 59 components change: + + - `badge`, `alert`, `button` declare a `children` slot and are not flagged `isContainer`. A child list under them no longer draws a false `not-a-container` warning. + - `page:tabs`, `page:accordion` are flagged `isContainer` but declare no `children` input. They render `items[].children`, never `schema.children`, so a child list under them now draws the warning the flag used to silence. + + Clause-②: no + + `not-a-container` stays a `warning`, so the default save gate refuses no page it accepted before and accepts no page it refused. Only the two modes that treat warnings as errors see the five-component change: `os validate --strict` and `os lint --strict`. Both run `validateJsxPages`. The package's public entry adds no export. +- f55922f: `dashboard-widget-options.ts` header: `stageOrder` is a `funnel`-only key, not `funnel` / `pyramid` + + The accepted-set census comment at the top of the module (carried into the + published `index.d.ts`) described `stageOrder` as "funnel/pyramid stage order". + There is no `pyramid` widget type: `ChartTypeSchema` refuses it, so an author + who copied the pair got a parse refusal. The line now says what the schema's + own `.describe()` says: `funnel` is the only widget type that reads the key. + Comment-only — the accepted set, the diagnostic code and the emitted JS are + unchanged. + ## 17.4.0 ## 17.3.0 diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index ec3eb36b9c8..4314bdc9eff 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/sdui-parser", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack constrained JSX-source → SDUI SchemaNode tree compiler (parse, never execute). Isomorphic, zero React. ADR-0080.", "main": "dist/index.js", diff --git a/packages/services/service-analytics/CHANGELOG.md b/packages/services/service-analytics/CHANGELOG.md index c49f233babf..cf802cb13cf 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,1925 @@ # Changelog — @objectstack/service-analytics +## 17.5.0 + +### Minor Changes + +- e526556: fix(service-analytics): a `min`/`max` over a `formula` field is typed from the formula's declared `returnType`, not described as `number` (#16236) + + > ⚠️ **Superseded within the same release window — ⛔ do not act on this entry.** + > Everything below was accurate when it was written and is kept as the record of what + > #16236 measured and built. It never reached a published version: **#17560** (director + > ruling, decision batch #127, 2026-09-13) refuses `min` / `max` over a `formula` field + > outright, on the compatibility table's own storage ground — a formula is VIRTUAL in SQL + > storage, no column is emitted, so no aggregate can be lowered to it whatever + > `returnType` says. At the version that compiles this entry such a measure answers + > `DATASET_INVALID` / **400** at compile time instead of carrying any `fields[].type`, and + > the `returnType?: string` member described at the foot of this entry is **not** on + > `AnalyticsServiceConfig.sourceFieldMeta` — it was added and removed inside one release + > window, so no published version ever carried it. ⇒ Read #17560's entry instead; the + > FROM → TO below never became a shipped behaviour. + + **Behaviour change — read this if any dataset measure aggregates a `formula` + field.** `AnalyticsResult.fields[].type` for such a measure column was always + `number`, whatever the formula computes. It is now translated from the field's + declared `FieldSchema.returnType`: + + ``` + FROM {"rows":[{"first_label":"alpha","latest_due":"2026-06-01"}], + "fields":[{"name":"first_label","type":"number"}, + {"name":"latest_due","type":"number"}]} + + TO {"rows":[{"first_label":"alpha","latest_due":"2026-06-01"}], + "fields":[{"name":"first_label","type":"string"}, + {"name":"latest_due","type":"time"}]} + ``` + + Both values were strings; both descriptors said `number`, so a renderer that + branches on the declared type never reached its textual or temporal branch. + + **The mapping is a TRANSLATION, not a pass-through.** `returnType` speaks the + authoring vocabulary (`number` / `text` / `boolean` / `date`); + `fields[].type` speaks `DimensionType` (`string` / `number` / `boolean` / + `time` / `geo`). Two of the four words do not exist on the wire at all: + + | declared `returnType` | `fields[].type` | + |:---|:---| + | `text` | `string` | + | `date` | `time` | + | `number` | unchanged — the producer's `number` is already correct | + | `boolean` | unchanged — three readings disagree on what `min`/`max` over a boolean returns | + + **A formula with no `returnType` is unchanged.** The key is optional — "absent + when the type can't be proven (an ambiguous/`dyn` expression)" — and an + unproven formula's measure column keeps the `number` it had. The absence is not + read as an answer. That tier is written down as a row in `measureResultType`'s + own table rather than left as an implied code path, and so is the treatment of + a word outside the declared four: left alone, never guessed at. + + **For hosts wiring `AnalyticsService` directly.** `AnalyticsServiceConfig`'s + `sourceFieldMeta` hook gains an optional fourth member on its return — + `returnType?: string` beside `type` / `defaultCurrency` / `max`. Additive: a + host that returns the three-member shape still satisfies the contract and gets + exactly today's behaviour for every column. `AnalyticsServicePlugin` relays the + key automatically, so a host on the plugin needs no change at all. + + ⚠️ **Superseded — see the banner at the top.** #17560 removed that member again in + the same release window, so the shape a host writes against is the three-member one + this paragraph calls today's. Nothing to do either way: a host that returns the + fourth key is ignored, not refused. +- 0252320: feat(service-analytics)!: `min` and `max` are judged by the aggregate × field-type table too — all 74 refused pairs answer `400 DATASET_INVALID` through one compile door (#17560) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, and the last + one this table owed. A dataset measure pairing `aggregate: 'min'` (or `'max'`) with any + of the **37** field types outside the numeric, temporal and boolean classes — for example + `text`, `select`, `lookup`, `autonumber`, `json`, `multiselect`, `file`, `location`, + `vector` or `formula`; the ADR-0087 entry registered below carries the full list — used to + compile and reach the backend; it is now refused by + `compileDataset` with `DATASET_INVALID` / **400** before any query is built. Shipped as + `minor` under the repo's launch-window convention for accept-set narrowings. + + ⛔ This changeset adds no rows to any table and restates none. The verdict is + `AGGREGATE_FIELD_TYPE_COMPATIBILITY`'s — the one table `@objectstack/spec` declared in + #16353 under the director ruling of decision batch #59 ("both legs, table in spec") — + read through `isAggregateCompatibleWithFieldType`. + + ## What was wrong + + The table refused these 74 pairs from the day it was declared, and **four declarations + gave three different answers about them**: + + | declaration | what it said about `min` × `text` | + |---|---| + | `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (spec) | refused | + | `dataset-compiler`'s compile leg | never judged — `if (!DERIVING_AGGREGATES.has(aggregate)) return;` | + | `measureResultType` (service-analytics, #15768) | a supported `'string'` result | + | two shipped test files, in prose | "ruled C — the table is to be AMENDED to accept it" | + + Driven through the real service door before anything was written, `min` / `max` over 13 + sampled refused pairs all compiled and emitted SQL, with `avg` × `datetime` as the + firing control (refused, `DATASET_INVALID` / 400, no SQL) — so the zero was a reading of + the tree rather than of a blind harness. + + The fourth row had nothing behind it. The card it cited (#17513) is closed as a + duplicate carrying zero rulings, and the one recorded ruling on this table says the + opposite. ⇒ The director ruling of decision batch #127 (2026-09-13) settled all three + sub-questions in one pass, because one shared fixture drove members of both halves: + + 1. **the string classes** (42 pairs) stay refused, as batch #59 ruled — ⛔ the table is + not amended; + 2. **the non-string classes** (32 pairs) are refused **and enforced**; + 3. **`formula`** is refused on the table's own storage ground — it is VIRTUAL in SQL + storage, no column is emitted, so no aggregate can be lowered to it whatever + `returnType` says. + + The divergence is real, and for these two aggregates it is the **ORDER** rather than the + arithmetic: string order is collation-dependent, so two backends answer two different + "smallest" values for one metadata document, and `min(jsonb)` does not exist on + PostgreSQL at all. + + ## What changed + + - **`dataset-compiler`**: the scope condition is gone. `assertAggregateFieldTypeCompatible` + judges all six `AggregationFunction` members against the table, through the same + `DATASET_INVALID` / 400 door. The refusal message names the divergence its own + aggregate class really has (`min` / `max` SELECT a stored value and diverge on order; + `sum` / `avg` DERIVE a number and diverge on arithmetic) and prescribes accordingly. + - **`measureResultType`** asks `isAggregateCompatibleWithFieldType` before it answers, so + the rule and the table agree **by construction**. Its `STRING_SOURCE_FIELD_TYPES` + branch and its `formula` branch are retired with them; `min` / `max` over the temporal + class still answers `'time'`, unchanged. + - **`AnalyticsServiceConfig.sourceFieldMeta`** no longer declares `returnType`. It was + carried (#16236) for one reader — the retired `formula` branch — and a declared input + nobody consumes is the declared-not-enforced shape Prime Directive #10 refuses. + + ⚠️ **That key was never released, so against every published version this removal is a + no-op.** #16236 is still a pending changeset in the same release window as this one; + the last published entry (17.4.0) says in as many words that `FieldSchema.returnType` + "is not on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape". The key was + therefore added and removed inside one window and no published tarball ever carried it. + + **Host fix, one line:** drop `returnType` from whatever your `sourceFieldMeta` returns. + You do not have to — the hook is a function RETURN position, so an extra key is not an + excess-property error and is simply ignored at runtime — but keeping it declares an + input nothing reads. Hosts on `AnalyticsServicePlugin` need no change at all: the plugin + stopped relaying the key in this same change. + + ## FROM → TO, and the one-line fix + + | you wrote | write instead | + |---|---| + | `{ aggregate: 'min' \| 'max', field: }` | `count` / `count_distinct` if you were counting; a **sort** on the list/report if you wanted the first or last RECORD | + | `{ aggregate: 'min' \| 'max', field: }` | store the quantity you meant as a numeric or temporal field and aggregate that | + | `{ aggregate: 'min' \| 'max', field: }` | a formula emits no column; aggregate the stored field the formula reads, or persist the computed value | + + ⚠️ **Untouched:** those field types used as a **DIMENSION** (grouping, labelling, + bucketing, filtering), `count` / `count_distinct` over any type, `min` / `max` over the + numeric, temporal and boolean classes, and every `sum` / `avg` row #16778 and #16099 + already settled. The refusal also still stands down rather than guessing wherever the + declared type cannot be resolved: no `sourceFieldMeta` wired, an unknown field, or a + `relationship.field` path whose column lives on a joined object. + + ⚠️ The hand-migration prescription ships as the ADR-0087 semantic TODO registered above, + which names the measure and the field type per affected pair — no lossless conversion + exists, because nothing can compute "the smallest text value" in a way every backend + agrees on. +- 14add48: fix(service-analytics)!: the analytics `where` door refuses a list in the equality slot instead of reading it as `IN` (#19888) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what the analytics faces of `@objectstack/service-analytics` accept. A caller `where`, a dataset `filter` or a measure `filter` that carries a list in the equality slot, `{ field: [...] }` (the empty list included) or `{ field: { $eq: [...] } }`, at any depth under `$and` / `$or` / `$not` or inside a nested relation, compiled before this change. It is now refused with `INVALID_FILTER` / 400. It ships as `minor` under the launch-window convention for accept-set narrowings. The remedy is `$in`: + + | you wrote | write instead | + |:--|:--| + | `{ stage: ['won', 'lost'] }` or `{ stage: { $eq: ['won', 'lost'] } }`, meaning "one of these values" | `{ stage: { $in: ['won', 'lost'] } }` | + | `{ stage: ['won'] }`, meaning one value | `{ stage: 'won' }` | + | `{ stage: [] }` | `{ stage: { $in: [] } }`, which matches no row, as the bare list did | + + `$in` charts the rows the implicit list charted before. The refusal also names `{ "$contains": "…" }` for "the stored list holds a value" on a multi-value field. + + Ruling 乙 of #19757 refuses a list in the equality slot at the shared comparand-shape face in `@objectstack/spec`, for every driver at once. The analytics `where` door met that face only for the `FilterArray` spelling (`['stage', '=', ['won', 'lost']]`), which was already refused. The object spelling was compiled by the analytics filter normalizer, which read one condition four ways: + + - `{ stage: ['won', 'lost'] }` compiled to `stage IN (...)`. On the ObjectQL path the engine received `{ stage: { $in: [...] } }`, so the engine's own shared-face check never saw the list. + - `{ stage: { $eq: ['won', 'lost'] } }` compiled to `stage = 'won'`, and `'lost'` was dropped without a word. + - `{ stage: { $eq: [] } }` compiled to no predicate at all, so the chart was drawn over every row. + - `{ stage: [] }` compiled to the FALSE constant. + + Each list is now handed to the shared face's equality arm before any node is built. Both spellings of one condition therefore get the same refusal, with the same wording, path and `$in` prescription. The draft-data preview runs the same gate, so a drafted chart refuses what the published chart refuses. Before, it compared each row against the list's string form. + + Who is affected: nothing in this repository's examples, seeds or docs authors the shape (measured over `examples/**`, `packages/**` and the fenced code in the docs). Stored datasets, dashboard widget filters, report runtime filters and measure filters in a deployment were NOT measured. In this release the authoring schema refuses the shape too, when such a document is saved (a separate change in `@objectstack/spec`, ADR-0087 entry `filter-equality-array-comparand-refused-at-save`). One position is judged here and not by the shared authoring schema: a list inside a nested relation, which this door flattens to a dotted member. A dataset `filter` or measure `filter` carrying one is refused when it is saved, in the same words (a separate change in `@objectstack/spec`, ADR-0087 entry `dataset-filter-nested-relation-equality-array-refused-at-save`). Any other `where` that reaches this door with one, such as a caller `where` or a dataset selection's `runtimeFilter`, is refused only when it is charted. The refusal names the field and the path. `$ne` with a list is not part of the ruling and is not judged here. The list operators (`$in`, `$nin`, `$between`) keep their lists, and every scalar, `null` included, compiles as before. +- e8f163f: fix(service-analytics)!: the read-scope compiler refuses a list under `$eq` instead of binding it (#19975) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what `compileScopedFilterToSql`, exported from `@objectstack/service-analytics`, accepts. A read scope carrying `{ field: { $eq: [...] } }` compiled before this change and is refused after it. It ships as `minor` under the launch-window convention for accept-set narrowings. The remedy is `{ field: { $in: [...] } }` for "one of these values". + + `compileScopedFilterToSql` compiles a row-level read scope into the SQL the analytics NativeSQL path and the `/analytics/sql` echo run. It already refused a list in the implicit equality slot (`{ field: [...] }`). The explicit spelling, `{ field: { $eq: [...] } }`, was compiled to an equality with the whole list bound as one parameter, so what the scope selected depended on how the executing database read a list, not on what the scope said. + + It is now refused, at any depth under `$and` / `$or` / `$not`, with the envelope every other refusal of this compiler carries: `READ_SCOPE_COMPILE_FAILED` / 500, with the message kept for the server log. This applies ruling 乙 of #19757, which the shared comparand-shape face in `@objectstack/spec` already enforces, to a compiler that face never sees. `$ne` with a list is not part of that ruling and is not judged here. + + No policy authored as metadata produces this shape. The refusal therefore reaches only a host-supplied `getReadScope` or a direct caller of `compileScopedFilterToSql`. A scalar, `null` or a `Date` under `$eq` compiles exactly as before, and a `{ $field }` reference there keeps its existing answer. +- 246314d: fix(service-analytics)!: the analytics `where` door runs every arm of the shared comparand-shape face on the object spelling, not only the equality arm (#20010) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what the analytics faces of `@objectstack/service-analytics` accept. A caller `where`, a dataset `filter` or a measure `filter` that carries one of the shapes below compiled before this change, at any depth under `$and` / `$or` / `$not` or inside a nested relation. It is now refused with `INVALID_FILTER` / 400, carrying the shared face's own message, path and prescription. It ships as `minor` under the launch-window convention for accept-set narrowings. + + | you wrote | what it did before | write instead | + |:--|:--|:--| + | `{ stage: { $in: ['won', null] } }`, meaning "one of these, or empty" | `stage IN ('won', NULL)`: the empty rows were never matched | `{ $or: [{ stage: { $in: ['won'] } }, { stage: { $null: true } }] }` | + | `{ stage: { $nin: ['won', null] } }`, meaning "has a value, and not one of these" | `stage IS NULL OR stage NOT IN ('won', NULL)`: only the empty rows | `{ $and: [{ stage: { $nin: ['won'] } }, { stage: { $null: false } }] }` | + | `{ amount: { $gt: null } }` (or `$gte` / `$lt` / `$lte`) | `amount > NULL`: no row, while for `$lt` / `$lte` the draft preview charted every non-empty row | `{ amount: { $eq: null } }` for "has no value", `{ amount: { $ne: null } }` for "has a value" | + | `{ amount: { $between: [null, 100] } }` | `amount >= NULL AND amount <= 100`: no row | `{ amount: { $lte: 100 } }` for a one-sided range; `$or` with `{ amount: { $null: true } }` to include the empty rows | + | `{ amount: { $between: ['', 100] } }` | `amount >= '' AND amount <= 100`: the blank compared as a value, and the ObjectQL engine path accepted it | the bound you meant, or `{ amount: { $lte: 100 } }` for a one-sided range | + | `{ stage: { $in: 'won' } }` or `{ stage: { $nin: 'won' } }` | laundered into a one-member list | `{ stage: 'won' }` / `{ stage: { $ne: 'won' } }`, or `{ stage: { $in: ['won'] } }` | + + The shared comparand-shape face in `@objectstack/spec` (`assertListComparandShapes`) is the one place that decides whether `$in` / `$nin` / `$between` received a list at all, for every driver. Three rulings put the null and blank positions on that same door: a null list member and a null `$between` endpoint (2026-08-31), a null ordering comparand (2026-09-01), and a blank `$between` endpoint (2026-09-20). The analytics `where` door met that face only for the `FilterArray` spelling (`['stage', 'in', ['won', null]]`), which was already refused. PR #20008 carried the face's equality arm to the object spelling. Every other arm now follows it: each field entry of the object-form `where` is handed to the face after the equality pass, so both spellings of one condition get the same refusal, byte for byte. This holds on the native SQL execute path, the `/analytics/sql` echo and the ObjectQL engine path. The draft-data preview runs the same gate, so a drafted chart refuses what the published chart refuses. Before, the preview charted rows for several of these shapes: `{ amount: { $lt: null } }` charted every non-empty row. + + Three shapes this door already refused now carry the face's wording instead of this package's own, the same wording the `FilterArray` spelling gets: a `$between` that is not a two-element list, a `$between` endpoint that is a `{ $field }` reference, and an `undefined` `$between` endpoint. Their verdict, code and status are unchanged. + + Who is affected: nothing in this repository's examples, seeds, docs or package sources authors any of the shapes. This was measured by a text scan over 4013 non-test files with positive controls. Stored datasets, dashboard widget filters and report runtime filters in a deployment were NOT measured. The authoring schema still admits the shapes, so such a document still publishes, and it is refused when it is charted. + + Not changed: `$ne` with a list is not judged by the face yet, so it compiles as before. `$in: []` / `$nin: []`, falsy list members (`0`, `''`, `false`), every non-null ordering comparand, a `{ $field }` in an ordering slot, and `null` in the equality slot (the has-no-value predicate) all compile as before. The comparand-TYPE face is not run on this door. An `undefined` comparand outside a `$between` endpoint keeps this package's own refusal. +- 980bc05: fix(service-analytics)!: the NativeSQL execute face and the `/analytics/sql` echo refuse a read scope the shared comparand faces refuse, as the ObjectQL execute face already does (#20018) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on the read-scope lowering, shipped as + `minor` under the launch-window convention (`check-changeset-no-major` refuses + `major` until GA; breaking-ness is carried by this banner and the ADR-0087 + disposition above, not by the level). + + **What changed.** `compileScopedFilterToSql` is the read-scope lowering behind the + NativeSQL execute face (`NativeSQLStrategy.applyReadScope`, base table and every + joined hop) and the `/analytics/sql` echo (`ObjectQLStrategy.generateSql`), and a + public export of this package. Once its own lowering succeeds, it now runs the two + shared comparand faces of `@objectstack/spec/data` on the scope. A scope they + refuse is refused as `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld + (the #5367 envelope), before any statement is built or executed. That is the + answer the ObjectQL execute face has given the same scope since #19995, so one read + scope now gets one verdict on every analytics face. + + **Which read scopes stop being served.** Each was lowered and executed before, and + each is refused by a standing ruling the shared faces carry. Measured on SQLite: + + | read-scope shape | what the native face and the echo served | + | --- | --- | + | a `null` member of `$in` | only the named non-null values; the NULL matched nothing | + | a `null` member of `$in` under `$not`, or of `$nin` | only the rows whose column is NULL, which the scope excludes | + | a `null` comparand under `$gt` / `$gte` / `$lt` / `$lte`, or a `null` `$between` bound | zero rows | + | a blank (`''`) `$between` bound | the rows inside the half-blank range | + | a bigint beyond ±2^53, or a binary comparand | zero rows | + | a plain-object or other non-plain-object comparand in a scalar position | the database refused the statement (`DATABASE_ERROR` / 500) | + + **Who is affected.** A host `getReadScope` provider, or a direct caller of + `compileScopedFilterToSql`, that produces one of these shapes. The ObjectQL + analytics face already refused all of them. No in-repo read-scope producer emits + them for a policy in this repository. An RLS `using` predicate can still be + written so that it lowers into the null shapes (a literal `null` inside an `in` + list, or an ordering comparison against `null`), but the RLS compiler drops such a + policy (#20212), so the analytics faces receive the deny sentinel and answer zero rows. + + **Fix.** State absence with the null predicate. "One of these values, or no + value" is `{ "$or": [{ "f": { "$in": ["a"] } }, { "f": { "$null": true } }] }`, + which in an RLS predicate is `f in ['a'] || f == null`. A one-sided range is `$gte` + or `$lte`. A comparand is a string, number, bigint within ±2^53, boolean, `null` or + `Date`. + + **Unchanged.** + + - Well-formed scopes compile to the same SQL and admit the same rows. That includes + the null predicates, an emptied `$in` beside an own-rows grant, and the spelling + above. + - A shape the lowering already refused keeps its own log sentence. + - The caller's own `where` never reaches this lowering, and it is untouched. +- 7ddf396: fix(service-analytics)!: the analytics `where` door runs the shared comparand-TYPE face on the object spelling, so a plain-object, binary, `Map`, class-instance, oversized-bigint or `undefined` comparand is refused like the `FilterArray` spelling and the engine refuse it (#20035) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what the analytics faces of `@objectstack/service-analytics` accept. A caller `where`, a dataset `filter` or a measure `filter` in the object spelling that carries one of the comparands below compiled before this change, at any depth under `$and` / `$or` / `$not` or inside a nested relation. It is now refused with `INVALID_FILTER` / 400, in the shared face's own message, path and prescription, before any SQL statement runs or any `engine.aggregate` call is made. It ships as `minor` under the launch-window convention for accept-set narrowings. + + | comparand in an object-form `where` | before (native SQL execute) | now, on every face | + |:--|:--|:--| + | a plain object under a scalar operator: `{ stage: { $ne: { a: 1 } } }`, `$gt`, `$eq`, the LIKE family | bound as the JSON text `'{"a":1}'`; `$ne` served EVERY row, the others matched nothing; the `/analytics/sql` echo answered `DATABASE_ERROR` / 500 | refused 400: "Filter comparand at where.stage.$ne is a plain object …" | + | a plain object as a `$between` endpoint or an `$in` / `$nin` member | the endpoint was compared as JSON text; the member was refused in this package's own sentence | refused 400, in the face's sentence | + | a `{ $field: 5 }` object (a non-string `$field`) under an ordering operator | bound as JSON text (it is not a field reference) | refused 400 as a plain object | + | a binary (`Uint8Array` / `Buffer`) under `$eq` / `$ne` or as an `$in` member | bound as the JSON text `'{"0":1,…}'`, never as a blob; `$ne` served every row | refused 400 | + | a binary, a `Map` or a class instance as the implicit comparand `{ stage: VALUE }` | read as a NESTED RELATION (`stage.0 = 1 AND …`, a 500 on the native path) or refused as a zero-operator wrapper | refused 400 as the value it is | + | a `bigint` beyond ±2^53 | bound as-is, answering no row | refused 400 | + | `undefined`, implicit or under any operator, including `$null` / `$exists` | refused 400 in this package's own sentence, except `{ $null: undefined }`, which compiled to IS NOT NULL; the draft preview answered no row | refused 400, in the face's sentence | + + The shared comparand-type face in `@objectstack/spec` (`normalizeFilterComparandTypes`) is the #7872 door: its accepted comparand types are `string | number | bigint | boolean | null | Date`, and it refuses everything else loudly at the compile face (maintainer ruling, 2026-08-12). `parseFilterAST` runs it on everything it returns and the ObjectQL engine's seam on every object-form `where`, so the `FilterArray` spelling of this door and the engine path already refused each row above. The object spelling now meets it too, after the comparand-shape face and before any node is built, the order `parseFilterAST` uses. Both spellings of one condition get the same refusal, byte for byte, on the native SQL execute path, the `/analytics/sql` echo, the ObjectQL engine path and the draft-data preview. + + A `bigint` within ±2^53 is not refused: the face narrows it to its number, and the condition every face lowers is the narrowed one. The rows do not change on the published faces. The draft-data preview used to order a bigint as text (`{ amt: { $gt: 2n } }` lost the row holding 10); it now evaluates the narrowed number and charts the published rows. + + Binary comparands are reconciled with the face rather than kept as a declared local extra of this package. Measured before this change, the `where` door never compared a binary as a blob on any face: the native path bound it as JSON text, the engine path and the `FilterArray` spelling refused it, and no producer can send one over JSON. This change does not touch the read-scope door, which refuses a binary comparand too since the read-scope lowering began running the same face (#20018), so a binary is now refused at both analytics doors. + + Refusals this door already gave in its own words now read in the face's words, the same words the `FilterArray` spelling gets: an `undefined` comparand, and a plain-object `$in` / `$nin` member or LIKE-family comparand. Their verdict, code and status are unchanged. The positions the face does not judge keep this package's sentences: an array or a `{ $field }` reference as a list member or a LIKE comparand, and an `undefined` inside an array comparand or under an operator outside the vocabulary. + + Who is affected: nothing in this repository's examples, seeds, docs or package sources authors any of the refused comparands. A text scan over 4013 non-test files found none, and it did find the shape in a code comment written for this change. Stored datasets, dashboard widget filters and report runtime filters in a deployment were NOT measured. Of the refused values, only the plain object can be stored as JSON. + + The refusal both analytics doors give for an unbindable `$in` / `$nin` / `$between` member no longer offers "(or a binary value)" as a repair, because neither door accepts a binary any more. It now names only the accepted set; its code, status and verdict are unchanged. + + Not changed: every string, number, boolean, `null` and `Date` comparand; a `{ $field }` reference in an ordering slot (served on the engine path); nested relations and dotted members; `$ne` with a list, which the shared face does not judge yet. +- 226e00c: fix(service-analytics)!: the analytics `where` door refuses a non-boolean `$null` / `$exists` flag, which it used to read as IS NOT NULL, the way every backend and the read-scope compiler already refuse it (#20040) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what the analytics faces of `@objectstack/service-analytics` accept. A `$null` or `$exists` flag whose value is not a boolean used to compile, in a caller `where`, a dataset `filter` or a measure `filter`, at any depth under `$and` / `$or` / `$not` or inside a nested relation. That covers a string such as `"false"`, a number, `null`, an array, a `Date` and a `{ $field }` reference. It is now refused with `INVALID_FILTER` / 400, in a message that names the operator, the field and the path, before any SQL statement runs or any `engine.aggregate` call is made. It ships as `minor` under the launch-window convention for accept-set narrowings. + + | flag in an object-form `where` | before | now | + |:--|:--|:--| + | `$null` or `$exists` with a string, a number, `null`, an array, a `Date` or a `{ $field }` reference | read as IS NOT NULL on the native SQL path, the `/analytics/sql` echo and the ObjectQL engine path (the engine received `{ "$ne": null }`); `POST /api/v1/analytics/query` and `/analytics/dataset/query` answered 200 | refused 400: `Operator "$null" on field "stage" requires a boolean comparand (true or false). Received …` | + | the same, under `$not` | the negation of IS NOT NULL: the rows with no value | refused 400 | + | the same, in the draft-data preview | refused 400 as an operator the preview does not evaluate | refused 400, in the published door's words | + | `true` or `false` | IS NULL or IS NOT NULL, per the contract | unchanged: the compiled tree, the SQL and the engine `where` are byte-identical | + | `undefined`, or a plain object | refused 400 by the shared comparand-type face | unchanged, in that face's words | + + `FieldOperatorsSchema` in `@objectstack/spec` declares both flags as booleans. The #5347 and #5369 rulings refuse a non-boolean one in every position and on every backend, because the backends read one in opposite directions: `driver-sql` compiled IS NULL for anything but `false`, and the JavaScript drivers compiled IS NOT NULL for anything but `true`. `driver-sql` refuses it, and so does this package's read-scope compiler. This door read every non-boolean as IS NOT NULL, so `"true"` and `"false"` asked for the same rows, and `{ "$null": "true" }` asked for the rows it excludes. + + The repair is to write the boolean the filter means. `"$null": true` matches rows with no value and `"$null": false` rows with one; `$exists` is the exact inverse. + + Over HTTP, both routes type the `where`, the `runtimeFilter` and the inline `dataset.filter` as `FilterConditionSchema`, whose field entries are open, so the body parse admitted the flag and the service served it. Both routes now answer 400 `INVALID_FILTER` from the service, with no statement run. + + A `where` that carries a non-boolean flag and also a defect this compiler finds only while lowering (a field constraint mixing `$` and non-`$` keys, an operator outside the vocabulary, a zero-operator constraint) is now answered with the flag refusal. The code and status are the same 400 `INVALID_FILTER`. A shape or type defect elsewhere in the same `where` is still answered first. + + Who is affected: nothing in this repository's examples, seeds, docs or package sources authors a non-boolean flag. A text scan over 4598 tracked non-test files found 22 matches: 21 are code comments, and one is an operator-name lookup table in `driver-memory`, not a filter. Stored datasets, dashboard widget filters and report runtime filters in a deployment were NOT measured. + + Not changed: a `true` or `false` flag; the `FilterArray` spelling, whose `is_null` and `is_not_null` take their boolean from the operator name; the read-scope door, which already refused a non-boolean flag in its own withheld envelope. +- a8bcce6: fix(service-analytics)!: both analytics doors refuse the two `$icontains` comparands `FILTER_TEXT_CASES` declares refused, an empty one and a non-string one, each door in its own envelope (#20068) + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what the analytics faces of `@objectstack/service-analytics` accept. An `$icontains` condition whose comparand is the empty string, or is not a string at all (a number, a boolean, `null`, a `Date`), used to compile on the analytics compilers. It is now refused before any SQL statement runs or any `engine.aggregate` call is made. It ships as `minor` under the launch-window convention for accept-set narrowings. + + `@objectstack/spec` declares both refusals as data: `FILTER_TEXT_CASES` carries a REJECTION row for an empty `$icontains` comparand and one for a non-string comparand, each `INVALID_FILTER` naming `$icontains`. It publishes the discrimination as `isRefusedTextComparand` and the reason as `textComparandRefusalReason`. The spec's parse door (`FilterConditionSchema`) and `driver-sql` already refused both. This package never asked, so one filter got two answers. Both analytics doors now call the published predicate and seat the published reason in their own envelope. + + | where the condition sits | before | now | + |:--|:--|:--| + | a caller's `where`, a dataset `filter` or a measure `filter`, either spelling, at any depth | `''` matched every row whose column has a value; a non-string was bound as its text and matched nothing. Native SQL, the `/analytics/sql` echo and `AnalyticsService.query` all served it | `INVALID_FILTER` / 400, with the spec's reason, naming `$icontains` | + | a row-level read scope, on the native SQL face and the echo | the same predicate: `''` admitted every row that has a value | `READ_SCOPE_COMPILE_FAILED` / 500, with the message withheld | + | a row-level read scope, on the ObjectQL face | the driver refused it as `INVALID_FILTER` / 400, and the message named the policy's field and comparand | `READ_SCOPE_COMPILE_FAILED` / 500, with the message withheld | + + The migration is the ledger entry named above: write a non-empty string, or drop the condition. An empty comparand was a predicate that constrained nothing, so the repair is to delete the condition. A number, boolean or `null` comparand is written as the string it was meant to match, or the operator was the wrong one. + + Over HTTP, `POST /api/v1/analytics/query`, `/analytics/sql` and `/analytics/dataset/query` already refused a caller-authored condition carrying either comparand, at their body parse (`400 VALIDATION_FAILED`). What this changes for an HTTP caller is the read scope. A row-level read scope supplied by the host (`getReadScope`) is refused in the withheld envelope on every analytics face. It is no longer served on the native SQL face, and it is no longer answered with a 4xx that carries policy content on the ObjectQL face. The CEL policy lowering never emits `$icontains`, and a filter placeholder never resolves to an empty string. + + Who is affected: nothing in this repository's examples, seeds, docs or package sources authors either comparand; every hit outside tests is a code comment. Stored datasets, dashboard filters and host-supplied read scopes in a deployment were NOT measured. A stored row saved before the parse-door refusal can still carry an empty comparand, and it is now refused at query time, where it used to answer every row that has a value. + + Not changed: a non-empty string comparand, whatever its case or content; an object or array comparand, still refused in its existing sentence; the non-text-column constant for an accepted comparand. `$contains`, `$notContains`, `$startsWith` and `$endsWith` keep their answer to an empty comparand: the table has no REJECTION row for them, and widening by analogy is the table's decision. +- 7c1039b: fix(service-analytics)!: the NativeSQL execute face and the `/analytics/sql` echo resolve a read-scope filter placeholder with the caller's context, and refuse one they cannot resolve, as the ObjectQL execute face already does (#20075) + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on the read-scope lowering, shipped as `minor` under the launch-window convention. `minor` is also the level a new accepted option key takes. + + `compileScopedFilterToSql` is the read-scope lowering behind the NativeSQL execute face (`NativeSQLStrategy.applyReadScope`, the base table and every joined hop) and the `/analytics/sql` echo (`ObjectQLStrategy.generateSql`), and a public export of this package. It never resolved a filter placeholder, so both faces bound `{current_user_id}`, `{current_org_id}` or a date macro as its literal text. The ObjectQL execute face hands the same scope to the engine, which resolves it with the caller's context. One read scope, two row sets. + + It now takes an optional `context` (`ReadScopeCompileOptions.context`) and resolves the scope with `resolveFilterTokens(scope, filterTokenContextFrom(context))` from `@objectstack/core`, the resolver the engine calls, before lowering it. Both strategies pass the request's context. + + | a read scope carrying | before, on the NativeSQL face and the echo | now | + |:--|:--|:--| + | a placeholder the caller's context resolves | its literal text was bound: an equality matched no row, and a `$ne` exclusion admitted every row, the caller's own included | the resolved value is bound and the echo prints it; the same rows as the ObjectQL face | + | an unknown placeholder, or a context token the request has no value for | served, with the literal bound | `READ_SCOPE_COMPILE_FAILED` / 500, message withheld, as on the ObjectQL face | + + Without a context the answer is the engine's for a context-less operation: a date macro resolves against UTC now, and a context token is refused. A placeholder is never bound as its literal text. + + Who is affected: a host whose own `getReadScope` returns scopes carrying placeholders, and a direct caller of `compileScopedFilterToSql`. The security service's read filter, the auto-bridged default, composes concrete values and is not affected. + + Not changed: a scope with no placeholder compiles to the same SQL and parameters as before. The caller's own `where` is untouched: `AnalyticsService` already resolves its placeholders and answers an unresolvable one `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED` / 400 with its message. The ObjectQL execute face is untouched. +- f2c7eef: An analytics cube's `public` now takes effect, and it defaults to visible: `CubeSchema.public` defaults to `true` (it was `false`), and the analytics service hides a cube that declares `public: false` from discovery and refuses every query against it (#20282). + + Clause-②: yes (narrowing) + + + + **BREAKING**: this narrows what the analytics API answers. A query or SQL dry run against a cube declared `public: false` (`POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`) was answered before this change and is now refused with `404 CUBE_NOT_FOUND`, and `GET /api/v1/analytics/meta` no longer lists that cube. The same happens to every cube in an artifact built by `os compile` before this release, which carries a materialized `public: false` from the old default. The remedy: delete `public: false` from any cube that is meant to be queried (cubes are visible by default), and recompile pre-release artifacts. It ships as `minor` under the launch-window convention; the widening half is the default moving to visible. + + Until this change nothing read `public`. `GET /api/v1/analytics/meta` listed a `public: false` cube and every query door answered it, so the flag withheld nothing. Its declared default, `false`, could not simply be switched on: enforcing it as declared would have hidden every cube that omits the key. The default is now the Cube.dev default (visible), and an explicit `false` is enforced: + + - `GET /api/v1/analytics/meta` omits a cube declared `public: false`, and `?cube=` naming one answers `[]`, the same as a name no cube has. + - `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` refuse it with `404 CUBE_NOT_FOUND` — the same refusal, byte for byte, that an unknown cube name gets, so a caller cannot use it to learn that a hidden cube exists. The one shared message names both possibilities, so it still tells an author how to expose a hidden cube. The refusal comes before any SQL is built, and it is never an empty result. + + `public` is visibility on the analytics API, not row security. An object's records stay governed by its permissions and row-level security on every door, whether or not a cube over it is hidden. What `public: false` does is exactly the two points above: the cube is left out of `/analytics/meta`, and queries and SQL generation against it are refused. The cube's definition stays readable on the metadata door, like any other authored schema. + + What to expect after upgrading: + + - **A cube that omits `public`** stays visible and queryable. It was visible before too, because nothing read the key. A client that parses cube metadata through the published JSON Schema now materializes `public: true` where it materialized `false`. + - **A cube that writes `public: false`** is now hidden and refused. If you wrote it only because it was the old default, delete the line (cubes are visible by default). A dashboard or report that queries such a cube starts answering `404 CUBE_NOT_FOUND` until you do. + - **A compiled artifact built before this release** carries a materialized `public: false` on every cube, because `os compile` writes the parsed stack with its defaults applied. Recompile it with this release before serving cubes from it. + - **Cubes the platform mints itself** stay visible: the cube inferred for an ad-hoc query on an object (the KPI path), a compiled dataset's cube (`POST /api/v1/analytics/dataset/query`), and `CubeRegistry.inferFromObject`. Each wrote a literal `false`, the old default, and now writes `true`. + + The showcase example's `showcase_delivery` cube, which is the app's demonstration of `/api/v1/analytics/*`, drops its `public: false`. +- 2b53993: fix(service-analytics): both analytics filter faces answer `$empty` by the field's declared type (#20445) + + Clause-②: yes (widening) + + `$empty: true | false` is declared by `@objectstack/spec` (`FieldOperatorsSchema`) with a per-type meaning: a text-like field is empty when it is null or `''`, a multi-value field (multiselect, checkboxes, tags, or a select / radio / lookup / user / file / image with `multiple: true`) when it is null or `[]`, and every other type only when it is null. `$empty: false` is the exact complement. Both of this package's filter faces now answer it by that table, through the spec's one expansion (`expandEmptyOperator`), instead of refusing it: + + - **The analytics `where`** (`/analytics/query`, `/analytics/sql`, dataset filters): `NativeSQLStrategy` and the `ObjectQLStrategy` SQL echo compile the field's declared row; the ObjectQL execute path hands `{ $empty }` to the data engine, which answers it once the engine's own arm lands (until then the engine refuses it, `INVALID_FILTER` / 400, as it does today). + - **Row-level read scopes** compiled to SQL (`compileScopedFilterToSql`): same rows, in the read-scope envelope. + + A multi-value field's empty list is tested with a JSON function per SQL dialect (`json_array_length` on SQLite, a `jsonb` comparison on Postgres, `JSON_LENGTH` on MySQL). + + **Refused, never guessed** — `INVALID_FILTER` / 400 on the `where` face, `READ_SCOPE_COMPILE_FAILED` / 500 on a read scope — when the host cannot name the field's declared type (no `sourceFieldMeta` wired, or no such field), when a multi-value field's datasource dialect is unknown, and when the flag is not a boolean (`$empty: 'true'` is refused like a non-boolean `$null`). + + Host API (two new optional members, hence `minor`): `AnalyticsServiceConfig.sourceFieldMeta` may now answer `multiple` beside `type`, and `AnalyticsServicePlugin` relays it from the field definition; `compileScopedFilterToSql` takes an optional `declaredValueShape` option. A host whose `sourceFieldMeta` answers `type` but not `multiple` has every multi-capable field it declared `multiple: true` (select / radio / lookup / user / file / image) read as single-valued, which is the null-only row. On such a field a read scope's `$empty: false` then admits rows holding `[]`, and `$empty: true` misses them. Relay the field's `multiple` from its definition to get the list row. + + `$empty` stays staged: it is not in `FILTER_OPERATORS`, and the view operators `is_empty` / `is_not_empty` still lower to `$null`. +- 0da638c: fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) + + + + **BREAKING** for an in-process caller that reaches an analytics face PAST the + schema door with a string the closed vocabulary does not contain: it used to be + answered, and is now refused. Shipped as `minor` under the repo's launch-window + convention. The driver half of #16041, whose spec change closed + `AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen + dashboard preset names; every value affected here was already refused at + `POST /analytics/query` and `/analytics/sql` when that landed. + + ## What was wrong + + #16041 closed the contract; the faces behind it never aligned, so the defect it + abolished simply moved onto the newly-blessed vocabulary. Measured on the built + `driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, + 2099): + + | input | before | after | + |:--|--:|--:| + | `today` | 1/5 | 1/5 | + | the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | + | `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | + + `driver-memory` recognised exactly `today`: every snake_case preset missed its + `startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose + two bounds were the preset's own NAME, which matched every `Date`-typed row + under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered + the same names — and unrecognised strings, and `today` — to the point window + `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is + whatever the dialect decides a vocabulary word compares as. So a dashboard + asking for one month got all of history on one backend and a nonsense + comparison on the other, at HTTP 200 on both. + + ## What it does now + + - **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / + `resolveAnalyticsDateRangeString` resolve every declared preset to + `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens + handed to the existing macro resolver, so `dateRange: 'this_month'` and a + `{month_start}` filter token cannot answer differently, and the anchoring on + `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) + come from that resolver rather than from each face. + - **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 + envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own + `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door + answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call + it, so "memory and SQL refuse identically" is one function rather than an + agreement. + - **The upper bound keeps #16179's separation.** A window a face RESOLVED is + compared exclusively (`$lt` / `<`) for the ten calendar presets and + inclusively for the three rolling `last_N_days`, whose bound is NOW; an + explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. + - The fifteen `driver-memory` date-range pins #16041 retired are reinstated in + preset form (DST cells re-measured under calendar semantics, not re-spelled), + and one cross-face conformance fixture holds all FOUR faces to the same + windows and the same refusal. + - **The draft-preview evaluator is the fourth face**, and it is in that fixture + for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — + the Live Canvas preview over a pending seed draft) carried the identical + `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, + silently, while the published chart beside it answered a real window — across + a publish boundary the preview exists to make continuous, since publish + materialises the same seed. + + ## FROM → TO + + Unchanged from #16041's — the spelling that is refused here is the spelling that + was already refused at the door. + + | you wrote | write instead | + |:--|:--| + | `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | + | `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | + | `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | + | `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + + The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's + code stays registered under `@objectstack/runtime` (the door that names the wire + vocabulary), and the waiver records that the shared constructor spelling it + lives one package over. +- 041d9fd: fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on a published + route — landing in the launch window as `minor` on all four packages (the + lockstep convention: during the window the bump level is not the carrier, this + banner and the disposition above are). Nothing that was already admitted + becomes refused **except** the requests `GET /data/` refuses today for + the same principal, which is the defect. Nothing that was refused becomes + admitted. + + `POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. + + The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. + + **This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. + + - **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. + - **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. + - **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. + - **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. + + The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. +- 5d12b16: fix(service-analytics): the ROW-SCOPE bridge to the `security` service tells the same three resolutions apart as the object-level one — a broken security service refuses the query instead of running it with no row policy (#16918) + + `AnalyticsServicePlugin` bridges to the `security` service twice: once for the OBJECT-level read grant (`admitObjectRead` → `canReadObject`, #16645) and once for the ROW-level read scope (`getReadScope` → `getReadFilter`, ADR-0021 D-C). The object-level bridge tells three resolutions apart — ABSENT admits, THROWING and METHOD-LESS deny at `error`. The row-scope bridge collapsed all three into one: + + ```ts + const trySecurity = () => { + try { + const svc = ctx.getService('security'); + return svc && typeof svc.getReadFilter === 'function' ? svc : undefined; + } catch { return undefined; } + }; + getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context); + ``` + + A throwing resolver and a registered service without `getReadFilter` both produced `undefined` — the same value an absent security service produces, and the value `ISecurityService.getReadFilter` reserves for one meaning only: *"this caller has no row restriction on this object"*. So on a deployment whose security service was wired but broken (a boot-order fault, a mis-registered plugin, a failing dependency, a provider that is not the contract it claims to be) analytics queries ran with **no row-level policy at all**, and nothing said so. One door of the file failed closed on a throwing resolver and its neighbour failed open — and the neighbour is the one carrying row-level policy. + + **What changes.** The bridge now resolves the same explicit three-way, at the same reporting level: + + - **ABSENT** — no `security` service resolved: **unchanged**. No row-scope provider on this deployment, which is a legitimate configuration (a single-tenant kernel that ships no `plugin-security`, where `/data` carries no row-level policy either) and is already reported loudly at init. ⛔ Deliberately not tightened: refusing here would break every such deployment. + - **THROWING** resolver, or a registered service with **no `getReadFilter`** — the query is **REFUSED**, and the reason is reported at `error` naming the object and which of the two states it was. The refusal is a throw, which `AnalyticsService.resolveReadScopes` — fail-closed since ADR-0021 D-C — already turns into "deny the whole query rather than emit SQL with that object unscoped". A log over an `undefined` would not have been a refusal. + + **This change only NARROWS what analytics serves, and only in a state where the security service is broken.** No deployment with a working `security` service, and no deployment with none, changes behaviour by so much as a byte. Nothing that was refused becomes admitted. + + **No published-surface delta.** No new error code (the refusal rides the seam's existing fail-closed error), no exported symbol, no key on `AnalyticsServicePluginOptions` or any payload, and no documented envelope changes shape. Graded `minor` rather than `patch` because it is a behaviour narrowing on a published package's read path, matching how its object-level sibling was graded in the same lockstep window. + + ⚠️ Deliberately **not** answered here: which tenant wall the platform's is (plugin-security's posture-gated Layer 0, or driver-sql's posture-independent auto-scope) — the escalated maintainer decision of triage condition 5. Refusing to serve is neutral between them: it answers *"should we serve at all"*, never *"what shape is the wall"*. +- 634f23d: fix(analytics)!: `AnalyticsServiceConfig.sqlDialect` declares its three-name accept set, and a host that answers outside it is told once (#16206) + + + + **BREAKING** for a TypeScript host that declares its `sqlDialect` hook as returning + `string`: the hook's declared return is now the three canonical dialect names or + `undefined`, so such a composition stops compiling until the host's own annotation + says which names it can answer. Shipped as `minor` under the repo's launch-window + convention, in which breaking-ness is carried by this banner and the disposition + above rather than by the bump level. Runtime behaviour for every host is unchanged: + the same three names were the only ones that ever did anything. + + ## What was wrong + + `AnalyticsServiceConfig.sqlDialect` — the hook a host answers to say which SQL + dialect backs an object — was typed as free `string`, while `normalizeSqlDialect` + has only ever recognised `sqlite`, `postgres` and `mysql`. Nothing said so, and + nothing told a host that answered otherwise. + + So a host that owns a SQLite datasource and answers the spelling its own stack uses + — knex's canonical `sqlite3`, or `better-sqlite3`, both of which `driver-sql` itself + lists in `SQLITE_EMIT_CLIENTS` — was read as `unknown`. And because `sqlDialectFor` + is tiered "cannot answer, do not block", **a wrong answer and no answer were the + same answer**: the host that tried hardest to help got the residue arm, silently. + + ## What it does now + + - **The vocabulary is declared**, on the type and in the docblock, as + `AcceptedSqlDialect` — `sqlite` | `postgres` | `mysql` — so a host reading the + config learns the accept set without running anything. The type and the runtime + membership set are generated from one `const` tuple, so a future widening cannot + land in one and miss the other. + - **A non-empty answer outside the set is diagnosed**: one `warn` naming the object, + the answer and the accepted set. It is emitted **once per distinct unrecognised + spelling** — the failure's identity — so the line count is bounded by the host's + own hook and never grows with query volume. + - **`undefined` stays silent and legal.** The hook is optional and "cannot answer, + do not block" is a supported composition, not a misconfiguration. A pin holds both + halves, because a diagnostic that also shouted at hosts who wired nothing would be + a worse defect than the one being fixed. + - **The accept set is NOT widened.** Teaching this package `driver-sql`'s knex + aliases would be a second copy of that driver's table, and an unrecognised + spelling is sometimes deliberate (`mariadb`, #11756). The answer is still read as + `unknown`; only the silence changed. + - **The plugin bridge translates the driver's own residue.** `SqlDriver.dialectName` + carries a fourth name, `unknown`, meaning "I cannot say"; handed on verbatim it + would have presented a correctly-behaving driver as a host answering out of + contract. It now arrives as `undefined`, this hook's own spelling for the same + thing. The dialect the compilers end up with is unchanged either way. + + ## Measured, and worth reading before relying on the residue arm + + Driven on sql.js through a host answering `sqlite3`, against the shared + `FILTER_TEXT_CASES` fixture, with a host answering `sqlite` as the control: **five of + the six case-EXACT cases come back with the wrong rows** — every case that + discriminates on ASCII case. `{ name: { $contains: 'acme' } }` answers `['1','2']` + where the table says `['2']`, and the negated form DROPS a row that belongs in the + result. That is #15684's fold, live on the arm this population lands on, and it is + reported rather than fixed here: closing it is that card's business, not this one's. +- 357f499: feat(service-analytics)!: a dataset measure whose `aggregate` its `field`'s declared type cannot carry is refused at compile time with `400 DATASET_INVALID` (#16737, compile leg of #16099) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface. A dataset + measure pairing `aggregate: 'avg'` with a `Field.datetime` used to compile to + `AVG(col)` and reach the backend; it is now refused by `compileDataset` before any + query is built. Shipped as `minor` under the repo's launch-window convention for + accept-set narrowings; the hand-migration prescription is registered under protocol + major 18 as `dataset-measure-aggregate-field-type-refused`. + + The pair is judged against `AGGREGATE_FIELD_TYPE_COMPATIBILITY` — the one table + `@objectstack/spec` declared in #16353 under the director ruling of decision batch + #59 (2026-09-06, "both legs, table in spec"). ⛔ This changeset adds no rows and + restates none: the refusal reads the shipped predicate, so the contract has exactly + one statement. + + ## What was wrong + + The answer to `AVG` over a temporal column was decided by the SQL dialect rather + than by the data. Both halves measured on this card: + + ``` + -- SQLite (better-sqlite3), the canonical UTC-text storage form (#3912) + select typeof(submitted_at), submitted_at from clm_contract limit 1; + text|2026-05-19T00:00:00.000Z + select avg(submitted_at) from clm_contract; + 2025.5 <- text->numeric coercion: the average YEAR + + -- PostgreSQL 16.13 + select avg(submitted_at) from t; + ERROR: function avg(timestamp with time zone) does not exist -- SQLSTATE 42883 + ``` + + The silent half is the dangerous one, and SQLite is the default dev datasource: + `derived: { op: 'difference', of: [avg_a, avg_b] }` over two such averages returned + `-0.85` and rendered on a tile labelled "average cycle time delta" — a number + indistinguishable from a correct one. Nothing refused it at any layer: not the + schema, not `os validate` / `os lint`, not the analytics service, not the renderer. + + ## What it does now + + - `compileDataset` refuses an incompatible `aggregate` × `field` pair with + `DATASET_INVALID` / **400**, naming the measure, the field, its declared type and + the accepted set (read off the table, never restated). Nothing reaches the driver. + - It reads the declared type from the `sourceFieldMeta` a host already wires, via a + new optional `DatasetCompileOptions.declaredFieldType` probe. + - **`derived` is covered by construction.** A derived measure's `of` operands are + base measures of the same dataset, so a dataset carrying a refused base measure + never finishes compiling and no `derived` op can be handed its output — including + when the selection names only the derived measure. + - Tiered "cannot answer, do not block" like every sibling probe: no + `sourceFieldMeta`, an unresolvable field, or a `relationship.field` path (whose + column lives on a joined object) leaves the pair unjudged. + + ## ⚠️ Scope: the compile leg executes the TEMPORAL rows only + + > ⚠️ **Superseded within the same release window.** This section was accurate when it was + > written and is kept as the record of where the compile leg stopped. Two later cards + > widened it before any of the three entries shipped, so at the version that compiles this + > entry the scope below is no longer the platform's: **#16099** judged `sum` / `avg` over + > every remaining field class (including `sum` over a `percent`), and **#17560** (director + > ruling, decision batch #127, 2026-09-13) judged `min` / `max` over every class the table + > refuses. ⇒ Three sentences in this section are false at that version and are corrected + > where they stand: the string rows are **not** awaiting a table amendment, `sum` over a + > `percent` does **not** compile as it did before, and `avg` / `sum` over a temporal field + > are **not** the only pairs whose behaviour changes. Read all three entries together. + + The gate judges only a measure whose field is declared `date` / `datetime` / + `time`; a field of any other class is never handed to the predicate. The + verdict for the pairs it does judge is the table's — no row is restated — but + which FIELDS are judged is narrower than the table, on purpose: + + - **String rows** (`min` / `max` over `text`, `select`, `lookup`, + `autonumber`, …) are **not enforced here**. ⚠️ This card recorded them as + 「under #16785, **ruled C** — the table itself is to be amended to accept + them」, because `measureResultType` (#15768) already typed those results as + `'string'` and pinned them end to end, so enforcing them from here would + pre-empt that ruling. **Both halves of that sentence turned out to be + wrong.** `16785` resolves to no issue, and decision batch #127 (#17560, + 2026-09-13) found no ruling C anywhere behind the citation — the one recorded + ruling on this table, decision batch #59, refuses the string rows. ⛔ The + table is **not** amended; #17560 enforces those rows and retires the + `measureResultType` opinion that disagreed with them. + - **Boolean rows** are not a refusal at all any more: #16685 was ruled A and + #16750 added `boolean` / `toggle` to `sum` / `avg` / `min` / `max`, so the + table ACCEPTS them and this gate never judged them. + - The table's `sum` × `percent` row is likewise **not** executed by this leg; + `sum` over a `percent` compiles exactly as it did before. ⚠️ True of this + card only — #16099 executes that row in the same release. + + ⇒ The only pairs whose behaviour changes **because of this card** are `avg` / + `sum` over a `date` / `datetime` / `time` field. ⚠️ ⛔ Not a statement about the + release: the full-table leg is #16099's and landed, and the `min` / `max` leg is + #17560's and landed, so at the shipping version every pair the table refuses is + refused at the compile door. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ aggregate: 'avg', field: }` | `{ aggregate: 'min' \| 'max', field: }` — a real instant of the field's own type | + | `{ aggregate: 'sum', field: }` | store the duration as a number (a computed "days open" field) and `sum`/`avg` that | + | `derived: { op: 'difference', of: ['avg_a', 'avg_b'] }` over temporal averages | fix the two operand measures; the `derived` spec itself is unchanged | + + ⭐ A duration is not recoverable from an aggregate over instants on any backend. + Where an "average cycle time" is wanted, the cycle length has to exist as a number + before it can be averaged. + + ## What is deliberately untouched + + `date` / `datetime` used as a **dimension** — grouping, bucketing, date-range + filtering — is unchanged; this is about aggregation only. `avg` over a genuine + numeric measure, `min` / `max` over a temporal one, and `count` / `count_distinct` + over anything all behave exactly as before. + + ⚠️ **Two faces stay uncovered, deliberately.** The refusal lives in + `compileDataset` and reads a `declaredFieldType` probe, so it applies only where + a host wires one: `/analytics/query` — the non-dataset face, whose measures a + Cube infers rather than an author declaring them — is NOT covered, and neither + is any other `compileDataset` caller that passes no probe (those stand down + unjudged rather than guessing). Closing those is #16099's, not this card's. + + Alongside the refusal, `service-analytics`' contradictory annotations about what a + SQLite `Field.datetime` column physically holds are reconciled to one statement — + **seven** source sites plus two test narratives, not the four the card quoted. Some + said the column holds an INTEGER epoch and ISO TEXT at once; one said flatly that it + IS an INTEGER epoch. Neither is current: since #3912 the column has ONE + storage form, canonical UTC text, with the epoch surviving only in a database not + yet converged by `backfillCanonicalDatetimes`. The fact is now stated once, on + `AnalyticsServiceConfig.coerceTemporalFilterValue`, and the other sites link to it. + No behaviour changes from that half. +- 3c557e2: **The published `DimensionLabelDeps` type (re-exported from this package's `index.ts`) gains + one new optional key, `translateSelectOptions`** — the surface the level is graded against, + per the same "a new key on a published exported type is the mechanical floor for clause ②" + rule #16778 shipped under. Backward compatible (optional, additive, no removed/renamed key, + no wire-shape change), so `minor` rather than `major`. + + A dataset's `select`-field dimension now renders its option label in the request's locale on + a dataset-backed chart, matching what `GET /meta/object/:name` (and hence the console's list + grid) already renders for the identical field. + + `dimension-labels.ts` resolved a select dimension's category label straight out of field + metadata's authored `options[].label` — always the author's own-language text, since + `SelectOptionSchema.label` is a plain string, never an inline locale map. The dotted + cross-object arm (`field: 'contract.direction'`) was unaffected: a relationship-path field + name never matches a key in the BASE object's own field map, so `resolveDimensionLabels` + skips it via `if (!meta) continue` before either branch runs — this fix changes nothing on + that path, and a regression test now pins that it is never even consulted. + + `DimensionLabelDeps` gains one new optional capability, `translateSelectOptions`, which the + plugin bridge (`plugin.ts`) implements by calling `translateObject` (`@objectstack/spec/system`) + — the SAME translator the object-metadata REST endpoint already uses — against the + deployment's i18n bundle, when an `i18n` service is registered. No new export, no new spec + key, no wire-shape change: `AnalyticsResult` carries the same `rows`/`fields` shape as before, + and a kernel with no i18n service configured (or nothing for the requested locale) falls back + to exactly today's authored-label text. + + A future widening of `LOOKUP_TYPES` (#16390) does **not** automatically inherit this: lookup / + master_detail labels resolve through the separate `fetchRecordLabels` capability (a related + RECORD's display name, not a field's authored `options[]`), which this change does not touch. + It does lower the cost of adding translated lookup-record labels later, though — the i18n + service bridge (`plugin.ts`'s `i18nService()` / `buildTranslationBundle()`) is now already + wired into this package and is a `ctx.getService('i18n')` away from reuse. +- e66da5c: feat(service-analytics)!: a dataset measure applying `sum` or `avg` to a field whose declared type cannot carry it is refused at compile time, for every field type and not only the temporal class (#16099) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, continuing the + one #16778 began. A dataset measure pairing `aggregate: 'sum'` with a `text` field (or + `avg` with a `select`, `json`, `lookup`, `formula`, … field) used to compile and reach + the backend; it is now refused by `compileDataset` with `DATASET_INVALID` / **400** + before any query is built. Shipped as `minor` under the repo's launch-window convention + for accept-set narrowings. + + ⛔ This changeset adds no rows to any table and restates none. The verdict is + `AGGREGATE_FIELD_TYPE_COMPATIBILITY`'s — the one table `@objectstack/spec` declared in + #16353 under the director ruling of decision batch #59 ("both legs, table in spec") — + read through `isAggregateCompatibleWithFieldType`. + + ## What was wrong + + #16778 landed the compile leg SCOPED to temporal source fields, leaving "every other + non-temporal pair the table refuses" as a stated residual that had never been driven. + Driven on this card, through the real service door: + + ``` + sum × text the table refuses the pair the compile leg does NOT throw SQL IS emitted + sweep 6 aggregates × 49 field types = 294 pairs; 155 refused by the table; + minus 6 temporal (#16778's) minus 42 `min`/`max` × the string classes; + residual 107 — and 107 of 107 were ACCEPTED by the compile leg + control avg × datetime / date / time → DATASET_INVALID / 400, no SQL emitted + ``` + + The control is what makes that a reading of the tree rather than of a blind harness: the + same service, door and `sourceFieldMeta` hook sees the pairs #16778 enforces refused. + + So `sum` over a `text` column reached whichever backend the object is bound to, and the + answer was a property of the dialect rather than of the data — the shape Prime Directive + #12 exists to remove, and the same shape #16778 closed for one field class. + + ## What it does now + + - `compileDataset` judges a measure whose aggregate DERIVES a number (`sum` / `avg`) + against the table for **every** declared field type, and refuses an unaccepted pair + with `DATASET_INVALID` / **400** — naming the measure, the field, its declared type + and the accepted set read off the table. Nothing reaches the driver. + - `sum` × `percent` is refused at last: the row `analytics-service.ts` has called + "incoherent" in a comment since before the table existed. `avg` × `percent` is still + ACCEPTED by the same table, which is what makes it a row and not a class. + - The refusal's closing prescription is now chosen by the source field's class: the + temporal sentence #16778 measured is kept verbatim for temporal fields, and a + non-numeric field is pointed at `count` / `count_distinct`, which accept every type + because they read no arithmetic off a value. + - Unchanged: `derived` is covered by construction (a dataset carrying a refused base + measure never finishes compiling), and the three "cannot answer, do not block" tiers — + no `sourceFieldMeta`, an unresolvable field, a `relationship.field` path. + + ## ⚠️ Scope: the DERIVING aggregates — and see #17560, which closed the other half + + > ⚠️ **Superseded within the same release window.** This section was accurate when it was + > written and is kept as the record of why this change stopped where it did. #17560 + > (director ruling, decision batch #127, 2026-09-13) then judged `min` / `max` too, so at + > the version that ships this entry **every** pair the table refuses is refused at the + > compile door. Read that entry beside this one. + + `min` / `max` SELECT one of the stored values; `sum` / `avg` DERIVE a number. This is the + line this package already draws — `measureResultType` branches on exactly that pair of + aggregates — and the defect is about a derived number, so the deriving aggregates are its + population. + + The `min` / `max` rows stayed with the table-amendment card (then **#17513**, since closed + as a duplicate of **#17560**, which ruled and landed them), and that is measured rather + than assumed. + Enforcing the residual whole was tried on this card: with `min` / `max` × the string + classes subtracted, **15** cases in `measure-result-type.test.ts` still went red, every + one of them on `min` × `json` — a pair the table refuses, in no ruling's scope, driven + end to end by the same shared fixture as the string rows. One dataset compiles every + measure in that fixture, so one refused pair reds the whole section. ⇒ `min` / `max` is + one question, and it is the table-amendment card's. + + ## Upgrading — FROM → TO + + Nothing an author writes is removed or renamed: `DatasetMeasure.aggregate` and + `DatasetMeasure.field` keep their spellings and their types. What narrows is which PAIRS of + values are accepted. The one-line fix, per shape: + + | FROM (compiled before, refused now) | TO | + |---|---| + | `{ aggregate: 'sum', field: }` | `{ aggregate: 'count_distinct', field: }` — counting reads no arithmetic off the value | + | `{ aggregate: 'sum' | 'avg', field: }` | store the quantity you meant as its own numeric field and aggregate that | + | `{ aggregate: 'sum', field: }` | aggregate the formula's numeric INPUT column; a `formula` is virtual in SQL storage, so no arithmetic aggregate can be lowered to it | + | `{ aggregate: 'sum', field: }` | `{ aggregate: 'avg', field: }` — a rate averages, it does not add | + | `{ aggregate: 'sum' | 'avg', field: }` | unchanged from #16778: use `min` / `max` for a real instant, or store a duration as a number and aggregate that | + + `min` / `max` are **not** affected by this change at all, over any field type. + + No shipped dataset in this repository declares a newly-refused pair — every one of the + eleven shipped dataset measures resolves to `number`, `currency`, `summary` or `progress`. + The refusal names the accepted set for the aggregate, read off the table. +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. + +### Patch Changes + +- 86c5052: fix(analytics): a `dateRange` array that is not a two-bound window is refused, once, instead of meaning three different things (#17124) + + `AnalyticsDateRangeSchema`'s array arm is a bare `z.array(z.string())` with no + length constraint, so `dateRange: ['2026-01-01']` is schema-valid and reaches the + analytics faces through `POST /analytics/dataset/query`, which types its selection + from `AnalyticsQuery` and never Zod-parses it. The four faces in this package that + read the arm answered it three different ways — measured over one authored + document and four rows: + + | face | `['2026-01-01']` meant | + |---|---| + | `ObjectQLStrategy.dateRangeBounds` | the point window `created_at >= '2026-01-01' AND <= '2026-01-01'` | + | `NativeSQLStrategy` | no time clause at all — the whole dataset | + | the draft-preview evaluator | an upper bound of the string `"undefined"`, which every ISO date sorts below — everything from that day onward | + | `DatasetExecutor`'s `compareTo` pass | the point window, shifted — compared against a primary pass that may have read all of history | + + For a dashboard that is one day's number, the whole dataset's, and everything + from that day onward, from the same document, decided by which backend answered. + `[]` and `[a, b, c]` split the same three ways, and `[null, null]` reached + `parseUTC(null)` as a bare `TypeError` — a 500 for a malformed request. + + One rule is now the single reading of the arm and all four faces call it; the + three divergent fallbacks are deleted. An array that is not exactly two string + bounds is refused with the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 + envelope — the answer the contract already gives for a `dateRange` that does not + denote a window. A two-element window is untouched on every face, bound for + bound, including the inclusive upper reading a caller's bounds keep (#16179) and + the half-open bare-day widening on the SQL side (#3777). + + ### Write both bounds + + | wrote | write instead | + |---|---| + | `dateRange: ['2026-01-01']` | `dateRange: ['2026-01-01', '2026-01-01']` | + + That spelling already selects exactly that one day on every face, and it is the + same instruction #16322 shipped for the single-day string dialect. + + ⭐ Shipped as `patch`, not as a breaking narrowing, because nothing DECLARED + moves. The spec's own refusal wording already states that *"an explicit window is + the two-element array [start, end] of ISO dates or {date-macro} tokens"*, and + #16322's shipped migration table already told authors to write a single day as + `['2026-01-20', '2026-01-20']`. A one-element array was therefore never a valid + document; it was an invalid one that four faces answered arbitrarily, and a + behaviour that was never one behaviour is not a behaviour this removes. The Zod + type admitting the shape is weaker than the contract the same file states — + tightening it is a separate, spec-owned question. +- 5ce3705: `DatasetSelectionSchema` — the ADR-0021 dataset selection is a Zod declaration now, and `POST /api/v1/analytics/dataset/query` parses the whole selection against it (#17551). + + `DatasetSelection` was a TypeScript **interface** with no Zod schema anywhere in the repo. PR #17548 doored that route, but only over the **seven** members the selection shares with `AnalyticsQuery`; the other **four** — `runtimeFilter`, `dateGranularity`, `compareTo`, `totals` — were declared in TypeScript, published in the api-surface, and enforced by nothing on the wire. The measured consequence is #17550: `compareTo: { kind: 'nonsense' }` came back as a previous-period comparison under an ordinary **200**, a number a dashboard renders and a person reads as fact. + + - **One declaration, in `packages/spec`.** `DatasetSelectionSchema`, `DatasetCompareToSchema` and `DatasetTotalsSchema` are authored in `api/analytics.zod.ts`, beside the `AnalyticsQueryRequestSchema` the sibling routes parse. `@objectstack/spec/contracts` now **re-exports** the `DatasetSelection` and `DatasetCompareTo` types from that schema instead of declaring interfaces of its own — the same move `AnalyticsQuery` made in #4538, taken here before a mirror could drift. + - **A transcription, not a new contract.** The seven shared members are read straight off `AnalyticsQuerySchema.shape`, so the claim that the two agree is structural rather than a hand-written list; the four dataset-only members are the already-published TypeScript members made executable. No member is added and nothing the interface permitted is refused. + - **Refusals carry a prescription.** An unrecognised `compareTo.kind` answers the sentence `datasetCompareKindRefusalMessage` builds — what arrived, the two windows the executor implements, what to do — and `@objectstack/service-analytics`' `shiftRange` now raises that same sentence with its own origin clause, so one condition keeps one wording. An unknown key is named, echoed and pointed at the canonical spelling (`where` → `runtimeFilter`, `granularity` → `dateGranularity`), and the retired `{ offset }` arm and the pre-#5011 bare-string form each carry their rewrite. + - ⚠️ **What narrows on the wire**, so an upgrading caller can look for it: a selection member whose value the published interface never permitted now answers `400 VALIDATION_FAILED` with `details.fields[]` instead of travelling into the executor. Measured against the sibling route spelling for spelling, `runtimeFilter` now behaves exactly as `/analytics/query`'s `where` does — three structurally-malformed filter spellings (`{ $or: 'x' }`, an `$or` branch that is not a filter object, `{ $not: 5 }`) are refused at the schema on both routes, and the four semantic ones (`{ stage: {} }`, `{ amount: { $between: [10] } }`, `{ $nor: […] }`, `{ $or: [] }`) still pass both and are answered deeper. The dataset route was the looser of the two; it is not any more. + - **No valid selection changes.** Every in-repo specimen and all five `@object-ui` call sites that build a selection today still pass, pinned in both packages; the route still forwards the caller's object to the service by identity, never a parse output, and the schema carries no default or transform that could override the engine's own timezone resolution chain. +- c81e7ff: fix(analytics): a `dateRange` preset plus `compareTo` is lowered and shifted instead of refused as an "invalid date" (#17973) + + `DatasetExecutor.runCompare` read the STRING arm of `dateRange` as + `[range, range]` — the degenerate fallback #17015 removed from every other + analytics face. `parseUTC` was handed the preset NAME, so a declared, honoured + member of the closed vocabulary was refused outright. Measured end to end + through the executor, a valid preset plus `compareTo`: + + ``` + DATASET_INVALID 400 [dataset-executor] invalid date in dateRange: "last_30_days" + ``` + + The diagnostic is not merely unhelpful, it is FALSE. `last_30_days` is exactly + what the schema, the dashboard date filter and the docs tell an author to + write, so "invalid date" sends them to check a date that is already correct — + a repair that does not exist. This face was not in #17015's kit, so nothing + measured it and nothing noticed. + + Both arms now go through one face lowering, which calls the shared + `resolveAnalyticsDateRangeString` for the string arm — the same call the + ObjectQL strategy, the native-SQL strategy, the draft-preview evaluator and + driver-memory's cube face make — and the lowered window is then projected onto + the comparison math's UTC calendar, with `endExclusive` honoured so that a + calendar preset's exclusive upper bound does not itself add a day to the + projected window. On the UTC calendar, `this_month` plus + `compareTo: { kind: 'previousYear' }` now compares September against the + previous September, rather than refusing. ⚠️ Outside UTC the projection costs a + day of its own — third note below. + + Three consequences worth knowing when you upgrade: + + - **A string outside the vocabulary now answers the shared envelope.** On this + path it used to be `DATASET_INVALID`; it is now + `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400, the ADR-0112 envelope the other + faces already raise, with the message that lists the thirteen declared preset + names. One condition, one envelope. Code keying on `DATASET_INVALID` for an + unrecognised `dateRange` STRING should key on + `ANALYTICS_DATE_RANGE_UNRECOGNIZED` instead. + - **The caller's explicit `[start, end]` window is untouched**, bound for bound, + with the inclusive upper reading it has always had — including the + `DATASET_INVALID "invalid date in dateRange"` refusal for a bound that is not + a date, which is unchanged. + - **⚠️ A calendar preset lowered in a NON-UTC zone gives a comparison window one + day too wide** — in either direction, depending on which side of UTC the zone + sits. The comparison math is UTC-calendar throughout (`parseUTC` reads a bare + day as UTC midnight, `toISODate` emits a UTC day), so a window computed + against another zone's calendar is projected onto UTC day boundaries: east of + UTC the start lands a day early, west of UTC the end lands a day late. + Measured through the executor, `this_month` plus + `compareTo: { kind: 'previousYear' }` frozen at `2026-09-09` — + `UTC` gives `['2025-09-01','2025-09-30']` (30 days, correct), + `Asia/Shanghai` gives `['2025-08-31','2025-09-30']` and `America/New_York` + gives `['2025-09-01','2025-10-01']` (31 days each). ⛔ This is NOT a + regression: the same input used to be refused outright, so no + previously-working input behaves differently — what changed is that the + preset arm produces a window at all, which is what makes the projection + observable. Tracked in #18245. It is deliberately not repaired here, because + a timezone-aware calendar-day extraction in this module would be the second + implementation `analytics-date-range.ts`'s own header exists to refuse. + + `runCompare` is also registered as a face in the shared `dateRange` conformance + kit, so the next face that forgets to lower a preset is caught by a test rather + than by a customer. +- fe0ae5c: analytics `dateRange`: one condition, one refusal wording + + An array `dateRange` that is not a two-bound window is refused by the + `service-analytics` faces with the platform's ONE shared sentence + (`analyticsDateRangeRefusalMessage`, origin `runtime`) instead of a + package-private second wording. The envelope is unchanged — + `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 — so nothing that classifies on + `code`/`status` is affected; only the `message` text changes, and it now agrees + byte-for-byte with the sentence the schema door answers with for the same value. + + The second wording existed because the shared sentence used to judge a bare + string against the preset vocabulary and to end with "Refused at the schema", + neither of which is true of an array refused past the schema door. Both grounds + were removed when `analyticsDateRangeRefusalMessage` gained its required + `origin` parameter and began describing a non-string by what is wrong with it. + + ⚠️ **The message no longer echoes the value you sent.** For an ARRAY + `dateRange` the shared sentence DESCRIBES the shape instead: what used to read + `dateRange ["a","b","c"] is a 3-element array` now reads `received a 3-element + array, not the two bounds [start, end]`. That applies to EVERY array shape this + face refuses, not to unusual ones only — `[null, null]` now reads `received an + array with a non-string bound`, and `['', '']` is where the description carries + least, `received a two-element array`. A bare STRING `dateRange` is still quoted + back to you. So a log line that used to carry the offending array no longer + does: if you need the value at that site, read it from the request you already + have, ⛔ not from the message. + + ⛔ If you match on the old text (`[service-analytics] dateRange …`), match on + `error.code === 'ANALYTICS_DATE_RANGE_UNRECOGNIZED'` instead — the message was + never the contract, the envelope is. +- ad067ad: fix(service-analytics): resolve `compareTo`'s comparison window on the reference calendar, not UTC + + `DatasetExecutor`'s `compareTo` day math carried its own local `parseUTC`/`toISODate` pair + and read every bound on the UTC calendar. The lowered preset window is a pair of INSTANTS + that open and close at the *reference zone's* midnight, so projecting them onto UTC days + moved a boundary in every non-UTC zone — and in opposite directions either side of the + meridian. `this_month` + `compareTo: { kind: 'previousYear' }` frozen at 2026-09-09 compared + 30-day September against a 31-day window: `Asia/Shanghai` opened at `2025-08-31`, + `America/New_York` closed at `2025-10-01`. No error, no warning — a slightly-too-wide + comparison leg rendered exactly like a correct one. + + The local pair is deleted. The bare-calendar-day arithmetic (year shift, previous-period + length, bucket ordinals) now runs through `@objectstack/core`'s `zonedDateStartToUtcMs` on + its zone-free UTC proxy, and the one seam that turns instants into days — the lowered + window's projection — goes through the same package's `bucketDateKey`, threaded with the + timezone `buildQuery` already resolves the primary pass in. UTC callers are unaffected. +- b49728f: `dimension-labels.ts` — the module header names the label-resolving option arm by the **property the resolver actually reads** (a declared, non-empty `options` list) instead of by the type name `select` (#18923). + + Doc comment only; it is published in `dist/index.d.ts` and `dist/index.d.cts`, so an upgrading reader's editor hover changes. No behaviour, no export, no schema. + + The header told the reader the arm was a type test: + + ``` + * - **select** — grouped by the stored option `value` (e.g. `backlog`), but the + * user-facing text is the option `label` (e.g. `Backlog`). + ``` + + The resolver in the same file never reads a type for it. All three decision points spell one predicate — `Array.isArray(meta.options) && meta.options.length > 0` — at `isLabelBearing`, at `resolveLabels` and in `resolveDimensionLabels`'s display pass; `type === 'select'` occurs zero times in the file, while the sibling `type === 'date'` arm shows the file does spell type tests where it means them. + + Naming a type is wrong in both directions, which is why the replacement names the property rather than a longer type list: + + - **It misses fields that do resolve.** `options` is optional on every field in the spec's field schema, so any field that declares one is resolved here whatever its type says. + - **It promises resolution for fields that carry none.** A free-input `tags` field may declare no options at all, and the display pass then leaves its stored value untouched. + + This closes the divergence that opened when the same sentence in `content/docs/data-modeling/analytics.mdx` and `content/docs/ui/dashboards.mdx` was moved to the property reading: the documentation was corrected, and the header the next editor of this file reads first was left behind. +- d7f7e34: Four readers of `FieldSchema.reference` gated the carrier with a truthiness test and then **propagated** it. `FieldSchema.reference` is declared an optional **string**, so the answer a reader owes for a carrier it cannot read is absence — and one of these four did worse than lose the information, it invented a name for it: + + ``` + out.push({ key, reference: String(f.reference) }) // -> reference: '[object Object]' + ``` + + Each site now reads the carrier through the one arbiter, `referenceCarrierOf`, and catches its refusal **at the site** — so the reader answers absence and reports, instead of aborting. That is the deliberate difference from `@objectstack/objectql`'s cascade seams, which let the same refusal propagate: those assert something positive about the schema on a write path, while these four are best-effort display and diagnostic readers whose own failure handling would have turned one unreadable field into a much wider loss. + + - **`@objectstack/plugin-approvals`** — `resolveLookupFields`. The stringified carrier was handed on as an object name to `engine.find()`, where it could never resolve and the failure was swallowed by the caller's `catch`. The field is now left out of the inbox display enrichment and logged; readable targets are unaffected. It is dropped rather than carried with an absent target because the sole consumer uses `reference` as the object name and has nothing to do with an entry carrying none. + - **`@objectstack/service-analytics`** — the ADR-0021 relationship → target-object resolver. An unreadable carrier became the joined table for a dataset's `include`; the resolver now answers `undefined`, which its existing fallback turns into the compiler's own refusal, plus one warning naming the field. + - **`@objectstack/cli`** — `os doctor`'s circular-dependency and unused-object checks, which put the carrier into a graph node and a name set. Both now report the unreadable carrier as a finding rather than skipping it, because "no circular references detected" and "defined but not referenced" are positive claims that an edge nobody could read cannot support. The same file's `collectViewObjectRefs` already narrowed its carrier this way. + + `null`, `undefined` and `''` are absence, not a wrong shape, and still pass silently at every one of these sites — a field is allowed to name no target. Each site's absence answer and its readable-target answer are pinned alongside the refusal. + + Upgrading: nothing conformant changes. A non-string `reference` is refused by `ObjectSchema.safeParse`, so a value in that shape only ever reaches these readers without having passed parse at all. +- a6a4361: The draft-data preview **refuses** a `where` operator it cannot evaluate instead of answering it for every row, so a drafted chart no longer silently ignores a filter and then changes at publish (#19810). + + `preview-evaluator.ts` evaluates a pending seed draft's rows in memory — the ADR-0037 P3 Live Canvas path — and its operator switch carried ten cases (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin`, `$contains`) and then `default: return true; // unknown operator — permissive (preview, reads only)`. Every other declared operator therefore matched EVERY row: `$icontains`, `$notContains`, `$startsWith`, `$endsWith`, `$null`, `$exists`, the staged `$like` / `$ilike`, and any typo. A drafted chart with `name $icontains 'acme'` charted the whole dataset and looked exactly like a working chart; the published chart, which runs the real filter doors, applied the filter. + + - **Fail-closed, and VISIBLE.** The operator is refused in the ADR-0112 `INVALID_FILTER` / 400 envelope this package's `where` door already speaks, through `filter-normalizer`'s exported `invalidFilterError`. No new error code and no new exported symbol. Refused rather than excluded from the result: an excluded row makes the preview merely *different* from publish — zero rows where publish draws numbers — which is the silent shape `lowerPreviewDateRange` abolished on this same evaluator; only a refusal reaches the author who can fix it. It is the call `uncompilableFieldOperatorError` states for the analytics cube face, and the posture `service-analytics` already takes for `$like` / `$ilike`. + - **The vocabulary and the evaluator are now ONE table**, the shape `memory-analytics`' `MONGO_TO_CUBE_OPERATOR` took for this same defect class: adding a row is the only way to widen what this face accepts, and forgetting to add one is a loud refusal rather than a wrong number. + - **The gate does not depend on the data.** It walks `where` before any row is read, so a seed draft holding zero rows — the state a draft is authored in — refuses too instead of answering an empty chart. + - ⚠️ **What it costs**: a drafted chart whose filter uses one of those operators now returns `400 INVALID_FILTER` in preview where it previously rendered a number. That number was computed over rows the filter excludes, and it changed at publish. Growing the preview's arms is deliberately separate work — the `FILTER_OPERATORS` docblock's ruling that a name must not land ahead of its evaluators reads the same in this direction, so an arm joins the table in the PR that measures it against the shared conformance kits. + - **The ten evaluated arms are byte-for-byte unchanged**, pinned in both directions (a matching row still matches, a non-matching row still does not). +- 44ce049: The draft-data preview **refuses** a field constraint with zero operators (`{ name: {} }`) instead of answering it with every row, so a drafted chart no longer shows rows for a filter publish refuses outright (#19835). + + `preview-evaluator.ts`'s `matchesWhere` iterated a field constraint's entries; an empty object has none, so the loop never ran and the row fell through to a MATCH. `matchesWhere({ name: 'Globex' }, { name: {} })` answered `true`. Every data driver refuses this shape (`driver-memory`, `driver-mongodb`, and `driver-sql` at the top level and inside `$and`/`$or`/`$not`), and so does this package's own `where` door, so the preview and publish gave opposite answers to the same filter. + + - **Refused in the ADR-0112 `INVALID_FILTER` / 400 envelope**, through the same `invalidFilterError` the preview already uses for an operator it cannot evaluate. No new error code and no new exported symbol. The message follows the drivers' wording: it names the constraint and its position (`where.$or[1].amount`), and gives the two legal repairs (name an operator, or write a direct comparand). + - **Not answered as "matches zero rows" either.** `{ status: {} }` does not mean "no rows". Read literally it means "rows whose status is anything", and the shape is almost always an authoring accident: a filter builder that recorded a field but never its operator. Only a refusal names the constraint to repair. + - **Nesting cannot route around it.** The check walks the whole `where` before any row is read, `$and` / `$or` / `$not` arms included. So a constraint in an `$or` arm that a matching row would short-circuit past still refuses, and so does a seed draft holding zero rows. + - ⚠️ **What it costs**: a drafted chart whose filter carries `{ field: {} }` now returns `400 INVALID_FILTER` in preview, where before it rendered a number computed over every row. Fix: name the operator the constraint was meant to carry, e.g. `{ status: { $eq: 'open' } }` or `{ status: 'open' }`. + - **Unchanged**: constraints that name an operator, implicit-equality comparands, and an empty *node* (`where: {}` or `$and: [{}]`, which is the identity and not a field constraint). +- 60fdaa9: fix(service-analytics): the ObjectQL execute face refuses a read scope carrying a filter placeholder the engine cannot resolve in the withheld `READ_SCOPE_COMPILE_FAILED` / 500 envelope, not the engine's `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED` / 400 (#19995) + + Clause-②: no + + A row-level read scope carrying an unknown filter placeholder, or a known one the request has no value for, used to reach `engine.aggregate` composed with the caller's own filter. The engine's placeholder resolver then refused it with a 400. A 4xx's message is relayed to the caller, and this one named the policy's placeholder. + + The ObjectQL strategy now runs the engine's own placeholder resolver on the scope by itself, with the token context the engine builds, at both engine-bound merge sites: the base aggregate (direct and cross-object) and the referenced object's scope in the cross-object label lookup. It does this before composing the scope. A refusal there is `READ_SCOPE_COMPILE_FAILED` / 500. `POST /analytics/query` and `POST /analytics/dataset/query` withhold its message, and the full text goes to the operator's log. + + Unchanged: which scopes are served. A placeholder the engine resolves, such as `{current_user_id}` for a signed-in caller, is resolved the same way here, and the scope is served. The caller's own `where` keeps its `FILTER_TOKEN_UNKNOWN` / 400 with its message. +- ab82001: fix(service-analytics): the analytics ObjectQL face asks the engine's own filter admission about a row-level read scope before composing it, and refuses a scope the engine refuses with the policy withheld (#19995) + + Clause-②: no + + The ObjectQL execute face composes each object's read scope into the `where` it hands `engine.aggregate`. A scope the engine refuses through a door that reads the object's declared fields (a text operator over a field that never holds a string, a temporal comparand the field cannot read, a filter on a formula field, a dotted path through a lookup) came back as the engine's `INVALID_FILTER` or `INVALID_FIELD` / 400. Both analytics HTTP doors relay a 400's message, and that message named the policy's field, and for some classes its operator or comparand. A read-scope refusal is a server fault whose detail belongs in the server log only (the #5367 ruling), so these scopes now answer `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld, like the other refusals this package's read-scope compiler and guards raise. The `driver-sql` refusals that read the `'policy'` provenance mark are unchanged: they stay a withheld `INVALID_FILTER` / 400. + + **How.** The analytics face asks the engine's judge-only admission, `IObjectQLEngine.judgeFilter`, about the scope on its own before composing it. It asks at every engine-bound merge: the direct aggregate, both merges on the cross-object path, and the record-label lookup behind a lookup dimension. The engine runs the same admission it runs when it executes and stops before any driver, so a scope the engine serves is still served. The caller's own `where` is not judged here and keeps the engine's answer, including its 400 and message. + + **Also fixed.** The record-label lookup `AnalyticsServicePlugin` supplies for a lookup dimension composed the referenced object's scope with only the vacancy guard. When a dataset sorted by that dimension's labels, a scope the engine refused there came back as its 400, with the policy in the message. When a dataset only displays the labels, a failed lookup is caught and the raw ids render, as before. The lookup now runs the same checks as the other merges and answers the same withheld 500. + + **Wiring, and what a host without it keeps.** + + - `AnalyticsServicePlugin` wires the judge automatically when it bridges `executeAggregate` to the kernel's `data` engine itself. That is the default composition, so nothing changes in host code. + - A host that constructs `AnalyticsService` directly can pass the new optional `AnalyticsServiceConfig.judgeFilter`. It must be the judgement of the engine its `executeAggregate` runs on. + - A host with no judge (a custom `executeAggregate`, or a `data` engine without `judgeFilter`) keeps today's behaviour everywhere except the plugin's record-label lookup. The scope shapes this package judges itself are still refused with the policy withheld, and the rest reach the engine unjudged, as before. That lookup's comparand and placeholder checks are new for every host that uses it, with or without a judge. So on such a host a referenced-object scope that fails one of them now answers the withheld 500 at that lookup, where it used to reach the executor. The host logs one `warn` that no judge is wired, with the remedy. +- 7b76fff: fix(service-analytics): the ObjectQL execute face refuses a read scope it cannot run in the withheld `READ_SCOPE_COMPILE_FAILED` / 500 envelope, not the engine's `INVALID_FILTER` / 400 (#19995) + + Clause-②: no + + A row-level read scope carrying a comparand the engine's shared comparand faces refuse — a list in the equality slot, a scalar under `$in` / `$nin`, a one-bound `$between`, a null list member, a plain-object or `undefined` comparand — used to reach `engine.aggregate` composed with the caller's own filter, and came back as the engine's `INVALID_FILTER` / 400. A 4xx's message is relayed to the caller, and this one named the policy's fields and comparands. The NativeSQL execute face and the `/analytics/sql` echo already refused the same scope as a server fault with the message withheld (the #5367 ruling), so one scope got two envelopes depending on which analytics face served it. + + The ObjectQL strategy now judges the scope on its own at both engine-bound merge sites (the base aggregate, direct and cross-object, and the referenced object's scope in the cross-object label lookup), with the same two shared functions the engine runs, before composing it. A refusal there is `READ_SCOPE_COMPILE_FAILED` / 500: `POST /analytics/query` and `POST /analytics/dataset/query` withhold its message, and the full text goes to the operator's log. + + Unchanged: which scopes are served. The judgement uses the engine's own functions, so a scope the engine serves is still served, including a `{ $field }` cross-field scope and an emptied `$in` beside an own-rows grant. The caller's own `where` still answers `INVALID_FILTER` / 400 with its message, whether the analytics door or the engine refuses it. +- bf37b99: fix(service-analytics): on SQLite, the text operators in analytics filters and read scopes compare the whole stored value and the whole comparand, instead of stopping at their first U+0000 (#20025) + + Clause-②: no + + `service-analytics` compiles its own SQL for `$contains`, `$notContains`, `$startsWith`, `$endsWith` and `$icontains`, in three places: the read scope applied to an analytics query (`compileScopedFilterToSql`), the `where` that `NativeSQLStrategy` executes, and the `ObjectQLStrategy` statement the `/analytics/sql` caller runs. On a SQLite datasource all three compiled these operators to `GLOB`, and SQLite's `glob()` reads both the pattern and the stored value only up to their first U+0000. Nothing raised, and the filter answered a different question. Measured on better-sqlite3 (SQLite 3.53.4) and sql.js (3.49.1), every one of these faces alike: + + - a comparand holding U+0000 was cut at it, so `$contains` / `$endsWith` could match every row and `$notContains` none; + - a stored value holding U+0000 was read only up to it, so `$contains` / `$endsWith` / `$icontains` missed a match after it, `$endsWith` could match what came before it, and `$notContains` / `$not` returned a row whose value does contain the comparand. + + On a read scope the first kind widens what the scope admits and the second narrows or widens it. `driver-sql` and `driver-turso` already compile these operators this way (the #19999 and #20024 fixes); this package re-emits their construct table rather than importing it, and its copy had kept `GLOB`. + + What changes: on the `sqlite` dialect these operators now compile to `driver-sql`'s constructs, cell for cell. `$contains`, `$notContains` and `$icontains` use `instr()`; `$endsWith` compares the value's trailing bytes over BLOB, with an empty comparand using `instr()` so it still matches every non-NULL value; a `$startsWith` comparand holding U+0000 uses `instr(…) = 1`. Such a filter now returns the rows `@objectstack/formula` and `driver-sql` return for it, on all three faces, bare and under `$not`, with `''` and NULL values included. The comparand is bound as written, so `*`, `?` and `[` in it are literal, as they were. `$icontains` still folds ASCII letters only, and `$notContains` still returns a row whose value is NULL. + + What does not change: + + - `$startsWith` with a comparand without U+0000 compiles to the same `GLOB` with the same bound pattern as before; the stored value's cut cannot change its answer. It keeps its index search (`EXPLAIN QUERY PLAN` over an indexed TEXT column on both engines); the other operators scanned the table under `GLOB` and still do. + - The Postgres and MySQL arms, and every comparand refusal that runs before the text arm, are untouched. + - A host that answers no SQL dialect for a SQLite datasource still gets the dialect-neutral `LIKE`, which SQLite also reads only up to the first U+0000. + - `$like` and `$ilike` are still refused by these compilers, as before. +- 6780e34: fix(service-analytics): a dataset measure column takes the source field's currency only when that field's `currencyConfig.currencyMode` is `'fixed'`. Otherwise it takes the tenant default (#20091) + + Clause-②: no + + `AnalyticsServicePlugin` passes each source field's metadata to the service through `sourceFieldMeta`. That hook passed on `currencyConfig.defaultCurrency` whatever `currencyMode` said, and `queryDataset` put it on the result column as `currency`. The column is resolved in this order: the measure's own `currency`, then that value, then `ExecutionContext.currency`. So a `dynamic` field's `defaultCurrency` showed on analytics, chart and dataset faces, where the tenant currency belonged. That covers a `currencyConfig` naming no mode too, which is `dynamic` by the schema default. Parsed through the spec, a `currencyConfig: {}` also carries the schema's placeholder `CNY`, and that reached the column as if an author had written it. + + - **What changes**: the relay now passes `defaultCurrency` on only under `currencyMode: 'fixed'`. This is the rule `CurrencyConfigSchema` declares, and objectui's field faces already follow it: only `fixed` gives a field one currency, and a field without one uses the tenant default at runtime. On both the live and the draft-preview path of `queryDataset`, the column now carries: + - the field's currency for a `fixed` field; + - `ExecutionContext.currency` for a `dynamic` field, a config naming no mode, an empty config, or no config at all. + - **What does not change**: a measure's explicit `currency` still wins, over a fixed field as well. A non-monetary measure still gets no code. `AnalyticsService.query` (the cube face) still carries no column currency. The `AnalyticsServiceConfig.sourceFieldMeta` type is unchanged. + - **Hosts that write their own `sourceFieldMeta`**: its `defaultCurrency` means the field's fixed currency. Return `currencyConfig.defaultCurrency` only when `currencyConfig.currencyMode === 'fixed'`, and return `undefined` otherwise. The TSDoc on `AnalyticsServiceConfig.sourceFieldMeta` states the rule. +- 536f2d5: fix(service-analytics): `$icontains` works on a query the ObjectQL strategy serves (#20098) + + Clause-②: no + + `ObjectQLStrategy` had no translation for the case-insensitive `$icontains` + operator, so every such filter on a datasource it serves failed. A valid + `{ name: { $icontains: 'acme' } }` included. `POST /analytics/query` answered + `500 INTERNAL_ERROR` ("ObjectQL strategy cannot express filter operator + "icontains""), while the native SQL face and the `/analytics/sql` echo served + the rows. + + The strategy now hands the engine the canonical `$icontains`, the same way it + passes `$contains`, `$notContains`, `$startsWith` and `$endsWith`. The engine + and the driver apply the case fold, so the ObjectQL face answers the same rows + as every other face: the fold is ASCII-only, so `'CAFÉ'` does not match + `'café'`. This covers the query's `where` in both spellings (`$icontains` and + the `FilterArray` `icontains`), under `$not`, a compiled dataset's scope and a + measure filter. An empty or non-string comparand is still refused with + `INVALID_FILTER` / 400 before the strategy runs. +- 70ce802: `AnalyticsService.queryDataset` no longer writes the service-wide cube and dataset registries: each call compiles its dataset into a scope of its own (#20356). + + Clause-②: no + + - **What changes**: a dataset query — an inline draft or a saved definition passed to `queryDataset` — used to register its compiled cube and compiled dataset under the dataset's name before it ran. From then on the name meant that request's definition for every later reader (`getMeta()` and `GET /api/v1/analytics/meta`, and every query by that name) until restart, whatever the request's own admission answered. The dataset is now compiled for the call only. The queries it runs resolve its name through a request-local lookup that overlays the shared registry read-only: the cube, the object-level admission and read-scope object sets, the join allowlist and the dataset scope all come from the call's own dataset, and a measure the call infers stays with the call. + - **What does not change**: the request is served as before, from its own definition, with the same admission, read scope and refusals. `registerDataset` still compiles and registers into the shared registry — the configuration door behind `AnalyticsServiceConfig.datasets` and embedders — and configured cubes are untouched. No refusal is added for a dataset whose name matches a configured cube. + - **The one observable difference**: a cube that only a `queryDataset` call ever compiled is no longer listed by `getMeta()`, and is no longer queryable by name through `query()` / `POST /api/v1/analytics/query` after that call returns. To make a dataset addressable by name, register it through `registerDataset` or `AnalyticsServiceConfig.datasets`. +- 50e273f: `AnalyticsService.query()` and `generateSql()` no longer write the service-wide cube registry before the object-level read admission has admitted the request, and never write a caller-named measure into a registered cube (#20381). + + Clause-②: no + + - **What changes**: both ad-hoc doors — `query()` (`POST /api/v1/analytics/query`) and `generateSql()` (`POST /api/v1/analytics/sql`) — resolved the query's cube and recorded what `ensureCube` minted straight into the shared registry, ahead of the admission check. A request refused `PERMISSION_DENIED` still left the cube it inferred for the refused object in the registry, and a suffix measure a caller named on a registered cube (`_sum`, `_count_distinct`, …) was appended to that cube for every later reader, whether the request was refused or admitted. Both doors now run in the same request-local scope `queryDataset` runs in: what `ensureCube` mints stays with the call, and the admission, read scope and strategy all read it from there. + - **What does not change**: every request is served as before, with the same admission, read scope, refusals, codes and statuses, and a caller-named suffix measure is still served to the caller who named it. A cube inferred for an ADMITTED ad-hoc query is no longer registered either; the separate #20381 entry that retires inferred-cube registration describes that change. Configured cubes and datasets registered at construction (`AnalyticsServiceConfig.cubes` / `datasets`) are untouched. + - **What `getMeta()` lists, the one observable difference**: `getMeta()` and `GET /api/v1/analytics/meta` no longer list a cube inferred for a refused request, and no longer list a suffix measure some caller named on a registered cube — a registered cube is listed as it was registered. +- c745e2b: A cube `AnalyticsService` infers for an ad-hoc `query()` or `generateSql()` request is no longer registered in the service-wide cube registry, even when the request is admitted, so `getMeta()` and `GET /api/v1/analytics/meta` list configured cubes only (#20381). + + Clause-②: no + + - **What changes**: an ad-hoc request naming an object that no cube is configured over (`POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`) is still served from a minimal cube inferred from that request's own members. That cube now lives only in the request that inferred it, like a suffix measure a caller appends to a configured cube. Before, an admitted request left it in the shared registry, so `getMeta()` listed it to every caller, including callers who may not read the object, together with the member names the first caller used. Its contents depended on who had queried what since boot, and it was lost on restart. + - **What does not change**: every request is served as before, with the same answer, admission, read scope, refusals, codes and statuses. A repeat request for the same object infers the cube again, through the same existence and source-field checks, and gets the same answer. Configured cubes (`AnalyticsServiceConfig.cubes`) and datasets registered through `registerDataset` (the constructor's `datasets`, or an embedder) are registered and listed as before, and they are now the registry's only writers. + - **What to do**: nothing, unless something reads `getMeta()` / `GET /api/v1/analytics/meta` expecting to find a cube that only an ad-hoc query inferred. No consumer in this repository does. Author that cube explicitly (`defineCube`, or the analytics service's `cubes` config) so that it is listed, and listed the same way after a restart. +- 40098a4: fix(service-analytics): an unrecognised `compareTo.kind` is refused, not answered with a previous-period window under a 200 (#17550) + + `shiftRange` had one branch and a fall-through — `previousYear` was named, and + **everything else** landed in the `previousPeriod` arm. No `default`, no + exhaustiveness check. So `compareTo: { kind: 'previousQuarter' }` came back as a + previous-period comparison under an ordinary **200**, and the caller was told + nothing. The wrong answer is a comparison **window**: a number a dashboard + renders and a person reads as fact, with no status, header or field in the + response to distinguish it from a real answer. + + `DatasetCompareTo.kind` has only ever declared two values + (`'previousPeriod' | 'previousYear'`), but `DatasetSelection` is a TypeScript + interface with no Zod schema anywhere, and `/analytics/dataset/query`'s door + parses only the seven members the selection shares with `AnalyticsQuery` — + `compareTo` is one of the four it projects away before its parse, and the route + forwards the caller's selection to the service untouched. So `kind` was checked + by `tsc` inside this repo and by nothing at all on the wire. + + ## FROM → TO + + | Input | Was | Now | + |:--|:--|:--| + | `compareTo: { kind: 'previousPeriod' }` | the equal-length window before | **unchanged** | + | `compareTo: { kind: 'previousYear' }` | the same window one year back | **unchanged** | + | `compareTo: { kind: }` | a previous-period window, **200** | `DATASET_INVALID` / **400**, naming the value received and both legal ones | + + The fix is to name one of the two declared windows, or drop `compareTo` — which + is what the refusal says. No accept set widens, no new error code is minted: the + refusal is the fourth member of the `datasetInvalidError` family + `resolveCompareDimension` already raises three times for the same document, so it + arrives at the route through the envelope that route already classifies on. + + ## Why this is a `patch` + + It pulls behaviour back onto the contract the type has always declared, rather + than narrowing past it: every input `DatasetCompareTo` permits returns + byte-identical windows, pinned by a control in the same change. What flips from + 200 to 400 is input the declared contract never permitted. The reachable-today + population for that input was measured on the tree — the dashboard authoring path + is already doored (`DashboardWidgetSchema` parses the widget's `kind` as a + `z.enum`, so a third kind cannot arrive through a parsed widget), and no producer + in this repository sends a third value. What is not enumerable from here is a + consumer outside it calling the published `shiftRange` export, or posting a + hand-rolled body to the dataset route; for those, the refusal replaces a wrong + answer with a located one. + + `alignedCompareBucketKey` reads the same two-valued `kind` and deliberately gains + no refusal of its own: it is not on the package's public surface, and its only + caller runs `shiftRange` first — both pinned, so exporting it turns the pin red + rather than silently reopening this defect. +- 113050e: A dataset dimension over a `user` or `tree` field renders the referenced record's display name, the same way a `lookup` dimension already did. A "by person" chart's axis is people's names, not a column of user ids. + + `packages/spec` declares one reference class — `REFERENCE_VALUE_TYPES` = `lookup`, `master_detail`, `user`, `tree`, "value points at another record … a record-id string in stored form" — and this service already treated it as one class where it annotates measure result types (`measure-result-type.ts` imports that very set). The label resolver, one file away, hand-wrote a two-member subset of it (`lookup`, `master_detail`), so within a single dataset query one axis came back as a name and the other as a raw id, for two fields that differ in one word: + + ``` + Field.user({ label: 'Person' }) -> { type: 'user', reference: 'sys_user' } + Field.lookup('sys_business_unit', { … }) -> { type: 'lookup', reference: 'sys_business_unit' } + ``` + + - **The subset is gone, not extended.** The resolver now asks `referenceTargetOf` (`@objectstack/spec/data`) — the declared single arbiter of "what does this reference field point at" — at all three sites that classified a dimension: the display pass, the `#3680` sort-key hook's `isLabelBearing`, and its `resolveLabels`. Adding two literals to a private `Set` would have left the next member of the class to be re-reported by the next user. + - **A `user` field authored without `reference` resolves too.** `sys_user` is a constant of the type, which `referenceTargetOf` materializes; requiring an author to restate it is exactly the disagreement between two readers of one field that arbiter exists to end. + - **The label read stays scoped (`#3602`).** Turning a user id into a name is a read of `sys_user`, and it travels the same `LabelScopeResolver` path every other member of the class travels — the referenced object's own RLS is resolved and ANDed into the lookup, and an unresolvable scope still fails closed to the raw id rather than fetching unscoped. This is the half of the change that had to land with it, not after it. + - **Nothing degrades into an error or a blank.** An orphaned or RLS-hidden user id, a `sys_user` with no display field, and a user object unknown to the engine all leave the raw id in place and answer the query, which is the pre-existing contract for an unresolved lookup id. + + No new authorable key and no new export: `DatasetDimensionSchema` is untouched, and a dimension's own declared `type` still does not decide this — the resolver reads the object field's type, as it always has. +- 54b3d1d: fix(service-analytics): a fail-closed row-scope refusal can no longer be served as an empty chart (#17130) + + `queryDataset` degrades to `{rows: [], fields: [], totals: []}` when a BARE error looks like a driver reporting an absent table — a deliberate leniency (#5033) so a dashboard widget over an unmounted object renders "no data" instead of failing. The test is a substring match over the message, and three of its six limbs — `not registered`, `unknown object`, `is not a registered object` — are exactly the phrasings a registry or security refusal reaches for. + + Both sites of the row-scope RESOLUTION stage refused with a bare `throw new Error(…)`: the `security` bridge in `AnalyticsServicePlugin`, and `AnalyticsService.resolveReadScopes`. They propagated only because their wording happened to miss all six — so any reword, or any refusal added to that stage later, could silently turn a fail-closed gate into a `200` with no rows. + + Both now declare `READ_SCOPE_COMPILE_FAILED` / `500` — the code the sibling read-scope LOWERING stage has answered with since #5367, so the registered wire vocabulary is unchanged. Two visible consequences for a deployment whose wired `security` service cannot answer a row-level read scope: + + - the refusal reaches the caller as a declared `500` instead of relying on its phrasing to escape the degradation path; + - its message is withheld from the response body by declaration (the operator still gets the full text, at `error`, from the producing site) rather than echoed. + + Every refusal message is byte-unchanged, and #5033's leniency is untouched: a genuine absent source table still degrades to the empty result with its `warn`, and a deployment with NO security service still runs unscoped exactly as before. A guard derived from the source (`refusal-wording-collision.test.ts`) now walks every `throw` in the package and fails if an un-enveloped refusal can be read as a missing source table. +- f3b28eb: Draft-preview analytics: `avg` answers the mean of the NON-NULL operands, and `null` when there are none — matching every live face + + A dataset measure `{ aggregate: 'avg', field: 'amount' }` compiles to the cube + metric `{ type: 'avg', sql: 'amount' }`, and the draft-preview evaluator built + its operand list with `rows.map((r) => Number(r[field]))`. `Number(null)` is `0` + and `Number.isFinite` accepts it, so every NULL entered the average as a zero + OPERAND and was counted in the divisor. `AVG(col)` is defined over non-null + values in every SQL dialect, so a drafted chart showed a different number than + the published one, silently — and where a group's column was NULL in every row + the number it showed was `0`: a plausible-looking average that a reader cannot + tell from one somebody measured. + + Measured on one dataset, one row set, two `AnalyticsService` instances differing + only in `draftRowsResolver` (the live half being `NativeSQLStrategy`'s generated + SQL on a real SQLite). Rows `{meals, null}` and `{meals, null}` answered + `avg_amount` null live and `0` on preview; rows `{travel, 10}`, `{travel, 20}`, + `{travel, null}` answered 15 live and 10 on preview. Both cells now answer the + live number. + + The empty answer is READ from the platform's own ruling rather than restated + here: `emptyGroupValueFor` (`@objectstack/spec/data`) returns the identity `0` + where counting or summing nothing is a measured fact and `undefined` — spelled + `null` on this wire — where there is nothing to answer. It is the same function + `fillEmptyGroups`, `sql-driver` and `driver-turso` read, and the one #16203 cited + when it moved `min`/`max` off the same idiom in this function. + + Unchanged, and pinned by the same differential: `sum` over a group with no values + still answers the ruled identity `0`, `count` over one still answers `0` + (#16218), `min`/`max` still answer `null` (#16203), and `avg` over a group that + has values still answers its mean. `sum` and the numeric `default` arm keep their + existing operand list — `0` is the additive identity, so the coercion never moved + `sum`'s answer, and the `default` arm serves the custom-SQL metric types, which + have no live standard to be moved towards. + + The `null` fires on an EMPTY group and never on an incoherent one. "No numeric + operand" is two different situations: no row carried a value at all — the empty + group the policy rules on — or rows carried values that do not read as numbers, + such as a `date` column under `avg`. The second is an incoherent + aggregate/field-type pair that #16099 owns and no layer refuses yet; it keeps the + numeric identity it has always had, since the live face answers a different + number again (SQLite's numeric affinity over a TEXT column) and a `null` there + would invent a third answer. That boundary is pinned from both sides — by + `preview-aggregate-operand-type.test.ts` (#16203) and by a control in the new + differential. + + The live path is unchanged. + + Bumped `patch` rather than `minor`, on the same reasoning the sibling #16218 + shipped under: the package's published surface is byte-unchanged — `src/index.ts` + is not in this diff and does not re-export `preview-evaluator.ts` at all, and + `aggregate()` is module-private — and the only user-visible effect is a drafted + chart's number moving to the number the published chart already showed. A value + correcting toward the live standard is a fix, not the backwards-compatible + feature addition `minor` denotes. It is a real value change for a consumer + reading the preview response (`0` becomes blank), which is why the card was filed + separately rather than ridden along with #16203 — but the `0` it replaces was + never a number the platform promised. +- fd5cff2: Draft-preview analytics: `count` over a declared field counts its non-null values, matching every live face + + A dataset measure `{ aggregate: 'count', field: 'payer' }` compiles to the cube + metric `{ type: 'count', sql: 'payer' }`, and the draft-preview evaluator carried + that field in and never read it — it answered the ROW count, nulls included, + while every SQL face lowers the same measure to `COUNT("payer")`, defined over + non-null values. A drafted chart therefore showed a different number than the + published one, silently, and the number it showed was the one `count(*)` gives: + the author's choice to count a specific column had no effect on the preview path. + + Measured on one dataset, one row set, two `AnalyticsService` instances differing + only in `draftRowsResolver` (the live half being `NativeSQLStrategy`'s generated + SQL on a real SQLite): rows `{meals, 'bob'}` and `{meals, null}` answered + `payer_count` 1 live and 2 on preview. Both now answer 1. + + Unchanged, and pinned by the same differential: `count` with no field and `count` + with `field: '*'` still answer the row count (the compiler writes + `sql: m.field ?? '*'`, so the star is the "no field declared" spelling), and + `count_distinct` still answers a cardinality. A group in which no row carries a + value counts `0`, never null — `emptyGroupValueFor` rules counting nothing the + identity `0`. + + The live path is unchanged. + + Bumped `patch` rather than `minor`: the package's published surface is + byte-unchanged — `src/index.ts` is not in this diff, `aggregate()` is + module-private and `evaluateAnalyticsQueryOverRows` is not on the barrel — and + the only user-visible effect is a drafted chart's number moving to the number + the published chart already showed, which is a correction toward the live + standard rather than the backwards-compatible feature addition `minor` denotes. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index bc315e0c2cb..2346de30a48 100644 --- a/packages/services/service-analytics/package.json +++ b/packages/services/service-analytics/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-analytics", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Analytics Service for ObjectStack — implements IAnalyticsService with multi-driver strategy pattern (NativeSQL, ObjectQL, InMemory)", "type": "module", diff --git a/packages/services/service-automation/CHANGELOG.md b/packages/services/service-automation/CHANGELOG.md index 7bfd87c6e3f..baab3192049 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,2003 @@ # @objectstack/service-automation +## 17.5.0 + +### Minor Changes + +- 0283cb9: feat(automation)!: an edge-branched `decision` is exclusive — the first out-edge whose condition holds, in declaration order, wins; `mode: 'inclusive'` takes every one (#15429) + + + + Clause-②: yes + + **BREAKING** — the run-time semantics of a shipped node type change. A `decision` node that + declares no `config.conditions` and branches on its out-edges used to take EVERY out-edge whose + condition held, one after another, while its schema, the docs and the engine's own comment all + called it an exclusive gateway; hotcrm#1555 rendered a refusal screen AND ran the conversion in + one execution. Maintainer ruling on #15429 (2026-09-23, 「跟主流对齐」): the gateway follows + BPMN's exclusive gateway, Salesforce Flow's Decision and n8n's Switch default, and taking every + true branch is a declaration the author writes down. + + | | before | after | + |:--|:--|:--| + | two conditioned out-edges, both hold | both successors run, sequentially, nothing reported | the FIRST declared one runs; the second records a `skipped` step | + | `config: { mode: 'inclusive' }` | accepted, never read | every out-edge whose condition holds runs, sequentially | + | none holds | the `isDefault` edge runs | unchanged | + | `mode` beside a non-empty `conditions` list, or outside `'exclusive' \| 'inclusive'` | refused by a direct parse only | refused at `registerFlow` and by `os validate`, with the schema's own sentence | + + ## Migration: FROM → TO + + `os migrate meta --from 17` lists the mechanical edits for existing sources and applies them + to the migrated stack: the ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit` + writes `mode: 'inclusive'` onto every decision that has no `conditions` list and two or more + conditioned out-edges, inside ADR-0031 regions included, so a migrated flow runs exactly as it + did. + + ```ts + // FROM — every true out-edge ran + { id: 'verdict', type: 'decision', label: 'Verdict?' } + // TO — what the conversion writes; delete the key where the conditions partition + { id: 'verdict', type: 'decision', label: 'Verdict?', config: { mode: 'inclusive' } } + ``` + + Then review each written key (the paired D3 entry `flow-decision-edge-branching-first-match` + carries the acceptance criteria): delete it where the conditions partition (`== 'a'` beside + `!= 'a'`, `>` beside `<=`, a guard beside `isDefault: true`), keep it where the flow relies on + more than one branch running for one record, and where the overlap was accidental narrow the + conditions into a partition and delete the key. `os validate` reports + `flow-decision-inclusive-overlap` on every decision that keeps the key with two or more + conditioned out-edges, so the review list is the lint output. + + ## BREAKING for flows stored in `sys_metadata` — maintainer ruling letter C on #15429 + + A `decision` node stored in `sys_metadata` (a flow built or edited in the Studio designer) with + **no `config.conditions`, no `mode`, and two or more out-edges carrying a `condition`** evaluates + **first-match** after this upgrade: where it took every out-edge whose condition held, it now takes + only the first one that holds, in the order the flow declares its edges. Nothing rewrites that row + — no stored-row migration, no cutoff, no read-path completion — because nothing about a stored row + says it was saved before the flip. The one-line fix, for a node that meant every branch: + + ```ts + { id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } } + ``` + + `os migrate meta --stored` (and `POST /api/v1/meta/_migrate-stored`) lists every such node under + `decisionModeReview` — flow row, node id, label and path — on a preview and an `--apply` run + alike, and writes nothing for it: the list moves no row outcome, no count and no exit code, so an + operator can review the candidates before and after the upgrade. A node leaves the list once it + declares `mode`, either member. Every such node in the measured corpus below is a partition, where + the new meaning runs exactly what the old one did. + + Authored sources and built artifacts keep the old behaviour instead, where the source's age is a + fact: `os migrate meta --from 17` writes `mode: 'inclusive'` (above), while the authoring funnel, + the automation engine's flow rehydration seam and the artifact-ingestion door all refuse the + conversion by id — a default flip replayed there would turn a decision written today against this + contract, where an omitted `mode` means exclusive, into an inclusive gateway. + + ## Reach, measured at landing + + - Release state: the npm registry's `latest` `@objectstack/spec` is `17.4.0` (`npm view`, + 2026-09-27), whose `json-schema/automation/DecisionConfig.json` declares `conditions` only — + `mode` has not shipped; `.changeset/19867-decision-config-mode.md` and + `.changeset/20168-decision-mode-beside-conditions-refused.md` are still unconsumed in this + tree. So `mode` reaches its first release together with the traversal that reads it and the + conversion that writes it; no published accept set narrows, and the registration and + `os validate` refusals narrow nothing that shipped. + - Corpus census (this repository at the branch base and `objectstack-ai/hotcrm` at `2f7b2326`, + read-only): 30 decision nodes across 48 flows; 17 have two or more conditioned out-edges and + no `mode` (the conversion's positives — every one a hand-written partition, including + hotcrm's `lead_conversion.decision_duplicate`, the #1555 node), 13 have one conditioned + out-edge (left alone), and no node of any other type carries a conditioned out-edge, so the + exclusive traversal is scoped to `decision` with nothing else to migrate. + - What the published surface gains: the D2 conversion and its D3 entry in the protocol-18 + chain (`spec-changes.json`, the upgrade guide), `DecisionConfigSchema.mode`'s describe and + docblock now state the run-time semantics, and `@objectstack/lint` gains + `flow-decision-mode-invalid` (gating) and `flow-decision-inclusive-overlap` (advisory). + + The traversal change is scoped to `decision` nodes: conditioned out-edges of any other node + type keep the every-true-edge traversal they had (none was measured to exist). +- c8a006f: An approval `decide()` that resumes a subflow CHILD now tells the caller when that resume bubbles into a PARENT run that stranded — instead of answering full success with nothing to distinguish it from a healthy composition (#15556; the #16472 family ruling, decision batch #76, option A). + + **The composition.** A parent flow parks at a `subflow` node whose child hosts the `approval` node, so the approvals row names the CHILD run. The decision door resumes the child, the child completes, `bubbleToParent` resumes the parent, and the parent's own downstream node throws. The parent lands on the engine's `'stranded'` exit — it consumed its suspension and is now terminal, repairable only by an operator's `restoreConsumedSuspension` — and `bubbleToParent` already logged that at `error` (unchanged by this fix). What the caller was TOLD did not: `resumed: true`, no `resumeError`, and a `runId` naming the healthy child — identical to what a fully healthy composition answers. + + ``` + FROM service.decide(requestId, { decision: 'approve' }, ctx) + -> { finalized: true, decision: 'approve', runId: '', resumed: true } + // identical to a healthy composition's answer — no caller can tell + + TO service.decide(requestId, { decision: 'approve' }, ctx) + -> { finalized: true, decision: 'approve', runId: '', resumed: true, + resumeError: "RESUME_FAILED: … its own flow run '' resumed, but the " + + "subflow parent above it — run '' — consumed its suspension " + + "and is now stranded: ", + resumeFailure: { code: 'RESUME_FAILED', runId: '', status: 'stranded', repairable: true } } + ``` + + **Additive only — no migration.** `ApprovalDecisionResult.resumeFailure` was already declared (and pinned) in `@objectstack/spec` ahead of this card; this fix is the first producer that fills it. No existing field changes shape, no status code moves (the door still never throws for this shape — `AGENTS.md`'s "a failure handed to the caller" answer does not apply here, since before this fix no caller was told at all), and the door's `error` log line is untouched. A consumer that already ignores unknown fields sees no difference; a consumer that reads `resumeFailure` can now tell a bubbled parent strand from a clean resume without diffing `runId` against a durable run history. + + **What did not move, on purpose.** `RESUME_IN_PROGRESS` / `STORE_UNAVAILABLE` bubble outcomes stay the functional degradation they always were (`warn`, unreported on `resumeFailure`) — the #16472 ruling is scoped to the one exit the engine calls `'stranded'`. The sibling `recall` door (`ApprovalRecallResult.resumeFailure`, #15970) is a separate card and is not touched here. + + **New public surface — the reason for `minor` on both packages, not `patch`.** Getting the parent's strand from the engine to the approvals door without touching `packages/spec` or the wire-visible `AutomationResult` (which a raw REST `POST …/resume` also serves verbatim, so a field there would leak an undeclared key onto every subflow resume, not only an approvals-mediated one) needed a small new internal channel: + + - `@objectstack/service-automation`: `AutomationEngine` gains a new public method, `takeSubflowParentStrand(childRunId: string): SubflowParentStrand | undefined` — read-once (deletes on read), populated only by `bubbleToParent`'s `'stranded'` exit. `SubflowParentStrand` is a new exported interface (`{ runId, repairable: true, error }`). + - `@objectstack/plugin-approvals`: `ApprovalResumeSurface` (already exported from the package entry) gains a matching optional member, `takeSubflowParentStrand?(childRunId): { runId, repairable, error } | undefined`. + + Both are additive and optional; nothing existing changes shape or behaviour. Neither reaches any wire payload — `AutomationResult`, the REST resume door's response, and every other published contract are byte-for-byte unchanged. +- 2f1a6f6: A flow screen field can now express a numeric bound, help text and a lookup target — spelled with the object field's own key names + + + + `ScreenFieldConfigSchema` was `.strict` over exactly + `name`/`label`/`type`/`required`/`options`/`defaultValue`/`placeholder`/`visibleWhen`, + so three ordinary authoring intents had **no expression at all**. They did not + degrade quietly — `max`, `helpText` and every lookup-target spelling were + refused BY NAME — but a loud refusal with no landing key is still a dead end, + and the reference app worked around all three in prose: a discount ceiling + interpolated into the `label` and the `placeholder` (with a comment explaining + why there was no `max`), and a `type: 'lookup'` field whose `placeholder` asked + a human to type a record id because the picker could not be pointed anywhere. + + Four keys land, and **their names are derived from `FieldSchema`, not invented** + — one platform, one field vocabulary, so a name learned on an object field means + the same thing on a screen field: + + | Key | Derived from | | + |:---|:---|:---| + | `min` / `max` | `FieldSchema.min` / `.max` | the bound pair | + | `inlineHelpText` | `FieldSchema.inlineHelpText` | help under the input — `FieldSchema` renames `help`/`helpText`/`hint`/`tooltip` onto it, so a screen-local `helpText` would have been a second contract for one question | + | `reference` | `FieldSchema.reference` | the object a `type: 'lookup'` field picks records from | + + **The bound is enforced, not advisory.** It rides to the client on + `ScreenFieldSpec` so the user is stopped at the input, **and** + `validateScreenInputs` re-checks it when the run resumes (`min_value` / + `max_value`, both already in the ADR-0114 D2 field-error catalog — no new error + code). A screen field's declared contract is the only contract behind it, so a + bound the dialog alone applied would be bypassed by any caller posting to + `resume` directly — the gap #4477 closed for `required`. + + That sentence needs no "when the value is a number" qualifier, because the + value SHAPE is checked first: on a `type: 'number'` field a present value that + is not a finite JSON number is refused with `invalid_type` (also already in the + catalog — still no new code), ⛔ **not coerced**. Before this, a bound pass that + compares numbers was satisfied by anything that never reached it, so `"25"` + under a `max` of `20` was conformant. One member of the open `type` vocabulary + is read as a value domain; every other widget hint stays open, and a bound on a + non-numeric field still constrains nothing. + + **Delivered with its rendering, not ahead of it.** The executor forwards all + four onto the wire and the Studio designer form offers all four as repeater + columns; `builtin-node-form-zod-ledger.test.ts` reconciles the two key sets + against the Zod in both directions, so a key declared here and absent from the + form fails that test rather than shipping as a field nobody can author. + + **BREAKING** in the accept-set sense, in TWO places — landing as `minor` on + both packages because the launch-window guard (`check-changeset-no-major`) + keeps breaking changes off `major` outside pre-mode, not because the narrowing + is small. Both were ruled (maintainer ruling A′, decision batch #130 item 1, + 2026-09-13); this release is **not** purely additive. + + 1. `reference` is **required** when `type` is `lookup`, as it is on an object + field. A picker with no target object resolves nothing — ADR-0078's own + example of silently-inert metadata — and a degraded shape that ships today + is not a reason to bend the contract to it. A stored flow with a bare + `lookup` screen field parsed before and does not now. There is **no lossless + conversion**: nothing in the metadata says which object the author meant, so + this is an ADR-0087 **semantic** migration entry — a structured TODO + (`screen-field-lookup-reference-required`) that names the flow and the field + for a human to answer — and ⛔ never a D2 conversion that would have to + invent a target. + 2. A non-number submitted for a `type: 'number'` screen field is refused on + resume (`invalid_type`) instead of passing silently. A resume bag that was + accepted before can be refused now; it was never doing what its author + declared. + + Everything else is additive: the bound itself fires only on a field that + declares one, which nothing did before this release. + + The neighbouring spellings are refused **with their landing key** rather than + with a bare key list: `help`/`helpText`/`hint`/`tooltip` name `inlineHelpText`, + and `object`/`referenceTo`/`targetObject`/`lookupObject`/`relatedTo`/`target` + name `reference`. ⚠️ `object` means different things one level apart — on the + screen **node** it renames to `objectName`, on a screen **field** it can only + mean the lookup target — so it earns its own row on both. + + **One stale claim corrected in passing, because this change falsified it.** The + flows translation surface documented `help`'s exclusion as *"`ScreenFieldConfig` + declares nothing help-shaped at all"*, in `translation.zod.ts`'s guidance string + (which enumerated the old key set verbatim), its doc block, and + `i18n-resolver.ts`'s `FLOW_SCREEN_FIELD_COPY_KEYS`. The screen field now + declares `inlineHelpText`, so the copy is real. The exclusion **stands** — the + flows bundle still carries `label` and `placeholder` only, and growing that face + is a ruled step against the #7646 enumeration, not a resolver-side accretion — + but its reason is now stated as a not-yet instead of telling an author the field + has no help copy when it has. ⛔ No translation key was added and no resolver + behaviour moved. +- 2c1011b: fix(spec)!: a blank string in a flow node's predicate slot — a `decision` branch `expression`, a screen field `visibleWhen` — is refused at authoring (#17493) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on two authored flow-node slots, shipped as + `minor` under the launch-window convention (`check-changeset-no-major` refuses + `major` until GA; breaking-ness is carried by this banner and the ADR-0087 + disposition above, not by the level). + + **What changed.** A `decision` node's `config.conditions[].expression` and a + `screen` node's `config.fields[].visibleWhen` are declared bare CEL text. A string + that is blank after trimming (`''`, `' '`, a tab or a newline) used to be + accepted there by `FlowSchema.parse`, `AutomationEngine.registerFlow` and + `objectstack validate`, and was then read as "no predicate": the evaluator answers + a blank decision predicate `false`, so that branch was not taken, and nothing said + so. It is now refused at those doors — by `FlowSchema.parse` with a `custom` issue + anchored at the slot (for example `nodes.1.config.conditions.0.expression`), and + by `registerFlow` and `objectstack validate` through that same parse — with a + message that leads with the published `PREDICATE_SLOT_STRING_REFUSAL` sentence, + the one these slots already answered with for a non-string value. Where such a + value already sits, the whole flow is refused: registered from the metadata + registry or `sys_metadata` at boot, it is skipped with a + `failed to register flow` warn naming it while the flows beside it register; a + `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole + stack; an artifact file is refused whole at load. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `conditions: [{ label: 'high', expression: ' ' }]` on a `decision` node | the predicate you meant — `{ label: 'high', expression: 'record.amount > 10000' }` — or, to keep what the blank did, `expression: 'false'` | + | `fields: [{ name: 'reason', visibleWhen: '' }]` on a `screen` node | the predicate you meant — `visibleWhen: "status == 'rejected'"` — or, to keep what the blank did, drop the `visibleWhen` key | + + **One-line fix:** write the predicate, or keep what the blank did — `'false'` on + a decision branch (the value the blank evaluated to), no `visibleWhen` on a + screen field (a blank one was read as absent). ⚠️ Do not drop a decision's only + branch: the node then routes by its out-edges alone, and the out-edge that branch + labelled is no longer held back. A blank structural `condition` is another case — + see the `flow-edge-condition-evaluated-slot-source-required` migration entry. + + **Unchanged.** A non-blank predicate parses, registers and validates as before; + a non-string in these slots keeps its existing refusal at `registerFlow` and + `objectstack validate`; `edges[].condition` and a node's `config.condition` keep + their own rule and sentence (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`); and + `AutomationEngine.evaluateCondition` still answers a blank predicate `false` for + a caller that reaches it directly. The `PREDICATE_SLOT_STRING_REFUSAL` constant + keeps its name and now also names the blank string, so code matching the + constant rather than a copy of its text is unaffected. +- cb1f274: fix(automation): a `wait` node must say what resumes it — the config block is required at the contract, and the executor stops defaulting to a duration-less timer (#17928) + + **BREAKING** — a `type: 'wait'` flow node with no `waitEventConfig` block, and a + `type: 'boundary_event'` node with no `boundaryConfig` block, no longer parse. + Under `eventType: 'timer'`, `timerDuration` is now required and may not be blank + — and that half sits on the `waitEventConfig` BLOCK, not on the node type, so it + bites on ANY node carrying the block: a `start` node spelled + `waitEventConfig: { eventType: 'timer' }` parsed before and is refused now. It is + still a narrowing in every direction (no shape starts parsing that did not), and + the block is inert on a node type no executor reads it from, so the practical + reach is `wait`. + + `eventType` has been required *inside* each block since protocol 17, so + `waitEventConfig: {}` was already a loud parse error. The block itself was + optional — so "omit the key" and "omit the block" were two documents with two + verdicts, and the accepted one was the silent one. It is also the state a + freshly created node is in, which is what made it reachable from a designer's + default screen rather than only by hand-authoring. + + What that document did, measured through a real `engine.execute()` run rather + than read off the source: + + ``` + FROM { id: 'pause', type: 'wait', label: 'Wait' } // parses clean + -> { success: true, suspend: true } // run status: paused + scheduled jobs: [] <- with a job service ANSWERING + variables: no `pause.waitUntil` <- cold boot cannot re-arm + log lines: 0 at any level <- warn, error, info, debug + + TO FlowNodeSchema.safeParse(...) + -> { success: false, + issues: [{ code: 'custom', path: ['waitEventConfig'], + message: 'a `wait` node requires a `waitEventConfig` block saying + what resumes it … `waitEventConfig: { eventType: 'timer', + timerDuration: 'PT1H' }` … or `{ eventType: 'signal', + signalName: 'order_paid' }` …' }] } + ``` + + The control — the same node with `{ eventType: 'timer', timerDuration: 'PT1H' }` + — armed the one-shot job and persisted the deadline, so the zeros above are a + reading of this path and not of a dead harness. + + **The executor follows the contract.** `wait-node.ts` carried + `(node.waitEventConfig ?? {})` and `String(wec.eventType ?? 'timer')` under a + comment declaring the second one deliberate — "a wait node without one is a + VALID TIMER WAIT". Both fallbacks are retired. A node that still reaches + `execute` without the block (a stored pre-migration document on a path that + skipped the parse) is now a **guard refusal** — `errorClass: 'guard'`, so a + `fault` edge cannot route a metadata defect into a handler that reports success + — and it **logs**, naming the node and the remedy, because the defect being + closed was silence. It never suspends with `success: true` again. Two smaller + corrections ride along in the same return: the timer branch stops answering + `output` as a present key holding `undefined` (it is absent when no deadline was + computed), and the reversed comment is deleted rather than left describing a + behaviour that is gone. + + **`screen.mode` now declares the default the executor applies; `http.method` + still declares none.** Both were read by running the executors with the key + absent, not by reading the Zod: + + | key | absent ⇒ the runtime applies | declared | + | --- | --- | --- | + | `ScreenConfig.mode` | `'create'` (object-form branch; the flat `fields` branch never reads it) | `.default('create')` | + | `HttpConfig.method` | `GET` inline, **`POST`** when `durable: true` | ⛔ none — two values, no single default | + + Declaring `.default('GET')` on `method` would materialise `GET` at parse time, + the durable arm's own `?? 'POST'` would never fire again, and every stored + durable callout that omits the method would silently change verb. That is the + defect this card exists to end, pointed the other way. + + **Migration.** A stored `wait` node with no block has no lossless conversion — + the missing value is an intent no artifact records, and the old runtime's pick + (`'timer'` with no duration) was not a wait at all — so this is an ADR-0087 D3 + semantic entry rather than a D2 conversion: `os migrate meta --from 17` names + each node to edit. Declare the resume condition and re-publish the flow. ⚠️ + Behaviour the fix deliberately changes: a run that used to park forever now + waits the duration you declare or the signal you name. + + **`boundary_event` gets the contract half only.** The runtime registers no + executor for that node type at all — a flow reaching one fails with + `NO_EXECUTOR` before any config is read, identically whether the block is + present or absent — so there is no silent executor branch behind it. The + refusal fixes the authoring surface; `try_catch` (ADR-0031) remains the native + construct for error handling. + + +- 5762eaf: fix(service-automation): on the synchronous path, a child run that REFUSES stops its parent, in `subflow` and in `map` alike (#18110, #18555) + + **Clause-②: yes (widening)** — `NodeExecutionResult` is barrel-exported from this package's single entry point, and it gains two new optional members. Nothing previously accepted is refused and nothing is retired, so this is a widening of the published executor contract, not a narrowing. Contract-review tier. + + A child flow that runs to completion in one go and ends on an `end` node declaring `outcome: 'refused'` used to roll up to its parent as an ordinary success. `subflow-node.ts` branched only on `child.status === 'paused'` and `!child.success`; a refused child is neither (`{ success: true, status: 'refused' }` — *a refusal is a successful evaluation that says no*), so it fell through the success exit. The parent walked the node's out-edges, recorded `completed` and fired its **own** `successMessage` over the child's refusal — the author got the exact opposite of what they wrote, fail-open. `map-node.ts` had the identical branch set and the identical hole: a refusing row let every row after it through. + + - **New on `NodeExecutionResult`: `refuse?: boolean` and `refusalMessage?: string`.** The executor-facing half of the unwinding protocol `suspend?: boolean` already uses. A node that sets `refuse` terminates its run as `refused` — a terminal status this package has published since #15788, so **no new status value** and nothing authorable changes. + - **`subflow` and `map` both set it** when their child run returns `status: 'refused'`. One channel, two call sites. + - **The child's `selected` / `acted` / `unmeasuredEffect` rollup (#4354) survives the refusal**, because the engine throws the refusal signal from the same position it throws the suspend signal: after the node's success step is pushed, after its `childSteps` are folded and after its output is written back. A child that refused really can have written rows before it said no. + - ⛔ **A refusal is still not a failure.** It does not consume retry budget, is not routable by a `fault` edge, and is not counted in `nodes[].failures`. + - **Region-boundary diagnostic, text only**: the message a structured region raises when a refusal tries to cross it now names whichever node carried the refusal, instead of asserting it was an `end` node — which, for a refusing `subflow`/`map` inside a region, sent the author looking for a node that was not in their region. Region **semantics** are unchanged. + + **Scope — the RESUMED leg is not covered.** This fixes the path where the child run finishes inside the parent's own `engine.execute` call and its outcome is read from that return value. A child that durably PAUSES first — a nested `approval` / `screen` / `wait` — and only refuses when it is later resumed still reaches its parent through the resume machinery, which reads the child's outcome at different seams and does not consult `status: 'refused'` at any of them. Both of those seams pre-date this change and neither is a regression of it, but neither is closed by it either, and the resumed leg is the one a screen flow actually takes. A follow-up card covers it: #18714. + + For third-party node executors this is additive: an executor that never sets `refuse` behaves exactly as before. +- 99fcb4a: `FlowRuntimeState` now declares `reason` — the optional sentence saying WHY a flow is not armed — and the automation engine populates it, so `GET /automation/_status` can tell a policy-disabled flow apart from a broken binding (#18235). + + Ruling G item 6 on #17396 names three surfaces that must each carry a DISTINCT reason for a flow left unarmed because package-authored scheduled work is switched off, and must never read as "binding failed". Two of them shipped: `getTriggerBindingAudit()` and the CLI startup summary. The third — a console — could not be built: Studio's only status door answers `FlowRuntimeState` rows, and that shape had no field a reason could travel in, so on the wire a policy-disabled flow was `enabled: true, bound: false, triggerType: 'schedule'`, byte-identical to one whose trigger is missing. + + **Clause-②: yes (widening)** — one new key on an already-published payload, so the shape a consumer reads against grows. Nothing previously emitted is removed or renamed, and no producer is required to write it. + + - **Optional, and additive by measurement.** Every producer of these rows — the engine, and the test doubles in `packages/runtime`, `packages/cli` and `packages/qa/dogfood` — writes `{ name, enabled, bound }` at minimum; a required key would have broken all of them and would demand a reason from rows that have none. The key is absent (not `undefined`-valued) on any row that is bound, disabled, or declares no trigger. + - **One vocabulary, not a new one.** The sentence is the one `getTriggerBindingAudit()` already answers for the same flow: both doors now read a single private `describeUnboundReason()` on the engine, so Studio and the boot summary cannot drift. A free-form string, matching the two surfaces that already carry this reason; ⛔ consumers render it, they do not parse it. + - **Read from the RECORD, never re-derived.** The policy sentence comes from the engine's recorded refusal (`policyDisabledFlows`, cleared the moment a flow gets past the gate), never from a live `resolveScheduledWorkPolicy()` read at call time. `_status` is served on demand, arbitrarily long after the bind — re-deriving would report a binding failure for a trigger that was never called, the defect the implementing round of #17396 already caught once. + - **Wire, not rendering.** `SCHEDULED_WORK_DISABLED_REASON`'s docblock is corrected: Studio's door now carries the reason, while displaying it distinctly remains objectui#9217's card. Declared is not delivered, and reaching the wire is not being shown. The published prose carrying the same claim moves with it — `content/docs/automation/flows.mdx`'s callout said the status door "has no field to say why", which this change makes false; both carriers are corrected in one landing, and neither now claims a console *renders* it. +- b929e0a: feat(connectors): a connector's declared `retryConfig` and `requestTimeoutMs` are executed, not just parsed (#18975) + + Clause-②: yes (widening) + + `ConnectorSchema.retryConfig` (eight sub-keys) and the two timeouts beside it + parsed, stored, and reached nothing. An author who wrote a retry policy — the + one `packages/spec/docs/SYNC_ARCHITECTURE.md` points at for a rate-limited + upstream, whose `retryableStatusCodes` default includes `429` — got + configuration that looked applied and did nothing, with no error and no + warning. ADR-0049 owed these keys a decision and ruled **implement**. + + **Where it landed: one wrapper, not a gateway.** `resilientFetch` + (`@objectstack/spec/shared`) already was the platform's outbound-HTTP call for + connectors — it gave every attempt a 30s timeout and a fixed exponential + backoff. What it could not express was the declared policy, so it gains exactly + the knobs that were missing (`strategy`, `backoffMultiplier`, `maxDelayMs`, + `jitter`, `retryOnNetworkError`), each defaulting to the behaviour it already + had. One new function, `connectorFetchOptions()` + (`@objectstack/spec/integration`), is the single mapping from a connector's + declared policy onto those options — one execution site, not one per connector + package. + + **How the authored value gets there.** `ConnectorProviderContext` gains + `retryConfig` and `requestTimeoutMs`, read-only and + resolved from the entry (the automation service parses `retryConfig` so a + factory reads real values instead of re-deriving the schema's defaults), so a + custom provider that does its own I/O can honour them. The built-in HTTP + providers — `rest` and `openapi` — honour them by construction. + + What an author now gets from each key: `strategy` picks the growth shape + (`exponential_backoff` / `linear_backoff` / `fixed_delay` / `no_retry`); + `maxAttempts` bounds the calls (it counts TOTAL attempts with the first + included, the contrast `content/docs/automation/flows.mdx` already draws against + `maxRetries`, and `maxAttempts: 0` still makes the one call and never retries); + `initialDelayMs` and `backoffMultiplier` shape the delay; `maxDelayMs` caps it, + applied after jitter so the declared ceiling is a real one — and an upstream + `Retry-After` longer than that ceiling ends the retry loop and returns the + response, rather than sleeping past a maximum the author declared; + `retryableStatusCodes` both widens and narrows what is retried; + `retryOnNetworkError` governs a thrown attempt; `jitter` can now be turned off; + `requestTimeoutMs` becomes the per-attempt deadline. + + **Two behaviour changes to know about.** A connector that declares a policy now + retries per that policy where it previously did not retry at all — that is the + fix, and a connector that declares none is on exactly its prior behaviour. + Separately, `connector-openapi`'s generated actions went through a naked + `fetch`: unbounded, never retried, and the one built-in HTTP path an authored + policy could never reach. They now go through the same wrapper as + `connector-rest` and `connector-slack`, which gives them the 30s per-attempt + timeout and bounded retry those two already had. + + **⚠️ `connectionTimeoutMs` is NOT made live, deliberately, and is the one thing + the ruling assumed that measurement refused.** A connector's call is a WHATWG + `fetch`, whose only cancellation surface is one `AbortSignal` over the whole + operation; nothing in that interface observes the connection phase separately. + Bounding time-to-response with it would kill a slow-but-connected upstream the + author meant to allow with a large `requestTimeoutMs` — breaking the very + promise the key makes. So this change leaves it unenforced, with the reason + recorded at the mapping and in `packages/spec/liveness/connector.json`, whose + row for it stays `dead`. That left it owed a second, narrower ADR-0049 + decision, and this same release takes it: `connector.connectionTimeoutMs` is + **retired**, and its own entry in this release says what to write instead. The + key never reaches `ConnectorProviderContext` in any release. + + Nine of the ten ledger rows flip `dead` → `live` with the consumer site named; + the tenth is `connectionTimeoutMs`, above. This change itself moves no + declaration: it leaves every key, every bound and every default on the + connector schema as it found them. +- 0b4022b: feat(automation): `GET /automation/:name/runs` retires `cursor` and computes `hasMore` (#19543) + + This door declared a pagination parameter it never spent and then reported, as a + literal, that there was nothing more to fetch. Both halves are closed here, per + the maintainer-approved ruling of 2026-09-21 (decision batch #204 item 2, + letter C of three). + + **BREAKING** — `cursor` no longer parses on `ListRunsRequestSchema`, its slot + is gone from `IAutomationService.listRuns`, and `@objectstack/client` no longer + declares or sends it on any of the three run-list surfaces + (`automation.runs.list`, `automation.listRuns`, + `client.environment(id).automation.listRuns`). It was declared on the wire, + *validated* at the boundary, forwarded into the service contract, appended by + the SDK, and read by no implementation. No emit site has ever written the + response half `nextCursor`, and the only ordering this door has is a required + but non-unique `startedAt` timestamp that nothing ever minted a resume point + from — so a caller looping "until the cursor runs out" re-read the first and + only window forever, with no error. + + ``` + FROM ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' }) + -> { name: 'f', limit: 20, cursor: 'n_007' } // forwarded, then dropped + + TO ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' }) + -> throws: '`cursor` was removed from GET /api/v1/automation/:name/runs in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) …' + ``` + + `cursor` is a `retiredKey()` tombstone rather than a deletion: the request + schema is not `.strict()`, so a bare deletion would have made Zod silently strip + whatever a generated client kept sending — a clean parse and a parameter that + never takes effect, which is this defect re-created one layer down (ADR-0104). + Writing the key is now a `tsc` error and a parse error carrying the + prescription. + + **The SDK is retired in the same stroke, and that is what makes the sentence + above true.** Retiring the key in the schema alone would have left the one + generated client this repo ships typing it `string` and sending it into a route + that no longer reads it — the exact ADR-0104 shape the tombstone exists to + prevent, re-created one layer down, for the channel most callers actually reach + this door through. So the option is gone from all three surfaces and no + `?cursor=` is appended on any of them; an untyped caller cannot smuggle it past + the retired schema either, which is pinned. Same call as when #6361 retired the + notifications `cursor`: the client dropped the option and recorded the removal + in its docblock. + + ``` + FROM client.automation.runs.list('f', { limit: 5, cursor: 'abc' }) + -> GET …/automation/f/runs?limit=5&cursor=abc // the key is dropped server-side + + TO client.automation.runs.list('f', { limit: 5 }) + -> GET …/automation/f/runs?limit=5 + // `{ cursor }` is now a TS2353 excess-property error; widen `limit` + // (1..100) and read `hasMore` instead. + ``` + + **⛔ `limit` is NOT retired, and its `.default(20)` stays.** The sibling + `/packages` door retired *its* `limit` alongside `cursor` (#17667) because + nothing read it. That does not transfer, and the ruling says so explicitly: here + `limit` is read end to end — the HTTP boundary enforces the declared `1..100` + range read off the schema itself, the service takes it as an option, and the + engine spends it as the run store's history window. Retiring it would have been + a regression, not a narrowing. + + **`hasMore` is now computed, and this is a behaviour change callers can see.** + The door shipped `{ runs, hasMore: false }` with the `false` written as a + literal, beside a list the engine had already cut with `.slice(0, limit)`. A + caller asking for one row of a thousand was handed one row and told that was all + of them. A request whose window is shorter than the matching run set now + receives `hasMore: true` where it previously received `false`; a caller that + read `false` as "this is the whole history" was always wrong and is now told so. + `nextCursor` stays absent — nothing mints one. + + Read the new `false` with **one qualification**: unfiltered it is exact, but + under `?status=` it means "no further match inside the window that was scanned" + rather than "none exists", because the durable history source has no status slot + and the window is taken before the filter is applied. Pushing the filter down is + a `RunStore` contract change this card did not scope. The published + `RunListResult.hasMore` docblock and the response schema's own description both + carry that qualification, so a consumer meets it where they meet the field. + + **How truncation is established, because the obvious signal is wrong.** + `runs.length === limit` cannot tell a flow holding exactly `limit` runs from one + holding ten thousand; the two windows are byte-identical. So + `AutomationEngine` over-reads its history source by exactly one row and compares + the merged, filtered, ordered set against the caller's window. + `RunStore.listHistory`'s signature is deliberately unchanged — over-reading is + expressible in the `limit` it already takes. + + **New:** `IAutomationService.listRunsPage`, an optional member returning + `{ runs, hasMore }` (the shape `IExportService.listExportJobs` already uses, + minus the cursor nothing mints), plus the exported `RunListResult`. The engine + implements it and `listRuns` is its `runs` half, so there is one implementation + and no second copy to rot. A deployment whose automation service does not + implement it answers `501` naming the member, never a `200` carrying a guessed + `hasMore`. + + **One strictness regression, stated because it reverses a recorded decision.** + `?cursor=a&cursor=b` used to answer `400 VALIDATION_FAILED` and now answers + `200` with the key ignored, like any other unrecognised query name. #7300 + validated the key rather than deciding it, so that a future cursor + implementation would not be the one to discover the type was unenforced; this + ruling decides it instead — there will be no cursor implementation on this + door — so the refusal would be validating a key the contract no longer has. + This route declares no closed query-parameter set, so an unrecognised name has + never been refused here on its own account. + + Clause-②: yes + + +- 863a775: A kernel can now carry its own scheduled-work policy. `AutomationEngineOptions`, `AutomationServicePluginOptions` (forwarded to the engine), `ScheduleTriggerPlugin` and `TimeRelativeTriggerPlugin` accept an optional `scheduledWorkPolicy`: a `ScheduledWorkPolicy` value, or a resolver called at each bind. When it is present, the engine's bind gate and each trigger's own gate read it instead of the deployment resolver. When it is absent, they call the zero-argument `resolveScheduledWorkPolicy()` exactly as before. + + It exists for a host that runs several kernels of different plans in one process. `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` is one reading for the whole process, so such a host could not turn scheduled work off for one kernel and leave it on for the kernel beside it: + + ```ts + const policy = { enabled: false, posture: 'single', requiresActingOrganization: false, runOwnership: 'unscoped' } as const; + kernel.use(new AutomationServicePlugin({ scheduledWorkPolicy: policy })); + kernel.use(new ScheduleTriggerPlugin({ scheduledWorkPolicy: policy })); + kernel.use(new TimeRelativeTriggerPlugin({ scheduledWorkPolicy: policy })); + ``` + + Give all three the same policy. The engine gates first, and each trigger keeps its own gate for hosts that drive it without the engine. If a trigger reads a different answer from its engine, the engine's audit reports that trigger's refusal as a binding failure. A hand-built value must keep the resolver's invariant, `requiresActingOrganization === (enabled && runOwnership === 'declared')`. + + A time-triggered flow that the per-kernel policy leaves unarmed is reported the same way as one the deployment leaves unarmed. `getTriggerBindingAudit()` and the `getFlowRuntimeStates()` row both give `SCHEDULED_WORK_DISABLED_REASON`, never a binding failure and never "add `requires: ['triggers']`". `ScheduleTrigger` and `TimeRelativeTrigger` also accept the same option in a new trailing constructor argument. The types `ScheduleTriggerPluginOptions`, `TimeRelativeTriggerPluginOptions`, `ScheduledWorkTriggerOptions` and `ScheduledWorkPolicySource` are exported from `@objectstack/trigger-schedule`. + + Nothing changes for a host that passes no policy. The deployment default keeps its meaning and its spelling, and `objectstack serve` is unchanged. This change only adds options, so there is nothing to migrate. + + Clause-②: no +- e462186: `create_record` / `update_record` field values accept the CEL value envelope, declared and evaluated together. + + A value in a `create_record` or `update_record` node's `fields` map may now be a CEL value envelope, `{ dialect: 'cel', source: '…' }`, with the same shape and dialect rules the `assignment` node's `assignments` map already has. The envelope is evaluated by the expression engine that flow conditions use, so the whole CEL stdlib is reachable from a field value, and the result is written with its type kept: + + ```ts + fields: { + subject: 'Quote for {account.name}', // `{token}` template — unchanged + total: { dialect: 'cel', source: 'round(amount * 100.0) / 100.0' }, // CEL, evaluated to the value written + } + ``` + + Clause-②: yes (widening) — a published authoring slot's accept set grows (a valid envelope in `fields.*` is newly evaluated), and the one newly refused shape is the edge the `assignments` map accepted when it gained the envelope: a malformed one. + + **What newly passes.** A valid CEL value envelope as a top-level `fields` value, on both nodes. Before this release the executor wrote such an object into the record verbatim: a text or JSON column stored `{"dialect":"cel","source":"…"}` and the run reported success, and a number column was refused by the data engine. + + **What newly refuses.** A top-level `fields` value that is a plain object with a string `dialect` key and is NOT a valid CEL value envelope. That covers a missing, empty or whitespace-only `source`, an `ast` with no `source`, a `template` or `cron` dialect, and a `source` that does not parse as CEL. Every door refuses it, located at `config.fields.`: `AutomationEngine.registerFlow` refuses the flow, `objectstack validate` reports an `expression-invalid` error, the runtime publish gate answers `422 INVALID_METADATA`, and the node's execute-time contract parse refuses it. Such an object used to be written as data. + + **The rule for nested and literal values.** Only the top-level value of each field is judged. An object nested inside a JSON value or an array is data, whatever keys it carries, and strings inside it still interpolate. A plain string is always a `{token}` template with its existing meaning, and every other literal is written as before. A JSON column whose intended literal value is itself an object with a string `dialect` key is now read as an envelope. To write such an object as data, bind it to a flow variable and write `'{thatVariable}'` (a sole token keeps its type). Measured: no flow in this repository or in HotCRM writes an envelope-shaped object into `fields`. + + **The refusal sentence is slot-neutral.** A refused field value used to be told it was "an assignment value". The sentence every value-slot refusal leads with is now `VALUE_ENVELOPE_REFUSAL`: "A value carrying a `dialect` key is read as an expression envelope, and this one is not a valid CEL value envelope." The published `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` is kept and is the same string, so code that matches on the constant keeps matching. Code that matched the old literal text ("An assignment value carrying…") does not. + + **New in `@objectstack/spec/automation`** (5 exports, 0 removed): + + - `VALUE_ENVELOPE_REFUSAL`, the slot-neutral refusal sentence. + - `FlowValueSlotSchema` / `FlowValueSlot` / `FlowValueSlotParsed`, the value contract every value slot shares (`AssignmentValueSchema` is the same rule under the assignment map's description). + - `resolveFlowNodeValueSlots(nodeType, config)`, which returns every authored value in the ledger's value slots, strings included. + - The expression ledger `FLOW_NODE_EXPRESSION_PATHS` has two new rows, `create_record.fields.*` and `update_record.fields.*` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries both CRUD contracts. + + **Author-time hint (`@objectstack/lint`).** `objectstack validate` warns when a value slot holds a `{…}` template expression, meaning arithmetic or a call to `round` / `floor` / `ceil` / `abs` / `min` / `max`, and points it at the envelope. The warning never fails a build, and the template form keeps working unchanged. Plain references, the `NOW()` / `TODAY()` macros and `$User` paths are not hinted. CEL's `now()` / `today()` are timestamps rather than the strings those macros write, and the flow's CEL scope binds no user. + + **Corrected guidance: `/ 100.0`, not `/ 100`.** The template dialect's `round()` arity refusal used to call `round(x * 100) / 100` the CEL authoring pattern. In CEL that expression truncates: `round()` returns an int, and int / int is integer division, so `x = 1234.5678` gives `1234` instead of `1234.57`. The refusal now prescribes `round(x * 100) / 100.0`, which is correct in both dialects. In the template dialect `/ 100` and `/ 100.0` give the same value. +- 487a784: fix(trigger-api,service-automation): an `api` flow with no per-flow secret is refused, at arm time and at registration (#20529) + + Clause-②: no (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner and the ADR-0087 disposition below, never by the level). + + ADR-0041's `trigger-api` acceptance criteria name a per-flow secret and HMAC + signature verification. The trigger used to arm a flow's inbound hook without a + secret, with only a warning, and that hook skipped signature verification. An + `api` flow whose start node carries no non-blank `config.secret` is now refused + in two places: + + - **At registration** (`@objectstack/service-automation`). `registerFlow` refuses + a flow whose binding resolves to the `api` trigger (`type: 'api'`, or a start + node with `triggerType: 'api'`) when the start node declares no non-blank + `config.secret`, whatever the flow's `status`. The error names the flow and + `config.secret`. The `/automation` create, update and clone doors answer it as + `400 VALIDATION_FAILED`, like every other registration refusal. At boot the + flow is skipped and the existing `[Automation] failed to register flow` warning + names it. + - **At arm time** (`@objectstack/trigger-api`). `ApiTrigger.start()` throws, + naming the flow and `config.secret`, before it stores a hook or subscribes a + queue consumer. The engine logs `Failed to bind flow` and the flow stays + unbound. This covers a host that binds the trigger without the engine. The + arm-time `armed WITHOUT a secret` warning is gone, since that state no longer + exists. Every armed hook verifies the signature on every post. + + **Fix.** Give the flow's start node a non-blank `config.secret` and sign each + post with it, as the `x-objectstack-signature` header already documents. A flow + that is only ever started explicitly (`engine.execute()`, or the `/automation` + trigger route) and is not meant to receive inbound posts is an `autolaunched` + flow. Declare it `type: 'autolaunched'`, with no `triggerType: 'api'` on its + start node, and it needs no secret. + + Unchanged: a flow that already carries a secret registers, arms and verifies + exactly as before. + + +- 92865f6: fix(service-automation)!: a whitespace-only `config.condition` is refused at `registerFlow`, the rule the edge door has carried since #15807 (#17322) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition): a flow + node's `config.condition` — a `decision` node's predicate, and on a `start` node + the **trigger gate** — is now refused at `registerFlow` when its source is blank + after trimming, where it used to register clean and answer a **silent `false`** + at every evaluation. + + Two doors, the same authored value, two fates until now. `FlowEdgeSchema.condition` + composes `EvaluatedExpressionInputSchema` (#15807), so `' '` on an edge is + refused at `FlowSchema.parse`, by name. A node's `config` is an open + `z.record(z.string(), z.unknown())`, so the same value passed through verbatim, + reached `AutomationEngine.evaluateCondition`'s empty-source arm — `exprStr.trim() + === ''` — and returned `false`, under a comment that names that arm as being for + an **unauthored** condition. `' '` was authored. The branch never ran, forever, + with nothing said at any layer. + + ```yaml + nodes: + - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: ' ' } } # the flow was gated shut + - { id: branch, type: decision, config: { condition: { dialect: cel, source: ' ' } } } # the same blank, through the envelope key + ``` + + > An expression in an evaluated slot needs a non-blank `source`: the expression + > engine evaluates `source` (the canonical persisted form) and + > cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` + > that is blank after trimming, would validate and register and then fault at + > run time. Write `{ dialect: 'cel', source: '…' }`. + + - **The rule is imported, not re-derived.** `registerFlow`'s structural pass runs + the condition's source through `EvaluatedExpressionInputSchema` itself, so the + node door and the edge door cannot drift into two notions of "blank" or two + sentences for it — the property the #15662 campaign built the shared refusal + for. Nothing is exported from this package to carry it, and no new export was + added. + - **Applied to the SOURCE, not to the whole value**, deliberately: the union + would also refuse an envelope with no `dialect` or with a dialect outside its + enum, and this slot admits both (`structuralConditionRefusal`'s docblock, + #4336). The narrowing is exactly the blank population and nothing else — a + `cron` envelope with a real source still earns its own pre-existing verdict, + and a bare string with a `{…}` brace trap still earns #1491's. + - **`evaluateCondition` is unchanged and still answers `false`.** It is the + shared evaluator and a public method on an exported class, so its throw + behaviour is itself a contract; and a stored flow reaches it whatever the + producer refuses. This change is at the producer only. + - **`structuralConditionRefusal` is unchanged.** A string is still a well-shaped + condition; the new refusal sits behind the shape one and in front of the CEL + one, and answers the evaluated-slot sentence rather than + `STRUCTURAL_CONDITION_SHAPE_REFUSAL`. + + **What an author does with a refused condition.** A whitespace-only condition was + never a predicate — the engine answered `false`, so the branch never fired, and on + a `start` node the flow never triggered. **Remove the `condition` key** if the node + was meant to be unconditional, or **write the expression** if it was meant to + branch. ⚠️ Those two are not interchangeable: a refused condition never fired, + while an absent `condition` on a decision node is an unconditional branch that + always fires and an absent one on a start node is a gate that always opens. + Deleting the key to clear the refusal inverts the node rather than preserving it. + Every condition with a non-blank source is unchanged, and nothing is renamed or + retired. + + **A flow ALREADY STORED in `sys_metadata` stops running entirely — the whole flow, + not just the branch.** Stored flows are deliberately not canonicalized by + `applyConversionsToStoredItem` (`spec/src/conversions/stored.ts`, and the same + skip in `metadata/src/loaders/database-loader.ts`'s `rowToData`); they canonicalize + at `registerFlow`, and each of the three boot paths in + `service-automation/src/plugin.ts` wraps that call in `try`/`catch`, logs one + `warn` naming the flow, and continues. So a node condition that used to answer a + silent `false` while the rest of the flow ran now takes the flow down with it: it + is never registered, its trigger is never armed, and the announcement is that one + warn line — `[Automation] failed to register flow` at boot, `[Automation] + cold-boot flow bind: failed to register flow` at the kernel:ready bind, + `[Automation] flow re-sync: failed to register flow` on a re-sync. The warn line + is also the locator: the refusal names the node and the slot, e.g. `node 'gate' + (start) condition`. A stack authored in config files has a second door, + `objectstack validate` — see the note below for what that door does **not** yet + say. + + **A repo-wide census on this branch found zero authored `config.condition` values + of this shape**, against a lit control: a textual probe over all 8,123 tracked + source files found **461** non-blank `condition:` string literals and **zero** + blank-after-trim ones in any authored flow (the four blank hits are two prose + examples inside #15807's own changeset and two `packages/lint` test fixtures). + There is nothing in this repository to rewrite. + + ⚠️ **Two follow-ups this change does not carry, both outside this card's package.** + (1) The ADR-0087 D3 entry named above, + `flow-edge-condition-evaluated-slot-source-required`, registers the decision this + change is a second face of — an evaluated slot requires a non-blank `source` — but + its `surface` and `acceptanceCriteria` name only `edges[].condition`. They need + widening to `config.condition` so a consumer replaying the chain is told to sweep + the node key too; that file is in `packages/spec`. + (2) `@objectstack/lint`'s `validate-expressions` applies only + `structuralConditionRefusal` to a structural condition, so `objectstack validate` + still reports nothing for a blank `config.condition` that `registerFlow` now + refuses — the two doors disagree until that rule is rebound as well. +- 9540590: `restoreConsumedSuspension` reaches a NESTED run: the ancestors a stranded descendant cascade-failed are journalled too, and the chain is re-armed as one unit + + `resumeInternal`'s catch arm journalled the consumed suspension of the run that + threw, and nothing else. For a nested run the ancestors were handled on both + paths with no journal at all: up-bubble (`failAncestors` walks `$parentRunId` + and calls `failSuspendedRun` on each suspended ancestor) and delegation (the + parent frame sees a failed child with no retryable code and calls + `failSuspendedRun` on itself). `failSuspendedRun` was `forgetSuspendedRun(run, + 'failed')` plus a `failed` log record — it journalled nothing. + + So the leaf was restorable while every ancestor was recorded `failed` with its + pause consumed and no snapshot (`restoreConsumedSuspension(PARENT)` answered + `NO_CONSUMED_SUSPENSION`), and restoring the leaf completed it into a parent + that never continues: `bubbleToParent` found no parent suspension and logged. + The operator ended up worse off than before using the exit. + + `failSuspendedRun` now journals the pause it consumes whenever the descendant + whose failure consumed it is itself repairable — from the same single producer + and onto the same durable terminal row as the strand's own snapshot, so the + chain is repairable from any replica and after a restart, not only from the + process that stranded it. `restoreConsumedSuspension` then repairs the chain as + one unit: it walks down to the stranded descendant and up through the ancestors + it cascaded into, and re-arms every member DEEPEST FIRST, so an ancestor becomes + resumable only after the run it is parked awaiting is parked again. The entry + point does not matter — naming any member of the chain repairs all of it — and + the continuation is then re-issued once, on the run that was named. + + Additive on the wire and in the type: the result's existing fields still + describe the run the caller named, and the new `chain` key is present only when + the repair was a chain repair. `ChainRestoreEntry` is exported for it. The + narrower `IAutomationService.restoreConsumedSuspension` contract in + `@objectstack/spec` is unchanged and the HTTP door's payload is unchanged — the + door answers `{ runId, restored, reason }` as it always did. + + Every member goes through the same per-run call as a flat restore — its own + in-process claim, its own strict live-suspension read, its own two-witness read, + its own durable park — so idempotence and the #14333 advance claim hold per run + in the chain: a second restore finds every member parked and answers + `RUN_SUSPENDED` without minting a second pause anywhere. + + ⛔ No ancestor is stamped `'stranded'`. That word is the resume result of a run + that consumed its OWN pause and then threw downstream, and nothing re-arms an + ancestor by resuming it; stamping it would send an operator to retry a recovery + that cannot succeed. The parent frame's delegation result still carries no + status at all, and an ancestor's repairability is carried by the journal and by + this verb's answer. + + Journalling is EARNED, not applied to every cascade: an ancestor whose + descendant is beyond repair is still consumed without a snapshot, because + re-arming it would promise a chain repair that could not be completed. + + **`@objectstack/plugin-approvals`** reports the consequence rather than causing + it: `inspectStrandedRequests` asks the engine per run, so a cascade-failed + ancestor whose descendant is repairable now comes back `runState: + 'repairable'` instead of `'unrepairable'`, and restoring either row repairs the + pair. `'unrepairable'` keeps its other causes — a run that never paused, a + snapshot no longer held, and a cascade whose descendant was itself beyond + repair. No plugin logic changed; the docblocks that documented the old + limitation did. +- 775e5ec: A run's durable history row records the terminal status the run actually reached — `completed`, `failed`, `cancelled` or `timed_out` — instead of folding all four into two. A restart no longer changes a run's answer. + + `RunRecord.status` declared two members (`'completed' | 'failed'`) while `AutomationEngine.recordLog`'s own terminal predicate admitted four and `ExecutionStatus` (`@objectstack/spec`) has declared them all along. Both ends of the store folded to match the narrower declaration: the write mapped everything that was not `completed` to `failed`, and the read mapped everything that was not `failed` back to `completed`. The distinction was therefore not hidden — it was **destroyed at write time**, so no later change could recover it for a row already stored. The cost was that one run answered differently depending on where you read it: `getRun` prefers the in-memory ring entry and said `cancelled`, while after a restart or a ring-buffer eviction the durable row answered, and it said `failed`. + + - **The write side.** `recordLog` writes the status its own terminal predicate admitted, resolved once into a `const` that also decides whether a row is written at all. The predicate is now the single declared vocabulary, `TERMINAL_RUN_STATUSES` (`engine.ts`) — three sites had a copy of that list and only one of them was ever going to be updated together with the writer. + - **The read side.** `ObjectStoreSuspendedRunStore` resolves the row's status once in the gate that already decided whether the row is terminal at all and hands the member to `deserializeTerminal`, which no longer re-reads or folds it. `listHistory`'s filter was the second copy of the two-member list — left alone it would have replaced a wrong status with a *missing row*, dropping cancelled runs out of the Runs list entirely. + - **The stored column.** `sys_automation_run.status` accepts the two added members, and the retention scope (`lifecycle.retention.onlyWhen`) counts them as terminal — a widened writer over a two-member sweep scope would have left `cancelled` and `timed_out` history rows never ageing out, on a table whose whole retention posture (ADR-0057) is that history is telemetry. `refused` is deliberately not added: `ExecutionStatus` declares it (#14945) but no engine path produces it, and an option nothing can write is declared-but-inert metadata (ADR-0078). + - **Rows already stored keep reading `failed`.** The information they lost is not recoverable and this change does not pretend otherwise — there is no backfill, because there is nothing to backfill *from*. Rows written from this release forward carry the distinction. + - **`TerminalRunStatus`** is exported for the same reason `ConsumedSuspensionDropNotice` is: `RunRecord` is barrel-reachable, and a host store implementing `recordTerminal` / `loadTerminal` has to be able to name the field it round-trips. + + Not a breaking change, and deliberately carries no breaking-change banner: the published contract (`IAutomationService.getRun` / `listRuns` return `ExecutionLog`, whose `status` is `ExecutionStatus`) has declared all four members since before this row existed. What changes is that the implementation stops under-reporting one the contract already promised — a consumer written against the declared contract is unaffected. Also no ADR-0087 migration entry: that ADR governs authorable metadata shapes on `sys_metadata`, and this is an engine-owned system data table whose existing values stay valid under the widened option set. +- cca6991: Flow `end` nodes honour `outcome: 'refused'` — a terminal `refused` run, distinct from `failed` + + `packages/spec` has declared the shape since 17.4.0: an `end` node accepts + `outcome: 'completed' | 'refused'`, a `refused` end requires a `message`, + `ExecutionStatus` carries `refused`, and `ExecutionLog` / `AutomationResult` / + the trigger response carry `refusalMessage`. The engine produced none of it — + it returned on every `end` node without reading its config — so an author who + wrote a refusal shipped a plain completion: the run recorded `completed`, the + caller got the flow's `successMessage`, and the authored reason reached nobody. + + The `end` node now honours it: + + - **The run terminates `refused`.** A refusal is a *successful evaluation that + says no*, so the result is `success: true, status: 'refused'` with no `error` + and no `errorMessage` — and, deliberately, no `successMessage`: the flow's + completion toast is for a completion. All three terminal producers answer + identically (a triggered run, a resumed screen flow, and an attempt under + `errorHandling.strategy: 'retry'`, where a refusal also stops the ladder + rather than consuming retry budget). + - **The `message` is rendered per record**, through the same interpolation a + `screen` node's `description` gets — one implementation (`interpolateText`), + never a second template engine — so `'Refused: {record.name} is a confirmed + duplicate'` reaches the caller naming the record. + - **Both are persisted on the run.** `sys_automation_run.status` gains + `refused` and a new `refusal_message` column carries the rendered text; the + refusal is never folded into `error`, which would tell every reader the run + broke. `RunRecord` gains `refusalMessage` and `TerminalRunStatus` gains + `refused`, so history rows are written, aged and read back like any other + terminal. + - **A refused run is never resumed.** It writes no continuation, so `resume` + answers `RUN_NOT_FOUND`. + + Untouched on purpose: a paused run still returns `silent` with no + `successMessage`, and a plain `end` — or one declaring `outcome: 'completed'` — + completes exactly as before. + + An `end` declaring `outcome: 'refused'` **inside a structured region** (a `loop` + body, a `try`/`catch` region) is refused loudly rather than honoured: a refusal + terminates the run and a region body cannot end one. Previously such a node was + a silent no-op like every other `end` in a region, so nothing that ever worked + stops working — put the refusing `end` on the top-level graph and route the + region's exit to it. +- ecdfc94: fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization behind a tenancy wall, and both its query and its run are confined to it (#16659, narrowed by #17396) + + + + > ⚠️ **Read this banner with #17396's ruling applied — it NARROWS everything below, and the narrowing shipped in the same launch window, so no released version ever saw the wider rule.** Two deployment facts now sit in front of every statement here, and neither is metadata: (1) package-authored scheduled work is gated by `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` and is **OFF by default in every tenancy posture and every kernel** — while it is off NOTHING below happens, because nothing arms; (2) with it on, the declaration requirement below applies under a **walled** posture (`group` / `isolated`) only. Under `single` an armed time-triggered flow declares nothing, carries no organization, and resolves the deployment's one organization beneath it exactly as it did before #16659. ⇒ Wherever this banner says "a time-triggered flow MUST declare", read "under a wall, with scheduled work switched on". The lint finding it announces, `flow-schedule-organization-missing`, is **deleted**: lint can see neither fact. + + **Registered as an ADR-0087 semantic migration** + (`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable + is renamed, retired or re-typed — no `packages/spec` key changes its name, its + type or its optionality, no stored shape moves, and every flow, node and + start-node `config` that parses today parses byte-identically afterwards, + because the start node's `config` is an OPEN record (ADR-0018) and the new + `organization` key is an addition to a slot that already accepted anything. So + `objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a + value only the deployment holds, a `sys_organization.id` minted at runtime, with + no authored artifact and no stored representation a rewrite could act on — and + inventing one is precisely what the ruling forbids. ⚠️ That is the argument + against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a + migration that cannot be expressed declaratively gets a structured TODO + (surface, reason, acceptance criteria) rather than nothing, and what follows IS + a prescription in that sense — declare `config.organization` once per + organization, no fan-out, then act on the three consequences of the split named + below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — + behaviour-only, no shape moved, a deployment judgement no transform can make, + registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before + this narrowing landed, so the enforcement rides the 17.x line by the + launch-window convention while the prescription belongs at the major boundary + where `migrate meta` users look. + + **BREAKING** in the accept-set sense, and in TWO places rather than one — + landing in the launch window as `minor` on all four packages (the lockstep + convention: during the window the bump level is not the carrier, this banner and + the disposition above are). Nothing that was refused becomes admitted. ⚠️ #17396 + changes that last sentence in one direction: under `single` with the switch on, + a flow that this changeset would have left unarmed **binds and runs**. That is a + widening, it lands in the same window, and it is why #17396's own changeset is + also a `minor`. + + 1. **Bind time.** A `schedule` or `time_relative` flow that declares no + `organization` is no longer armed. + 2. **Run time — the DATA PLANE.** A time-triggered run now carries a + `tenantId`, and a `time_relative` sweep now carries one on its own query. + Where a run previously read, updated and deleted across every organization, + it is now confined to the one it declares. + + ⚠️ **Read (2) as a narrowing that can stop something that was working**, because + it is one. Two shapes to plan for, and neither is hypothetical: + + - **A deployment running ONE time-triggered flow to cover ALL organizations must + now declare one flow per organization.** That is the ruling + (「不允许跨组织的定时任务」) and it is the whole point, but it is migration + work: there is no fan-out, and a sweep wanted in N organizations is N + declarations. Nothing detects the shape for you — the flow simply starts + seeing one organization's rows. + + ⚠️ **And the split has three effects the sentence above does not carry.** Each + is deployment work, and none of them is detected for you either: + + 1. **A NULL-organization row fans out N-fold.** The driver's scope is + `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no + tenant column value stays visible to a *scoped* read — this PR's own + negative control fixture selects exactly that row under scope, on purpose. + After the split every `organization_id IS NULL` row in a swept object is + therefore matched **once per flow**: N runs, N notifications, each acting + as a different organization. Before the split it was matched once. ⇒ Either + backfill the tenant column on swept objects or declare the object + platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the + scope rather than multiplying under it. + 2. **The current window's dispatch claims are abandoned.** The dedup key + embeds the FLOW NAME — `schedule::` and + `time-relative:::` — so N differently-named + flows claim under N different keys. A window already delivered under the + old name can deliver again, once, under each new one. ⇒ Cut over at a + window boundary, or accept one duplicate window. + 3. **A run suspended before the upgrade is not retroactively confined.** + Resume rebuilds the run's context from `context_json` + (`suspended-run-store.ts`), and a row written before this change carries no + `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills + it. Not a regression (that is how it already ran), but the banner would + otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight + suspended time-triggered runs, or accept that the tail of them is + unconfined. + - **On a SINGLE-organization install a time-triggered flow WAS delivering** — + the #8844 guard derives the only organization there — and after this change it + is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` + that install loses nothing at run time once the line is added: the scope is + `org = :tenant OR org IS NULL` and its one organization is the only scope there + was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss + has no legal configuration.** That driver refuses *any* call handed a tenant + scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) + — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / + `bulk*` / `aggregate`, one call at a time, regardless of how many + organizations the install holds. So a time-triggered flow that touches + per-organization data on that driver is refused per call if it declares an + organization and unarmed at boot if it does not. The declaration is not what + breaks it — the driver has no row-level tenant isolation to offer either way — + but this change is what moves such a flow from the "no organization context at + all → served" case into the refused one. Multi-organization deployments use + `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are + genuinely platform-global can declare them so (`tenancy: { enabled: false }`, + ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the + refusal on data that really is per-organization. + + A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. + + Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 + + A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. + + - **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. + - **`@objectstack/lint`** ⚠️ **nothing, after #17396.** This changeset originally added `flow-schedule-organization-missing` at `warning`; that id is deleted in the same window and was never published. The reason is the rule family's own criterion — *is this stack enough to know the flow is dead?* — answered honestly: it is not, because the deployment switch and the tenancy posture decide it and neither is in any stack. The near-miss diagnostic it shared with the triggers stays at BIND, where both facts are readable. + - **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. + - **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. + + **What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. + + ⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. + + **Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. + + No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. + + **What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: + + - **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. + - **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. + + **The four flows this repo itself ships** — ⚠️ this paragraph is superseded by #17396 and kept for the record of what was measured. Their answer is now the deployment switch, not an authoring repair: off, they are listed as *disabled by deployment policy*; on under `single`, they run as written; on under a wall, they still need a declaration no package can carry. The original measurement follows. + + **They stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. +- f04be62: feat(types,triggers,service-automation,runtime,cli,spec,lint)!: package-authored scheduled work is a deployment decision — `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, off by default everywhere (#17396) + + + + Maintainer ruling, 2026-09-12, verbatim, untranslated: + + > schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,有没有可能定义为一个环境变量,根据环境变量控制? + + > 如果多租户暂时只接禁用定时任务,完整的考虑一下影响面。 + + > group 默认也关,云端每库一租户全局默认关 + + **A new deployment variable, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, decides whether this deployment runs PACKAGE-AUTHORED scheduled work at all** — time-triggered flows (`type: 'schedule'` with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. It is read at boot beside `resolveTenancyPosture` and is ⛔ **not** a metadata concept and ⛔ **not** a new spec key: whether a clock-driven workload is affordable is a fact about the deployment — its database, its tenants, its budget — that no package author can know, and a metadata key would ask them to. + + **OFF by default, in every posture and in every kernel.** Unset means off; `true` / `1` / `on` / `yes` (case-insensitive) means on. ⛔ Deliberately not the opt-out `!== 'false'` shape `OS_MULTI_ORG_ENABLED` uses, which reads a typo as "on" — here that would arm exactly the workload an operator meant to refuse. + + ⛔ **Platform-internal scheduled work is NOT gated** and runs either way: approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill. The boundary is **authored by a package**, not "runs on the job service" — the platform's own maintenance is part of the runtime a deployment asked for. + + **BREAKING**, in two directions, and both land inside the same launch window as #16659 / PR #17334, so no published version ever saw the rule this narrows. + + 1. **A NARROWING, and it is the one to plan for.** A deployment that upgrades and does nothing runs **no** packaged time-triggered flow and **no** packaged `defineJob`. Anything that was firing from a package stops. ⇒ Set `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` if you depend on it. Nothing detects the shape for you at authoring time, by design — but nothing is silent either: every such flow is listed in `getTriggerBindingAudit()` and the `os dev` / `os start` startup summary with a DISTINCT reason, **disabled by deployment policy**, ⛔ never as "binding failed"; the packaged-job loop says so once per app at `info` with the count; and `os doctor` prints the effective value in both states. + 2. **A WIDENING of what binds.** With the switch on and tenancy posture `single`, a time-triggered flow that declares **no** `config.organization` now binds and runs — under #16659 alone it was refused. That posture holds exactly one organization (a second is refused), so the run carries **no** organization and every tenant-scoped insert beneath it resolves that one the way a single-organization install always did; a `timeRelative` sweep there runs **unscoped**. ⛔ Nothing is invented: the key is OMITTED, never filled from the install, the platform organization, or the swept record's own `organization_id`. + + **Under a walled posture (`group` / `isolated`) the 2026-09-08 ruling on #16659 stands unchanged**: a time-triggered flow declares `config.organization` or it is not armed, there is no fan-out, and no organization is ever chosen for it. `group` is walled here for a measured reason rather than by analogy — `resolveSystemWriteOrganization` refuses an organization-less system insert under any wall and `TenancyService.defaultOrgId()` answers `null` (ADR-0093 D3), so an organization-less group-wide sweep could read the whole group while every row it inserts is refused. Which organization such a sweep's inserts belong to is not yet decided; until it is, `group` behaves as walled. + + **`flow-schedule-organization-missing` is DELETED** from `@objectstack/lint` (the id and its exported constant, `FLOW_SCHEDULE_ORGANIZATION_MISSING`; both are unreleased — they were introduced by the still-unconsumed #16659 changeset in this same window, so no consumer can be holding either). The rule family's criterion is *is this stack enough to know the flow is dead?*, and the honest answer here is no: the deployment switch and the tenancy posture decide it, and neither is in any stack. A finding that is false for the default deployment is noise. ⛔ The near-miss diagnostic did **not** go with it — `describeMissingScheduleOrganization` and its `organizationId` / `tenantId` / … scan still fire at BIND, the one door that can read both facts, and only where the key is actually required. + + **ADR-0087 semantic entry 18 (`schedule-flow-acting-organization-required`) is REWRITTEN, not added.** Its acceptance criteria required every time-triggered flow to declare; that is no longer the rule. It now prescribes the two decisions in order — decide the switch, then declare per organization under a wall — and records that `os lint` reporting nothing is the criterion being met rather than a check that was skipped. The unconsumed `.changeset/schedule-trigger-acting-organization.md` carries a banner saying the same, so a reader of either one cannot get the narrower half alone. + + **Where the switch is read, and where it is not.** Both triggers gate at `start()`, ahead of the descriptor and the declaration, so an operator on a deployment that was never going to run a flow is not sent to fix a descriptor nothing would have read. `AutomationEngine.activateFlowTrigger` reads the same resolver and does not call `start()` at all when it is off — that is what keeps the audit's reason precise, since a refusal arriving as a THROW can only be reported through the catch that says "Failed to bind". Neither read is cached: the resolver reads `process.env` live, so a host that rebinds after the environment changes sees the value current at the bind. The scope is `schedule` and `time_relative` only — `record_change` and `api` are fired by a caller that already exists and already carries an identity, and a kind added to `FlowTriggerKind` later is OUTSIDE the switch until someone decides otherwise, because a new capability that disappears on arrival is the worse default. + +### Patch Changes + +- eac58c3: `try_catch`'s catch-region binding is annotated as the plain declared type. `TryCatchErrorValueSchema` declares `code: z.string().optional()`, so the local `TryCatchErrorValue & { code?: string }` intersection in `builtin/try-catch-node.ts` added nothing the exported `TryCatchErrorValue` did not already carry, and the comment paragraph beside it explained a spec/engine divergence that no longer exists (#15669). + + **No behaviour change, and nothing executable moves.** The object literal is untouched: `nodeId`, `message`, `code` and `iteration` / `item` are bound under exactly the same conditions as before, so a catch region still branches on `{$error.code}` and still reads an absent `code` as "no classified code", never as "nothing failed". Measured on the built package: `index.js`, `index.cjs`, `index.d.ts` and `index.d.cts` are **byte-identical** before and after; only `index.js.map` / `index.cjs.map` shift (by one byte each), because the replacement comment is two lines longer and the sourcemap encodes line positions. + + The annotation was proven redundant before it was removed — `TryCatchErrorValue` and `TryCatchErrorValue & { code?: string }` are mutually assignable, and `TryCatchErrorValue['code']` is exactly `string | undefined` — and the binding it describes is genuinely pinned: dropping `code` from the literal reddens the two `#14419` discriminator tests in `builtin/create-record-duplicate-code.test.ts`. +- 216b066: A run whose nodes all succeeded is no longer answered `failed` — or, under `errorHandling.strategy: 'retry'`, RE-EXECUTED — because its terminal run-history write threw (#16274) + + `AutomationEngine.execute()` and `executeWithoutRetry()` each called `recordLog({ status: 'completed' })` from inside the `try` whose `catch` exists for **node** failures, so a throw out of a history write on a run that had already finished successfully was handled as though a node had thrown. This is the initial-execution half of the pattern fixed on the resume path in 17.4.0; that fix deliberately scoped these two sites out. + + **The consequence was measured, and it is a double run, not just a mislabelled one.** `execute()`'s node-failure arm ends at the retry strategy branch, which hands the false `failed` result to the retry loop; the loop reads `result.success` and therefore re-enters `executeWithoutRetry()` — the whole flow, every node, again. Driven with `maxRetries: 2`: a flow whose node always succeeded ran it **three** times and wrote three `failed` rows, unattended, inside one `execute()` call, with the node's side effects repeated each time. Controls on the same instrument: the identical flow on healthy sinks runs the node once, and a genuine node failure runs it three times (retry working correctly). + + **What can throw there is a host surface, not in-repo code** — which is why it could not be reproduced from inside the package and why the package owed the fix: + + - the run-summary line `logger.info(line, meta)`, on by default (`runSummaryLog: 'info'`) and calling a **host-injected** `Logger`. This one needs no store at all. + - `store.recordTerminal(record)` throwing **synchronously**, before it returns a promise — the `void write.catch(...)` beneath that call only ever sees a returned promise's rejection. Both stores shipped in this package are `async` methods and cannot do it, but `SuspendedRunStore` is an exported interface whose `recordTerminal` is optional, so a host store is unconstrained. (A store returning a non-thenable escapes identically: `write.catch` is then itself a synchronous `TypeError`.) + + On that second variant the old code did not even answer `failed`: the node-failure arm's own `recordLog({ status: 'failed' })` threw again out of the same store and escaped `execute()` entirely — a rejected promise where `AutomationResult` is declared. + + What changes: + + - **Each completion-path history write is guarded at its own call site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. The caller is told the truth — `success: true`, no `status`, the flow's `successMessage`, and a `summary` recomputed by the same pure function `recordLog` runs first — the node runs exactly once, and one `completed` row is recorded rather than `1 + maxRetries` `failed` ones. + - **The swallowed failure is reported once per run at `error`**, with the consequence and the fix in the first line: the run completed, its terminal history row never landed, nothing retries it, and the run must not be re-run. The thrown text rides the structured slot. + + ⛔ No `catch` arm's meaning is widened: a genuine node failure still reaches the node-failure arm, is still recorded `failed`, still carries the node's own text, and is still retried the full `1 + maxRetries` times. +- b722547: fix(service-automation): a delegating node rolls its COMPLETED child's contained failures into the run-level `failed` (#16314) + + The services half of #15617's ruling (maintainer 「同意」 on option 1, decision batch #55). The spec half landed the slot: `ExecutionStepMetrics.failures`, declared as *"node executions that failed inside a child run this execution delegated to and went on from"*, folding into `nodes[].failures` and so into `FlowRunSummary.failed`. Until this, nothing populated it — the engine's fold could not see a child's losses, so a parent that delegated its rows reported `failed: 0` while its children lost them. `acted` had rolled up since #4354; the failure count had not, and the two paragraphs of the declaration disagreed for exactly that shape. + + **What moves on the wire.** For a run whose `subflow` or `map` child COMPLETED while containing failures, the delegating node's `nodes[].failures` and the run-level `failed` grow by the child's own `failed` — and the summary line prints it. The measured target from #15617, driven on the real engine: + + ``` + parent loop { subflow(child) }, one child failing per five rows + before status=completed selected=5 acted=4 skipped=0 failed=0 + after status=completed selected=5 acted=4 skipped=0 failed=1 + children failed = [0, 0, 1, 0, 0] (unchanged — the child keeps its own row) + ``` + + **The boundary, unchanged and pinned as the control.** A child that **failed** rather than contained is the delegating step's own failure, counted once through `nodes[].failures` exactly as it always was: `call: {runs: 5, failures: 1}`, parent `failed = 1`, with nothing of the child's own `failed` riding up. That is the one place this rule parts from `acted`'s, which does carry a failed child's writes. Implementing the symmetric-looking version would count one loss twice, and the control test is red on it. + + **A delegating node's `status` is unaffected.** `FlowRunNodeSummary.status` is declared judged on the node's OWN executions, so a `subflow` step that ran fine and rolled a child's losses up reads `success` with `failures > 0` — and on such a node `failures` may exceed `runs`, as the field declares. The fold takes the status verdict before it adds the roll-up. + + Three producers, each measured rather than assumed: `subflow-node.ts` (synchronous child), `map-node.ts` (per-item children — it does **not** share `subflow`'s roll-up path and needed its own), and `AutomationEngine.creditChildRun` (a child that PAUSED, whose parent step was written at suspend time; both the child-resume up-bubble and the parent-resume down-delegation are completion paths, which is what puts them inside the declared rule). + + `failed` keeps its convention: absent is "not tracked", never zero — an absent `metrics.failures` means the execution delegated nothing or the child tracked no count, and nothing writes a `0` that would claim a measurement. + + PR #15609's narrowed wording — *"no node execution **of this run** failed"* — was true only while the paragraphs disagreed, and is widened back here in the summary-line comment and in `content/docs/automation/flows.mdx`: `failed=0` now reads *"nothing this run caused failed, subflows included"*. + + No API moves: no new export, no new key on any published payload, and the node executors' `NodeExecutionResult.metrics` shape is the spec's already-published one. +- bea41f6: A run that genuinely failed is still answered in the declared shape when its own terminal run-history write throws (#17562) + + `AutomationEngine.execute()` and `executeWithoutRetry()` each ended their node-failure `catch` with an unguarded `recordLog({ status: 'failed' })`. That `catch` **is** the handler for node failures and there is no outer one, so a throw out of the history write escaped the method entirely and left `execute()` a **rejected promise**, where its declared return type is an `AutomationResult`. This is the failure-arm half of the completion-path guard shipped just before it, and the same shape already landed on the resume path's failure arm in 17.4.0. + + **What is lost is the shape, not the verdict.** The run really did fail, so nothing misleads an operator: there is no false `failed` and no double run. But a caller that branches on `{ success: false, status: 'failed' }` gets an exception instead, so the transport's `status` arm is bypassed and `errorMessage` (the author's failure text) and `summary` (how far the run got before dying) never arrive — a REST route or SDK caller sees a 500-class throw for a run that had a perfectly good failure envelope waiting, and the node's own error text is replaced by the history driver's. + + Reproduced with a control, the identical flow and the identical node failure differing only in the store: + + ``` + store = SYNC-THROW -> {"kind":"threw","error":"run-history driver refused the terminal row"} + store = HEALTHY (control) -> {"kind":"returned","status":"failed","error":"work blew up"} + ``` + + **What can throw there is a host surface, not in-repo code** — the same two statements the completion-path fix names: the default-on run-summary line `logger.info(line, meta)`, which calls a host-injected `Logger` and needs no store at all; and `store.recordTerminal(record)` throwing **synchronously**, before it returns a promise, which the `void write.catch(...)` beneath that call cannot see. Both stores shipped in this package are `async` and cannot do it, but `SuspendedRunStore` is an exported interface whose `recordTerminal` is optional, so a host store is unconstrained. + + What changes: + + - **Each failure-path history write is guarded at its own call site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. The caller now receives the envelope it was always promised — `success: false`, `status: 'failed'`, the **node's** own text in `error`, the flow's `errorMessage`, and a `summary` recomputed by the same pure function `recordLog` runs first. + - **The retry budget survives the loss.** On the retry path the throw used to reject out through the retry loop and `execute()` both, ending the run early; the remaining attempts now run as the author's policy says. + - **The swallowed failure is reported once per abandoned write at `error`**, with the consequence and the fix in the first line: the run failed, its terminal row never landed, nothing retries it, and the caller *was* told the run failed so nothing needs re-driving. The thrown text rides the structured slot. + + ⛔ No `catch` arm's meaning is widened: the suspend arm, the input-schema refusal and the retry strategy branch are untouched, and a genuine node failure against healthy sinks is answered exactly as before. +- bce5270: fix(automation): a `wait` node whose `timerDuration` yields no wait is refused loudly instead of parking the run forever (#18179) + + #17928 closed the **absent** `waitEventConfig` block: the contract now requires + the block, and requires a non-blank `timerDuration` under `eventType: 'timer'`. + Neither half can evaluate the string. `timerDuration` is `z.string()`, so + `'not-a-duration'`, `'1 hour'`, `'P'`, `'PT0S'`, `'0'` and `'-5'` are all + documents that SAVE — and `parseIsoDuration` answers `undefined` for every one + of them, exactly as it did for the absent key. + + Measured through a real `engine.execute()` run with a job service **answering**, + not read off the source: + + ``` + FROM waitEventConfig: { eventType: 'timer', timerDuration: 'not-a-duration' } + -> FlowNodeSchema.safeParse(...) // succeeds — the document saves + -> { success: true, suspend: true } // run status: paused, forever + scheduled jobs: [] <- with a job service ANSWERING + variables: no `pause.waitUntil` <- cold boot cannot re-arm it + log lines: 0 at any level <- warn, error, info, debug + + TO -> { success: false, errorClass: 'guard', error: "wait 'pause': timerDuration + \"not-a-duration\" is not a usable wait — …" } // run status: failed + one `warn` naming the node, the offending value and the remedy + ``` + + The state the old path left behind was **un-refused, un-armed, un-persisted and + un-logged, while reporting success**: neither the arming branch (guarded on the + deadline) nor the "no job service" fallback (guarded on the service) could run, + so control fell straight through to the suspending return. The comment there + pointed at recovery via a later boot's re-arm pass "when the deadline was + persisted" — and no deadline had been persisted. + + **The remedy the refusal prints.** Write an ISO-8601 duration + (`timerDuration: 'PT1H'`, `'P3D'`, `'PT90M'`) or a QUOTED positive millisecond + count (`'60000'`), then re-publish the flow. For a pause with no deadline, + declare an `eventType` that names its resumer instead (`'signal'` / `'webhook'` + / `'manual'` / `'condition'`). + + Zero and negative are the same verdict and deliberately not a separate one: + `'PT0S'` is not a short wait, it is a deadline already past, and it parks just + as permanently as an unparseable string. + + ⚠️ Behaviour this deliberately changes: a stored flow carrying one of these + values used to reach `paused` and report success. It now fails the run at that + node. Nothing that parsed stops parsing — no authorable key is removed, renamed + or narrowed — and the refusal is `guard`-class, so a `fault` edge cannot route + the metadata defect into a handler that reports success. +- 97466dd: fix(service-automation): a child that PAUSES and then refuses now rolls its refusal up on both resumed legs — the delegated resume and the up-bubble (#18714) + + **Clause-②: no** — nothing published moves. The two arms are added inside `AutomationEngine`'s private `resumeInternal` / `bubbleToParent`, and the one new type (`ChildRunRefusal`) is module-private, not barrel-exported. No schema key, no closed-set member, no export and no registry entry changes; `refused` has been a published terminal status since #15788 and no new status, code or `ERROR_CODE_LEDGER` entry is minted here. + + #18110 / #18555 gave the `subflow` and `map` executors an arm for `child.status === 'refused'`, and that arm reads the value `engine.execute` **returned** to them — so it covers exactly one shape: a child that runs straight through without pausing. A child that durably PAUSES first (a nested `approval` / `screen` / `wait`) never returns through that call at all. Its outcome reaches its parent on one of two **resumed** legs instead, and neither had an arm. Both pre-date #18110/#18555 and neither is a regression of it; that delivery named the two executors and matched its ruling exactly, and its own changeset filed this card for the remaining half. + + The two legs failed **differently**, so each gets its own arm and its own pin: + + - **Delegated resume** — `engine.resume(parentRunId)`, the path a screen-flow runner takes when it holds one stable run id and posts every wizard step to it. The delegation block tested only `paused` and `!success`; a refused child is neither, so it fell through the ordinary success exit. Measured: the parent answered `{ success: true, successMessage: … }`, its run row recorded **`completed`**, and the node downstream of the `subflow` **ran**. The refusal was lost **fail-open** — the identical shape #18110 closed on the synchronous leg. + - **Up-bubble** — `engine.resume(childRunId)`. `bubbleToParent` was called on the completion path only, so a child resumed to a refusal resolved exactly one of the two runs it is responsible for. Measured: the child row recorded `refused` correctly and the parent stayed **`paused`**, in `listSuspendedRuns()`, indefinitely. Nothing looks wrong; a run is **leaked**. + + What changed: + + - **One terminal shape, both legs.** Each leg records the child's refusal and hands it to a single throw site inside the resume's traversal `try`, which raises the engine's existing internal refusal signal — so the refusal leaves through the same `finishRefusedRun` chokepoint every other producer already uses. ⛔ Deliberately not a second terminal exit per leg: this file's history is a list of outcomes that became a function of which route a run took. + - **The throw site sits past the consumption and before the traversal.** The parent's own suspension is consumed exactly as it is on every other way a resume can end, so the terminal row and the pause can never disagree; and nothing downstream of the awaiting node runs. + - **The parent's terminal row reads `refused`**, carrying the child's already-rendered `refusalMessage` verbatim, and the parent's own `successMessage` stays silent. ⛔ Not `failed`: a refusal is not a failure — it must not consume retry budget, must not be routable by a `fault` edge and must not be counted in `nodes[].failures`. + - **The child's #4354 rollup (`selected` / `acted` / `unmeasuredEffect`) survives on both legs**, for the same reason it survives on the synchronous one: the refusal is raised after the awaiting step has been credited. A child that refused really can have written rows before it said no. + - **Chains of any depth resolve**, because the up-bubble arm resumes the parent for real — the parent consumes its pause, records its own terminal row and bubbles to *its* parent in turn, by the same induction completions already rely on. ⛔ Not a direct ancestor walk like the failure cascade's: that verb records ancestors `failed`, which is the wrong word here. + - **The child's own resumer is told exactly what it was told before** — the bubble is still best-effort at the engine layer and never rewrites the child's envelope. + + Unchanged: the synchronous leg (#18110/#18555), the region-containment refusal (#18881 — a different error type on a different path, which neither resume leg raises or consumes), the retryable delegated resume-bag codes (#14379), the terminal child-failure cascade, and the `RESUME_IN_PROGRESS` / `STORE_UNAVAILABLE` / stranded gradings on the bubble. + + ⚠️ **Behavioural direction**: a run that previously finished green over a refusing paused child now terminates `refused`, and a parent that previously sat in `listSuspendedRuns()` forever is now resolved. Both are the authored outcome arriving where it never did; a composition that depended on the fail-open was depending on the defect. +- 554e928: A node that **durably suspends inside a structured region body** now FAILS the run with a named refusal that carries the region node, the suspending node and the sub-flow — instead of being read as an ordinary region failure that a `try_catch` could contain, after which the run reported success over a sweep that had processed nothing (#18881, the runtime half of #15646's ruling D). + + An ADR-0031 region body — a `loop` body, a `parallel` branch, a `try_catch` try or catch region, **at any depth** — runs synchronously inside the enclosing run and cannot park it on a durable pause. #3267 ruled that limit 禁. `runRegion` already converted such a suspension, but into a plain `Error`, which is indistinguishable from a node that simply failed. + + Measured on the card's reproduction, `loop { try_catch { map(pausing child) } }`, before this change: + + ``` + result.success true // the catch handler ran and "recovered" + run.status completed + summary.failed 0 // over 0 of 10 child runs + ``` + + The `map`'s progress state (`.$mapState`) is written into the **enclosing** scope, so the residue a contained refusal leaves is read back as progress by the next entry to the same node: iteration 2 saw `started === collection.length`, ran nothing, and reported success. A sweep that reports green having done nothing is the worst available failure, and it is the one the run-level `failed` counter (#14456) was built to expose. + + What changed: + + - **`FlowRegionSuspensionRefusalError`** (new internal module `region-suspension-refusal.ts`, ⛔ not exported from the package entry) carries `regionNodeId`, `regionKind`, `suspendedNodeId` and `subFlowName` as fields as well as in its message, so a reader never parses the sentence. It is branded as a #3863 guard refusal, so a `fault` edge on the enclosing container cannot route it either. + - **`try_catch` re-throws it** from both the try-attempt arm and the catch-region arm rather than treating it as a region failure, and ⛔ spends no retry attempt on it — re-entering the region would re-enter the pausing node, and the metadata is what is wrong. **`parallel` re-throws it** rather than folding it into its returned (and therefore routable) branch failure. `loop` already re-threw unchanged. + - **One refusal is one failure.** The region node's own frame records the `EXECUTION_ERROR` step and publishes `{$error}`, exactly as any thrown node failure does; every enclosing container the unwind passes through records nothing, so `summary.failed` counts the fault and ⛔ not the nesting depth. + + ⛔ **Nothing changes for a region whose nodes complete synchronously.** `loop { map(synchronous child) }`, `parallel { branch: [map(synchronous child)] }` and #15616's regression suite run exactly as before — pinned as explicit controls beside every refusal case, because without them a reader cannot tell "the durable pause is refused" from "the region path was closed off". + + ⛔ **No authoring-time rule is added here**: #18688 landed that half in `packages/spec` and it refuses `screen` / `wait` / `approval` / `approval_revise` / `end` inside a region body by type. `map` and `subflow` are deliberately not refused there — whether they pause is decided by the child flow record their `config.flowName` names — which is exactly why the runtime arm has to exist. + + ⛔ **No new `error.code`.** The closed `ERROR_CODE_LEDGER` (ADR-0112) lives in `packages/spec`; the refusal is named by its type and its fields, and the step it produces keeps the `EXECUTION_ERROR` code every thrown node failure has always carried. +- fc29c74: feat(spec)!: retire `connector.connectionTimeoutMs` — declared, bounded, defaulted, served back, and never applied as a deadline + + **BREAKING** — `connector.connectionTimeoutMs` is removed. ADR-0049 + enforce-or-remove; maintainer ruling 2026-09-22, letter A. It is the narrower + **second** decision this key was owed: the earlier ruling that made its nine + liveness siblings live (`retryConfig.*`, `requestTimeoutMs`) left this one dead + on a stated reason rather than by oversight, and `packages/spec/liveness/connector.json` + has been asking for this decision since. + + The key was bounded (`min(1000).max(300000)`), defaulted (`30000`), + `.describe()`d, authorable on both carriers and served back by + `/meta/connector`. Every signal an authoring surface can give said it worked. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `connector.connectionTimeoutMs` (on `Connector` and on `DeclarativeConnectorEntry`, so `stack.connectors[]` and `PUT /meta/connector/:name`) | `requestTimeoutMs` — the deadline the platform keeps, applied as `resilientFetch`'s per-attempt timeout. For a connect-only bound, configure it at a connector provider or upstream gateway on a transport that can separate the phases. | + | `ConnectorProviderContext.connectionTimeoutMs` (handed to every `ConnectorProviderFactory` — added after `@objectstack/spec@17.4.0` and never in a release, see below) | `ctx.requestTimeoutMs`, or the factory's own `providerConfig` where the provider owns the vocabulary. | + | The `ZodObject` combinators on `ConnectorSchema` and `DeclarativeConnectorEntrySchema` — `.extend()`, `.omit()`, `.pick()`, `.partial()`, `.merge()`, `.strict()`, `.keyof()`, `.safeExtend()` | Both exports are now `z.preprocess` **pipes** (the residue stage below), so those methods no longer exist on them. **Build on the object and re-wrap:** `acceptRetiredDefaultResidue(, { connectionTimeoutMs: 30000 })`, the `EffectiveObjectPermissionSchema` route. ⚠️ `.superRefine()` still *exists* on a pipe but returns a schema with no read-through `shape`, so refine before wrapping, not after. Parsing, `z.input` / `z.infer`, and the read-through `.shape` are unchanged. | + + **The one-line fix: delete the key.** `os migrate meta --from 17` lists the + mechanical edits for existing sources; apply them by hand. + + The three interface members withdrawn with it were **never in a release**: + `ConnectorProviderContext.connectionTimeoutMs`, + `RestConnectorOptions.connectionTimeoutMs` and + `OpenApiConnectorConfig.connectionTimeoutMs` all entered with `b929e0a662`, + after the `@objectstack/*@17.4.0` tag, and leave in this same release. A factory + or caller built against a released version never saw them; only code written + against an unreleased `main` in between can read them, and it stops. + + ⚠️ Runtime behaviour is **unchanged for every shipped provider**, because none + ever applied the value: a connector that authored `connectionTimeoutMs: 1000` + made exactly the same calls, with exactly the same deadlines, as one that did + not. What does change is observable and intended: the def served by + `GET /connectors` no longer echoes a connect deadline nobody keeps. + + ### ⭐ This is NOT the zero-mention retirement shape + + Measured with `git grep -n connectionTimeoutMs SHA -- . ':!packages/spec'` at + `e07843b5a6`, the tree this retirement landed on: **thirteen** non-test source + occurrences over seven files in five + packages — **six reads** (`openapi-connector.ts:242`, `openapi-provider.ts:193`, + `rest-connector.ts:134`, `rest-provider.ts:64`, `plugin.ts:307`, + `plugin.ts:1589`), **four type declarations**, and **three** surviving hardcoded + `30000` writes. Reading the retirement as "nothing referenced it" loses the + finding. Measured across all six reads, every one is a **pass-through**: the + value's only termini were the def `GET /connectors` echoes and the fingerprint + that decides whether to re-materialize. `connectorFetchOptions()` — the one + mapping from authored policy onto the platform's outbound `fetch` — was handed + `{ retryConfig, requestTimeoutMs }` only. Carrying a number is not honouring it, + and ADR-0049 forbids the parsed-unmarked-unenforced state whether the inert + value travels or sits still. + + Nor was the `实现` arm available. A connector's outbound call is a WHATWG + `fetch`, whose only cancellation surface is ONE `AbortSignal` covering the whole + operation; nothing in that interface observes the connection phase. Bounding + "time until the response arrives" with this key would kill a slow-but-connected + upstream the author meant to allow with a large `requestTimeoutMs` — breaking + the very promise the key makes. (undici's `connectTimeout` needs a custom + dispatcher: Node-only, and a new subsystem underneath every connector, which the + ruling that made the siblings live forbids.) + + ### The retirement kit + + - The **authorable key** is a `retiredKey()` tombstone on `ConnectorSchema`, + registered as `integration/Connector:connectionTimeoutMs` and + `integration/DeclarativeConnectorEntry:connectionTimeoutMs` in + `RETIRED_KEYS_BY_MAJOR[18]`. The schema is not `.strict()`, so a bare deletion + would strip an authored key in silence (ADR-0104): the tombstone is audible in + both channels — `tsc` (input type `never`) and the parse, which raises the + prescription itself. `DeclarativeConnectorEntrySchema` carries it too — both + published carriers wrap the same private `ConnectorBaseSchema` — so + `stack.connectors[]` and the `/meta/connector` door refuse it too: every value + but the retired default `30000`, which the residue stage below strips first. + - **A D2 conversion, `connector-connection-timeout-ms-removed`** — one strip per + `connectors[]` entry, a pure lossless delete. ⭐ The ruling left whether one was + owed to be **measured** ("a D2 conversion only if a stored connector row can + carry the key"). It can, and both legs were measured before the tombstone + landed: `getMetadataTypeSchema('connector')` — what `PUT /meta/connector/:name` + validates against — parsed a body carrying the key and its output **retained** + the authored value, so the number reached `sys_metadata`; and + `applyConversionsToStoredItem('connector', …)` is live for this type. Rows + written on 17.x therefore replay clean. + - **A D3 semantic entry, + `connector-provider-context-connection-timeout-ms-retired`**, for the withdrawn + `ConnectorProviderContext` member (never in a release, above). A provider + factory is code: there is no authored source and no `sys_metadata` row for a + conversion to rewrite, so the removal reaches a factory author who read it — + possible only against an unreleased `main` — as a `tsc` error and as that + entry. + - **No def leaves.** The key was a bare `z.number()`, never a `ConfigSchema` + shape, so `RETIRED_DEFS_BY_MAJOR[18]` gains nothing — and `api-surface/` and + `json-schema.manifest/` are byte-identical, which is the correct reading for a + key-only tombstone rather than a missed regeneration. + - `authorable-surface/integration.json` gains two `[RETIRED]` rows; + `authorable-defaults/integration.json` loses the two `= 30000` rows. + - The liveness row **stays** `dead` with a `REMOVED` note, because `retiredKey()` + keeps the key in the walked shape. Its previous note claimed "every occurrence + outside `packages/spec` is a WRITE". That reading was **correct at the SHA the + card cited and dated** (`0870fb5418` — exactly five non-spec source hits, all + five `connectionTimeoutMs: 30000,`) and was superseded by `b929e0a662`, the PR + the card itself flagged as pending. It is **stale, not false**, and the row now + carries both readings with their trees rather than one undated claim. + - **An `acceptRetiredDefaultResidue` stage** (#12840), `{ connectionTimeoutMs: 30000 }` + on both carriers. The key was `.optional().default(30000)`, so a 17.x parse + materialized it into **every** connector — measured on both sides of the + retirement: the released + `@objectstack/spec@17.4.0` emits `connectionTimeoutMs: 30000` for an entry that + authored only `name`/`label`/`type`, and the tombstone **without the stage** + refuses that exact object at `connectionTimeoutMs`. With the stage, as it + ships, that object is **accepted and the key stripped** before the tombstone + reads it — on `ConnectorSchema`, `DeclarativeConnectorEntrySchema`, the + `/meta/connector` schema and `stack.connectors[]` alike. + The D2 does **not** discharge the obligation, and the precedent shows it: + `ObjectPermission:allowPurge` carries a D2 **and** the residue stage, for its + own reason (a released toolchain materialized its default into every built + artifact's entries). The reason *here* is a different one — this schema has a + second door: `AutomationEngine.registerConnector` parses `ConnectorSchema` for + a def a plugin or provider factory builds **in code**, where no conversion + ever runs, and in 17.4.0 all four shipped connector packages put that `30000` + straight into the def literal. So the emitted `30000` is accepted-and-stripped, + while every other value (`15000`, `1000`, the string `"30000"`) keeps the + tombstone's refusal — at `connectionTimeoutMs`, or at + `connectors.0.connectionTimeoutMs` inside a stack — and nothing is un-retired: + `z.input` stays `never` and the `[RETIRED]` row stays. + - **No deprecation window** (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」), + and no staged retirement. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no download, dependent or source + telemetry was consulted for. The pinned sibling checkout **was** measured: zero + occurrences of the name at objectui `87af769e`, against a lit control on the same + command and scope, so no sibling fix or pin bump rides with this. + + `Clause-②: yes (narrowing)` — a published authorable key is removed on two + carriers, so the accept set a consumer writes against narrows. Nothing is + widened and nothing is renamed. Contract-review tier. + + +- 90ff10a: `AutomationContext` declares `callerParamKeys?: string[]` — the flow doors say which `params` keys the caller supplied, and a `screen` node reads that instead of inferring it (#19846). + + Clause-②: yes + + A screen whose fields the run's caller already supplied continues without pausing (#15787). Deciding "did the caller supply this field?" from the params bag was an inference: the bag a flow receives also holds the subject record's columns and the launched row's id, which the door seeds itself. Two constructions still skipped a screen that should have paused — both a non-default `recordIdField` with a `recordIdParam` naming a key the record lacks, on an object-less action whose record has a `recordId` column, or on an object-bound action whose record shadows `recordId` and the `Id` alias. Maintainer ruling on #15705, verbatim: 「15705同意」. + + **What the key says.** The keys of the caller's own `params`, recorded before the door seeds anything. An empty array means the caller supplied nothing; an absent key means the producer does not say. + + **Who fills it.** The action door (`dispatchFlowAction`: `POST /api/v1/actions/...` and MCP `run_action`) and the trigger door (`buildAutomationContext`: `POST /api/v1/automation/:name/trigger`, the legacy `POST /api/v1/automation/trigger/:name`, and a declarative `type: 'flow'` endpoint). Record-change, time-relative and webhook triggers, and code calling `execute` directly, leave it absent; the schedule trigger, whose run has no caller, states an empty list (#19900). `subflow` and `map` nodes drop the parent's list from the child run's context, because it describes the parent's bag. + + **What the screen does with it.** When the key is present, a field is caller-supplied when its name is in the list and `params` holds a value for it; the inference is not consulted. A present value that is not an array names nothing, so the screen pauses. When the key is absent, the inference from #15787 applies unchanged. + + **Accepted cost, precisely:** both doors leave out of that list the keys they use to carry the launched row's id — `recordId`, the camelCase `Id` alias, and on the action door the action's own `recordIdParam` — even when the caller's bag names them, because a client that mirrors the row id into `params.recordId` is addressing the row, not answering the screen. On a run started through either door, a field named like one of those keys is therefore not caller-supplied: a required such field is collected interactively, and an optional one does not count as answering the screen. + + **What moves for a headless caller, through either door:** + + - the two constructions above pause instead of skipping; + - a field whose value equals a column of the subject record, or equals the row id, now counts as supplied when the caller named it — the inference could not tell those from the seeds and paused; + - a field named like the action's `recordIdParam` no longer counts as supplied when the caller sent that key with a value other than the row id — the inference counted it; the door now treats that key as the row-id channel. + + Nothing moves for an implementation of `IAutomationService`: the key is optional, and a context without it keeps its prior meaning. +- 7e6ca17: fix(plugin-approvals, service-automation, service-messaging): five system objects title their records with a text formula instead of the raw id (#20015) + + Clause-②: no + + ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation, and an explicit `nameField` takes precedence over the render-only `titleFormat`. Five system objects declared `nameField: 'id'` beside a composite `titleFormat`. A renderer that follows ADR-0079's order therefore showed the raw record id as the record page's title for: + + - `sys_approval_request`, whose `titleFormat` is `{process_name} · {record_id}`; + - `sys_approval_action`, whose `titleFormat` is `{action} · {step_name}`; + - `sys_approval_approver`, whose `titleFormat` is `{approver} · {request_id}`; + - `sys_automation_run`, whose `titleFormat` is `{flow_name} · {node_id}`; + - `sys_http_delivery`, whose `titleFormat` is `{label} → {url}`. + + Each object now declares `display_title`, a formula field with `returnType: 'text'` over the same columns, and points `nameField` and `displayNameField` at it. This is the migration the `titleFormat` schema text prescribes: "a composite to a formula field designated as nameField". The record title is now the text the `titleFormat` described. Where a source column is nullable (`step_name`, `node_id`, `label`), a row without it is titled by the other column alone. + + A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads and write responses now carry `display_title`. For these objects the server-side title accessor (`resolveRecordTitle`) now returns the formula's text instead of the raw id. + + `titleFormat` stays on all five objects, unchanged, for renderers that still read it first. `$search` resolution is unchanged: a formula field is never a search target, and neither was `id`. +- 40b315b: feat(spec)!: retire the connector resilience family — `health` (health probe + circuit breaker), `status` and the nested `webhooks`, sixteen keys nothing read (#20273) + + **BREAKING** — `connector.health` (the `healthCheck` probe, eight keys, and the + `circuitBreaker`, six keys), `connector.status` and the connector-nested + `webhooks` are removed from `ConnectorSchema` and `DeclarativeConnectorEntrySchema` + — so from `defineConnector`, `stack.connectors[]`, the `PUT /api/v1/meta/connector/:name` + door and `AutomationEngine.registerConnector`. ADR-0049 enforce-or-remove, one + batch for the family, by the maintainer's criterion: does the mainstream platform + offer this capability? Author-configured health probes and circuit breakers are + not connector metadata in the mainstream (breakers live in API-gateway + infrastructure), and an authored status and a nested webhook list duplicate what + is already delivered here by other keys. + + Measured before removal, each against a lit control: zero reads of any of the + sixteen keys outside `packages/spec`. No loop ever polled a connector endpoint, + counted consecutive failures or tripped a breaker, and none of the four + `fallbackStrategy` behaviours existed. Nothing read an authored `status`: the + runtime's dispatchability answer is the COMPUTED `state` (`ready` / `degraded`) + on `GET /api/v1/automation/connectors`, which no authored value sets. A webhook + nested in a connector was never registered as a `webhook` item, so it was never + materialized into `sys_webhook` and never delivered. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `connector.health` (`healthCheck.*`, `circuitBreaker.*`, including `monitoringWindowMs` and the pre-rename `monitoringWindow`) | delete the block. Put health probes and circuit breaking in the connector provider or an upstream gateway. | + | `connector.status` | delete the key. `enabled: false` on a declarative entry is what withdraws a materialized instance or marks a catalog-only descriptor; whether a registered connector can be dispatched is the computed `state`. | + | `connector.webhooks` | delete the array. A webhook that is actually delivered is declared in the stack's top-level `webhooks:` collection — moving one there STARTS deliveries this connector never made, so decide per webhook. `events` and `signatureAlgorithm` have no counterpart there. | + | `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` (schemas, types, `…Parsed` types) | no replacement — nothing parsed or constructed them. | + + **The one-line fix: delete `health:`, `status:` and `webhooks:` from every connector.** + `os migrate meta --from 17` lists the mechanical edits for existing sources. + + ⚠️ Runtime behaviour is deliberately **unchanged**: none of the sixteen keys ever + changed what a connector did. What changes is the answer an author gets — each + key is refused at parse with a prescription, and in `tsc` (its input type is + `never`), instead of being saved with no effect. + + ### The retirement kit + + - **Tombstones.** `health`, `status` and `webhooks` are `retiredKey()` tombstones + on the private `ConnectorBaseSchema` both published carriers wrap (the schema + is not `.strict()`, so a bare deletion would be a silent strip, ADR-0104). + `RETIRED_KEYS_BY_MAJOR[18]`: `integration/Connector:{health,status,webhooks}` + and `integration/DeclarativeConnectorEntry:{health,status,webhooks}`. + - **Retired-default residue.** `status` was `.default('inactive')`, so every 17.x + parse emitted `status: 'inactive'` into every connector; that exact value joins + `connectionTimeoutMs: 30000` in the residue stage (accepted and stripped, so a + def a 17.x toolchain built still registers). Every other value is refused. + - **Seven defs leave whole** (`RETIRED_DEFS_BY_MAJOR[18]`): the four + `integration/` schemas and three enums listed above. + - **D2 conversion `connector-resilience-keys-removed`** (step 18, retired from + the load path): strips the three keys from `connectors[]` and from stored + `sys_metadata` connector rows (the rehydration seam replays it), one notice per + key, as a lossless delete. Nested webhooks are stripped, never moved. + - **The chain.** In the same step, `connector-health-and-trigger-durations-unit-in-key` + renamed `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs`. That + breaker half is absorbed by this removal: the renamed key is itself removed, so + an author holding either spelling ends with no `health` block. The + conversion's `triggers[].interval` → `intervalSeconds` rename is unaffected. + - **D3 entry `connector-resilience-keys-retired`** carries the family's + judgement: which probe, breaker or nested webhook the author actually relied + on, and where it goes now. + - **Writers deleted.** The four shipped connector packages wrote + `status: 'active'` and the automation service's degraded husk wrote + `status: 'error'`; nothing read either back, and both writes are gone. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no telemetry was consulted for. + + Clause-②: no (narrowing) + + +- a36b526: `sys_automation_run.variables_json` states its presence discriminator in ONE direction, and a row-rebuilt snapshot no longer claims its steps are the pause's + + Three corrections to text this package ships. No behaviour changes; every shape + described below is the ruled design, measured as it already is. + + **`variables_json` said `⇔` where only `⇒` holds.** The field description + declared "present on a completed/failed row" and "the row's run had a pause its + resume consumed before a downstream node failed" to be equivalent. The forward + direction holds — nothing but the consumed-suspension path writes that column on + a terminal row. The reverse does not, for one shape: a run that stranded, was + restored and then finished. `recordTerminal` upserts the SAME `run_` row + with all four snapshot columns explicitly `null` — deliberately, so + "restorable" cannot outlive the condition it describes — which leaves that row + equal, across every column the discriminator is read from, to the row of a run + that never paused at all. Absence means "nothing to restore now", never "this + run never had one", and the restore verb already refuses in exactly those terms: + it names the status it observed and declines to say which. The description now + says so. + + **A snapshot rebuilt from a row does not carry the step log as of the pause.** + `deserializeConsumedSuspension`'s docblock said its `steps` are the log "AS OF + THE PAUSE". That is true of the engine's process-local journal copy only, which + slices `run.steps` back to the step count at the pause; the trimmed array is + never persisted. `steps` are the one field the rebuild takes from the row's own + `steps_json`, which is the terminal row's log of the WHOLE run — and both bounds + on that column keep the failure on purpose (history compaction retains every + failure; the byte cap trims the head). A row-rebuilt snapshot therefore carries + steps the pause did not have. It re-arms the same run regardless: the pause is + `nodeId` plus `variables` / `context` / `correlation`, none of which the step log + feeds. + + **`recordTerminal` now names the verb that reads what it writes** — the + restore path in `engine.ts` — and the three properties of the write that are + that verb's inputs rather than local detail. Its summary line also said + "completed / failed" where the terminal vocabulary has had four members since + the fold was removed from both ends of this write. + + Both falsifying shapes are pinned in `suspended-run-store.test.ts`, including the + indistinguishability itself: the restored-then-finished row and a never-paused + row compare equal across those five columns, with the same comparison separating + them while the snapshot is still there. +- ae6dcf6: `notify` now reports the recipients it addressed, so a run that notified nobody stops reading like a run that had nobody to notify + + A `notify` node whose delivery count came back zero contributed `acted: 0` and nothing else to the run summary. A flow whose only effect-bearing node is that one then folded to `selected: 0, acted: 0, unmeasured: 0` — byte for byte the summary of a run that had nothing to notify about, and of a run whose `notify` node never executed. The run read healthy, and the only trace was a log line. + + `emit()` returns `delivered: 0, enqueued: 0` on several paths, each after logging and nothing else: an audience that resolved to no recipient, a preference filter that suppressed every (recipient × channel) pair, a dedup hit, every enqueue failing. A stack with no messaging service installed lands in the same place. All of them were silent in the summary, so this is not one cause being fixed — it is the whole class becoming visible. + + The node now reports `selected` — the recipient entries it addressed — on every path that reaches a recipient list, alongside the `acted` / `unmeasuredEffect` rules it already had. Those two are unchanged, so a delivering run keeps its existing `acted` (inline) or `unmeasured` (outbox) reading and stays outside the broken-sweep filter; a zero-delivery run now reports `selected: N, acted: 0` with no `unmeasured`, which is the platform's declared "matched N, acted on none, and that zero is trustworthy" signature and puts the run **inside** `selected > 0 AND acted = 0 AND unmeasured = 0` — the filter that exists for exactly this, and whose first clause the old reading could never satisfy. + + The zero is deliberately NOT reported as `unmeasuredEffect`. That flag means the count is unknown; this count is known and it is zero, and claiming otherwise would take the run out of the very filter it belongs in. + + `selected` counts audience entries, not resolved users: the entry (`role:manager`, a bare id) is what the node has, since expansion happens inside the messaging service and is not reported back. +- a2509d7: fix(service-automation): a `null` / `undefined` envelope is refused attributed, not as a raw `TypeError` (#16439) + + `AutomationEngine.evaluateValueEnvelope` derives its verdict from `valueEnvelopeRefusals` — the same call `registerFlow` makes — so registration's reject set and evaluation's reject set are one set by construction. That covered every malformed **envelope**, and exactly two shapes fell outside it: `null` and `undefined`. Neither published primitive judges them (the shape rule is a no-op on anything not `isExpressionEnvelopeShaped`, and `validateExpression` reads an absent `source` as "not authored"), so both returned no findings and the method went on to read `envelope.source` off nothing — `TypeError: Cannot read properties of null (reading 'source')`, with no `where`, no source and no rule. Driven across the ten shapes the card enumerates, eight failed attributed and only these two did not. + + Both now fail attributed like the other eight, led by the published `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` sentence and carrying the `where` and the source. The rule is stated in the **shared** refusal, never as a guard in the evaluator: a reject reason living only on the evaluation side would end the very property this design has. + + Refused rather than admitted, and the asymmetry with the predicate path is deliberate: `structuralConditionRefusal` admits `null` / `undefined` because the condition *field* is optional, so absence there means "the author wrote no predicate". A value slot's envelope **is** the value, so an absent one is a caller handing nothing where a value was required. + + **Why `patch`, not `minor` and not nothing.** Nothing changes for authored metadata: the only production call site guards with `isExpressionEnvelopeShaped`, which neither shape satisfies, and the value-role feeder emits only envelope-shaped objects, so `registerFlow` never presents a nullish value to the shared refusal — measured, and pinned. An authored `null` in an `assignments` slot is still a literal, still parses and still registers. What does move is the runtime behaviour of a **public method on an exported class**: a direct caller that passed a nullish envelope used to get a language-level `TypeError` and now gets an attributed `Error`. That is a published surface, so it is not silent — but it adds no API, no option and no capability, and no correct caller has to adapt, which is what makes it a patch rather than a minor. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [7465eeb] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index 495fa8939e2..9185ececa96 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-automation", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Automation Service for ObjectStack — implements IAutomationService with plugin-based DAG flow execution engine", "type": "module", diff --git a/packages/services/service-cache/CHANGELOG.md b/packages/services/service-cache/CHANGELOG.md index 10b104a812b..596b9d13ab7 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,478 @@ # @objectstack/service-cache +## 17.5.0 + +### Patch Changes + +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/observability@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index 3df43d3cb20..07a3bfa15c8 100644 --- a/packages/services/service-cache/package.json +++ b/packages/services/service-cache/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cache", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Cache Service for ObjectStack — implements ICacheService with in-memory and Redis adapters", "type": "module", diff --git a/packages/services/service-cluster-redis/CHANGELOG.md b/packages/services/service-cluster-redis/CHANGELOG.md index 5469fb2b690..2e8d462b304 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,443 @@ # @objectstack/service-cluster-redis +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/service-cluster@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index a96d1985f9d..4bbcfcd8c28 100644 --- a/packages/services/service-cluster-redis/package.json +++ b/packages/services/service-cluster-redis/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cluster-redis", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Redis cluster driver for ObjectStack — implements IPubSub/ILock/IKV/ICounter against Redis using ioredis.", "type": "module", diff --git a/packages/services/service-cluster/CHANGELOG.md b/packages/services/service-cluster/CHANGELOG.md index 2184ca65b4d..fb4b8e13ed3 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,465 @@ # @objectstack/service-cluster +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index e868357c6e6..5395f0d1eb3 100644 --- a/packages/services/service-cluster/package.json +++ b/packages/services/service-cluster/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cluster", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Cluster Service for ObjectStack — pluggable PubSub/Lock/KV/Counter primitives. Memory driver included; postgres/redis drivers ship separately.", "type": "module", diff --git a/packages/services/service-datasource/CHANGELOG.md b/packages/services/service-datasource/CHANGELOG.md index 95bf060b588..74f8094a4d0 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,621 @@ # @objectstack/service-external-datasource +## 17.5.0 + +### Patch Changes + +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- 43e17b8: `os datasource introspect` now generates the authorised `*.object.ts` shape + + The Object draft rendered by `generateObjectDraft` (and served by + `POST /api/v1/datasources/:name/external/tables/:remote/draft`) used the + annotated-object-literal form. The director-seat ruling of 2026-09-12 (decision + batch #122 item 1) makes `ObjectSchema.create({ … })` the one authorised shape + for a `*.object.ts`, and the draft is destined for a committed `*.object.ts` — + the command's own `--out objects/wh_order.object.ts` example says so. Drafts + generated before this release were therefore written in the shape the platform + refuses. + + FROM → TO, for a draft you already committed — one mechanical rewrite: + + ```ts + // FROM + import type { ServiceObject } from '@objectstack/spec/data'; + + const wh_order: ServiceObject = { + name: 'wh_order', + // … + }; + + export default wh_order; + + // TO + import { ObjectSchema } from '@objectstack/spec/data'; + + export const wh_order = ObjectSchema.create({ + name: 'wh_order', + // … + }); + ``` + + Two things change beyond the wrapper. The spec import is now a **value** + import, because the factory runs when the file is evaluated — an `import type` + would be elided and the module would throw on its own first line. And the + export is **named only**: the `export default` is gone, matching the barrel the + scaffolder writes (`export { X } from './x.object.js'`). A barrel that imported + the draft as a default (`import X from './x.object.js'`) becomes + `import { X } from './x.object.js'`. + + Regenerating the draft is the other route: re-run + `os datasource introspect --table --out `. + + The two authored comment blocks the draft carries — the remote-primary-key note + and the ADR-0028 unprefixed-name TODO — are unchanged. + + Clause-②: no +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [32be735] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [d46deba] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [7c2c5ae] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [be5c602] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [9ccc417] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [62bce5c] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [e07843b] +- Updated dependencies [1f89ba0] +- Updated dependencies [1f0b341] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [467fa76] +- Updated dependencies [3bd28e2] +- Updated dependencies [beac798] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [0142415] +- Updated dependencies [a0920b4] +- Updated dependencies [ae7a35a] +- Updated dependencies [9bfbacb] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [98f722a] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [61609ed] +- Updated dependencies [172b4cf] +- Updated dependencies [fc6ddb8] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [9d81af7] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [3557f85] +- Updated dependencies [57c2b73] +- Updated dependencies [f09d412] +- Updated dependencies [adbbc5d] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8d76c2d] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [2491729] +- Updated dependencies [84880f9] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [55daf89] +- Updated dependencies [8d1f7ab] +- Updated dependencies [e01d347] +- Updated dependencies [dbddf02] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [d3958ba] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [15bf186] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [fc0db22] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [3e8b492] +- Updated dependencies [5b674f5] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [40626bd] +- Updated dependencies [e2c55ed] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [bdea10a] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/driver-turso@17.5.0 + - @objectstack/driver-mongodb@17.5.0 + - @objectstack/driver-sqlite-wasm@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index 3951961fe2e..d217927d3f0 100644 --- a/packages/services/service-datasource/package.json +++ b/packages/services/service-datasource/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-datasource", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "The datasource service (ADR-0015): external-table federation (introspect/draft/import/validate) + runtime UI datasource lifecycle (list/test/create/update/remove + REST routes). Open-source mechanism; the tier line falls on which ICryptoProvider / driver factory a host injects.", "type": "module", diff --git a/packages/services/service-i18n/CHANGELOG.md b/packages/services/service-i18n/CHANGELOG.md index 799e3fbaade..8f90c662bef 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,484 @@ # @objectstack/service-i18n +## 17.5.0 + +### Patch Changes + +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 9a906c141a2..2fc00c05c52 100644 --- a/packages/services/service-i18n/package.json +++ b/packages/services/service-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-i18n", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "I18n Service for ObjectStack — implements II18nService with file-based locale loading", "type": "module", diff --git a/packages/services/service-job/CHANGELOG.md b/packages/services/service-job/CHANGELOG.md index 67420b72cf1..6e3542698dd 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,504 @@ # @objectstack/service-job +## 17.5.0 + +### Patch Changes + +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index 5b347a805d3..4318d70992c 100644 --- a/packages/services/service-job/package.json +++ b/packages/services/service-job/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-job", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Job Service for ObjectStack — implements IJobService with setInterval and cron scheduling", "type": "module", diff --git a/packages/services/service-knowledge/CHANGELOG.md b/packages/services/service-knowledge/CHANGELOG.md index 74def67373c..85231373285 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,465 @@ # @objectstack/service-knowledge +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index 53bc307d472..c85ef7d257f 100644 --- a/packages/services/service-knowledge/package.json +++ b/packages/services/service-knowledge/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-knowledge", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Knowledge Service for ObjectStack — orchestrator implementing IKnowledgeService over pluggable IKnowledgeAdapter backends (RAGFlow, LlamaIndex, Dify, in-memory).", "type": "module", diff --git a/packages/services/service-messaging/CHANGELOG.md b/packages/services/service-messaging/CHANGELOG.md index 7144589d704..b7029a0b1db 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,738 @@ # @objectstack/service-messaging +## 17.5.0 + +### Minor Changes + +- a370073: `sys_inbox_message` rows now carry **`actor_id`** — who caused the notification — and the actor travels there end to end from the `emit()` that raised the event. + + Until now an inbox row could not answer "did I cause this?". The actor stopped one layer upstream on `sys_notification.actor_id`, and the shipped default permission sets grant a member no read on `sys_notification`, so the value was behind an FK hop into an object the reader cannot open. Consumers implementing the standard "do not notify me of my own action" rule had nothing to compare, and the visible failure was the notification that says *you* just did the thing you just did. + + The path, one leg per seam, no new read anywhere: + + - **`Notification.actorId?: string`** (`channel.ts`) — the per-recipient unit every channel implementation consumes gains an optional member, with the same semantics as `sys_notification.actor_id`. + - **`emit()`** projects `EmitInput.actorId` onto that unit on the P0 inline path, and **`enqueueDeliveries`** snapshots it into the delivery row's payload on the P1 outbox path — beside the rendered title/body, under the rule the enqueue path already states in its own comment: an event edited after enqueue cannot rewrite an in-flight send. `DeliveryPayload.actorId?: string` is declared rather than left to that type's index signature. + - **The dispatcher** reads it back off that snapshot in `processRow`. It deliberately does **not** re-read `sys_notification`, which would cost one read per delivery and break the snapshot rule. + - **The inbox channel** writes `actor_id: n.actorId ?? null`, and `sys_inbox_message` declares `actor_id` as a `sys_user` lookup. + + **A digest row keeps `actor_id` null by construction.** A collapsed group has no single actor, so asserting "you caused this" over a message that also carries other people's events would be wrong; `processDigestGroup` sets no actor and the object's own description says so. + + **Existing rows read `actor_id` null**, which a consumer's `row.actor_id === currentUserId` evaluates as "not mine" — the pre-change behaviour for rows written before this release. Nothing is backfilled: the value was never captured on those rows, so any backfill would be invented. +- 690f083: `NotificationDispatcher` reaps once per tick instead of once per claim, backs off while the outbox is idle, and `emit()` wakes it (#17610) + + **What an idle dispatcher cost.** Against an EMPTY `sys_notification_delivery` outbox every tick walked `partitionCount` partitions (default 8) and ran `claim()` and `claimDigest()` in each — and each of those opened with the environment-wide visibility-timeout reap before its candidate SELECT. Measured on a real `ObjectQL` + `SqlDriver`: **32 statements a tick, 16 of them the identical reap UPDATE**, on a fixed 500 ms interval that never let up, one loop per warm kernel. On remote Turso every statement is an HTTP round trip. + + **Now:** + + - **The reap runs once per tick**, before any claim — an idle tick is `1 + 2 × partitionCount` = 17 statements. Its predicate names no partition, so one run returns every claim that had expired when the tick began; a claim that expires during the tick is returned by the next one. A crashed node's `in_flight` rows are still recovered within one tick of `claimTtlMs` passing, and a claim is still never re-taken before its TTL. + - **The loop backs off while idle.** Every tick that claims nothing doubles the delay to the next, from `intervalMs` up to `maxIdleIntervalMs` (default 30 s; `MessagingServicePlugin` option `dispatchMaxIdleIntervalMs`). A tick that claims work snaps back to `intervalMs`. With the defaults, ten idle minutes are 24 ticks instead of 1,201. + - **`emit()` wakes the dispatcher.** `MessagingService.setOutbox(outbox, { onEnqueued })` fires once per `emit()` that enqueued at least one delivery; the plugin points it at the new `NotificationDispatcher.wake()`, which ticks immediately — or once more, right after a tick already in flight. + + **Latency bound.** A notification emitted in the process that runs the dispatcher goes out on the tick `wake()` starts, no later than before. While idle, work nobody announces is noticed within one backed-off interval, at most `maxIdleIntervalMs` (30 s by default): a deferred delivery coming due (retry schedule, quiet hours, digest window), a row enqueued by a process that does not run this dispatcher, and a crashed node's expired claim (recovered within `claimTtlMs` + `maxIdleIntervalMs`). Set `dispatchMaxIdleIntervalMs` to `dispatchIntervalMs` to keep the fixed interval. + + **Contract additions — all optional, nothing to change on upgrade.** `INotificationOutbox` gains an optional `reap(opts: ReapOptions)` — the visibility-timeout recovery `claim()` / `claimDigest()` already open with, as a method of its own — and `ClaimOptions` gains an optional `skipReap`. Both built-in stores (`SqlNotificationOutbox`, `MemoryNotificationOutbox`) implement them. A custom outbox without `reap()` keeps working as it is: the dispatcher probes for the method and, when it is absent, lets each claim reap as before — correct, at the old per-claim cost; implementing `reap()` and honouring `skipReap` is what earns the once-per-tick cost. Direct callers of `claim()` / `claimDigest()` are unaffected: without `skipReap` they reap exactly as before. Also new: `NotificationDispatcher.wake()`, the dispatcher's `maxIdleIntervalMs` option, and `MessagingService.setOutbox`'s optional second argument. +- e7fea46: `sys_notification_delivery` reaps its terminal-failure rows after **7 days** instead of 90 (#17611) + + **⚠️ Operational consequence, stated plainly: `dead` and `suppressed` delivery rows are now deleted 7 days after they were created.** Any report, SLA reading, dashboard or manual investigation that consulted them — "which notifications failed to send, and why" — must now read inside that window. Before this change those rows survived for 90 days. Nothing else about the table changes: `pending`, `in_flight` and `success` rows keep the same 90-day window they have always had, and no row is reaped sooner than before except the two terminal-failure statuses. + + **What was wrong.** Fan-out writes one delivery row per `(event × recipient × channel)`. A tenant with no transport configured for one of those channels dead-letters that channel's row on its **first** attempt, and every `notify` writes another one. Measured on a production tenant: 2,876 `email`/`dead` rows against 2,876 `inbox`/`success` rows, `max(attempts) = 1`, zero pending, growing +316 rows/day. Those rows carry no work — nothing ever claims, retries or acks them again — but they sat in the table the dispatcher's claim query reads on every hop for the full 90-day window, so the cost of every claim rose linearly with time. + + **The change** is one declaration on the object, using spec keys that already ship and are already consumed by the platform Reaper: + + ```ts + lifecycle: { + class: 'telemetry', + ttl: { field: 'created_at', expireAfter: '90d' }, + retention: { + maxAge: '7d', + onlyWhen: { status: { $in: ['dead', 'suppressed'] } }, + }, + }, + ``` + + `retention.onlyWhen` scopes the short window to the terminal-failure statuses — the same shape `sys_job_queue`, `sys_automation_run` and `sys_upload_session` already declare. No channel interface member, no new status value, no change to fan-out. + + The `ttl` leg is not new behaviour: it restates the 90-day bound the object has always declared. `lifecycle.retention` is a single block, so scoping it to terminal rows would otherwise have left `pending` / `in_flight` / `success` with **no age bound at all** — unbounding the larger half of this table's growth on the very change that exists to bound it. Both legs run: `LifecycleService.reapObject` takes `ttl` and `retention` in independent branches. `success` is deliberately outside the scope; delivery history stays at the table window. + + **If you override this object's lifecycle windows through the `lifecycle` settings namespace, re-read your configuration.** `retention_overrides.maxAge` for `sys_notification_delivery` used to move the whole table's window; it now moves the **terminal-failure** window only, and `expireAfter` moves the table window. An override left in place keeps parsing and keeps applying — to a narrower set of rows than it did before. + + **⚠️ This is worth nothing where the Reaper does not run.** The whole benefit is delivered by `LifecycleService`, which `OS_LIFECYCLE_DISABLED=1` or the plugin switch turns off. A deployment with lifecycle disabled kept these rows forever before this change and keeps them forever after it; a declaration is not a sweeper. Check that the Reaper is enabled before reading this entry as a bound on your table. +- a9096af: `HttpDispatcher` reaps once per tick instead of once per partition, backs off while `sys_http_delivery` is idle, and `enqueueHttp()` / `redeliverHttp()` wake it (#17623) + + **What an idle dispatcher cost.** Against an EMPTY `sys_http_delivery` outbox every tick walked `partitionCount` partitions (default 8) and ran `claim()` in each — and each claim opened with the environment-wide visibility-timeout reap before its candidate SELECT. Measured on a real `ObjectQL` + `SqlDriver`: **16 SQL statements a tick, 8 of them the identical reap UPDATE**, on a fixed 500 ms `setInterval` that never let up, one loop per warm kernel. It is the shape #17610 removed from `NotificationDispatcher`, still running beside it. On remote Turso every statement is an HTTP round trip. + + **Now:** + + - **The reap runs once per tick**, before any claim — an idle tick is `1 + partitionCount` = 9 statements. Its predicate names no partition, so one run returns every claim that had expired when the tick began; a claim that expires during the tick is returned by the next one. A crashed node's `in_flight` rows are still recovered within one tick of `claimTtlMs` passing, and a claim is still never re-taken before its TTL. + - **The loop backs off while idle.** Every tick that claims nothing doubles the delay to the next, from `intervalMs` up to `maxIdleIntervalMs` (default 30 s, the notification dispatcher's default). A tick that claims work snaps back to `intervalMs`. With the defaults, ten idle minutes are 24 ticks and 216 statements instead of 1,201 ticks and 19,216. + - **`MessagingServicePlugin`'s `dispatchMaxIdleIntervalMs` sets the ceiling for both dispatchers**, the way `dispatchIntervalMs` and `partitionCount` already govern both. + - **Writes in this process wake the dispatcher.** `MessagingService.setHttpOutbox(outbox, { onEnqueued })` fires after an `enqueueHttp()` that enqueues a delivery — not one that parks an undeliverable record, which is `dead` on arrival — and after a `redeliverHttp()`. The plugin points it at the new `HttpDispatcher.wake()`, which ticks immediately, or once more right after a tick already in flight. + + **Latency bound.** A delivery enqueued or redelivered in the process that runs the dispatcher goes out on the tick `wake()` starts. While idle, work nobody announces is noticed within one backed-off interval, at most `maxIdleIntervalMs` (30 s by default): + + - a retry coming due is attempted less than `min(its delay + intervalMs, maxIdleIntervalMs)` late, because the backoff restarts from `intervalMs` at the attempt that scheduled it; + - a row enqueued by a process that does not run this dispatcher; + - a crashed node's expired claim, recovered within `claimTtlMs` + `maxIdleIntervalMs` (about 35 s at defaults, where it was about 5.5 s). + + Set `dispatchMaxIdleIntervalMs` to `dispatchIntervalMs` to keep the fixed interval. + + **Contract additions — all optional, nothing to change on upgrade.** `IHttpOutbox` gains an optional `reap(opts: HttpReapOptions)` — the visibility-timeout recovery `claim()` already opens with, as a method of its own — and `HttpClaimOptions` gains an optional `skipReap`. Both built-in stores (`SqlHttpOutbox`, `MemoryHttpOutbox`) implement them. A custom outbox without `reap()` keeps working as it is: the dispatcher probes for the method and, when it is absent, lets each claim reap as before — correct, at the old per-claim cost. Direct callers of `claim()` are unaffected: without `skipReap` they reap exactly as before. Also new: `HttpDispatcher.wake()`, the dispatcher's `maxIdleIntervalMs` option, the `HttpReapOptions` type, and `MessagingService.setHttpOutbox`'s optional second argument. + + **One loop, not two copies.** The timer loop — idle backoff, collapsing wakes into one follow-up tick, `stop()` — moved out of `NotificationDispatcher` into a module both dispatchers share. `NotificationDispatcher`'s behaviour and public surface are unchanged; its #17610 tests pass as they were. +- 4be4e04: `IHttpOutbox.ack()` takes an optional third argument, the claim credential, and `HttpDispatcher` now always passes it (#17634). A late ack from a claim the visibility-timeout reap had taken back — a send that outran `claimTtlMs` while another dispatcher re-claimed the row — used to write its outcome by row id over that dispatcher's live attempt: a delivery still in progress could be marked `dead`, or one attempt's outcome overwrite another's. Handed the credential, `SqlHttpOutbox` and `MemoryHttpOutbox` perform the compare-and-set `INotificationOutbox.ack()` has performed since #11859: the outcome is written only while the row is still `in_flight` under the same (`claimedBy`, `claimedAt`) pair `claim()` stamped on it. A lost claim writes nothing and throws the new `HttpAckError` (`DELIVERY_NOT_ELIGIBLE`, the code this package already raises for a delivery row in the wrong state); the dispatcher logs `http-dispatcher: ack refused, claim no longer held`, carries on with the rest of its batch, and whoever holds the row re-drives the delivery. + + Nothing written against the two-argument `ack(id, result)` has to change. An `IHttpOutbox` implementation that does not read the third argument compiles and works as before, and a caller that does not pass it gets the by-id write it always got — that arity is deprecated, because it checks no ownership. New exports: `HttpClaimCredential` and `HttpAckError`. A subclass that overrides a built-in store's `ack()` should forward the third argument to `super.ack()`, or its dispatcher acks keep the old unchecked write. +- a2c2852: Notification fan-out asks a channel whether the tenant can send on it before writing anything, so a channel with no transport no longer produces `sys_notification_delivery` rows that exist only to dead-letter (#17732). + + `MessagingChannel` gains one **optional** member, `isAvailable(ctx, { organizationId })`, answering `{ available: true }` or `{ available: false, reason }` from the closed vocabulary `CHANNEL_UNAVAILABLE_REASONS` (today: `transport_not_configured`). `emit()` consults it once per channel per emit — availability is a property of `(tenant × channel)`, not of a recipient — and a channel that answers unavailable gets no delivery row and no `send()` call on either the outbox (P1) or the inline (P0) path. + + - **Optional means available.** A channel that does not implement the member is treated exactly as before. Every existing implementation, in this repo and in yours, keeps working unchanged with no edit; the same is true of a channel that is registered but unknown to this version. ⛔ There is no way to configure the opposite default. + - **The suppression is recorded, not swallowed.** `sys_notification` gains one key, `suppressed_channels` — `[{ channel, reason }]`, `NULL` when nothing was suppressed — written in the *same* insert that creates the event row, so the feature costs no additional write. `EmitResult` gains the matching `suppressed` array, so a caller is never handed a delivery count that silently omits a channel it asked for. + - **The `email` channel answers from the transport it was handed** — a service-registry lookup, no I/O, nothing cached. Mail configuration in this tree is the `mail` settings namespace at `scope: 'global'`, materialised into a single in-memory transport that the settings change bus hot-swaps, so there is no per-tenant row to read and a memoized answer would survive the settings save that fixed it. The query still takes the tenant context so a future tenant-scoped transport needs no interface change. + - **A probe that throws is treated as available** and logged at `warn`: a broken availability check degrades into today's behaviour, never into a silent notification outage. + - ⚠️ **Unchanged on purpose**: a channel named in `channels` that is not *registered* at all keeps its existing path — the inline fan-out reports it as a failed delivery, the outbox enqueues a row the dispatcher dead-letters. It has no implementation to ask, and widening this ruling to cover it is filed separately. +- e07eecf: Mount the email and SMS channels per lookup instead of deciding once at `kernel:ready` + + The messaging plugin registered its email and SMS channels behind `if (getEmail())` / + `if (getSms())` inside a `kernel:ready` hook. That guard ran exactly once, so a transport + service that registered later in the same boot — from a plugin ordered after this one, from + `kernel:bootstrapped` / `kernel:listening`, or at runtime — never got its channel, and every + `notify` naming that channel was refused as "not registered" for the life of the process. + + New public surface (which is why this grades `minor` and not `patch`, per the 2026-09-04 ruling + that a purely additive widening of a published surface takes at least a minor): + `MessagingService.registerChannelProvider(id, resolve)` mounts a channel that is resolved on + every lookup, and the plugin now mounts both channels through it: the mount tracks the + transport instead of recording a verdict about it, and the dispatcher — which has always + looked channels up dynamically — picks up a late transport without a restart. A composition + that never registers the transport is unchanged: the channel is not mounted, fan-out refuses + it, no delivery row is written, and nothing is recorded in + `sys_notification.suppressed_channels`. + +### Patch Changes + +- 920f887: `DbQueueAdapter` backs off while `sys_job_queue` is idle instead of polling flat at 1 s, and the loop that does it is now published from `@objectstack/core` as `DispatchLoop` (#17612). + + A registered-but-idle queue issued **3600 candidate reads an hour, per queue**, whatever was in the table — on a remote driver, 3600 HTTP round trips an hour of pure idle cost. Measured over one simulated idle hour on the engine boundary the adapter really talks to: **3601 reads before, 124 after**, with the flat-poll number re-measured on the same harness as a control so the new one is a reading about the backoff rather than about a loop that stopped ticking. + + - **One mechanism, not a third copy.** The idle-backoff loop was written for `NotificationDispatcher` (#17610), shared with `HttpDispatcher` (#17623), and lived unexported inside `@objectstack/service-messaging`. `DbQueueAdapter` was the third polling worker needing it. It moves to `@objectstack/core` — the package all three already depend on — because it is a timing primitive owned by neither the messaging domain nor the queue domain, and having `service-queue` depend on `service-messaging` to reach it would invert the dependency direction. **New export from `@objectstack/core`: `DispatchLoop`, `DispatchLoopOptions`, `DEFAULT_MAX_IDLE_INTERVAL_MS`.** + - **Nothing published moved.** `@objectstack/service-messaging` exports only its `index`, which never carried the loop; its two dispatchers now import it from `@objectstack/core` and its own surface is byte-unchanged. + - **New option `DbQueueAdapterOptions.maxIdleIntervalMs`** (default 30 s). Each tick that claims nothing doubles the delay to the next from `pollIntervalMs` up to this ceiling; anything claimed, and every wake, snaps it straight back. **Setting it at or below `pollIntervalMs` restores the flat poll exactly.** + - ⚠️ **What the backoff costs, and what it does not.** Work published through this adapter now wakes the loop, so a due `publish()` and `replay()` are picked up at the base interval as before — the ceiling is never on their latency path. What it does cost is up to `maxIdleIntervalMs` of extra latency on work this process was never told about: a row another node wrote, a deferred row coming due, a crashed worker's lease expiring. A deferred `publish()` deliberately does **not** wake the loop, since that tick would claim nothing and would throw the backoff away. +- 879b512: `email-channel` and `sms-channel` — `send()` now REFUSES when its transport is not installed, instead of returning `{ ok: true }` for a delivery nothing was sent for (#18424). + + Two members of one object answered one condition differently, and the one a caller acts on said success: `isAvailable()` correctly returned `{ available: false, reason: 'transport_not_configured' }` while `send()` returned `{ ok: true }` — "capability not installed — no-op". The `sys_notification_delivery` row reached `status: 'success'`, nothing went red, no row dead-lettered, and a deployment with an unconfigured email or SMS transport reported every notification as delivered. + + - **`send()` now answers with the reason `isAvailable()` already returns.** `{ ok: false, error: "transport_not_configured: no 'email' service is registered; nothing was sent to ''" }`. The token is the declared `CHANNEL_UNAVAILABLE_REASONS` member, held inside that closed set by its type annotation — ⛔ no new error code, so nothing new to aggregate on. + - **`classifyError()` grades it `permanent`**, so the row dead-letters on attempt one rather than burning the retry ladder against a transport no attempt can install. Driven, ⛔ not assumed: in the composition `MessagingServicePlugin` ships, the mount gate (`lazyChannelMount`, #18050) already answers this same condition by unmounting the channel, and the dispatcher acks such a row `dead` with `attempts: 1`. Both compositions now end one condition the same way. + - **⛔ Not a suppression.** A suppression is fan-out's pre-write answer on `sys_notification.suppressed_channels`; by the time `send()` runs the delivery row exists and `SendResult` has no suppression arm. `channel-availability.test.ts`'s boundary — an unmounted channel is REFUSED, ⛔ not suppressed (#18041) — is unmoved, and this change lands on its refusal side. + + **What changes for a consumer:** a delivery attempted with no transport now reports failure. If you compose these channels yourself through the public `createEmailChannel` / `createSmsChannel` exports with a resolver that can answer `undefined`, deliveries that silently "succeeded" will now appear as `dead` rows carrying `transport_not_configured` — register the transport, or drop the channel from the notify's channel list. Deployments using `MessagingServicePlugin` are unaffected: there the channel is not mounted at all while its transport is absent, and fan-out already refused it. + + Clause-②: no +- cd5fdaa: docs(email): the shipped carriers said "best-matching locale"; the resolver matches `(name, locale)` exactly (#18499) + + Clause-②: no — no accept set moves and no published payload key changes; the + corrected prose ships as JSDoc in each package's `dist/*.d.ts` (and, for + `@objectstack/service-messaging`, inside the bundled `dist/index.js`), which is + why this is a changeset rather than `skip-changeset`. + + `packages/plugins/plugin-email/src/template-loader.ts` already enumerates + "the EmailService picks the best-matching locale" as a FALSE declaration, and + three shipped carriers still stated it. Measured against the code at this + branch's base rather than against the card's transcription: + + - `createSysEmailTemplateLoader.load` — `locale` given ⇒ exact `{ name, locale }` + match ordered by `id`, or `null`; `locale` absent ⇒ `{ name, locale: 'en-US' }` + first, and only if that misses `{ name }` ordered by `locale` ascending; + - `EmailService.resolveAndRenderTemplate` — `wanted = input.locale?.trim() || + 'en-US'`, then exactly one retry at the literal `'en-US'` when the call NAMED a + locale, then `TEMPLATE_NOT_FOUND`; the unpinned rung is reachable only for a + call that named no locale. + + No language-subtag folding anywhere on that path, and nothing that could be + called a "best match". Corrected: + + - `sys_email_template`'s object doc (`@objectstack/platform-objects`) now states + the exact match, the single `en-US` rung and the no-locale last resort; + - `sys_notification_template.locale`'s sibling-declaration comment + (`@objectstack/service-messaging`) said "both resolve a template by + best-matching locale", which was false in a second way: the two resolvers do + not agree. `NotificationTemplateStore.load` walks `(topic, channel, locale)` + through a candidate list — the named tag, its primary subtag, then + `DEFAULT_LOCALE` (`'en'`) — so it DOES fold a subtag, where + `sys_email_template` does not. Only the shared 16-char BCP-47 bound is shared; + the resolution is not, and the comment now says so; + - `template-loader.ts`'s own "What was wrong" block quoted two sentences it can + no longer quote — one was already stale at this base (the + `EmailTemplateDefinitionSchema.locale` text it reproduces has zero occurrences + in `packages/spec` today) and the other is corrected above. Both bullets are + now cited rather than quoted, so a later rewording cannot strand them again. + + No resolution behaviour changes: every edit in this changeset is prose. +- 564ac2f: `sms-channel` now declares `isAvailable()`, so fan-out can suppress it on an absent transport exactly as it already suppresses `email` (#18567 — #17732's unfinished half). + + `email-channel` was the only implementation of the optional `MessagingChannel.isAvailable` member in the repository. Fan-out's `resolveChannelAvailability` treats a channel without that member as AVAILABLE — the deliberate default that keeps every third-party channel working — so one condition, "there is no transport", was answered two ways depending on which channel was asked: `email` was suppressed before any `sys_notification_delivery` row was written, while `sms` got a row per recipient that the pipeline could only dead-letter. + + - **The answer is the token `send()` already refuses with**, read off the `TRANSPORT_NOT_CONFIGURED` constant rather than retyped: `{ available: false, reason: 'transport_not_configured' }`. ⛔ No new error code and no new reason token — the vocabulary stays the closed `CHANNEL_UNAVAILABLE_REASONS` set, so the refusal on the delivery row, the suppression record on `sys_notification.suppressed_channels` and the availability answer all name one condition. + - **The probe does no I/O.** It is a service-registry closure call, so fan-out consults it inline and holds no cache — the `sms` settings namespace is `scope: 'global'` and its transport is hot-swapped by the settings change bus, so a memo would save nothing and would keep answering "unavailable" straight through the settings save that fixed it. + - **⛔ It does not weaken #18424 / PR #18562.** `send()`'s refusal is unchanged; it now answers the residue a pre-write suppression cannot cover — a transport present at emit and gone by dispatch, where the delivery row already exists. + + **What changes for a consumer:** if you compose the `sms` channel yourself through the public `createSmsChannel` export with a resolver that can answer `undefined`, an `emit()` targeting `sms` with no transport installed now writes **no** `sys_notification_delivery` rows for that channel and instead records `{ channel: 'sms', reason: 'transport_not_configured' }` on the `sys_notification` event's `suppressed_channels`, returned to the caller as `EmitResult.suppressed`. Those are the same rows that previously existed only to dead-letter, so `result.enqueued` drops and `result.suppressed` gains an entry. Deployments using `MessagingServicePlugin` are unaffected: there the mount gate refuses first and the channel is never registered, which is a composition fact and ⛔ not a suppression. + + Clause-②: no +- 7e6ca17: fix(plugin-approvals, service-automation, service-messaging): five system objects title their records with a text formula instead of the raw id (#20015) + + Clause-②: no + + ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation, and an explicit `nameField` takes precedence over the render-only `titleFormat`. Five system objects declared `nameField: 'id'` beside a composite `titleFormat`. A renderer that follows ADR-0079's order therefore showed the raw record id as the record page's title for: + + - `sys_approval_request`, whose `titleFormat` is `{process_name} · {record_id}`; + - `sys_approval_action`, whose `titleFormat` is `{action} · {step_name}`; + - `sys_approval_approver`, whose `titleFormat` is `{approver} · {request_id}`; + - `sys_automation_run`, whose `titleFormat` is `{flow_name} · {node_id}`; + - `sys_http_delivery`, whose `titleFormat` is `{label} → {url}`. + + Each object now declares `display_title`, a formula field with `returnType: 'text'` over the same columns, and points `nameField` and `displayNameField` at it. This is the migration the `titleFormat` schema text prescribes: "a composite to a formula field designated as nameField". The record title is now the text the `titleFormat` described. Where a source column is nullable (`step_name`, `node_id`, `label`), a row without it is titled by the other column alone. + + A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads and write responses now carry `display_title`. For these objects the server-side title accessor (`resolveRecordTitle`) now returns the formula's text instead of the raw id. + + `titleFormat` stays on all five objects, unchanged, for renderers that still read it first. `$search` resolution is unchanged: a formula field is never a search target, and neither was `id`. +- d4c897e: fix(plugin-approvals, plugin-security, service-messaging, service-realtime): nine system objects that relied on `titleFormat` declare a title pointer, so their record title is no longer the raw id (#20044) + + Clause-②: no + + ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation, and an explicit `nameField` takes precedence over the render-only `titleFormat`. Nine system objects declared a `titleFormat` and no pointer. When such an object is registered, the registry's designate-only pass picks the first title-eligible field as `nameField`, and for these nine that field is `id`. A `/meta` read serves that pointer as if it had been declared, so a renderer that follows ADR-0079's order showed the raw record id as the record page's title. + + Eight of the titles are composites. Each of those objects now declares `display_title`, a formula field with `returnType: 'text'` over the same columns, and points `nameField` and `displayNameField` at it: + + - `sys_approval_delegation`: `{delegator_id} → {delegate_id}`; + - `sys_position_permission_set`: `{position_id} → {permission_set_id}`; + - `sys_user_permission_set`: `{user_id} → {permission_set_id}`; + - `sys_user_position`: `{user_id} → {position}`; + - `sys_notification_delivery`: `{channel} → {recipient_id}`; + - `sys_notification_preference`: `{user_id} · {topic} · {channel}`; + - `sys_notification_subscription`: `{principal} · {topic}`; + - `sys_presence`: `{user_id} ({status})`. + + `sys_notification_receipt`'s title is the single column `{state}`, so its `nameField` and `displayNameField` now name `state` directly. + + This is the migration the `titleFormat` schema text prescribes: "Migrate a single-field title to nameField, a composite to a formula field designated as nameField". The record title is now the text the `titleFormat` described. Every column these titles read is required, so the formulas carry no null guard. Each formula reads only its own row's columns, never a field of a looked-up record. + + A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads and write responses of the eight objects now carry `display_title`, and the server-side title accessor (`resolveRecordTitle`) returns the title text instead of the raw id. No row scope, permission set or API method changes. + + `titleFormat` stays on all nine objects, unchanged, for renderers that still read it first. The set of fields `$search` scans is unchanged: a formula field is never a search target, and neither was `id`. On `sys_notification_receipt`, `state` was already in the set and now leads it. No search-companion column is provisioned for any of the nine. + + The new `display_title` label and help text are in each package's English bundle. The zh-CN, ja-JP and es-ES bundles carry the generator's English fill for them, recorded in the source-hash companions. +- 7010085: fix(service-messaging): the durable fan-out refuses a channel nobody registered instead of writing a delivery row for it + + `MessagingService.emit()` on the reliable-delivery (outbox) path wrote one + `sys_notification_delivery` row per recipient for a channel the composition had + never registered, and the dispatcher dead-lettered every one of them on attempt + one. The inline path had always refused this case; only the durable path wrote + the rows, so a deployment whose flows notify on `['inbox','email']` without an + email plugin accumulated guaranteed-dead rows in the hot delivery table. + + The durable path now reports the same failed delivery outcome the inline path + reports — `ok: false`, `error: "channel '' not registered"`, counted in + `EmitResult.failed` — and writes no row. The refusal is logged once per channel + per emit with the number of rows it refused, not once per recipient. + + The refusal is deliberately **not** recorded in + `sys_notification.suppressed_channels`: that key answers "why can this tenant not + send on this channel", and an unregistered channel is a composition fact, + identical for every tenant in the process. The event row's column set is + unchanged. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index ba231c205a3..7c21a4cb8b4 100644 --- a/packages/services/service-messaging/package.json +++ b/packages/services/service-messaging/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-messaging", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Messaging Service for ObjectStack — outbound notification dispatch (ADR-0012). Ships the MessagingChannel registry, emit() fan-out, and the always-on inbox channel; other channels (email/webhook/push/IM) plug in.", "type": "module", diff --git a/packages/services/service-package/CHANGELOG.md b/packages/services/service-package/CHANGELOG.md index 9100c037c22..7febd436897 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,482 @@ # @objectstack/service-package +## 17.5.0 + +### Patch Changes + +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index 3af381fdcd7..7a0f1dec186 100644 --- a/packages/services/service-package/package.json +++ b/packages/services/service-package/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-package", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Package management service for ObjectStack — publish, install, and manage packages", "type": "module", diff --git a/packages/services/service-queue/CHANGELOG.md b/packages/services/service-queue/CHANGELOG.md index ea83913bab9..65b5feb4cfc 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,528 @@ # @objectstack/service-queue +## 17.5.0 + +### Minor Changes + +- 920f887: `DbQueueAdapter` backs off while `sys_job_queue` is idle instead of polling flat at 1 s, and the loop that does it is now published from `@objectstack/core` as `DispatchLoop` (#17612). + + A registered-but-idle queue issued **3600 candidate reads an hour, per queue**, whatever was in the table — on a remote driver, 3600 HTTP round trips an hour of pure idle cost. Measured over one simulated idle hour on the engine boundary the adapter really talks to: **3601 reads before, 124 after**, with the flat-poll number re-measured on the same harness as a control so the new one is a reading about the backoff rather than about a loop that stopped ticking. + + - **One mechanism, not a third copy.** The idle-backoff loop was written for `NotificationDispatcher` (#17610), shared with `HttpDispatcher` (#17623), and lived unexported inside `@objectstack/service-messaging`. `DbQueueAdapter` was the third polling worker needing it. It moves to `@objectstack/core` — the package all three already depend on — because it is a timing primitive owned by neither the messaging domain nor the queue domain, and having `service-queue` depend on `service-messaging` to reach it would invert the dependency direction. **New export from `@objectstack/core`: `DispatchLoop`, `DispatchLoopOptions`, `DEFAULT_MAX_IDLE_INTERVAL_MS`.** + - **Nothing published moved.** `@objectstack/service-messaging` exports only its `index`, which never carried the loop; its two dispatchers now import it from `@objectstack/core` and its own surface is byte-unchanged. + - **New option `DbQueueAdapterOptions.maxIdleIntervalMs`** (default 30 s). Each tick that claims nothing doubles the delay to the next from `pollIntervalMs` up to this ceiling; anything claimed, and every wake, snaps it straight back. **Setting it at or below `pollIntervalMs` restores the flat poll exactly.** + - ⚠️ **What the backoff costs, and what it does not.** Work published through this adapter now wakes the loop, so a due `publish()` and `replay()` are picked up at the base interval as before — the ceiling is never on their latency path. What it does cost is up to `maxIdleIntervalMs` of extra latency on work this process was never told about: a row another node wrote, a deferred row coming due, a crashed worker's lease expiring. A deferred `publish()` deliberately does **not** wake the loop, since that tick would claim nothing and would throw the backoff away. + +### Patch Changes + +- 8a017af: `sys_job_queue`'s claim path no longer sorts the whole queue on every poll, and a job's due time is now a SQL predicate instead of a filter applied after `LIMIT` (#17612). + + `DbQueueAdapter.claimBatch` — the 1s poll every `DbQueueAdapter` runs — read the queue as `WHERE queue = ? AND status = 'pending' ORDER BY priority ASC, scheduled_for ASC`, while `sys_job_queue` declared `['queue','status','scheduled_for']`. The sort's **first** key, `priority`, was in no declared index at all, so the equality prefix seeked and the planner then built a sorter over every pending row in the queue, every tick. Measured on both Turso faces: + + ``` + SEARCH sys_job_queue USING INDEX idx_sys_job_queue_queue_status_scheduled_for (queue=? AND status=?) + USE TEMP B-TREE FOR ORDER BY + ``` + + - **The declared index becomes `['queue','status','priority','scheduled_for']`**, replacing `['queue','status','scheduled_for']` — the table still declares three. The full-queue sort is gone on both faces; what remains is a sorter bounded to rows tying on the whole indexed prefix, because a paged read carries one ORDER BY term the caller never writes — the unique tie-breaker of the deterministic-paging contract (ADR-0053 D-A1), here `id`. ⛔ That last term is deliberately **not** closed by appending `id` to the index: `id` is an unbounded `Field.text`, and a text column a declared index keys on without a `maxLength` makes MySQL reject the index DDL outright (`check:keyed-text-bounds`, ER_BLOB_KEY_WITHOUT_LENGTH). + - **Due-ness moved into `where`** as `$or: [{ scheduled_for: null }, { scheduled_for: { $lte: now } }]`, the same shape `SqlOutboxStore.claim` uses. It had been a JS filter applied to rows `LIMIT` had already chosen, so a window full of not-yet-due high-priority jobs hid already-due work behind it indefinitely: at the default `batchSize: 10` (candidate window 30), 30 future-dated `priority: 1` rows plus one due `priority: 100` row claimed **0** per poll, forever. It now claims 1. + - **`priority` still decides claim order.** The alternative — dropping it from the sort — would have left a declared, documented field (`Lower = higher priority`) with no runtime effect at all. + - ⚠️ **On an existing database the superseded index is not dropped.** The retrofit adds `idx_sys_job_queue_queue_status_priority_scheduled_for` and leaves `idx_sys_job_queue_queue_status_scheduled_for` in place (measured: 3 indexes before, 4 after, no row touched), so a provisioned table carries one redundant index until an operator drops it through the migrate-plan path. A freshly created table gets three. +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index fd7f8ecd93e..59ba6d7bf85 100644 --- a/packages/services/service-queue/package.json +++ b/packages/services/service-queue/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-queue", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Queue Service for ObjectStack — implements IQueueService with in-memory and durable DB-backed (sys_job_queue) adapters", "type": "module", diff --git a/packages/services/service-realtime/CHANGELOG.md b/packages/services/service-realtime/CHANGELOG.md index 9089ad39e3d..cfc5b71b044 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,530 @@ # @objectstack/service-realtime +## 17.5.0 + +### Patch Changes + +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- d4c897e: fix(plugin-approvals, plugin-security, service-messaging, service-realtime): nine system objects that relied on `titleFormat` declare a title pointer, so their record title is no longer the raw id (#20044) + + Clause-②: no + + ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation, and an explicit `nameField` takes precedence over the render-only `titleFormat`. Nine system objects declared a `titleFormat` and no pointer. When such an object is registered, the registry's designate-only pass picks the first title-eligible field as `nameField`, and for these nine that field is `id`. A `/meta` read serves that pointer as if it had been declared, so a renderer that follows ADR-0079's order showed the raw record id as the record page's title. + + Eight of the titles are composites. Each of those objects now declares `display_title`, a formula field with `returnType: 'text'` over the same columns, and points `nameField` and `displayNameField` at it: + + - `sys_approval_delegation`: `{delegator_id} → {delegate_id}`; + - `sys_position_permission_set`: `{position_id} → {permission_set_id}`; + - `sys_user_permission_set`: `{user_id} → {permission_set_id}`; + - `sys_user_position`: `{user_id} → {position}`; + - `sys_notification_delivery`: `{channel} → {recipient_id}`; + - `sys_notification_preference`: `{user_id} · {topic} · {channel}`; + - `sys_notification_subscription`: `{principal} · {topic}`; + - `sys_presence`: `{user_id} ({status})`. + + `sys_notification_receipt`'s title is the single column `{state}`, so its `nameField` and `displayNameField` now name `state` directly. + + This is the migration the `titleFormat` schema text prescribes: "Migrate a single-field title to nameField, a composite to a formula field designated as nameField". The record title is now the text the `titleFormat` described. Every column these titles read is required, so the formulas carry no null guard. Each formula reads only its own row's columns, never a field of a looked-up record. + + A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads and write responses of the eight objects now carry `display_title`, and the server-side title accessor (`resolveRecordTitle`) returns the title text instead of the raw id. No row scope, permission set or API method changes. + + `titleFormat` stays on all nine objects, unchanged, for renderers that still read it first. The set of fields `$search` scans is unchanged: a formula field is never a search target, and neither was `id`. On `sys_notification_receipt`, `state` was already in the set and now leads it. No search-companion column is provisioned for any of the nine. + + The new `display_title` label and help text are in each package's English bundle. The zh-CN, ja-JP and es-ES bundles carry the generator's English fill for them, recorded in the source-hash companions. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index 6de40730c70..573f810743e 100644 --- a/packages/services/service-realtime/package.json +++ b/packages/services/service-realtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-realtime", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Realtime Service for ObjectStack — implements IRealtimeService with WebSocket and in-memory pub/sub", "type": "module", diff --git a/packages/services/service-settings/CHANGELOG.md b/packages/services/service-settings/CHANGELOG.md index 7c756981ad9..f6bf47d2fd9 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,657 @@ # @objectstack/service-settings +## 17.5.0 + +### Minor Changes + +- d0f1845: **BREAKING for per-app translation bundles** — the translation bundle type splits in two: `settings` is a PLATFORM group and a per-app bundle may no longer declare it (#15178) + + Clause-②: yes + + `TranslationDataSchema` served two different bundles at once — the per-app one an + application authors (`stack.translations`, `defineTranslationBundle`) and the + code-authored bundles the platform packages ship. It now names the **per-app** + bundle entry and declares ten groups; the new `PlatformTranslationDataSchema` / + `PlatformTranslationBundleSchema` (types `PlatformTranslationData` / + `PlatformTranslationBundle`) carry the eleven-group platform face, `settings` + included. + + ### Migration — FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `defineTranslationBundle({ 'zh-CN': { settings: { mail: { title: '邮件投递' } } } })` | delete the `settings` group — there is no per-app replacement key | + | `defineStack({ translations: [{ 'zh-CN': { settings: … } }] })` | delete the `settings` group from the bundle entry | + | `const b: TranslationBundle = { en: { settings: … } }` — a PLATFORM package's own bundle | `const b: PlatformTranslationBundle = { en: { settings: … } }` | + | `const d: TranslationData = { settings: … }` — a PLATFORM package's own locale entry | `const d: PlatformTranslationData = { settings: … }` | + + **The one-line fix for an application: delete the `settings` group.** Settings copy + is not application-authorable at all — `settings` is keyed by + `SettingsManifest.namespace` and only platform code declares a manifest, so the + only namespaces a per-app entry could ever address were the platform's own. + `settingsCommon` is **not** affected — the Settings UI shell strings (the source + badges, under `settingsCommon.sourceLabels`) stay on the per-app face; only the + per-namespace manifest copy under `settings` leaves. + Run `os migrate meta --from 17` to list the mechanical edits for existing + sources; apply them by hand. + + ### What the deletion changes, which is not nothing + + ⚠️ This is **not** a lossless delete, and the record says so rather than claiming + the house phrase. Both bundles load into ONE served tree — `AppPlugin`'s + `loadTranslations` and every platform plugin's `kernel:ready` contribution both + call `II18nService.loadTranslations`, which deep-merges — and the + `resolveSettings*` family and the console's settings labels read that merged + tree. So an app-authored `settings` branch did resolve. + + **It was a gap filler, not an override.** The app's bundles are loaded in + `AppPlugin`'s own `start()` (kernel Phase 2); the platform's settings + translations arrive from `SettingsServicePlugin`'s `kernel:ready` hook (Phase + 3); `deepMerge` gives the **later** source the leaf. So the platform won every + key both bundles defined, and a per-app entry rendered **only where the platform + bundle carried no string for that key and locale** — the platform ships `en`, + `zh-CN`, `ja-JP` and `es-ES`. + + **What to expect after upgrading.** Where the platform already carried the + string, nothing changes on screen — that value was the one being served all + along. Where your entry was filling a gap, that Settings screen now renders the + **manifest's own literal, which is English** (the `?? fallback` every + `resolveSettings*` helper ends in). Those are the screens to re-read. If a + platform string is wrong or missing for your locale, correct it in the platform + bundle (`@objectstack/service-settings`'s `settingsBuiltinTranslations`) — do + not re-add the app-side copy, which the platform overwrites on every boot + wherever it has its own value. + + No deprecation window: the per-app door refuses the key by name from this major, + and the rejection carries the prescription above. + + ### Unchanged + + The registered `translation` metadata type (`TranslationItemSchema`) is not + changed by THIS entry — this ruling covers the file-authored bundle. (Superseded + in the same release: #19620 narrows the item door too; see its own changeset.) `GET + /api/v1/i18n/translations/:locale` still declares it on its response, because the + served document is the merged tree; `GetTranslationsResponseSchema` is typed + against the platform face for exactly that reason. + + Ruling batch #132 item 2 letter ② (2026-09-13) — 「同意」. The card's original + "removal" disposition is struck: `settings` is a live platform key. + + + +### Patch Changes + +- 24b7085: The auth settings console states the email-verification rule each audience posture enforces (#20413). + + Clause-②: no + + The Audience group description and the `audience_posture` help said every posture other than invitation-only forces email verification on. That stopped being true when posture `open` began honouring a deployment's opt-out. Both strings, in the manifest and in the `en`, `zh-CN`, `es-ES` and `ja-JP` bundles, now state the rule the auth plugin enforces: + + - `email_domain` always forces email verification on. + - `open` forces it on unless the deployment turns it off, with `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false` or `emailAndPassword.requireEmailVerification: false` in the stack config. + - A `false` saved in the settings console is refused under `open`. + + Text only: no key, option, default or accepted value changes. +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- f6189a4: Stop reporting an unmounted `sys_audit_log` as a failed audit write. + + `buildConfigChangeAuditSink` wrote the `config_change` compliance row blind and reported + the throw it got back. On a deployment that never mounted the OPTIONAL + `@objectstack/plugin-audit` — `objectstack serve --preset minimal`, an EE host that mounts + no `audit`, a hosted tenant kernel — there is no ledger to write to, so every tenant + settings write produced a durability complaint about a deployment behaving exactly as + composed, plus an `Insert operation failed` line per write from the engine one frame down. + + The sink now probes the engine registry for `sys_audit_log` before the write and skips at + `debug` when it is absent, so no insert is attempted and neither channel says anything. The + probe records nothing and is re-taken per write, so a ledger mounted later in the same boot + starts recording. An engine that cannot answer the probe still gets the write attempted. + + No API change: the exported signature, the row shape, `CONFIG_CHANGE_ACTION` and + `CONFIG_CHANGE_OBJECT_NAME` are all unchanged. The remaining fault arm — a ledger that IS + mounted whose insert genuinely fails — now reports on the `error` channel rather than + `warn`, which is AGENTS.md's durability-degradation level for a write that claims to be + audited and is not. + + Clause-②: no +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index ff1cbda03e7..928d29d057a 100644 --- a/packages/services/service-settings/package.json +++ b/packages/services/service-settings/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-settings", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Settings service for ObjectStack — manifest registry + K/V resolver (OS_* env > Tenant > User > Default) + REST routes. See ADR-0007.", "type": "module", diff --git a/packages/services/service-sms/CHANGELOG.md b/packages/services/service-sms/CHANGELOG.md index 84b1d5ad0e5..4413e6a7400 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,484 @@ # @objectstack/service-sms +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [ee6fbd7] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [4f1a56b] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [c9246fa] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [d438b3a] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [2aac821] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [a754563] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [7d63088] +- Updated dependencies [87c37ae] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [374d9d3] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [2c87a48] +- Updated dependencies [dd2fd20] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [45c2cf9] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [9ca49eb] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index 4acf73ea57c..a47e1a83edd 100644 --- a/packages/services/service-sms/package.json +++ b/packages/services/service-sms/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-sms", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "SMS service for ObjectStack — ISmsService + transport-pluggable outbound delivery (Aliyun / Twilio / log).", "main": "dist/index.js", diff --git a/packages/services/service-storage/CHANGELOG.md b/packages/services/service-storage/CHANGELOG.md index 3b943e6fbe0..9413728b800 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,586 @@ # @objectstack/service-storage +## 17.5.0 + +### Minor Changes + +- 6ff5b56: **Clause-②: yes** — a new REQUIRED member on two published option types (`S3StorageAdapterOptions.keyPrefix`, and the `s3` member of `StorageServicePluginOptions`), so the accept set a consumer writes against narrows. Contract-review tier. + + **BREAKING** — `S3StorageAdapterOptions` and `StorageServicePluginOptions.s3` now require `keyPrefix: string | null`. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + The **S3 adapter can now be confined to a key namespace**, and the confinement is structural rather than conventional: a caller holding the adapter has no door through which it can reach an unprefixed key. + + `keyPrefix` is applied on `upload` / `download` / `delete` / `exists` / `getInfo`, on both presigned doors, on every multipart door, and into `list()`'s `Prefix` — and it is stripped off every key and every `list()` cursor coming back. Callers therefore supply and receive unprefixed keys at every door, in both directions, and `list('')` enumerates this adapter's namespace and nothing else. Keys are concatenated, never path-joined, so a caller key such as `../elsewhere` stays a literal key inside the namespace instead of escaping it. + + **Why it is required rather than optional.** A shared bucket with no namespace has one thing keeping one deployment out of another's objects: that every `sys_file` metadata check above the adapter was written correctly. On a route that takes an identifier out of a request, one missed check is a cross-deployment read the object store cannot refuse, because what it sees is a well-formed key. An optional prefix reproduces exactly that gap the first time a host forgets to set it, silently — so the choice is made at the call site or the code does not compile. `null` is the written, greppable way to ask for bucket-root keys, and it produces byte-identical keys to those written before this option existed. + + For the same reason an empty or whitespace-only string is **refused at construction** rather than treated as "no prefix": that is what an unset environment variable looks like after interpolation. A leading `/` and any `..` segment are refused too, and a missing trailing `/` is appended — the last of those is load-bearing, not tidiness: S3 `Prefix` is a raw string match, so `tenant_1` without the delimiter also matches `tenant_10/...`, and one namespace would enumerate its neighbour through the isolation mechanism itself. + + Two further seams move with it: + + - `StorageServicePlugin` carries the **host's** namespace onto every adapter a `storage` settings re-read rebuilds, and deliberately reads no prefix out of the settings values. A boundary an administrator inside the deployment can set or clear is a preference, not a boundary; without this, one settings save returned a hosted deployment to a shared, unprefixed key space. A host that declared no `s3` constructor options expressed no namespace, and settings-configured S3 stays bucket-root as before. + - `resolveStorageTarget` puts the namespace in the target's **`location`**, not merely its fingerprint: two prefixes in one bucket are two disjoint object sets, so moving the prefix strands what the old one held exactly as moving the bucket does, and the swap must print the migration warning. `env_7` and `env_7/` normalise to one target, so the same namespace spelled two ways is not read as a move. + + `LocalStorageAdapterOptions` is deliberately unchanged: `resolvePath()` already refuses any `..` and joins every key under `rootDir`, so the local adapter's containment boundary exists and a second mechanism would be two ways to say one thing. + + **Migrating:** every `new S3StorageAdapter({ ... })` and every `new StorageServicePlugin({ adapter: 's3', s3: { ... } })` gains one member. Single-tenant deployments write `keyPrefix: null` and their keys do not move. Deployments sharing a bucket write the namespace they want and should treat the change as a store move — existing objects are not migrated into the new namespace. + + + +### Patch Changes + +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/observability@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index d85b4a1f685..43ed6fbcb06 100644 --- a/packages/services/service-storage/package.json +++ b/packages/services/service-storage/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-storage", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Storage Service for ObjectStack — implements IStorageService with local filesystem and S3 adapter skeleton", "type": "module", diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index b1788ecb887..46425b3598f 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,14895 @@ # @objectstack/spec +## 17.5.0 + +### Minor Changes + +- 6057357: `composeStacks(…, { manifest: 'preserve' })` emits each definition **once**, in the body of the package that owns it: a multi-package release artifact no longer carries a flattened copy of its collections at the top level (#14512, ADR-0130 D4's 2026-09-22 addendum). + + Measured on `examples/app-multi-package` (two packages, three definitions): `dist/objectstack.json` goes from 8,223 to 5,328 bytes, −35%, and the ratio does not improve with size — it was one extra copy of everything a package owns. The artifact's own `manifest`, `packages`, `plugins`, `devPlugins`, `devLogins`, `api`, `server`, `i18n`, `runtimeModule`, `onEnable` and `devHint` are untouched at the top level; the 35 package-owned collections are what moves. + + **BREAKING** for anything that read a compiled multi-package artifact's top-level collections directly. Every reader the platform ships was converted first — the ruling's order was readers first, emitter last — and this lands only with #15004's acceptance probe green on both shapes, which boots a two-package collection zoo through all five load boundaries and fails if any subsystem sees an empty collection. + + - **No authored metadata changes, and no artifact on disk is invalidated.** The authoring shape is identical: N `defineStack` packages plus a project config composing them with `manifest: 'preserve'`. ADR-0130 D4's read-both rule is untouched — `packages` present ⇒ iterate it, `packages` absent ⇒ `manifest` as a single-element list — so an artifact built before this change, which carries both halves, loads exactly as it did. + - **A single-package artifact keeps today's shape byte for byte** (ADR-0130 D7): the emitter strips nothing below two package entries. + - **The flattened half is dropped only where it is a COPY — three conditions, and a composition failing any one of them keeps today's additive shape**: (1) two or more package entries; (2) every input's collections attributed to a body — an input declaring no `manifest` has no package that could own its collections, and an input already carrying `packages` contributes those entries untouched; (3) the bodies reproduce the flattened collections item for item. Condition 3 is what `objectConflict: 'merge'` / `'override'` and a standalone action bound onto a SIBLING package's object fail: composition RECONCILES those into a top-level definition no body carries, so the flattened half is not a copy of anything and stays. Nothing here is refused — a composition that was legal before stays legal. + - **Why the copy goes rather than being compressed**: one definition was serialized twice with nothing keeping the copies equal. Where they differ today the flattened one is the reconciled copy and the platform's reader prefers it deterministically, which is exactly why this change removes the second copy only where the first one is redundant. One measured consequence on the compiled path: a seed dataset declared once reached the runtime's shared seed registry twice, and now reaches it once. + + Clause-②: yes (narrowing) + + +- 7382c5d: feat(spec): `element:filter` and `element:form` are refused BY NAME at the node, and the typo suggester stops renaming authors into retired types (#15110) + + Two halves of one vocabulary defect, and only one of them is a narrowing. + + **BREAKING** — a bare `element:filter` / `element:form` component node no longer + parses. Both elements were retired whole at element grain (ADR-0049 + enforce-or-remove): no renderer for either ever shipped in objectui, framework + or cloud. Every authorable key became a `retiredKey` tombstone at the time, but + the node itself kept parsing, and each schema's own docblock recorded that as a + limitation rather than an intention: + + > A bare node with empty `properties` parses clean (the open `type` union + > accepts any string, so a node-level refusal is not expressible here) + + It is expressible one level up. Both names join + `RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses them with + a located prescription — the same door already built for `user:profile`. + + ``` + FROM PageComponentSchema.safeParse({ type: 'element:filter' }) + -> { success: true } // nothing renders it; the console + // drew the unknown-type panel + + TO PageComponentSchema.safeParse({ type: 'element:filter' }) + -> { success: false, + issues: [{ code: 'custom', path: ['type'], + params: { retiredComponentType: 'element:filter' }, + message: '`element:filter` was removed in @objectstack/spec 17 …' }] } + ``` + + **The prescription is not new prose.** Each node message is the element-grain + TAIL of that element's own `retiredKey` tombstones with the `property ` + clause dropped, so the node door and the props door carry one text — pinned + byte-for-byte in `component.test.ts`. An author who writes `element:filter` is + told to delete the component and use a view's `userFilters` quick-filter bar or + the list toolbar's filter builder; an author who writes `element:form` is sent + to the object-bound `object-form` block. + + **What does NOT change.** The rows stay in `ComponentPropsMap` — deleting one + would demote a loud retirement to a silent skip on every reader that dispatches + on it — so both rows keep refusing each retired key with its own per-key + prescription, and `isKnownComponentType` still answers `true` for both. The open + string arm is untouched: `object-grid`, `mcp:connect-agent`, `custom.widget` and + every live `element:*` member parse exactly as before. The two D2 conversions + still strip the keys and still leave the node; what changes is that the node + they leave is now refused by name instead of sitting inert, and their prose says + so. + + **The other half is a plain bug fix, no accept set involved.** + `KNOWN_COMPONENT_TYPE_CANDIDATES` — the typo-suggestion pool behind the + `component-type-unknown` authoring rule — was derived from every known type, + retired ones included. Measured through the rule: + + ``` + FROM type: 'element:fitler' -> hint: "Rename `element:fitler` → `element:filter`." + TO type: 'element:fitler' -> hint: "Use a declared component type from the standard + vocabulary, or … give it its own namespace …" + ``` + + The tool was renaming an author INTO a retired element — a rename the parser + refuses. The pool is now the known set minus whatever the vocabulary retired, + derived from the retirement map rather than restated beside it, so a type + retired tomorrow leaves the pool the day it lands. Live spellings are + unaffected: `global:serch` still proposes `global:search`, `record:detials` + still proposes `record:details`, `element:butotn` still proposes + `element:button`. + + Also corrected: the vocabulary docblock described the `ComponentPropsMap` row + set as a superset of the enum by "exactly" the string-arm registrations plus the + two tombstoned elements — one member short since `user:profile` joined it. + + +- ea2940d: fix(spec): `ActionEngineFacade.delete` declares the id ARRAY the runtime has always accepted, and says which convention is the contract (#15117) + + `delete(object, id: string)` declared one id. The runtime facade + (`buildActionEngineFacade` in `packages/runtime`) has accepted `string | string[]` + all along — normalising the argument and issuing one `ql.delete` per id — and + described that in a comment as a tolerance two handler suites happened to cause. + The declaration was simply behind the behaviour, and the one first-party suite on + the array form could only reach it by hand-rolling a private copy of the + interface (a copy that had already drifted on `find`). + + The slot is now `delete(object: string, idOrIds: string | string[])`, and the + member's doc comment states the contract instead of leaving it to be inferred + from a runtime comment two packages away: + + - **Both spellings are contract.** One row is `delete(object, id)`; a set is + `delete(object, ids)` — a handler holding a list does not have to unroll it + into a loop to stay on the contract. + - **The array form is a convenience over the same per-row path** — not a bulk or + atomic delete. There is no transaction around the set: a failure part-way + leaves the ids before it deleted. An empty array deletes nothing and resolves. + + Nothing is removed and nothing narrows: every existing single-id call still + type-checks, and no runtime behaviour changes — this release makes the published + type describe what was already being served. That makes it non-breaking, not a + patch: widening a published parameter is a purely additive widening of a public + surface, which takes at least `minor` whatever the commit type says. Handler authors who copied the + facade into a local context type to reach the array form can delete the copy and + annotate with `ActionHandlerContext` / `ActionHandler` from `@objectstack/spec/ui`. +- 7d0f911: **BREAKING for action handlers** — `ActionEngineFacade.find` takes the engine's query ENVELOPE; the bare-filter parameter shape is withdrawn (#15124) + + Clause-②: yes (narrowing) + + `ctx.engine.find(object, query)` now takes `EngineQueryOptions` — the same + options bag `IDataEngine.find` and ObjectQL's own `engine.find` take, named by + identity rather than restated. **One platform, one query shape.** + + ### Migration — FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `ctx.engine.find('task', { status: 'open' })` | `ctx.engine.find('task', { where: { status: 'open' } })` | + | `ctx.engine.find('task', { amount: { $gt: 100 } })` | `ctx.engine.find('task', { where: { amount: { $gt: 100 } } })` | + | `ctx.engine.find('task', {})` | unchanged — an empty envelope is still the unfiltered read | + + The rewrite is lossless and mechanical: the filter moves under `where`, verbatim. + `tsc --noEmit` over your handlers finds every unmigrated call — see below. + + ### Why the shape was withdrawn rather than the bar closed + + Until now this parameter was the `where` HALF of a query while every other + `find` on the platform took the whole envelope, and the runtime wrapped what it + was given. That made the most natural spelling the wrong one, silently: an + author who passed the engine's own envelope reached the engine as + `{ where: { where: … } }` — a filter on a field named `where` — which matches no + row and resolves to `[]` with **no error at all**. A handler that made the + mistake ran to completion over zero rows for as long as it shipped, and its own + hand-written test double, written to the same belief, passed every assertion. + Because an empty `{}` skipped the wrap, one unfiltered read kept working under + either belief, so a dead handler looked partially alive. + + Refusing `where` at the top level instead — intersecting the old parameter with + `{ where?: never }` — was rejected: it asserts a vocabulary fact the spec + declares nowhere, reserving the field name `where` across every customer's data + model to buy one parameter's compile-time check. Aligning the parameter removes + the ambiguity at its root and reserves nothing. + + ### What the new declaration refuses, measured + + If your handler is typed with the published `ActionHandlerContext`, a bare filter + no longer type-checks on **either** path you can reach it by: + + - an object literal (`{ status: 'completed' }`) fails the excess-property check — + a field name is not an envelope key; + - a filter held in a `FilterCondition` variable fails **TS2559** — every envelope + key is optional, so a bag of field names has no property in common with it. + + The envelope's own keys are typed too: `where: 'a = b'`, `fields: 'id,subject'` + and `limit: '50'` are each refused. + + **If your handler is NOT typed with it** — a handler in an `objectstack.config.js` + / `.mjs`, one annotated with your own copy of the context type, or a `(ctx: any)` + handler — nothing above reaches you, so the facade refuses the withdrawn shape at + **runtime** instead, before the engine, with the same prescription: + + ``` + find('task') was given a key 'status' the query envelope does not carry. + ctx.engine.find(object, query) takes the engine QUERY ENVELOPE, not a bare + filter — move the filter under `where`: find(object, { where: { … } }). + Envelope keys: context, cursor, distinct, expand, fields, limit, offset, + orderBy, search, searchFields, top, where. + ``` + + ⚠️ **That refusal matters most for a filter whose value is `null`.** The engine's + own unknown-option check exempts a `null` value, because on an option bag a + `null` is a withdrawal. On a filter it is the "rows with no X" idiom, so + `{ deleted_at: null }` would have been dropped unexecuted and the read would have + widened to **every row** — including the ones you were excluding — with no error + at all. It is refused instead. + + ### What this opens + + `fields`, `orderBy`, `limit`, `offset` and `expand` are reachable from an action + handler for the first time — under the old parameter there was nowhere to carry + them. A caller-supplied `context` is **ignored**: this facade is trusted and + context-less by design, and the runtime stamps its own elevated + `ExecutionContext` last. Do not write one — it reads as authorization and is + none. + + ### Checking a migrated handler + + Do not settle for "it still resolves". A handler that had been passing the + envelope was returning `[]` on **every** call, so a suite written against the + mistake passes and the row count is the only witness. Re-run each migrated + handler against seeded data and assert it returns the rows its filter selects. + + +- d0f1845: **BREAKING for per-app translation bundles** — the translation bundle type splits in two: `settings` is a PLATFORM group and a per-app bundle may no longer declare it (#15178) + + Clause-②: yes + + `TranslationDataSchema` served two different bundles at once — the per-app one an + application authors (`stack.translations`, `defineTranslationBundle`) and the + code-authored bundles the platform packages ship. It now names the **per-app** + bundle entry and declares ten groups; the new `PlatformTranslationDataSchema` / + `PlatformTranslationBundleSchema` (types `PlatformTranslationData` / + `PlatformTranslationBundle`) carry the eleven-group platform face, `settings` + included. + + ### Migration — FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `defineTranslationBundle({ 'zh-CN': { settings: { mail: { title: '邮件投递' } } } })` | delete the `settings` group — there is no per-app replacement key | + | `defineStack({ translations: [{ 'zh-CN': { settings: … } }] })` | delete the `settings` group from the bundle entry | + | `const b: TranslationBundle = { en: { settings: … } }` — a PLATFORM package's own bundle | `const b: PlatformTranslationBundle = { en: { settings: … } }` | + | `const d: TranslationData = { settings: … }` — a PLATFORM package's own locale entry | `const d: PlatformTranslationData = { settings: … }` | + + **The one-line fix for an application: delete the `settings` group.** Settings copy + is not application-authorable at all — `settings` is keyed by + `SettingsManifest.namespace` and only platform code declares a manifest, so the + only namespaces a per-app entry could ever address were the platform's own. + `settingsCommon` is **not** affected — the Settings UI shell strings (the source + badges, under `settingsCommon.sourceLabels`) stay on the per-app face; only the + per-namespace manifest copy under `settings` leaves. + Run `os migrate meta --from 17` to list the mechanical edits for existing + sources; apply them by hand. + + ### What the deletion changes, which is not nothing + + ⚠️ This is **not** a lossless delete, and the record says so rather than claiming + the house phrase. Both bundles load into ONE served tree — `AppPlugin`'s + `loadTranslations` and every platform plugin's `kernel:ready` contribution both + call `II18nService.loadTranslations`, which deep-merges — and the + `resolveSettings*` family and the console's settings labels read that merged + tree. So an app-authored `settings` branch did resolve. + + **It was a gap filler, not an override.** The app's bundles are loaded in + `AppPlugin`'s own `start()` (kernel Phase 2); the platform's settings + translations arrive from `SettingsServicePlugin`'s `kernel:ready` hook (Phase + 3); `deepMerge` gives the **later** source the leaf. So the platform won every + key both bundles defined, and a per-app entry rendered **only where the platform + bundle carried no string for that key and locale** — the platform ships `en`, + `zh-CN`, `ja-JP` and `es-ES`. + + **What to expect after upgrading.** Where the platform already carried the + string, nothing changes on screen — that value was the one being served all + along. Where your entry was filling a gap, that Settings screen now renders the + **manifest's own literal, which is English** (the `?? fallback` every + `resolveSettings*` helper ends in). Those are the screens to re-read. If a + platform string is wrong or missing for your locale, correct it in the platform + bundle (`@objectstack/service-settings`'s `settingsBuiltinTranslations`) — do + not re-add the app-side copy, which the platform overwrites on every boot + wherever it has its own value. + + No deprecation window: the per-app door refuses the key by name from this major, + and the rejection carries the prescription above. + + ### Unchanged + + The registered `translation` metadata type (`TranslationItemSchema`) is not + changed by THIS entry — this ruling covers the file-authored bundle. (Superseded + in the same release: #19620 narrows the item door too; see its own changeset.) `GET + /api/v1/i18n/translations/:locale` still declares it on its response, because the + served document is the merged tree; `GetTranslationsResponseSchema` is typed + against the platform face for exactly that reason. + + Ruling batch #132 item 2 letter ② (2026-09-13) — 「同意」. The card's original + "removal" disposition is struck: `settings` is a live platform key. + + +- 6175da8: `IMetadataService` declares `loadManyKeyed?` — the keyed plural loader read now sits on the contract beside its two declared siblings `loadMany?` and `loadDiagnosed?` (#15385). + + Clause-②: yes + + A verb family lives whole on the contract. `MetadataManager.loadManyKeyed(type)` shipped as a public member with no declaration on the interface its siblings are declared on, so the one cross-package caller — the ObjectQL governance audit — narrowed the service slot with a **local structural type** written beside the call site. That local type is deleted in the same change and the call site reads the contract. + + The vocabulary is not new: `loadManyKeyed`, and the `{ name, data }` item shape it answers with, are already published on `MetadataLoader`, which declares the same member as optional over its own loader-local options type. What this adds is the member's place on `IMetadataService`. + + ```ts + loadManyKeyed?( + type: string, + options?: Record, + ): Promise>; + ``` + + **What it is for.** The key is a fact about the **store** — `register()`'s own `name` argument — and it travels *beside* `data`, never folded into it, so `data` stays byte-identical to what the unkeyed plural read would return and no consumer ever sees a synthesised `name`. An item whose stored body has no top-level `name` is legal and deliberate (an org customization container's identity is the object it targets), and such an item has no identity at all in a plural read keyed by `data.name` — it is dropped, silently. That is why this is a second member rather than a widened return type on the existing one. + + **What moves for consumers.** Nothing breaks. The member is **optional**, like `loadMany?` and `loadDiagnosed?` beside it, so every existing `IMetadataService` implementation still satisfies the contract unchanged and the `typeof … === 'function'` probe stays the way a caller asks for it. What changes is that a caller no longer has to declare the shape itself to stay typed: intersecting the slot with a hand-written structural type was the only way to reach the member without erasing the lookup to `any`, and that workaround is now unnecessary. `MetadataManager`, which already implements the member, needs no edit. + + This is the position `loadDiagnosed` was in before #4127 batch 4 declared it, and it is resolved the same way. Ruled in decision batch #123 item 5 (2026-09-12), maintainer verbatim: 「同意」. + + `content/docs/kernel/contracts/metadata-service.mdx` gains the member in the same change. +- 0283cb9: feat(automation)!: an edge-branched `decision` is exclusive — the first out-edge whose condition holds, in declaration order, wins; `mode: 'inclusive'` takes every one (#15429) + + + + Clause-②: yes + + **BREAKING** — the run-time semantics of a shipped node type change. A `decision` node that + declares no `config.conditions` and branches on its out-edges used to take EVERY out-edge whose + condition held, one after another, while its schema, the docs and the engine's own comment all + called it an exclusive gateway; hotcrm#1555 rendered a refusal screen AND ran the conversion in + one execution. Maintainer ruling on #15429 (2026-09-23, 「跟主流对齐」): the gateway follows + BPMN's exclusive gateway, Salesforce Flow's Decision and n8n's Switch default, and taking every + true branch is a declaration the author writes down. + + | | before | after | + |:--|:--|:--| + | two conditioned out-edges, both hold | both successors run, sequentially, nothing reported | the FIRST declared one runs; the second records a `skipped` step | + | `config: { mode: 'inclusive' }` | accepted, never read | every out-edge whose condition holds runs, sequentially | + | none holds | the `isDefault` edge runs | unchanged | + | `mode` beside a non-empty `conditions` list, or outside `'exclusive' \| 'inclusive'` | refused by a direct parse only | refused at `registerFlow` and by `os validate`, with the schema's own sentence | + + ## Migration: FROM → TO + + `os migrate meta --from 17` lists the mechanical edits for existing sources and applies them + to the migrated stack: the ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit` + writes `mode: 'inclusive'` onto every decision that has no `conditions` list and two or more + conditioned out-edges, inside ADR-0031 regions included, so a migrated flow runs exactly as it + did. + + ```ts + // FROM — every true out-edge ran + { id: 'verdict', type: 'decision', label: 'Verdict?' } + // TO — what the conversion writes; delete the key where the conditions partition + { id: 'verdict', type: 'decision', label: 'Verdict?', config: { mode: 'inclusive' } } + ``` + + Then review each written key (the paired D3 entry `flow-decision-edge-branching-first-match` + carries the acceptance criteria): delete it where the conditions partition (`== 'a'` beside + `!= 'a'`, `>` beside `<=`, a guard beside `isDefault: true`), keep it where the flow relies on + more than one branch running for one record, and where the overlap was accidental narrow the + conditions into a partition and delete the key. `os validate` reports + `flow-decision-inclusive-overlap` on every decision that keeps the key with two or more + conditioned out-edges, so the review list is the lint output. + + ## BREAKING for flows stored in `sys_metadata` — maintainer ruling letter C on #15429 + + A `decision` node stored in `sys_metadata` (a flow built or edited in the Studio designer) with + **no `config.conditions`, no `mode`, and two or more out-edges carrying a `condition`** evaluates + **first-match** after this upgrade: where it took every out-edge whose condition held, it now takes + only the first one that holds, in the order the flow declares its edges. Nothing rewrites that row + — no stored-row migration, no cutoff, no read-path completion — because nothing about a stored row + says it was saved before the flip. The one-line fix, for a node that meant every branch: + + ```ts + { id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } } + ``` + + `os migrate meta --stored` (and `POST /api/v1/meta/_migrate-stored`) lists every such node under + `decisionModeReview` — flow row, node id, label and path — on a preview and an `--apply` run + alike, and writes nothing for it: the list moves no row outcome, no count and no exit code, so an + operator can review the candidates before and after the upgrade. A node leaves the list once it + declares `mode`, either member. Every such node in the measured corpus below is a partition, where + the new meaning runs exactly what the old one did. + + Authored sources and built artifacts keep the old behaviour instead, where the source's age is a + fact: `os migrate meta --from 17` writes `mode: 'inclusive'` (above), while the authoring funnel, + the automation engine's flow rehydration seam and the artifact-ingestion door all refuse the + conversion by id — a default flip replayed there would turn a decision written today against this + contract, where an omitted `mode` means exclusive, into an inclusive gateway. + + ## Reach, measured at landing + + - Release state: the npm registry's `latest` `@objectstack/spec` is `17.4.0` (`npm view`, + 2026-09-27), whose `json-schema/automation/DecisionConfig.json` declares `conditions` only — + `mode` has not shipped; `.changeset/19867-decision-config-mode.md` and + `.changeset/20168-decision-mode-beside-conditions-refused.md` are still unconsumed in this + tree. So `mode` reaches its first release together with the traversal that reads it and the + conversion that writes it; no published accept set narrows, and the registration and + `os validate` refusals narrow nothing that shipped. + - Corpus census (this repository at the branch base and `objectstack-ai/hotcrm` at `2f7b2326`, + read-only): 30 decision nodes across 48 flows; 17 have two or more conditioned out-edges and + no `mode` (the conversion's positives — every one a hand-written partition, including + hotcrm's `lead_conversion.decision_duplicate`, the #1555 node), 13 have one conditioned + out-edge (left alone), and no node of any other type carries a conditioned out-edge, so the + exclusive traversal is scoped to `decision` with nothing else to migrate. + - What the published surface gains: the D2 conversion and its D3 entry in the protocol-18 + chain (`spec-changes.json`, the upgrade guide), `DecisionConfigSchema.mode`'s describe and + docblock now state the run-time semantics, and `@objectstack/lint` gains + `flow-decision-mode-invalid` (gating) and `flow-decision-inclusive-overlap` (advisory). + + The traversal change is scoped to `decision` nodes: conditioned out-edges of any other node + type keep the every-true-edge traversal they had (none was measured to exist). +- 7843663: **BREAKING for authored metadata** — an ADR-0031 structured region body (`loop.config.body`, a `parallel` branch, `try_catch`'s `try` / `catch`) now refuses two node populations at parse: a node whose TYPE parks the run on every execution, and an `end` node (#15646, absorbing #18112). + + Clause-②: yes + + The flow accept set shrinks for five node types inside region bodies — shapes the runtime never honoured. Both refusals are the authoring-time enforcement of a limit the engine already holds at run time and #3267 ruled 禁: **a region body runs synchronously inside the enclosing run, so it can neither park that run nor terminate it.** + + ``` + ✗ nodes.1.config.body.nodes.0.type: A `approval` node may not sit inside a structured region — + `loop 'sweep' body → try_catch 'guard' try` is a region body and the `approval` node `sign_off` + is inside it. A region body runs synchronously and cannot durably pause … + ``` + + **What is refused** + + - **A node that pauses on EVERY execution** — `screen`, `wait`, `approval`, `approval_revise`. + - **An `end` node**, whatever its `outcome`. An `end` in a region was a no-op, and a refusing one was converted into a region error at the boundary; neither is what the author wrote. + + **⛔ What is deliberately NOT refused: `subflow` and `map`.** Their shipped executors also declare `supportsPause: true`, but they pause exactly when the child flow their `config.flowName` names pauses — a **different metadata record**, not in hand while this flow is parsed. Refusing them by type would also refuse `loop { map(synchronous child) }`, a shape that runs correctly today and is covered by an existing regression suite. A parse-time rule refuses what is statically wrong; a region-contained node that actually suspends is a fact only the run holds. **Nothing an author wrote with a region-nested `map` or `subflow` needs editing for this release.** + + **Why it was silent, measured.** The engine converts a suspension raised inside a region into an error — but the executor has already written its progress state into the ENCLOSING scope by then. Contain that error in a `try_catch` and the residue is read back as progress by the next entry to the same node. On a real `AutomationEngine`, `loop { try_catch { map(pausing child) } }` over 3 iterations × 2 items: not one item's subflow completed, only two of three iterations reached the catch, and iteration 3 read `started === collection.length`, ran nothing, and returned `success` with `summary.failed = 0`. ⚠️ Read that for the MECHANISM, not for this change's reach — the shape it was measured on is a `map`, and making that run's refusal loud is a separate change to the automation engine, not this one. + + ### Migration — FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `loop { body: [ …, end ] }` | `loop { body: [ … ] } → end` — give the region a normal exit and put the terminator, with its `outcome` / `message`, on the top-level graph | + | `loop { body: [ wait ] }` | a top-level `wait`, with the top-level graph as the repeating construct — a region body cannot park the run, so the nested form never waited | + | `parallel { branches: [ [ approval ] , … ] }` | put the `approval` on the top-level graph and fan out around it, or split the branch's pausing half into a `subflow` the top-level graph calls | + + The one-line fix is always the same: **move the node onto the top-level graph and route the region's exit to it.** ⛔ Not mechanically convertible — hoisting a node out of a region is a graph rewrite (new edges, a changed exit, sometimes a deleted container) and which shape the author meant is an intent no artifact records, so this ships as an ADR-0087 D3 structured TODO rather than a D2 conversion. + + + + **⚠️ Two boundaries this refusal does not reach, stated rather than discovered.** A pausing node type contributed by a **plugin** is not refused: ADR-0018 left the node-type namespace open and a parse has no registry. A region nested past **`MAX_REGION_DEPTH` (32)** is not judged: the parse walk stops there, and unlike a duplicate node id there is no second spec refusal behind it. For both, the engine's run-time refusal is the only one — unchanged by this change, and not fixed by it. + + ⛔ No engine source is edited. What the refusal does to the run time is stated rather than left to be discovered: `AutomationEngine.registerFlow` and the ADR-0087 stored-row rehydration seam both go through `FlowSchema.parse` (`canonicalizeStoredFlow`), so a flow carrying a refused shape no longer registers or rehydrates — it is met at LOAD, not at the region boundary, and a stored row that carries one stops loading until it is rewritten. The engine's own run-time refusals for these shapes stay in place but are reachable only through the two boundaries above; for the `end` arm those are the only remaining path, because the refusal signal it answers is raised at exactly one site — an `end` node whose `outcome` is `refused`. + + **Published surface.** `FLOW_PAUSE_CAPABLE_NODE_TYPES` is published with the four types above. ⚠️ Read its contents, not its name: it is the UNCONDITIONALLY pausing set, not every type that can pause — `subflow` and `map` declare `supportsPause: true` and are deliberately absent, for the reason above. The identifier is unchanged, so this release removes no export. +- ce57857: feat(spec)!: every engine-evaluated expression slot requires a non-blank `source` — the #15430 rule generalised from the flow-node ledger to the other 36 declaring positions (#15811, decision batch #122 item 2) + + + + **BREAKING** accept-set narrowing on 36 published metadata slots. Each of them + composed `ExpressionInputSchema` and now composes `EvaluatedExpressionInputSchema`, + so an envelope carrying only `ast` (`{ dialect: 'cel', ast: … }` with no `source`) + and a `source` that is blank after trimming — through the envelope key or through + the bare-string shorthand — are refused at the door instead of parsing and then + faulting at run time. The prescription is registered under protocol major 18 as + the semantic migration `evaluated-expression-slots-source-required`. + + **⚠️ Graded `minor`, not `major`, and the ruling said `major`.** Decision batch + #122 item 3 ordered a 「`major` changeset」. This repo's launch-window convention + ships breaking changes as `minor` while the fixed group versions in lockstep, and + `scripts/check-changeset-no-major.mjs` enforces it: a `major` marker here would + promote all ~70 packages to a whole-stack major release, which is a release act. + The convention's own written carriers for breaking-ness are used instead and both + are present — this **BREAKING** banner and the ADR-0087 disposition above. The + ruling's substance (a breaking narrowing, carried by an ADR-0087 semantic + migration entry) is delivered; only the marker differs, and it differs because a + repo gate forbids the marker. + + **What is NOT narrowed.** `ExpressionSchema` / `ExpressionInputSchema` remain the + persistence contract (`source` OR `ast`), by item 2 of the same ruling, and so + does `PredicateInputSchema`, which is a plain alias of the latter. A slot that + only PERSISTS an envelope is untouched; the narrowing is at the slots an engine + EVALUATES. An `ast` carried BESIDE a string `source` stays admitted everywhere. + + **The population was re-derived, not inherited.** By identity — a negative + lookaround on identifier characters, so `CronExpressionInputSchema` and + `TemplateExpressionInputSchema` cannot leak in as substrings — over + `packages/spec/src`, non-test: 34 declaring source lines, two of which are + file-local alias consts (`ui/action.zod.ts` `ActionConditionInputSchema`, + `system/settings-manifest.zod.ts` `SettingsVisibilityInputSchema`) that mount two + slots each, giving **36 declaring positions**. Three of them reach the schema as a + union member rather than head-of-declaration (`RecordAlertProps.visible`, + `ServiceLevelIndicator.successCriteria`, `TraceSamplingConfig.composite[].condition`). + + On **two of those three the sibling arm is untouched**: `RecordAlertProps.visible` + still takes a boolean literal, and `ServiceLevelIndicator.successCriteria` still + takes its structured `{ threshold, operator, percentile? }` object — including one + that happens to carry a `dialect` key. + + ⚠️ **On the third, `TraceSamplingConfig.composite[].condition`, the sibling arm + narrows too, and deliberately.** Its structured-filter arm is a bare + `z.record(z.string(), z.unknown())`, which accepted `{ dialect: 'cel', ast }` as an + ordinary filter — so swapping the expression arm changed nothing at all there. That + arm now declines any object carrying a `dialect` key, and six shapes the base + accepted THROUGH THAT ARM ALONE (measured: the base's `ExpressionInputSchema` + refused every one of them) are refused at this slot: + + | authored `condition` | base | now | + |---|---|---| + | `{ dialect: 'cel' }` | accepted | refused | + | `{ dialect: 'js', source: 'x' }` | accepted | refused | + | `{ dialect: 'nope', source: 'x' }` | accepted | refused | + | `{ dialect: 'cel', source: 5 }` | accepted | refused | + | `{ dialect: 'cel', source: 'x', meta: { rationale: 5 } }` | accepted | refused | + | `{ dialect: 'zzz', foo: 1 }` | accepted | refused | + + FROM → TO at that slot: if the value really is a **structured filter**, drop the + `dialect` key (`{ dialect: 'cel', service: 'api' }` → `{ service: 'api' }`); if it is + an **expression**, give it a dialect this platform evaluates and a non-blank `source` + (`{ dialect: 'js', source: 'x' }` → `{ dialect: 'cel', source: 'x' }`). A structured + filter that carries no `dialect` key — `{}`, `{ service: 'api' }`, + `{ attributes: { 'http.route': '/v1/orders' } }` — is accepted exactly as before. + + **Why an authoring-time refusal and not a run-time one.** Measured at the + chokepoint, `celEngine.evaluate` never silently succeeds on either shape — it + returns a `parse` fault — so what happened next was decided entirely by the + slot's fail policy, and the two halves of that population fail in opposite + directions: fail-CLOSED slots (`ObjectFieldGroup.visibleWhen`, + `RowCrudActionOverride.visibleWhen`, `BulkActionDef.visible`, the two + settings-manifest `visible` slots) hid a group, a row button, or silently excluded + every selected record from a bulk run and reported them as *skipped*; fail-SOFT + slots left a gate that had stopped gating. Nothing in between said a word: the + authoring lint `validateVisibilityPredicates` measured 0 findings on an `ast`-only + envelope and 0 on a blank `source`, against two control legs that each measured 1. + + **`@objectstack/formula` gains `printCelAst(ast)`** — the inverse of + `parseCelToAst`, and the lossless half of the migration: an `ast`-only CEL + envelope is printed back to surface syntax mechanically, with no judgment asked of + the author. It is lossless about MEANING, not bytes (the printer re-renders from + the parse tree, so `'x'` comes back as `"x"`), and it answers `null` — never a + guess — for anything it cannot round-trip through the platform's own bounded + parser. That `null`, and every blank `source`, are what the semantic migration + entry's structured TODO covers. + + **The published TypeScript interface `RowCrudPredicates` narrows with it** + (`Expression | ExpressionInput` → `EvaluatedExpression | EvaluatedExpressionInput`), + because it mirrors the two `RowCrudActionOverride` slots and a type that still + promised an `ast`-only envelope would advertise what the schema now refuses. + + **So do the four expression constructors — `expression()`, `cel`, `tmpl`, `cron` + (and therefore the `F` / `P` aliases) — which now return `EvaluatedExpression` + instead of `Expression`.** Each one assigns a `string` to `source` + unconditionally, so the wider return type described none of them; it was slop + that cost nothing until an evaluated slot began requiring `source`, at which + point ``visibleWhen: P`…` `` — the spelling the spec's own docblock teaches — + stopped type-checking, and `@objectstack/platform-objects` failed its DTS build + on exactly that. `EvaluatedExpression` is assignable to `Expression`, so every + persistence-contract slot keeps accepting these values unchanged; what the + narrower return type adds is that an evaluated slot accepts them too. An author + who genuinely has no `source` was never calling these constructors — an + `ast`-only envelope is an object literal, and an evaluated slot refuses it on + purpose. +- 744a0a3: feat(spec)!: retire the plugin-security scan-result surface — zero consumers after the scanner retirement (#15932) + + **BREAKING** — the plugin-security scan-result family is removed. ADR-0049 + enforce-or-remove; maintainer ruling 2026-09-07 (director seat, decision batch + #65), adopted verbatim 「同意」. + + This is the second half of the scanner retirement — issue 14919, a number since + deleted from the board, landed as PR #15930. That change retired `PluginSecurityScanner`, + the `@objectstack/core` class that shipped as a security control and returned + `status: "passed"` for every plugin it was ever handed. The **schemas** it fed + survived it — and that scanner's type-only import was their only importer of any + kind, so the family went from one type-only importer to **zero consumers** while + staying fully published: 27 authorable rows on `authorable-surface/kernel.json`, + six `api-surface` exports, two authorable defaults and two json-schema manifest + keys, with no `.parse` or `.safeParse` site against either schema anywhere. An + author could write any of it, be accepted, and get nothing. That is the + declared-not-enforced shape, one layer out from the class removed for the same + reason. "Declare an owner to enforce" was refused by name: it would rebuild the + scanner just retired. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `KernelSecurityScanResult`, `KernelSecurityScanResultParsed`, `KernelSecurityScanResultSchema` (exports) | nothing — delete the import. No replacement type exists. | + | `KernelSecurityVulnerability`, `KernelSecurityVulnerabilityParsed`, `KernelSecurityVulnerabilitySchema` (exports) | nothing — delete the import. No replacement type exists. | + | `PluginSecurityManifest.scanResults` | delete the key | + | `PluginSecurityManifest.vulnerabilities` | delete the key | + | `PluginQualityMetrics.securityScan` | delete the key | + + **The one-line fix: delete the keys and every import of the two types.** Plugin + security scanning is not a platform capability and there is no replacement + schema. What the platform does still enforce is unchanged: `permissions` and + `sandbox` on the same `PluginSecurityManifest`, and artifact provenance through + `verifyPluginArtifactIntegrity` and the plugin signature verifier — which tell + you an artifact is the one its publisher signed, and never that it is safe. For + dependency vulnerabilities use the tools built for it against your own project + (`npm audit` / `pnpm audit`, Dependabot, the GitHub Advisory Database, OSV), and + treat an unaudited third-party plugin as untrusted code. A publisher who used + `scanResults` to advertise diligence keeps the surviving `securityContact` and + `vulnerabilityDisclosure` blocks, which are contact terms rather than a verdict. + + ⚠️ Runtime behaviour is deliberately **unchanged**. Nothing ever read any of + these keys, so deleting one removes no check that was running. A consumer that + gated on `securityScan.passed === true` was gating on nothing — the remediation + is to audit with a real tool, not to find a replacement key. + + ### The retirement kit + + - The two **defs** leave the build whole — `RETIRED_DEFS_BY_MAJOR[18]` + (`kernel/KernelSecurityScanResult`, `kernel/KernelSecurityVulnerability`) — + because nothing parses them, so there is no author a tombstone could reach. + - The three **authorable keys** are `retiredKey()` tombstones registered in + `RETIRED_KEYS_BY_MAJOR[18]`. Neither carrying shape is `.strict()`, so a bare + deletion would strip an authored key in silence (ADR-0104): the tombstone is + audible in both channels — `tsc` (input type `never`) and the parse, which + raises the prescription itself. + - **No D2 conversion.** A plugin security manifest and a plugin registry entry + are package artifacts a publisher ships, never stack collection members and + never stored `sys_metadata` rows, so the chain has no seam that would see one + — the disposition the sibling `kernel-plugin-security-durations-unit-in-key` + entry already records for this same manifest. The D3 semantic entry + `plugin-security-scan-result-surface-retired` carries the judgement. + - `PluginSecurityManifest.vulnerabilities` is a **forced consequence**, not one + of the four names the ruling listed: it was the last authorable referent of + `KernelSecurityVulnerability` and could not outlive the def. + - **No deprecation window** (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」). + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no download, dependent or source + telemetry was consulted for — exactly as that retirement's own changeset says of its + three exports. That was an input to the ruling, not a reason to soften the removal. + + ⛔ **Untouched, and not checked:** the marketplace `'scanning'` status + (`marketplace.zod.ts`). The ruling made it conditional on a producer grep of + `objectstack-ai/cloud`, and that repository was not reachable from the session + that executed this card, so it stays exactly as it is and its absence from this + diff is not evidence about it. + + ⚠️ **The two members the ruling paired with it were ALREADY GONE** — measured on + this tree, not assumed. The incident `'malware'` type was a member of + `system/IncidentCategory`, and the whole incident-response family was retired by + #15513 (maintainer ruling 2026-09-05 — two days *before* the 2026-09-07 ruling + that made it conditional). `marketplace-admin.zod.ts` was deleted outright with + the cloud subpath (#16526). Both files return zero tree entries here, against a + lit control where `'scanning'` still returns a live declaration. So the + conditional question is **one** enum member wide, not three. + + `Clause-②: yes (narrowing)` — a published surface is removed: six exports leave + the built `.d.ts` and three authorable keys stop being writable, so the accept + set a consumer writes against narrows. Nothing is widened and nothing is + renamed. Contract-review tier. + + +- c7d4825: `ToolExecutionContext.confirmedBlueprintIdentity` — the consent digest a route-owning layer stamps on a confirm replay — is now declared in the protocol instead of in one consumer's augmented type (#15937). + + Clause-②: yes (widening) — one new OPTIONAL member on a published interface, so the shape a consumer writes against grows. Nothing previously admitted is refused, no member is renamed or retired, and no producer is required to write it. Contract-review tier. + + `packages/spec/src/contracts/ai-service.ts` declares the tool-execution context a tool handler may rely on. A published handler in `objectstack-ai/cloud` — the `apply_blueprint` authorization gate — already makes a matching blueprint-identity digest one clause of the decision to build a whole app (cloud#1954 / cloud PR #2005), but the member it reads was declared only on cloud's own augmented `ToolExecutionContext` and reached by a structural cast. The protocol is this project's baseline, so a field a handler authorizes on is declared here. + + - **The member is optional and fail-closed.** `undefined` means "no confirmed identity on this turn" and authorizes nothing — the same reading `actor` and `isSystem` already carry (#2991): absence is never a grant. The docblock states it, and the type enforces the handler-side half of it, because a read of `string | undefined` does not compile into a path that assumes a confirmation. + - **Provenance is part of the declaration**, in the shape `userMessageText` already carries: populated by whichever layer owns the agent route (cloud, post-cloud ADR-0025), only ever by in-process server code on that route, and never derived from a request body, a tool argument or the transcript. + - **Nothing in this repository reads it yet**, and nothing here changes behaviour: this is the declaration half. Deleting cloud's augmentation and replacing its cast with the typed read is a cloud follow-up, blocked on this field being published and pinned. + - **The contract is now asserted.** `confirmed-blueprint-identity-contract.pin.test.ts` pins that the member lives on `ToolExecutionContext`, reaches a handler through `ChatWithToolsOptions.toolExecutionContext`, stays optional, and is typed `string` — each negative leg paired with a positive one on the same helper, so a leg that stops detecting anything turns the test-layer type-check red rather than passing quietly. +- fe71032: feat(driver-sql,objectql,cli)!: the ADR-0104 file-family column step, and the kernel→driver supply that arms it (#15989) + + + + **BREAKING** on the published storage behaviour of `@objectstack/driver-sql`. A deployment that runs `os migrate files-to-references --apply` now has its media columns **retyped and their values rewritten** into the bare-`sys_file`-id encoding, and its driver writes bare ids from the next boot. This completes the maintainer ruling on #15041 (「15041 应该改为实际 id 保存。选A,其他同意」) whose encoding half shipped in the previous release. + + Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + ## The column step + + `os migrate files-to-references --apply` gains a further step, run **only after** the backfill and its self-check report zero blocking rows — and it moves nothing at all until three gates pass: + + 1. the migration's own gate (zero blocking rows); + 2. **every** abort pre-check, across **every** planned column, before a single statement runs; + 3. no refusals — a column the driver could not plan stops the columns it could. + + **PostgreSQL** and **SQLite** only. ⛔ MySQL is refused by name and belongs to #17788, where its statement ORDER is settled against a real instance rather than transcribed. + + Per column, the shape is read off the column's **physical type**, not off the dialect: a `json` column is retyped (`ALTER … TYPE varchar(2048) USING (col #>> '{}')`), while a column that is already `varchar` — the population `os generate migration --format sql` creates and a JSON-arm driver fills with quoted ids — has its values unquoted in place. SQLite has only the second shape, since it has no json type. + + ### ⛔ The abort clause is NOT the one the ADR sketched + + The #15041 addendum prescribed the retype with nothing in front of it while *requiring* the step to abort "on the first cell that is not a JSON string". Those two sentences contradict each other, and which was wrong was settled by running it. Measured on live PostgreSQL 16.13, `USING (col #>> '{}')` is **accepted** over a row holding an inline metadata blob, because `#>> '{}'` extracts *any* json type as text: the bytes survive, but the column is no longer `json`, so an object becomes a plain string in a column whose declared contents are ids — silently, in a migration that reports success. The director ruling (decision batch #120 item 1) replaced the clause with the pre-check that implements the requirement: `json_typeof(col) IS DISTINCT FROM 'string'` on PostgreSQL, and `json_valid(col) AND json_type(col) <> 'text'` on SQLite, where excluding invalid JSON is what keeps a re-run idempotent over cells a previous run already moved. + + Both the destructive form and the guarded one are executed side by side, on one fixture, in this release's own test suite — so the difference stays a measurement rather than a comment. + + ## The kernel→driver supply seam + + `SqlDriverConfig.fileColumnsMoved` shipped last release and no host outside the driver supplied it. It is supplied now: `ObjectQL.registerDriver` hands every driver that has the seam a closure over the new `ObjectQL.haveFileColumnsMoved()`, which reads `sys_migration.columns_moved_at` — and requires the `adr-0104-file-references` flag to be verified **as well**, since the stamp alone would attest a column move with nothing attesting the values inside it. + + ⭐ **Every way of not knowing still answers "not moved".** The option omitted, a resolver that throws or rejects or answers a non-`true` value, a resolver that never runs because the host never calls `initObjects`, a driver with no such seam, no `sys_migration` object, no row, an unreadable table, a null or empty stamp — all the JSON arm. That is the encoding every deployment in the world is on, and a driver that guessed the other way would write bare ids into a JSON column. + + ⛔ **A host that names `fileColumnsMoved` in its own config wins**, in either polarity. The engine only ever fills an empty slot, and never contradicts an explicit composition: overruling a declared `false` is precisely the bare-ids-into-a-JSON-column failure this mechanism exists to prevent. + + ## New published surface + + - `@objectstack/spec` — `hasMovedFileColumns(flag)`, the single arbiter of the conjunction above, beside `isDataMigrationFlagVerified` and `authorisesIrreversibleAction`. + - `@objectstack/objectql` — `ObjectQL.haveFileColumnsMoved()`, sharing one memoized read (and one `invalidateDataMigrationFlags()`) with `isFileReferencesMigrationVerified()`, so the two answers can never come out of one another's date. + - `@objectstack/platform-objects` — `recordFileColumnMove(engine, migrationId)`, which refuses to stamp a deployment with no verified flag row. `readDataMigrationFlag` now carries `columns_moved_at`; it previously dropped it, which made a moved deployment indistinguishable from an unmoved one to every caller. + - `@objectstack/driver-sql` — `SqlDriver.setFileColumnsMovedResolver()`, `SqlDriver.planMediaColumnMove()`, and the statement builders `mediaColumnMovePlan` / `mediaColumnMoveDialect` / `isJsonColumnType` with `MEDIA_COLUMN_MOVE_DIALECTS`, `MEDIA_COLUMN_MOVE_ROLLBACK_NOTES` and `MEDIA_ID_MOVE_WIDTH`. The statements live in the package that owns the dialects and measured them; a second copy in the CLI would be a second copy of the clause the ruling got wrong. + + ## What does NOT change + + A deployment that does not run `--apply` is byte-for-byte where it was: the column stays `json`, the write still JSON-encodes, and the read still accepts both encodings. A backfill re-run does not set the stamp and — deliberately — cannot clear it either: `recordDataMigrationRun` omits the key rather than writing a preserved value, so a ledger read that FAILS cannot demote a moved deployment back onto the JSON arm. A partial or failed column step records nothing at all, which leaves such a datastore on the arm that reads both encodings. + + `multiple: true` media is untouched on both arms: its value is a list of ids and a JSON column on every deployment. +- 74eaab8: feat(spec,core)!: the startup contract describes what the kernel produces — the orchestrator vocabulary is retired and `PluginStartupResult` is declared once (#16059) + + + + **BREAKING** — a published exported surface is removed, landing in the launch window as + `minor` (the lockstep convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition above). + + `@objectstack/spec` declared a plugin startup ORCHESTRATOR that was never built, and its + one shape that *is* real had drifted away from the kernel that produces it. The maintainer + ruling on this card keeps a startup-result contract, and makes it describe what the kernel + actually returns. + + ## What is removed + + `IStartupOrchestrator` (`orchestrateStartup` / `rollback` / `checkHealth` / + `startWithTimeout`) and the three schemas it tied together. Nothing in any repository + implemented the interface and nothing parsed the schemas; `healthCheck` and `HealthStatus` + named a per-plugin startup health probe the runtime has never had. + + | removed | from | what to write instead | + |:--|:--|:--| + | `IStartupOrchestrator` | `@objectstack/spec/contracts` | nothing — plugin startup is the kernel's own boot loop | + | `StartupOptionsSchema` / `StartupOptions` / `StartupOptionsParsed` | `@objectstack/spec/kernel`, `/contracts` | `startupTimeout` on the plugin; `rollbackOnFailure` on the kernel config | + | `StartupOptions.healthCheck` | (with the schema) | **no replacement** — no startup probe system exists | + | `HealthStatusSchema` / `HealthStatus` | `@objectstack/spec/kernel`, `/contracts` | **no replacement** — see above | + | `StartupOrchestrationResultSchema` / `StartupOrchestrationResult` | `@objectstack/spec/kernel` | `ObjectKernel.getPluginStartupDurations()` | + + `StartupOptions.parallel` and `StartupOptions.context` have no replacement either: the + kernel starts plugins sequentially and passes its own `PluginContext`. + + ## What survives, re-declared + + `PluginStartupResultSchema` / `PluginStartupResult` stay on both entries, rewritten to the + shape `@objectstack/core` has always returned from `ObjectKernel.startPluginWithTimeout()`. + `@objectstack/core` now **imports** that type instead of declaring a twin, so the two + cannot drift again. + + | member | before (spec) | after (spec and core, one declaration) | + |:--|:--|:--| + | `plugin: { name, version? }` | required | **removed** — write `pluginName: string` | + | `pluginName` | absent | `string`, required | + | `success` | `boolean`, required | unchanged | + | `durationMs` | `number`, **required** | `number`, **optional** (absent when the plugin declares no `start()`) | + | `startTime` | absent (it was core's own deprecated alias) | **removed** — read `durationMs`, which always carried the same value | + | `error` | serializable projection | unchanged (a thrown `Error` satisfies it) | + | `timedOut` | absent | `boolean`, optional — set when the failure was the timeout | + | `health: HealthStatus` | optional | **removed** — no probe ever filled it | + + **The one-line fix:** rename `plugin: { name }` to `pluginName`, delete `health`, and read + `durationMs` wherever you read `startTime`. All three old spellings are `retiredKey()` + tombstones on the surviving schema, so each is a `tsc` error at the construction site and a + parse error carrying the prescription. + + `startTime` is the one member whose removal a reader can OBSERVE: `@objectstack/core` + populated it beside `durationMs` with the identical elapsed value, under its own ADR-0087 + L1 deprecation, and `ObjectKernel.startPluginWithTimeout()` stops setting it here. Mirroring + it on the contract was the alternative and the tree refuses it — `check:duration-unit-keys` + (ruling B on #14478) fails an elapsed number whose key name carries no unit, and neither of + that rule's two schema-declared exemptions fits: it is not an `EpochMs` instant and it + mirrors no external standard. Renaming it to `startTimeMs` would mint a spelling nothing has + ever produced, for a member already documented as slated for removal. + + For `@objectstack/core` consumers the members are unchanged; the one narrowing is that + `PluginStartupResult.error` is now typed as the serializable projection + (`name` / `message` / `stack?` / `code?`) rather than `Error`. The kernel still puts the + thrown instance there, so `result.error instanceof Error` still narrows — only code that + reads an `Error`-only member such as `cause` off it without that guard needs the guard. + + ## The retirement kit + + Route 3 of the `spec-property-retirement` playbook: no authored document carried any of + the three defs, so there is no seam for a D2 conversion and no author to hand a tombstone + to. `RETIRED_DEFS_BY_MAJOR[18]` (`kernel/StartupOptions`, `kernel/HealthStatus`, + `kernel/StartupOrchestrationResult`) plus the D3 semantic entry + `startup-orchestrator-retired` **are** the declaration, and the three + `json-schema.manifest/kernel.json` keys plus their 16 `authorable-surface/kernel.json` + lines are deleted deliberately in this same change. The two keys of the SURVIVING result + schema (`plugin`, `health`) take the tombstone route instead, registered in + `RETIRED_KEYS_BY_MAJOR[18]`, because that def keeps emitting and its type is imported by + `@objectstack/core`. + + Runtime behaviour is deliberately unchanged: nothing ever read the retired surfaces, and + the kernel boot loop is untouched. +- 0b788da: The query TRANSPORT dialect is declared — keys AND values — and the `findData` fold now derives from that one declaration. + + `FindDataRequestSchema.query` declared `QuerySchema` — the canonical QueryAST — while the shipped `findData` door also accepted a second spelling of the same query through the same slot: `$filter` / `$top` / `$skip` / `$orderby` / `$select` / `$expand` and the plural `filters`. `@objectstack/metadata-protocol` folded them from a module-private table whose own comment called them "the wire-only spellings no schema declares". Two dialects, one slot, one of them declared — so every caller speaking the second was unverifiable at build time and unrejected at runtime. + + **New in `@objectstack/spec/data`** (9 exports, 0 removed): + + - `QueryTransportParamsSchema` / `QueryTransportParams` / `QueryTransportParamsParsed` — the transport parameters, each carrying the value of the canonical slot it folds onto. + - `QUERY_TRANSPORT_ALIAS_SLOTS` — `RPC_QUERY_ALIAS_SLOTS` extended with the transport-only spellings (`filters` / `$filter` onto `where`, `$expand` onto `expand`). + - `QUERY_TRANSPORT_DOLLAR_ALIASES` — the `$`-to-bare pairs that fold in two hops (`$top` onto `top` onto `limit`). + - `QUERY_TRANSPORT_DOLLAR_PARAMS` — the `$` spellings a boundary quotes when it refuses an undeclared one. + - `QueryWithTransportSchema` / `QueryWithTransport` / `QueryWithTransportParsed` — the query slot whose declared input is the AST or its transport spelling and whose parsed output is the AST plus the `count` flag. + + **`FindDataRequestSchema.query` is that slot now.** Its `z.input` admits the canonical AST, the transport spelling, or a bag carrying both. Its `z.output` is `QueryAST & { count?: boolean }` — the canonical AST, plus the response total-count flag, which rides inside this slot on the wire and is read off it by `findData` rather than passed to the engine. The output is CONSTRUCTED: the fold's result is parsed by the AST schema and that parse's result is what leaves the transform, so a transport key or a non-AST value cannot reach a consumer. The transport form is the FLATTENED SPELLING of the canonical AST with a 1:1 alias table — never a second semantics — so `QuerySchema` itself is untouched and still drops a `$` key as unknown. + + **One semantics means one set of VALUES, not only one set of keys, and that is what this declaration now enforces.** Every spelling of a slot accepts the same value shapes; each is lowered to the canonical member's declared shape, or refused. What lowers: a stringly-typed `$top` / `$skip` (`'50'` becomes `50`), a comma list on `$select` / `$searchFields` / `$expand`, a `{field: direction}` sort record, a relation-name list on `populate`, `'true'` / `'false'` on `$count`, and the input-only `FilterArray` sugar (`['status', '=', 'open']`) on every spelling of the filter slot — `where` included — lowered through `parseFilterAST`, the one declared sink (#5158 ruling C; `QuerySchema.where` still refuses the array). + + **What is REFUSED at the parse**, because lowering it would mean parsing the spec must not do, and because emitting it would put a value under the AST type that the AST does not declare: + + - a non-numeric `$top` / `$skip` (`$top: 'abc'`, `$top: ''`) — `400` instead of an engine call with `limit: null`, i.e. an UNBOUNDED read under a `200`, or `limit: 0`; + - a JSON-encoded `$filter` string (`'{"status":"open"}'`); + - an OData sort EXPRESSION on `$orderby` / `sort` (`'name desc'`, `'-created_at'`, `['name']`) — the record and `SortNode[]` forms are unaffected; + - a filter array no lowering can express, such as the INFIX join `[condA, 'and', condB]` — the prefix form `['and', condA, condB]` is the one the platform reads, and the engine already answered `400` for the infix one; + - a `$count` that is neither the boolean nor `'true'` / `'false'`; + - two spellings of one slot carrying different values — reported at the canonical path, quoting the spelling the caller actually wrote (`$orderby`, not `orderBy`). + + These refusals narrow no DECLARED surface: none of these value shapes was ever declared — `FindDataRequestSchema.query` was `QuerySchema`, which STRIPPED every one of these keys rather than declaring it. + + **Five of them were nonetheless SERVED, and now answer `400 VALIDATION_FAILED` at the ingress.** The route forwards the ORIGINAL body, not the parse output, so a key the old schema stripped still reached the door, which read it and answered `200`. A `POST /data/:object/query` body written one of these five ways stops working; each has a declared spelling that means the same thing: + + | body that now answers `400` | what the door served it as | write instead | + |---|---|---| + | `{ $orderby: 'name desc' }` | `orderBy: [{ field: 'name', order: 'desc' }]` | `{ $orderby: { name: 'desc' } }` — or `{ orderBy: [{ field: 'name', order: 'desc' }] }` | + | `{ sort: '-created_at' }` | `orderBy: [{ field: 'created_at', order: 'desc' }]` | `{ sort: { created_at: 'desc' } }` — or `{ sort: [{ field: 'created_at', order: 'desc' }] }` | + | `{ $orderby: ['name'] }` | `orderBy: [{ field: 'name', order: 'asc' }]` | `{ $orderby: { name: 'asc' } }` — or the `SortNode[]` form | + | `{ $filter: '{"status":"open"}' }` | `where: { status: 'open' }` | `{ $filter: { status: 'open' } }` | + | that same JSON string on `filters` or `filter` | `where: { status: 'open' }` | the object form on whichever of the two keys you write | + + **`GET /data/:object` still serves every one of those shapes.** The querystring path does not parse through this schema at all — `FindDataRequestSchema` is parsed at exactly one call site, the POST handler — so `?$orderby=name desc`, `?sort=-created_at` and `?$filter={"status":"open"}` answer exactly as before. What narrowed is the POST body alone — the platform has not stopped accepting these spellings everywhere. + + The remaining refusals in the list narrow nothing that was served correctly; they move an unservable body's refusal earlier — from the engine, or from a wrong answer under a `200`, to the ingress that can name the parameter to fix. + + **`@objectstack/metadata-protocol` folds by the spec export** instead of its own table, and both resolved tables — plus the `$`-parameter list its `UNSUPPORTED_QUERY_PARAM` refusal quotes — are pinned byte-equal to their pre-change values. An undeclared `$` spelling is still refused loudly with the same `400 UNSUPPORTED_QUERY_PARAM`; the sentence now quotes `QUERY_TRANSPORT_DOLLAR_PARAMS` rather than a hand-copied list, so a spelling added to the table cannot leave the refusal naming a set the door no longer has. + + Measured and unchanged: `getData` takes `select` / `expand` directly and carries no `query` slot, and `updateManyData` / `deleteManyData` take `records[]` / `ids[]` — none of the three has a transport-dialect split to declare. + + Clause-②: yes (widening) +- f7a3495: feat(spec)!: `composeStacks` `objectConflict: 'merge'` refuses a fixed-shape config object both objects declare with different values (#16075) + + + + **BREAKING** accept-set narrowing on `composeStacks({ objectConflict: 'merge' })` + — shipped as `minor` under the repo's launch-window convention for breaking + changes. Maintainer ruling on #16075 (ruling record 5563452716, director + decision batch #61, option 1, verbatim 「同意」): the #14848 refusal extends to + fixed-shape config objects. + + **What changed.** #14848 made `'merge'` refuse every object-level + **collection** two stacks declare differently, and left everything else on + later-wins. "Everything else" included eight **fixed-shape config objects** on + `ObjectSchema` — `userActions`, `external`, `tenancy`, `access`, `lifecycle`, + `enable`, `publicSharing`, `protection`. Measured on `main` @ `44ce049a8` + before this change, each of the eight composed to the LATER object's + declaration wholesale, with nothing said: `enable: { trackHistory: true }` + beside `enable: { apiEnabled: true }` lost `trackHistory`, and an add-on + package's `access: { default: 'public' }` switched a core package's + `access: { default: 'private' }` off — the posture downgrade `composeStacks` + already refuses at the top level for `api` / `server`. + + Now, when both objects declare one of them with different values, + `composeStacks` throws the refusal it throws for a collection — same code + (`STACK_COMPOSE_COLLECTION_CONFLICT`), same `status: 422`, same three-line + shape — naming the object, the key and both stacks by manifest id: + + ``` + composeStacks conflict: object 'shared' is defined in multiple stacks and its 'access' is declared with different values by 'com.example.a' (stack #0) and 'com.example.b' (stack #1). + objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) is not merged, and neither is a fixed-shape config object (userActions, external, tenancy, access, lifecycle, enable, publicSharing, protection): the later declaration would replace the earlier one wholesale, silently dropping every member 'com.example.a' (stack #0) set. + Fix: declare 'access' on 'shared' in exactly one of the two stacks, make the two declarations identical, or use { objectConflict: 'override' } to hand the whole object to the later stack. + ``` + + The config-object half of the refusal set is **derived from `ObjectSchema`'s + shape**, like the collection half — every key whose declared type, through + optional/default wrappers, a `lazy` or a `pipe`'s authored side, is a plain + object and not a collection — so a config object added to the object schema + joins the refusal without an edit to the composer. The collection refusal's + message now lists both kinds; its first and last lines are unchanged. + + **What did not change.** + + - `fields` keeps its documented shallow merge (later fields win, earlier + fields kept). + - **Identical** declarations on both sides pass through and are carried once + — the reading `'merge'` already gives an identical collection. Because the + strict parse fills a config object's member defaults, "identical" is judged + on the parsed objects: `enable: { apiEnabled: true }` and + `enable: { apiEnabled: true, trackHistory: false }` are the same declaration. + - A config object only the earlier object declares is kept; a later object + that does not declare it (or declares it `undefined`) leaves it in place. + - A **scalar** the later object declares (`label`, `sharingModel`, …) still + replaces the earlier one. So does a key whose type is a **union** admitting + an object beside a non-object form — `systemFields` (`false` or an options + object) and `titleFormat` (a template string or an expression object): a + union is not a fixed shape, and the ruling covers the fixed-shape keys only. + - The default `'error'` and `'override'` are untouched, message for message. + + **Who is affected.** Measured on `origin/main` @ `44ce049a8`: **zero** + non-test call sites in `packages/**`, `examples/**`, `apps/**` pass + `objectConflict` at all — the one non-test `composeStacks` call + (`examples/app-multi-package`) passes `{ manifest: 'preserve' }` and takes the + default `'error'`. An external author who opted into `'merge'` and relied on + the later package's config object winning silently now gets the refusal above; + the fix is the one it names. + + Clause-②: yes +- 7a25a3e: `ObjectStackProtocolImplementation` and `SysMetadataRepository` no longer open their refusal messages with a bracketed tag restating the `code` the same throw declares — `error` carries the human sentence, `code` carries the machine token, and the token is no longer duplicated onto the prose axis. + + Clause-②: yes + + Every refusal `ObjectStackProtocolImplementation` and `SysMetadataRepository` raised opened with a lowercase `[tag]` that was the restatement of the `code` that very throw declared: `[no_draft]` in front of `NO_DRAFT`, `[item_locked]` in front of `ITEM_LOCKED`, and so on for 38 throw sites across the two producers. They were not invisible. `withoutDeclaredCodePrefix` strips a leading restatement only when the message opens with the declared code followed by a colon (`INVALID_REQUEST: …`); the bracketed lowercase spelling matches neither the casing nor the separator, so it was never stripped and reached the caller in `error.message`. The repo's own de-duplication mechanism existed and did not fire here. + + The maintainer ruling of 2026-08-29 on the `/data` door shipping `FORBIDDEN:` in front of a localized refusal is ONE envelope semantics — `error` is HUMAN LANGUAGE, `code` is the MACHINE TOKEN — and a prefix is removed *because* the same fact already rides the `code` axis. All 38 met that condition by construction. + + ## FROM → TO + + | before | now | + | --- | --- | + | `error: "[no_draft] No pending draft exists for view/task_list."` | `error: "No pending draft exists for view/task_list."` | + | `error: "[item_locked] view/task_list is locked (_lock=…)."` | `error: "view/task_list is locked (_lock=…)."` | + | `error: "[NOT_OVERRIDABLE] 'action' is not allowOrgOverride…"` | `error: "'action' is not allowOrgOverride…"` | + + **`code` is unchanged on every one of them**, and it is where the token always also was — `NO_DRAFT`, `ITEM_LOCKED`, `NOT_OVERRIDABLE`, and the 14 others. A reader matching `error.message` for a bracketed tag reads `error.code` for that tag, upper-cased, instead; a reader already using `code` needs no change. The HTTP `status` is untouched. + + - **Measured, not assumed, before it was removed**: 37 literal openers plus one written as `` `[${code}]` `` from the same variable the throw assigns to `err.code` three lines down — that one spelled by interpolation, so it was invisible to every grep for a literal tag and is absent from the card's own inventory. + - **Nothing consumed the tag.** The only consumers found anywhere are strippers: `@object-ui/react`'s `extractWriteErrorMessage` and two `plugin-detail` call sites each remove a leading bracketed prefix before showing the sentence to a user, next to the `SCREAMING_SNAKE:` strip. They confirm the tag was arriving and they cannot break on its absence — the regex simply matches nothing. + - **Two bracketed vocabularies are deliberately kept**: the `path [zod code]` locators inside a validation headline and the `[rule]` locators the author-time gate composes. Neither restates a declared `code` — they name WHICH finding, a fact the envelope carries nowhere else. + - **The published docs that quoted the openers are corrected in the same change.** `ProtocolSchema`'s promotion `describe()` said the lookup 「answers 404 `[no_draft]`」 and now names `NO_DRAFT`, the axis that still carries it; `content/docs/references/api/protocol.mdx` is regenerated from it, never hand-edited. The error catalog's two documented `INVALID_REQUEST` payloads showed a `message` opening with the tag beside a `code` field already carrying the token, and now show what the platform emits. + - ⛔ **Three carriers in `content/docs/releases/v17/` are deliberately left**: release pages record what shipped and a code change does not rewrite them. + - **Pinned as an absence**, because nothing else would notice one coming back: a re-introduced tag reds exactly one per-door pin and a newly-written refusal reds none. +- 839d1b0: fix(spec)!: `CronSchedule.timezone` is judged by the `iana_time_zone` membership predicate (#16292) + + **BREAKING** — an accept-set narrowing on a published authoring key. + `CronScheduleSchema.timezone` was a bare `z.string().optional().default('UTC')`, so + `defineJob` and `JobSchema.parse` took `timezone: 'UTC+8'` at authoring and build time + and said nothing. It is now judged by `isValueDomainMember('iana_time_zone', …)` — the + predicate `@objectstack/spec/shared` already exports, and the same judge the four + `valueDomain: 'iana_time_zone'` columns (`sys_business_unit.timezone`, + `sys_organization.timezone`, `sys_job.timezone`, `sys_report_schedule.timezone`) are + written against. Shipped as `minor` under the repo's launch-window convention for + accept-set narrowings. + + No job that ran yesterday stops running. The value was already carried unchanged to + `CronJobAdapter.schedule`, where croner — constructed with a callback — throws on a + non-member and `AppPlugin` records a per-job `FAILED TO SCHEDULE` at `error` level plus + a `jobScheduleFailuresTotal` increment: the job was declared and never ran. What moves + is WHEN its author is told, from the first environment that boots to `defineJob` / + `os build`. So a stack whose job carries a zone the platform cannot honour now stops + building instead of booting-and-not-running. + + Membership is the `Intl.DateTimeFormat` probe rather than a checked-in list, so the + accepted set is the host's own tz database — deliberately, and identically to those four + columns, the settings door and `resolveAuthzContext`. It is what every `Intl`-based + consumer downstream accepts, so the parse-time answer and the schedule-time answer + cannot disagree on one host. `UTC`, the key's own declared default, is a member on every + conforming runtime, so an omitted key is untouched. + + `interval` and `once` schedules carry no zone and are unaffected. The boundary type + `JobSchedule.timezone` on `@objectstack/spec/contracts` is a third, separate door and is + deliberately left out of this change. + + Clause-②: yes (narrowing) + + +- 6059b29: feat(spec): `IScopedObjectRepository.updateById` declares its answer — the record or `null`, not `any` (#16786) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #15280 used for `SqlDriver.update()` and the `TursoDriver.update()` override, PR #14434 before it on `@objectstack/driver-memory`, and PR #17255 for this card's `objectql` half, which was re-graded from `patch` to `minor` mid-round for exactly this reason). + + `updateById(id, data)` declared `Promise` — the last wide member of a contract whose siblings answer what they mean. It now declares `Promise | null>`: the written record, or `null` when the id matched nothing. + + The declaration is what every layer under it already says, measured rather than inherited: + + - the engine door it forwards to, `IDataEngine.update`, declares `Promise | number | null>`; + - that door's by-id exit calls `IDataDriver.update(object, id, data)`, which declares exactly `Promise | null>`; + - `packages/objectql`'s `ObjectRepository.updateById` declared `Promise` to MATCH this member rather than independently of it, and PR #17255 said so in its own docblock when it deliberately left this half open. + + The `number` limb `update` carries — the affected-row COUNT a predicate write resolves — is **not** declared here, and that is a measurement too: the implementation binds both the payload id and a pure-id `where` and never declares `multi`, so the shared update dispatch answers `by-id` for every call this signature admits. A falsy id (`0`, `''`) is a REFUSAL, not a `null`: it identifies no row, so the dispatch rejects and the call throws. + + Ruling A on #16231 settled the rule — #15823's `find()` narrowing extends to the sibling doors — and enumerated `scoped-context.ts:148` / `:164`, not this member. It is narrowed because the measurement says the declaration was wider than every implementation and wider than the door it forwards to, ⛔ not because a ruling named it. + + A hook or service that assigned the result into a record slot, or read a field off it, through an `IScopedObjectRepository`-typed door now separates the `null` arm first. No runtime behaviour changes. The in-repo census through the interface-typed door is the contract's own suites, which already answer the narrow shape. + + +- 88a072e: fix(spec): an object permission that declares a depth axis beside the super-user bit which short-circuits it is now REFUSED, instead of being stored and counted as coverage (#16870) + + **BREAKING** — `ObjectPermissionSchema` no longer accepts a `readScope` beside + `viewAllRecords: true`. Two sibling shapes are refused with it, read off the + same resolver lines rather than guessed at. + + The pair was accepted with **zero diagnostics**, materialised into + `sys_permission_set.object_permissions`, and counted by a capability census + reading the deployed shape as coverage — while the read stayed org-wide. + `PermissionEvaluator.getEffectiveScope` answers `org` on the super-user bit + **before** it consults the depth key, and `getDeclaredScope` (the ADR-0090 D10 + delegated-path input) carries the identical short-circuit ahead of the identical + read, so the declared narrowing was dropped from the delegation fold as well. + + ⇒ the author declared a narrowing, the platform stored it, an audit of the + deployed shape reported the capability as exercised, and the read was still + org-wide. That is ADR-0049 `declared ≠ enforced` at the capability container + itself, and the accept set is the only door that stops the declaration from + being STORED: a diagnostic raised later fires after the shape is already there. + + ``` + FROM ObjectPermissionSchema.parse({ allowRead: true, viewAllRecords: true, + readScope: 'own_and_reports' }) + -> { …, viewAllRecords: true, readScope: 'own_and_reports' } // stored, unread + + TO -> ZodError, located at ['readScope']: + "readScope: 'own_and_reports' is declared beside viewAllRecords: true, + which already grants org-wide read. … Delete readScope if the org-wide + read is intended, or set viewAllRecords: false if the narrowing is." + ``` + + **Which pairs move, and the one that deliberately does not.** The refusal is the + two short-circuits, transcribed: + + | declaration | resolver | verdict | + |:--|:--|:--| + | `readScope` + `viewAllRecords: true` | `opClass === 'read' && (viewAllRecords \|\| modifyAllRecords)` | **refused** | + | `readScope` + `modifyAllRecords: true` | same disjunct | **refused** | + | `writeScope` + `modifyAllRecords: true` | `opClass === 'write' && modifyAllRecords` | **refused** | + | `writeScope` + `viewAllRecords: true` | the write short-circuit does not name `viewAllRecords` | **accepted — honoured, and refusing it would delete a real grant** | + + ⛔ **What `viewAllRecords: true` GRANTS is untouched.** This changes which + declarations are accepted, never what an accepted one does — a permission- + semantics change is not in this change's remit. `viewAllRecords: true` alone, + `viewAllRecords: false` beside a `readScope` (the ordinary, honoured shape), and + a bare `readScope` all parse exactly as before; each is pinned as a + cost-direction guard in `permission.test.ts`, and an ablation that widens the + refusal one shape too far turns the `writeScope`-beside-`viewAllRecords` pin red. + + **The wire surface stays tolerant.** The refinement rides on the AUTHORING + wrapper only; `EffectiveObjectPermissionSchema` extends the unrefined base, so a + server still running an older toolchain can return a stored pair in an + effective-permission response without crashing a client (#4001's authorable/wire + split). `AccessMatrixEntry` likewise keeps describing the pair: it is a derived + SNAPSHOT shape whose committed `access-matrix.json` may predate this refusal, and + its tolerance is now stated with that reason in `explain.test.ts` rather than + reading as evidence that the platform accepts the declaration. + + **Scope is one object-permission entry**, which is exactly the resolver's input — + `resolveObjectPermission` returns a single entry (explicit, else the `'*'` + wildcard) and never merges two. A super-user bit in one permission set widening + past another set's `readScope` is ADR-0090's documented additive "widest wins" + semantics, not a contradictory declaration, and is not judged here. + + **Nothing in the fleet moves.** Measured across shipped defaults, both seeded + examples, two built access matrices, the built artifact fixture and every tracked + `.ts` / `.json`: **0** object permissions carry any refused pair, with lit + controls on every probe (130 nodes declaring `viewAllRecords`, 53 of them `true`, + 18 declaring `readScope`; 133 brace-local `viewAllRecords: true` literals). + + +- 3d8779d: **BREAKING** — retire `ListViewSchema.navigation.view`, the detail-view binding nothing + ever resolved. + + `navigation.view` was an unconstrained string whose describe promised *"the form view to + use for details"*. No layer from spec to console ever resolved a view by that name. Its + one read in the shipped console passed the value into the **second argument of + `onNavigate`** — the slot that otherwise carries the navigation-MODE token — so an + authored name did not select a view, it **substituted for the mode**. A consumer in the + same bundle reads that argument against a closed two-value vocabulary (`edit` / `view`), + so any other authored value matched neither branch: invisible on grids whose handler + takes one argument, a dead row click on the ones that do not. + + The enumeration behind the removal was exhaustive rather than sampled — every `.view` + property read in the bundle (exactly three) and every `formViews` read — and **no read + anywhere is keyed by an authored view name**. There was no path by which the key could + resolve one. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-13 (director decision + batch #126 item 4, option B). Zero authored instances in this repository; the one + external author removed its occurrence. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `navigation: { view: 'summary_view' }` on a list view | `navigation: { }` — delete the key. Then publish the layout you wanted as a `record` page on that object and mark the one that should open `isDefault` | + | `navigation: { mode: 'drawer', view: 'edit_form' }` | `navigation: { mode: 'drawer' }` — the mode, size and every other key of the block are **unchanged** | + + **The one-line fix:** delete `view` from the list view's `navigation` block; to choose + what opens for a record, assign a `record` page to the object and let `isDefault` pick + the one that opens. + + Nothing regresses by deleting it: the key never selected anything. What decides how the + detail is surfaced is `mode` and `size`, and both are untouched. + + ## The retirement kit + + - **`navigation.view`** — a `retiredKey()` tombstone on `NavigationConfigSchema`. `tsc` + types the key `never`, so writing it fails at the authoring site; a value reaching a + parse raises the prescription rather than a bare unrecognized-key report. Refused at + all three doors — `ListViewSchema`, `ObjectListViewSchema` and the flattened + `PUT /api/v1/meta/view` overlay — and pinned at each. + - **ADR-0087 disposition: a D3 SEMANTIC entry**, `list-view-navigation-view-retired`, not + a D2 conversion. A mechanical strip would delete the key without recording which list + view lost it, and an author who wrote it wanted a named detail layout — a want page + assignment serves and a stripped key does not record. So the TODO names the surface and + hands the judgement back, which is what a semantic entry is for. The tombstone + prescription therefore carries **no** `os migrate meta` sentence: that sentence is owed + only where a conversion covers the surface. + - **The five surviving keys of the block** — `mode`, `preventNavigation`, `openNewTab`, + `size`, `width` — are unchanged, and pinned accepting beside the refusal. A tombstone + that broke its live siblings would satisfy every refusal assertion while being a larger + bug; `navigation` is one closed shape, so that blast radius is the whole block. + - **`ui/NavigationConfig:view`** is registered in `RETIRED_KEYS_BY_MAJOR[18]`, which is + also what starts its aging clock. + + ## What is deliberately NOT in this change + + `view/list/navigation`'s six children are unclassified in the liveness ledger because + `check-liveness` drills one level. That is #17424's subject and is cited here, not fixed: + the ledger row for `navigation` itself is untouched, and no row exists for `view` to + update. + + The sibling `objectui` contract twin — `ViewNavigationConfig`, a re-export of this very + type — is in the other repository and is left to it. Its parity pin authors + `{ view: 'summary_view' }` as a legal value, so it needs the tombstone pin before that + repo picks up a spec carrying this retirement. + + Clause-②: no + + +- 0bd7dae: `KanbanConfigSchema` now declares `titleField` — optional `z.string()`, the key the board already reads and the schema refused by name (#16894). + + `KanbanConfigSchema` is a `strictObject`, and it was the one item-titled view config of its family that omitted the key: `GalleryConfigSchema`, `TimelineConfigSchema`, `CalendarConfigSchema`, `GanttConfigSchema` and `ListMapConfigSchema` all declare `titleField` under the same name and the same `z.string()`. An author writing `kanban: { titleField: 'subject' }` — the spelling the renderer honours — was refused with `unrecognized_keys=["titleField"]`, while objectui's own mirror accepted it only by not looking. Declared here under the director seat's decision batch #87 (objectstack-ai/objectui#8367), confirmed by the maintainer verbatim 「批 #87 同意」. + + **Clause-②: yes (widening)** — one new declared key on a published, strict accept set, so the set a consumer writes against grows. Nothing previously admitted is refused, and nothing is retired. Contract-review tier. + + - **Optional, not required.** The shape is the one `CalendarConfigSchema` already writes down for this exact key: absence resolves through the ADR-0079 record display-name chain (`titleFormat` → `displayNameField` → type-aware derivation → `'Untitled'`), so requiring it would demand more than the renderer reads — the shape ruling #13748 forbids (「不要求超过渲染器真正需要的」). `TimelineConfigSchema` and `GanttConfigSchema` spell it required and are the two siblings this declaration deliberately does not copy. + - **No migration, no tombstone.** Nothing moves or is renamed: a board authored before this release parses unchanged, and `kanban.titleField` is simply no longer refused. + - **The generated projections move with it** — `authorable-surface/ui.json` gains `ui/KanbanConfig:titleField`, and the `ListView` / `ObjectListView` kanban shape lines in `content/docs/references/ui/view.mdx`, `content/docs/references/api/protocol.mdx` and `content/docs/references/data/object.mdx` gain `titleField?: string`. +- 57343f7: **BREAKING** — remove `page.assignedProfiles`, and answer `profiles:` / `assignedTo:` with the permission-set route instead of correcting an author into the retired vocabulary. + + `PageSchema` carried an authorable key named for the concept **ADR-0090 D2** deleted ("The Profile concept is removed — `isProfile` deleted, not deprecated"), and the schema's own alias table rewrote an authored `profiles:` **into** it — two files from `security/permission.zod.ts`, which answers the same word with *"`profiles` is not a PermissionSet field (ADR-0090 D2: no Profile concept)"*. One word, two opposite answers, depending on which schema received it. + + It also enforced nothing. Measured across this repository and objectui at the ruling: **zero readers** — every hit was a declaration, a generated artifact, prose, a `CHANGELOG` or a round-trip test — so a page that "assigned profiles" stayed open to every caller who could reach it, while the Studio form and four locale bundles told the author it was an access list. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `assignedProfiles: ['sales_manager']` on a page | delete the key. Gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (`sys_position_permission_set`) | + | `profiles: [...]` on a page (the alias corrected it into `assignedProfiles`) | the same — the alias is now a refusal naming the permission-set route, and it never accepted the key anyway | + | `assignedTo: [...]` on a page | the same | + + **The one-line fix:** delete the key; page audience is the permission set's. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + + ## The retirement kit + + - **A `retiredKey()` tombstone, not a bare deletion.** `PageSchema` is still parsed from the `page` metadata-type root, so there is an author to teach: `tsc` types the key `never`, and a value reaching a parse raises the prescription rather than a bare unrecognized-key report. The key therefore stays in the walked shape, which is why its liveness row stays too (as `dead`, the `rls.priority` precedent) and why the authorable-surface baseline marks it `[RETIRED]` rather than losing the line. + - **The two alias entries are gone from `aliases` and present in `guidance`.** This narrows nothing: an alias table runs only from the `unrecognized_keys` path, so `profiles:` and `assignedTo:` were *already refused* — the entries only decorated the rejection, and they decorated it with the retired word. Measured before and after on the built artifact: same `issue.code`, same `path`, different text. + - **`page.form.ts`** — the `assignedProfiles` input and its `helpText: 'Profiles that can access this page'` are removed, and with them the four locale bundles that shipped it translated (`zh-CN` 「指定配置文件」, `ja-JP`「割り当てプロファイル」, `es-ES` "Perfiles asignados"). A form input for an unwritable key is the false-compliant UI half of a retirement. + - **Three records that asserted the key WAS enforced are corrected in the same change** — one place alone only moves the lie. `liveness/page.json` graded it `live` on the strength of an objectui bridge at `react/src/spec-bridge/bridges/page.ts`, a path that does not exist in that repo (the row itself stays, regraded `dead`: the tombstone keeps the key in the walked shape, so the row remains and records why). `api/protocol.zod.ts` and `metadata-protocol`'s search-sweep comment both said the page's "own audience gate" applied at page render; it did not, and a page has no audience gate of its own. + + ## What an operator with a STORED page sees + + A `sys_metadata` `page` row written before this release can carry `assignedProfiles`. Nothing breaks at read: the ADR-0087 conversion `page-assigned-profiles-removed` (protocol 18) replays on rehydration and strips the key, so the row is served canonical. `os migrate meta --stored --apply` rewrites the rows so the warn stops; the next save through `PUT /api/v1/meta/page` heals one row the way it heals any pre-protocol shape. + + ⚠️ The strip is the mechanical half only. The paired D3 semantic entry `page-assigned-profiles-audience-to-permission-set` carries the judgement: which permission set a given profile name corresponds to is not derivable by a walker, so each name in a retired list has to be re-expressed as a permission set plus a position. Deleting the key **changes no behaviour and closes no hole** — the page was already open to everyone who could reach it. It stops an unkept promise from being made. + + +- 271d6bb: Record the acting agent on the audit row — ADR-0090 D10 rule 4 dual attribution + + A `sys_audit_log` row written by an MCP OAuth client acting for a human used to + be byte-identical to a row that human wrote in the Console. The envelope carried + the delegation (`principalKind: 'agent'` + `onBehalfOf`), the row did not, and + nothing in between copied it: `assembleExecutionContext` consumed the OAuth + `azp` as a boolean and dropped the value, so the acting client did not exist + downstream of the door at all. + + The delegation now travels the whole way and lands on the row: + + - `ExecutionContext.performedBy` (`{ clientId }`) — decided at the `/mcp` OAuth + door, on the same branch that already decides `principalKind: 'agent'` and + `onBehalfOf`; a member of the closed entry field set like every other. + - `HookContext.provenance.performedByClientId` — the hook-layer carrier, beside + `flowRunId` and `attributedUserId`. Provenance, not `session`: no + caller-gating hook may read the client as the caller. + - `sys_audit_log.metadata` gains `{ performed_by, on_behalf_of }` on a delegated + write, and nothing at all on a personal one — the two shapes are told apart by + absence rather than by guesswork. + + Additive, and attribution only. `user_id` stays the human, so owner-stamping, + `current_user.*` RLS and the `sys_user` join are untouched (ADR-0073 D3 — + attribution is not ownership). `actor` is untouched too: ADR-0118 D1/D5 keeps + that column two-valued — a user id, or `null` for the system — and answers + "which non-user acted" with an added attribution field rather than a second + actor vocabulary. No existing row changes meaning, and no historical row is + rewritten. + + Rule 4's third element, the run id, is NOT delivered here and is not declared + either: nothing on the request path mints one today (`ExecutionContext.traceId` + is declared but resolved by no transport entry point), and declaring a carrier + nothing populates is the defect this change exists to close. +- 1e20f81: feat(spec)!: `ListViewSchema.sort` retires the bare string clause — the PRODUCER half of the sort seam, so the contract stops minting documents its own consumer refuses (#17053; objectui#8221, decision batch #77 option B) + + + + **BREAKING** accept-set narrowing at `view.sort` — the list-view doors + (`ListViewSchema`, and the `ObjectListViewSchema` copy behind `object.list` / + `object.listViews.*`) — shipped as `minor` under this repo's launch-window + convention for breaking changes, the same grade its sibling + `object-block-sort-item-array` took for the two `ComponentPropsMap` doors. The + mechanical prescription is registered under protocol major 18 as + `list-view-sort-string-clause-to-array`. + + **Why this is graded on the seam, not on the string.** objectui ruled one sort + orthography platform-wide — the array (objectui#8221, decision batch #77, + 2026-09-07, option B) — and objectui PR #8758 executes it: `convertSortToQueryParams` + refuses a runtime string and its diagnostic names the array form. `ListViewSchema` + is the producer of exactly those documents: `object.list.sort` is what + `deriveRelatedLists` reads. So until this release a view authored with + `sort: 'created_at desc'` **validated here, cleanly, and then failed downstream** — + the contract minting a shape its consumer rejects, with the author told off by + the wrong layer. Re-measured on this tree before the change, with `bogusProp` + refused by name on the same call as the firing control: `'name desc'`, `'-name'` + and the array form all returned `success: true`, and only a bare number was + refused (`sort/invalid_union`). + + `sort` survives as a key, one union arm lighter, so this is a VALUE narrowing with + no `retiredKey()` tombstone to hang a prescription on. The surviving array member's + own `error` map carries it, keyed on `issue.input` being a string — the same shape + `view.type`'s retired `'page'` value and `view.exportOptions`' retired `'pdf'` value + already use in this schema. Every other invalid value (a number, an object, a + string reaching a *descendant* such as a misspelled `order`) keeps zod's default + report, so nobody is told a clause they never wrote "was removed". + + **Migration** (`list-view-sort-string-clause-to-array`, a D2 conversion, not a + semantic TODO — the rewrite is lossless and wholly mechanical): + `sort: 'created_at desc'` becomes `sort: [{ field: 'created_at', order: 'desc' }]`; + a bare field name meant ascending, so `sort: 'created_at'` becomes + `sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required on the entry + and is written out rather than omitted; a comma-separated clause becomes one array + entry per key, in the same order. `os migrate meta --from 17` lists these edits for + author sources, and stored rows replay them through `applyConversionsToStoredItem`. + + **The narrowing was not free, and the population was measured rather than assumed.** + A tree-wide census over both the TS and JSON spellings of a string-valued `sort`, + read as STRUCTURES rather than counted as tokens, found the clause authored on + three live in-tree sites, all converted here: the shipped showcase list view + `examples/app-showcase/src/ui/views/task.view.ts` (`'estimate_hours desc'`, carried + since objectui#2601 as a deliberate live coverage fixture for the string form), the + frozen `packages/lint` snapshot of that same shipped shape, and the published + `skills/objectstack-ui` list-view rule. The census fired: it *found* documents, and + `tsc` independently reds on the first two the moment the arm is removed. Sites + deliberately NOT converted, having been read rather than grepped: ObjectQL + `query.sort` and the wire `normalizeSortNodes` (different doors, different + dialects), `packages/spec`'s `book`/`doc` field-mapping records whose `sort: 'order'` + is an unrelated key of the same name, and the `packages/lint` rule fixtures, which + feed the PRE-parse walker and never reach this schema. + + **Not moved by this release.** `RecordRelatedListProps.sort` keeps its declared + string arm. That string is the `'field'` / `'-field'` dialect normalised by + objectui's own `RelatedList.normalizeSortSpec`; it never reaches + `convertSortToQueryParams`, and retiring it was not ruled. For the same reason the + conversion above declines any clause that does not parse as ` [asc|desc]`: + guessing a direction for `'-name'` would invent an ordering the author never wrote, + so on a list view it meets the door's prescription instead. +- 38472ce: `CalendarConfigSchema` now declares **`allDayField`** — the fifth field binding on a calendar config, and the one key the rest of this package already published as a member while the schema refused it by name. + + **The trap this closes.** The `object-calendar` door refuses a flat `allDayField` and prescribes, verbatim: *"Write this as a key of the `calendar` config object instead — `calendar: { startDateField, endDateField, titleField, colorField, allDayField }`."* That block's `calendar` prop `.describe()` publishes the same five-key shape, and it ships to `content/docs/references/ui/component.mdx`. An author who followed the prescription on a stored view was refused a **second** time, by a different schema with a different message — `Unrecognized key(s) on this calendar configuration: allDayField` — and neither message said the key was not a member at all, so the natural next move was to assume a typo and try more spellings. + + **Why the schema was the wrong half, measured rather than assumed.** The key is honoured, not inert. At the objectui pin this repo builds against, `ListView`'s `collectViewFields` reads `calendar.allDayField` into the fetch projection and its calendar branch forwards the authored block onto the `object-calendar` node, where `getCalendarConfig` resolves it; objectui then made it load-bearing in the render itself. Trimming the prescription instead would have left a shipped capability with no protocol carrier — and the mirror that carries it today keeps `.passthrough()` explicitly so the key is not stripped, which means a later hardening there would silently drop it. + + **What is authorable, and what still is not.** + + ```ts + // accepted + calendar: { startDateField: 'start_date', endDateField: 'end_date', + titleField: 'subject', colorField: 'status', allDayField: 'is_all_day' } + + // still refused — one key per concept, not a second authorable spelling + { type: 'object-calendar', allDayField: 'is_all_day' } + ``` + + `allDayField` **names a boolean field, not a value**: a record whose flag is true draws as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit it and the renderer's existing inference is untouched — an event with no end date draws as all-day — so every calendar that never authored the key renders exactly as before. + + **The opening is one key wide.** `defaultView` stays refused on this config: it is the renderer's initial view mode, a UI preference rather than a field binding, and it already has its own declared home as an `object-calendar` component prop. Unknown keys are refused in the same shape as before, and `startDateField` is still required. + + Purely additive: nothing that parsed before is refused now, and no key is renamed or removed. +- 8b48903: feat(spec): `spec-changes.json` ships a per-release section, verified against both tarballs (#17080) + + Clause-②: yes (widening) — one new OPTIONAL section on a published artifact plus one new + `os validate --json` key. Nothing previously present is renamed, retired or reshaped: the + `aggregate` and `perMajor` records and every existing key keep their spelling and meaning. + Contract-review tier. + + `spec-changes.json` (ADR-0087 D4) is keyed to the **protocol major**, while this repo's + launch-window convention ships BREAKING entries as **minors**. A consumer crossing one minor + therefore reads a file whose finest question is "16 → 17" — answered long ago — with + `added: 0, removed: 0`, which reads as *nothing changed*. Measured on the published tarballs: + between `@objectstack/spec@17.3.0` and `17.4.0` the export surface gained **225** exports and + lost **51**, and the shipped manifest reported zero of each. + + **What ships now.** The published artifact carries a `release` section — `fromVersion` → + `toVersion` at package-version resolution, with `added` / `removed` (the exports that arrived + and left, each named `": ()"`) and `converted` / `migrated` (the ADR-0087 + D2/D3 entries first registered in that release): + + ```bash + jq '.release | {fromVersion, toVersion, added: (.added | length), removed: (.removed | length)}' \ + node_modules/@objectstack/spec/spec-changes.json + os validate --json | jq .specReleaseChanges # the same data, via the CLI + ``` + + **The committed copy is unchanged and stays deterministic.** The section is a function of a + previously *published* tarball, so it is generated at publish time only; `check:spec-changes` + keeps the registry-only projection in the tree exactly as it was. + + **A wrong change file is worse than none, so it is gated.** Before anything reaches npm the + release lane recomputes the delta from the two tarballs — the previously published one and the + one about to be published — and refuses to publish when the section disagrees, naming the + disagreeing exports and the direction of each disagreement. A release whose data would mislead + does not ship. + + **Absence stays distinguishable from zero.** When the previous tarball carries no export + snapshot the section is omitted rather than emitted empty, and `specReleaseChanges` is `null` + in exactly that case: a consumer must never read "could not be computed" as "nothing changed", + which is the defect this closes. + + New public exports on `@objectstack/spec`: `SpecReleaseChangesSchema`, + `SpecReleaseSurfaceSchema`, `composeReleaseChanges`, and the types `SpecReleaseChanges`, + `SpecReleaseSurface`, `PreviousReleaseRegistries`, `ReleaseSurfaceDiff`. +- 2d235bc: `element:text.variant` accepts the nine values objectui's text node publishes — `h1`–`h6`, `body`, `caption`, `overline` — and still accepts `heading` and `subheading` (#17108). + + Clause-②: yes (widening) + + Release 1 of 2 for the objectui#7450 convergence (director batch #71, 2026-09-07, maintainer verbatim 「其他同意」), split across two releases by the maintainer's decision of 2026-09-09, option B. This release is **additive only**: the accepted set grows by seven and nothing is refused that was accepted before, so an out-of-repo author can converge on a released pin before any spelling stops working. + + Measured on the 17.3.0 declaration, per value, through `ElementTextPropsSchema.safeParse`: `h1`–`h6` and `overline` were refused with `invalid_value`; they are accepted now. `heading`, `subheading`, `body` and `caption` were accepted and are accepted now. A value outside the eleven — `small` — is still refused with `invalid_value` at path `variant`, so the enum remains a closed set rather than having stopped judging `variant` at all. + + - **`.optional().default('body')` is kept, deliberately.** An `element:text` node parsed without a `variant` still materialises `variant: 'body'`, exactly as before. Absence is the one thing a widening must not move, and the `ui:text` side of the platform deliberately does *not* synthesise `body` for an absent `variant` (objectui#6942) — that asymmetry is pre-existing and is left where it was. + - **⛔ Nothing is retired.** `heading` and `subheading` become named refusals carrying migration hints in **release 2**, which is a separate card and is blocked on a value-level retirement mechanism that does not exist yet: `retiredKey()` and ADR-0087 D2 retire a *key*, not a *value*. Authors who want to move early can write `h2` for `heading` and `h3` for `subheading`; neither spelling stops working in this release. + - **No renderer changes here.** `element:text`'s renderer, its designer inspector options and its i18n rows are objectui's, on the released pin, and land on objectui's side of the sequence. + + Generated projections follow the declaration: the `content/docs/references/ui/component.mdx` property table widens. `check:api-surface` reports nothing removed or narrowed. +- 146c291: feat(spec)!: retire the `scheduled` cache-warmup strategy — the cron it selected left in this same major, and nothing ever warmed on a cadence (ADR-0049) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` (the + lockstep convention: `major` is refused by `check-changeset-no-major`, and breaking-ness + is carried by this banner plus the ADR-0087 disposition above). + + `CacheWarmup.strategy` no longer accepts `'scheduled'`. + + | | before | after | + |:--|:--|:--| + | accept set | `'eager' \| 'lazy' \| 'scheduled'` | `'eager' \| 'lazy'` | + | describe | `… lazy (on first access), scheduled (cron)` | `… lazy (on first access)` | + | a document writing it | parsed green | **refused**, with the prescription | + + **The one-line fix:** write `strategy: 'eager'` (warm at startup) or `strategy: 'lazy'` + (warm on first access). For a warmup on a **cadence**, declare a `job` — that is the one + cron slot this platform evaluates: + + ```ts + defineStack({ + jobs: [{ name: 'warm_config_cache', schedule: { expression: '0 * * * *' }, handler: 'warmConfigCache' }], + }); + ``` + + ## Why + + `cron-typed-positions-retired` (17.x → 18, #16320) deleted `CacheWarmup.schedule`, the + cron key this enum member selected, and left the member standing on the reading that it is + "a value, not a position the ruling names". That was a statement about that ruling's + **scope**, not a finding that the value was sound. After the deletion the member declared a + warmup cadence with **no key left to configure it and no engine that has ever run one**, + while its own `.describe()` still promised `(cron)` — ADR-0049 declared-not-enforced, in + the form Prime Directive 10 names outright: a capability advertised that the runtime does + not deliver. + + Nothing on the platform reads `CacheWarmupSchema`: outside its declaring file it resolves + to the generated reference page's import line, the `declaration-map` / `export-origins` + catalogues, the ADR-0058 D7 ledger comment and two of this package's own test files — zero + runtime consumers, measured beside a lit control (`ConnectorSchema`, 46 files, same sweep). + So **no runtime behaviour changes**: no warmup has ever run on a schedule, before or after. + What changes is that the contract stops promising it. + + ## The retirement kit + + - the member leaves `z.enum(['eager','lazy','scheduled'])` and the `.describe()` stops + saying `(cron)` (`system/cache.zod.ts`) + - the prescription hangs on **the enum's own `error` map, dispatched by `issue.input`** — + the established route for an enum-VALUE retirement (`crypto.hash` on + `HookBodyCapability`, `object.managedBy: 'system'`, `HotReloadConfig.stateStrategy`). + There is no value-level analogue of `retiredKey()` and none is invented here. Only the + value that **used to be legal** gets the "was removed" sentence; `strategy: 'sheduled'` + keeps zod's own enum message, which already lists the legal values + - an **ADR-0087 D3 semantic entry**, `cache-warmup-scheduled-strategy-retired` — a semantic + entry rather than a D2 conversion because there is **no source to rewrite**: `CacheWarmup` + is bound to no metadata type and embedded in no stack collection, so no authored document + and no stored row has ever carried this value, and `os migrate meta` has nothing to list. + That is also why the prescription carries **no `os migrate meta` sentence** — it would + promise a listing the tool cannot produce, which is the very defect this card is about + - **nothing in `RETIRED_KEYS_BY_MAJOR`** — no authorable *key* changed — and **no + `retiredKey()` tombstone**, which tombstones keys, not values + - pin tests (`system/cache.test.ts`): the refusal and its prescription, a **lit control** + that a typo is *not* told it "was removed", and that the surviving members and the + `'lazy'` default still parse. `cron-typed-positions-retirement.test.ts`'s warmup fixture + moves to `'eager'`, since a fixture must be well-formed under the current schema + + ## ⚠️ The four surface ratchets are byte-identical across this change, and that is correct + + An enum-VALUE narrowing moves no position, no exported name and no expression-typed slot: + `authorable-surface/` keys on **positions** (`system/CacheWarmup:strategy` stays — the key + is untouched), the ADR-0058 D7 ledger on **expression-typed slots**, and `api-surface/` / + `json-schema.manifest/` on **names**. None of them reads a def's *value set*, so none of + them can fail on this change — the `crypto.hash` precedent measured exactly this. The pin + tests above are therefore not a formality: they are the only instrument this retirement + has, and a green CI run on its own says nothing about whether the value is gone. +- 4db1bf1: **BREAKING** — the export-job API family, the `IExportService` contract and `ScheduleState` leave the public surface (#17158). + + A `major`-class change, recorded as `minor` under the launch-window convention. Maintainer ruling A (decision batch #122 item 3, 「同意」), landing route A (decision batch #221 item 2, 「同意」: objectui retired its side first, in objectui#10247), and a scope note (「同意」) that puts the export-job list pair in; ADR-0049 enforce-or-remove. + + **Why.** `@objectstack/spec` declared a complete asynchronous export API — create a job, poll its progress, fetch a download link, list jobs, schedule a recurring export, cancel — and nothing on the platform served any of it. `@objectstack/rest` mounts no `/api/v1/data/export` route and no `POST` on `/api/v1/data/:object/export`; `IExportService` had no provider; and no package, example, app or skill in this repository, in objectui at the pinned sha, or in cloud read any of the names. An AI following the generated API reference wrote calls that answer `404`. The scheduled-export shapes were worse than unserved: after the cron positions were deleted earlier in this release line, `ScheduledExport.schedule` and `ScheduleExportRequest.schedule` were REQUIRED blocks that could hold no schedule, so an author who filled in the `timezone` believed they had scheduled something. `ScheduleState` described the runtime state of a scheduled flow that no scheduler ever wrote or read. + + ### FROM → TO + + | removed | from | what to write instead | + | --- | --- | --- | + | `ExportJobStatus`, `CreateExportJobRequestSchema` / `CreateExportJobResponseSchema`, `ExportJobProgressSchema` (with their types and `…Parsed` aliases) | `@objectstack/spec/api` | nothing — no route ever created or tracked an export job. To export records, call the served synchronous door `GET /api/v1/data/:object/export` (the SDK's `data.export`), which answers the file itself as CSV, JSON or XLSX. | + | `GetExportJobDownloadRequestSchema` / `GetExportJobDownloadResponseSchema`, `ListExportJobsRequestSchema` / `ListExportJobsResponseSchema`, `ExportJobSummarySchema` (with their types and `…Parsed` aliases) | `@objectstack/spec/api` | nothing — no job ever existed to download or list. | + | `ScheduledExportSchema`, `ScheduleExportRequestSchema` / `ScheduleExportResponseSchema` (with their types and `…Parsed` aliases) | `@objectstack/spec/api` | a `Job` (`system/job.zod.ts`) whose handler performs the export, with its cadence on `Job.schedule.expression` — the one cron slot the platform evaluates. | + | `ExportApiContracts` | `@objectstack/spec/api` | nothing — every route it named was unserved. | + | `IExportService`, `CreateExportJobInput`, `CreateExportJobResult`, `ExportJobDownload`, `ListExportJobsOptions`, `ExportJobListResult`, `ScheduleExportInput` | `@objectstack/spec/contracts` | nothing — no provider ever bound the contract. | + | `ScheduleStateSchema`, `ScheduleState`, `ScheduleStateParsed` | `@objectstack/spec/automation` | nothing — a scheduled flow declares its cadence on its start node (`config.schedule`), and its run history is `ExecutionLog` / `FlowRunSummary`. | + + **The one-line fix: delete every import of the names above, and every request to `/api/v1/data/export/…` or `POST /api/v1/data/:object/export`.** The compiler finds the imports (`TS2305: Module '"@objectstack/spec/api"' has no exported member …`); a hard-coded path has to be searched for. No behaviour is lost — none of those requests was ever answered. + + **What stays.** `ExportFormat`, `ExportImportTemplateSchema`, the import validation shapes and the whole import-job family in the same module — `ImportJobStatus`, `CreateImportJob…`, `ImportJobProgress…`, `ListImportJobs…`, `ImportJobApiContracts` — are served and unchanged, as is `GET /api/v1/data/:object/export`. + + **Read together with the cron-positions retirement in this release.** That entry says `ScheduledExport.schedule` / `ScheduleExportRequest.schedule` keep their `timezone` and `ScheduleState` keeps `timezone`, `status` and `nextRunAt`; this retirement removes those defs whole, so none of those shapes remains to carry them. + + ⚠️ Runtime behaviour is deliberately **unchanged**: nothing ever mounted a retired path or parsed a retired shape, so every request answers exactly as before. The removal retracts a false claim, not a capability. **No deprecation window** (startup-stage posture: retirements take effect immediately). + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** Inside this repository the names occurred only in their declarations, their own tests, generated artifacts and prose; objectui at the pinned sha names none of them in code (it retired its unimplemented async-export path in objectui#10247), and cloud names none; `@objectstack/spec` is published, so readers elsewhere were not measured. + + The ADR-0087 D3 semantic entry `export-job-family-retired` carries the judgement, and the thirteen defs are registered in `RETIRED_DEFS_BY_MAJOR[18]`: none of these shapes is a stack collection or a metadata type, so there is no source for a D2 conversion to rewrite and no carrier key for a tombstone. + + Clause-②: no + + +- bdb247d: `@objectstack/spec/kernel` exports `SEED_WRITE_EXECUTION_CONTEXT`, the one spelling of the seed-write posture every seeder now reads + + The execution context a seed write must use — `isSystem`, `skipTriggers`, + `seedReplay` — had **no exported form**, so every seeder held a private copy of + it and nothing held the copies equal. There were three on `main`: + `SeedLoaderService.SEED_OPTIONS` (`@objectstack/metadata-protocol`), + `SEED_WRITE_OPTIONS` (`@objectstack/runtime`'s `AppPlugin`, whose own docblock + already recorded that it "mirrors" the first) and `SEED_CONTEXT` + (`@objectstack/verify`'s fixture writer, which spelled it a third time + specifically because the runtime kept its copy module-private). + + **Why a shared constant rather than three accurate copies.** `skipTriggers` is + what suppresses "on create" automation for seed rows, and `isSystem` alone does + **not** suppress dispatch. A seed path that lost that flag once seeded with + automation live while the main path had it suppressed — a self-trigger loop that + wedged first boot (#3760). A constant whose divergence re-opens a boot-wedging + defect is a kernel semantic, not a local detail. + + **What is exported, and what deliberately is not.** The **inner** + `ExecutionContext` value, and nothing wrapped around it: + + ```ts + import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; + + await ql.insert(object, rows, { context: SEED_WRITE_EXECUTION_CONTEXT }); + ``` + + The `{ context: … }` options bag stays at the call site. It is what all three + sites ultimately hand to `insert`, but it is an options envelope rather than the + posture: its type differs per engine method, so freezing one bag onto the + protocol surface would serve `insert` and no other operation, and it is + precisely the convenience bundle this export is not. + + ⛔ **No behaviour change.** The value is byte-identical to all three previous + copies, the three flags keep their existing meanings, and no seed path changes + what it writes or how. The three former copies now read this export, so the two + option bags are `{ context: SEED_WRITE_EXECUTION_CONTEXT }` and the `verify` + context is the export itself. + + **Additive, so `minor` on `@objectstack/spec`**: one new name on the existing + `./kernel` entry point, no existing export removed, renamed or narrowed. The + three consumers take `patch` — their published `dist` changes (an import edge, + and the constant now resolves through `@objectstack/spec/kernel`) while their + own public surfaces do not move. +- d5c91dd: feat(spec): an app-declared capability token is not a platform system permission at the `everyone` anchor + + `describeHighPrivilegeBits` counted **any** non-empty `systemPermissions` as a + high-privilege bit, so a permission set carrying the capability token its own + app declared could not be bound to the `everyone` audience anchor: + + ``` + FROM describeHighPrivilegeBits({ systemPermissions: ['clm_requester.access'] }) + -> 'system permissions' // the app's own navigation gate, refused + TO describeHighPrivilegeBits({ systemPermissions: ['clm_requester.access'] }, + { declaredCapabilities: ['clm_requester.access'] }) + -> null + ``` + + One list carries two unlike things: the platform's own powers (`manage_users` + and friends) and a capability a package **declared for itself** (ADR-0066 D1, + entering `sys_capability` with `managed_by: 'package'` + `package_id` + provenance). An app whose navigation gates on its own token therefore could not + ship the set every employee holds — the set's own gate made it unbindable — and + authors were pushed toward declaring no gates at all, the opposite of what + ADR-0066 D1 exists to encourage. + + **The discriminator is provenance, not spelling.** Both predicates + (`describeHighPrivilegeBits`, `describeAnchorForbiddenBits`) take a new optional + `AnchorBindingContext` naming the capability names *this stack declared*; a + token on that list is the app's own gate and is not counted. ⛔ A naming-syntax + rule (dotted ⇒ app token) was considered and rejected: it misjudges in silence + the first dotted platform permission — `setup.access` is one today — and the + first undotted app token. + + **What is still refused**, each pinned in `high-privilege.test.ts`: + + - a platform capability name, **however it is declared** — a package declaring + `manage_users` cannot launder it past the gate (the platform floor); + - any token absent from the declared list, and every token when no list is + passed — omission gets the pre-change verdict, so the narrowing fails closed; + - a mixed set: one unexcused token still refuses the whole set; + - the `guest` tier (ADR-0090 D9), which does not honour the excusal at all — + D5 speaks for authenticated members, and anonymous visitors are not that. + + **No shipped behaviour moves in this release.** Every current caller invokes the + predicates with the old arity, and with no context the code path is identical — + so this release widens the API, not any live anchor binding. The + `@objectstack/plugin-security` boot refusal and the `@objectstack/lint` + `security-anchor-high-privilege` rule pass the declared list in a follow-up, in + the ruled order (protocol first). + + ADR-0090 D5's offending-bit list is revised to match in its own governed PR + (objectstack#17814), per the ruling's 「ADR-0090 修订单独受管 PR」: the offending + bit is a `systemPermissions` entry naming a **platform** system permission. + Both halves are phase ①; ⛔ neither lands without the other following. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/spec`'s published `files[]` ships `dist`, and + the new code reaches it. + + Counts below are taken on a **clean full build of this head** — an empty `dist`, + then `pnpm --filter @objectstack/spec build` with both passes (JS and DTS): exit + 0, `check-dts-emitted` reporting 34/34 declaration files, and + `dist/.build-input-hash` and `.build-input-hash-dts` both matching `src`. The + build state is named because it changes the answer: on a JS-only `dist` — one + still mid-DTS, or built under `OS_SKIP_DTS` — every declaration file is missing + and each count below that reaches one is halved. + + | identifier | built files | where | + |---|---|---| + | `declaredCapabilities` | **4** | `security/index.js`, `index.mjs`, `index.d.ts`, `index.d.mts` | + | `AnchorBindingContext` | **2** | `index.d.ts`, `index.d.mts` — a type, so the declarations are its whole published reach | + | `appDeclaredCapabilityNames` | **2** | `index.js`, `index.mjs` — module-private, so it has no declaration presence at all | + | `describeHighPrivilegeBits` | **4** | the positive control: a symbol already known to ship | + + Negative control: a sentence occurring **only** in the ADR revision — `As first + written, the bullet above made` — occurs in **0** built files, and `docs/adr/**` + is in no package's `files[]`. ⚠️ The control has to be a sentence the source + does not also carry: `The platform floor is absolute` reads 2, not 0, because + that sentence is in this predicate's JSDoc as well as in the ADR, and an emitted + JSDoc reaches `index.d.ts` / `index.d.mts` like any other declaration text. +- 0e51278: `SessionUser.image` is declared `z.string().nullish()` — a string, `null`, or the key absent are all accepted — so a signed-in user who never set an avatar parses against the schema this platform publishes (#17235). + + `z.string().optional()` admitted a string or the key's absence, and refused `null`. better-auth owns the avatar column, stores it nullable, and serialises it present-and-null, so every `/auth/*` session body the platform produces carried a value the declaration rejected. Measured through a real `AuthManager` (better-auth 1.7.2) over a real `ObjectQL` on a real `SqliteWasmDriver`: `get-session`, `sign-up/email` and `sign-in/email` all serve `"image": null` for a freshly signed-up user, and the full envelope failed on exactly that one path: + + ``` + SessionResponseSchema.safeParse(await client.auth.me()) + -> [{ path: ["data","user","image"], code: "invalid_type", + message: "Invalid input: expected string, received null" }] + ``` + + That parse now succeeds on all three routes. + + - **The declaration was the thing that was wrong.** AGENTS.md Prime Directive #12's default — fix the producer, never widen the consumer — rests on a premise it states out loud, that we own both ends. We do not: the nullable column belongs to a third-party model, so PD #12's own exit clause ("change the spec only when the spec itself is genuinely wrong, and then deliberately") is the operative sentence. Normalising `null` away at the producer seam was considered and refused: it is a permanent rewrite layer between the platform and a dependency's data model. + - **A pure widening, and nothing else.** `.nullish()`, not `.nullable()`: the key's ABSENCE is a legal shape today and no producer was ever measured omitting it, so `.nullable()` would have retired a live shape as the price of admitting `null`. Every body legal before this change is still legal. + - **Still refuses what it should.** A number and an object are rejected at `data.user.image` exactly as before; the only accept-set row that moved is `null`. + - **No key is added or removed** — `image` was already authored and already published, so no authorable surface moves and nothing is retired. +- 48203ff: feat(spec)!: retire `ObjectKanbanProps.quickAdd` — the `object-kanban` board forwarded it and nothing ever read it (ADR-0049) + + + + **BREAKING** — `quickAdd` is retired from the `object-kanban` component props. Executes the + objectui#8285 director-seat ruling (decision batch #91, 2026-09-08, standing maintainer + delegation), ruled **option B**: the key leaves the board and stays only on the `kanban-ui` + block, where a React host can supply the runtime function the control needs. + + | | before | after | + |:--|:--|:--| + | `object-kanban` | `quickAdd: true` parsed clean and did nothing | refused by the tombstone, with the prescription | + | `kanban-ui` (objectui block) | the control works when the host passes `onQuickAdd` | **unchanged** | + + **What was actually wrong.** Measured at the `.objectui-sha` pin this repo builds against + (`53ded82bf`): the board FORWARDS the key — `ObjectKanban.tsx:931` spreads the authored bag + into `KanbanRenderer`, which passes `quickAdd={schema.quickAdd}` alongside + `onQuickAdd={schema.onQuickAdd}` (`plugin-kanban/src/index.tsx:196`) — but `KanbanImpl` + gates the affordance on **both** (`:355`, `:368`), and `onQuickAdd` is a host-supplied + FUNCTION that JSON cannot carry and that no producer puts on an `object-kanban` node. + `ObjectKanban.tsx` names neither half of the pair (0 occurrences each, against 6 for the + sibling `onCardClick` in the same file), so the gate was permanently false. + + **And the drop was not silent, which is what made it worse than silence.** objectui's html + tier reported the published key as `unknown-prop` — the same diagnostic a typo gets — and + its registry↔spec ledger records it as `ESCALATED (object-kanban.quickAdd — measured NOT + honoured)`. An author following the published contract met a tool that contradicted it, with + nothing in either message to say which side was wrong. The tombstone collapses both halves + onto one answer. + + ## What to write instead + + Nothing, on this board: there is no per-column quick-add affordance on `object-kanban` and + there never was one. Delete the key. + + ```ts + // before — parsed clean, rendered nothing + { type: 'object-kanban', properties: { objectName: 'crm_task', groupBy: 'status', quickAdd: true } } + // after + { type: 'object-kanban', properties: { objectName: 'crm_task', groupBy: 'status' } } + ``` + + The control itself is not withdrawn from the platform. It stays on the `kanban-ui` block, + which a React host renders directly and can hand the `onQuickAdd` slot to — that is what the + ruling preserved deliberately. + + Existing sources: `os migrate meta --from 17` lists the mechanical edits; apply them by hand. + + The retirement kit: + + - a `retiredKey()` tombstone on `ObjectKanbanPropsSchema` — `tsc` types the key `never`, and + a value reaching the parse raises the prescription rather than a bare unknown-key verdict + - the D2 conversion `object-kanban-quick-add-removed` (`RETIRED_KEYS_BY_MAJOR[18]` entry + `ui/ObjectKanbanProps:quickAdd`, wired into the protocol-18 chain step) — a **pure lossless + delete**, since the key never had an effect to preserve, scoped by component `type` so the + live `kanban-ui` spelling stays out of its reach + - the `authorable-surface/ui.json` row becomes `ui/ObjectKanbanProps:quickAdd [RETIRED]`, and + the generated reference page prints the prescription in place of the old describe + - the schema docblock's read-point list is corrected in the same stroke: it named `quickAdd` + among the keys reached "via the forwarded schema", a sentence true about the FORWARD and + false about the READ — which is how the key kept re-authorizing itself + - pin tests (`ui/component.test.ts`): the refusal carries the prescription; a clean parse does + not materialize the key; and the control pair separating the tombstone's answer from the + strict unknown-key arm's, so a shape that had merely DROPPED the key could not pass + - no liveness-ledger row (component props are not an enrolled ledger type) and no form or + i18n edit: zero `object-kanban` components are authored anywhere under `examples/` or + `apps/` (control: `object-grid` 3, `object-metric` 8 in the same corpora, same instrument) + - `api-surface/` is unchanged, correctly: it ratchets export existence, and no export leaves — + `ObjectKanbanProps` still exists, one key narrower +- 2f1a6f6: A flow screen field can now express a numeric bound, help text and a lookup target — spelled with the object field's own key names + + + + `ScreenFieldConfigSchema` was `.strict` over exactly + `name`/`label`/`type`/`required`/`options`/`defaultValue`/`placeholder`/`visibleWhen`, + so three ordinary authoring intents had **no expression at all**. They did not + degrade quietly — `max`, `helpText` and every lookup-target spelling were + refused BY NAME — but a loud refusal with no landing key is still a dead end, + and the reference app worked around all three in prose: a discount ceiling + interpolated into the `label` and the `placeholder` (with a comment explaining + why there was no `max`), and a `type: 'lookup'` field whose `placeholder` asked + a human to type a record id because the picker could not be pointed anywhere. + + Four keys land, and **their names are derived from `FieldSchema`, not invented** + — one platform, one field vocabulary, so a name learned on an object field means + the same thing on a screen field: + + | Key | Derived from | | + |:---|:---|:---| + | `min` / `max` | `FieldSchema.min` / `.max` | the bound pair | + | `inlineHelpText` | `FieldSchema.inlineHelpText` | help under the input — `FieldSchema` renames `help`/`helpText`/`hint`/`tooltip` onto it, so a screen-local `helpText` would have been a second contract for one question | + | `reference` | `FieldSchema.reference` | the object a `type: 'lookup'` field picks records from | + + **The bound is enforced, not advisory.** It rides to the client on + `ScreenFieldSpec` so the user is stopped at the input, **and** + `validateScreenInputs` re-checks it when the run resumes (`min_value` / + `max_value`, both already in the ADR-0114 D2 field-error catalog — no new error + code). A screen field's declared contract is the only contract behind it, so a + bound the dialog alone applied would be bypassed by any caller posting to + `resume` directly — the gap #4477 closed for `required`. + + That sentence needs no "when the value is a number" qualifier, because the + value SHAPE is checked first: on a `type: 'number'` field a present value that + is not a finite JSON number is refused with `invalid_type` (also already in the + catalog — still no new code), ⛔ **not coerced**. Before this, a bound pass that + compares numbers was satisfied by anything that never reached it, so `"25"` + under a `max` of `20` was conformant. One member of the open `type` vocabulary + is read as a value domain; every other widget hint stays open, and a bound on a + non-numeric field still constrains nothing. + + **Delivered with its rendering, not ahead of it.** The executor forwards all + four onto the wire and the Studio designer form offers all four as repeater + columns; `builtin-node-form-zod-ledger.test.ts` reconciles the two key sets + against the Zod in both directions, so a key declared here and absent from the + form fails that test rather than shipping as a field nobody can author. + + **BREAKING** in the accept-set sense, in TWO places — landing as `minor` on + both packages because the launch-window guard (`check-changeset-no-major`) + keeps breaking changes off `major` outside pre-mode, not because the narrowing + is small. Both were ruled (maintainer ruling A′, decision batch #130 item 1, + 2026-09-13); this release is **not** purely additive. + + 1. `reference` is **required** when `type` is `lookup`, as it is on an object + field. A picker with no target object resolves nothing — ADR-0078's own + example of silently-inert metadata — and a degraded shape that ships today + is not a reason to bend the contract to it. A stored flow with a bare + `lookup` screen field parsed before and does not now. There is **no lossless + conversion**: nothing in the metadata says which object the author meant, so + this is an ADR-0087 **semantic** migration entry — a structured TODO + (`screen-field-lookup-reference-required`) that names the flow and the field + for a human to answer — and ⛔ never a D2 conversion that would have to + invent a target. + 2. A non-number submitted for a `type: 'number'` screen field is refused on + resume (`invalid_type`) instead of passing silently. A resume bag that was + accepted before can be refused now; it was never doing what its author + declared. + + Everything else is additive: the bound itself fires only on a field that + declares one, which nothing did before this release. + + The neighbouring spellings are refused **with their landing key** rather than + with a bare key list: `help`/`helpText`/`hint`/`tooltip` name `inlineHelpText`, + and `object`/`referenceTo`/`targetObject`/`lookupObject`/`relatedTo`/`target` + name `reference`. ⚠️ `object` means different things one level apart — on the + screen **node** it renames to `objectName`, on a screen **field** it can only + mean the lookup target — so it earns its own row on both. + + **One stale claim corrected in passing, because this change falsified it.** The + flows translation surface documented `help`'s exclusion as *"`ScreenFieldConfig` + declares nothing help-shaped at all"*, in `translation.zod.ts`'s guidance string + (which enumerated the old key set verbatim), its doc block, and + `i18n-resolver.ts`'s `FLOW_SCREEN_FIELD_COPY_KEYS`. The screen field now + declares `inlineHelpText`, so the copy is real. The exclusion **stands** — the + flows bundle still carries `label` and `placeholder` only, and growing that face + is a ruled step against the #7646 enumeration, not a resolver-side accretion — + but its reason is now stated as a not-yet instead of telling an author the field + has no help copy when it has. ⛔ No translation key was added and no resolver + behaviour moved. +- 23fc5d6: An action can now **declare which bulk dispatch contract its body is written for**, and a list view that wires it the other way is refused at authoring time instead of handing the body the opposite input in silence. + + A list view has always been able to wire the same declared action two ways, and the two deliver opposite shapes to the same body: `bulkActions: ['']` promotes the action to a def and dispatches it **once per selected row** (that row's `recordId`, no `_selectedIds`), while a `bulkActionDefs` entry with `execution: 'aggregate'` makes **one** dispatch for the whole selection (every id in `params._selectedIds`, no `recordId`). The action declared neither, so both mismatches failed quietly and in opposite directions — an aggregate body wired bare-string read `_selectedIds` as `undefined`, fell into its single-record branch and reported success for one row out of ten; a per-record body wired aggregate found no `recordId` and threw its own "nothing selected", which reads like a selection bug. Nothing caught either: `recordId` and `_selectedIds` are both built-in action params (ADR-0104), so the strict params gate admits either bag without a word, and the wiring lives on the view while the declaration would live on the action, so no single parse has both halves. + + - **`ActionSchema` gains `execution`**, and it is `bulkActionDefs`' own vocabulary — the same key, the same two values (`'perRecord' | 'aggregate'`), the def's `BulkActionExecutionSchema` **imported rather than re-declared**, so there is no second spelling to drift. The near-miss keys (`dispatch`, `dispatchContract`, `bulkExecution`, `bulkDispatch`) rename onto it; ⛔ `mode` deliberately does **not**, because on an action `mode` is a declared key of its own. + - **`@objectstack/lint` gains `action-dispatch-contract-mismatch`** (severity `error`), a member of the reference-integrity suite, so it runs on `os validate`, `os lint` and `os compile` at once. It names the action, the view and **both** contracts — the declared one and the wired one — and offers both ends of the fix, because which end is wrong is the author's call. It judges every list tier: a view's `list`, each `listViews.`, and an object's own `listViews`. + - **⛔ No silent default.** `execution` is optional and an action that omits it is *undeclared*, never defaulted to a contract — which is also the honest state of a body written to serve both (it reads `recordId` *and* `_selectedIds`), and why no third enum member was added. Existing sources are migrated by the new ADR-0087 semantic entry `action-bulk-dispatch-contract-undeclared`, which derives the declaration from the view wirings where they are unambiguous and hands back a structured TODO where one action is wired both ways. + + Nothing about dispatch changes: this release adds a declaration and a build-time refusal measured against it. Existing apps are unaffected until they declare the key — the new rule has nothing to judge on an undeclared action, by construction. +- 7b1e4a4: feat(spec,metadata-protocol): `os migrate meta --stored` lists every stored page filter the record-filter conversion leaves as stored, as a TODO naming the page, the block and why — the ADR-0087 D3 TODO channel (#17321, ruling B item 2) + + **Clause-②: yes** — `@objectstack/spec` gains public exports (`CONVERSION_TODO_CODE`, + `ConversionTodoDetail`, `ConversionTodoNotice`, and the optional `ApplyConversionsOptions.onTodo` + and `ConversionContext.reportTodo`), and `@objectstack/metadata-protocol` gains + `StoredMigrationTodo` and `StoredMigrationRow.todos`. No door's accept set moves, and nothing + that was left as stored before starts converting: every stored body is rewritten exactly as it + was. + + **What was silent.** The D2 conversion `page-component-filter-record-to-rule-array` leaves a + stored filter as stored wherever no lossless rule-array spelling exists — above all a record + carrying `$and` / `$or` / `$not`, which is never flattened. It emitted nothing for such a site, + and `os migrate meta --stored` reads conversion notices as its change signal, so a page whose + only legacy filter carried a combinator was reported as **already on protocol**. + + **What it says now.** Each such site is a structured TODO (code `OS_METADATA_CONVERSION_TODO`) + carrying its path, the shape left in place, and a reason that names the block (its type, and its + `id` when it has one) and what blocks the rewrite — the combinator by name, the operator + (`$null`, `$exists`, an AST `like`), the null or array value, the rule the door would refuse, or + the inline rows the block renders. The stored pass lists them under their row, whatever the + row's outcome: + + ```text + ⚠ 1 row(s) are outside this pass — each row's reason says why: + • page/pipeline_board [env-wide] — the conversion chain rewrites nothing here: it left 1 site(s) of this row as stored, … + TODO page-component-filter-record-to-rule-array: {"$or":[…]} left as stored at pages[0].regions[0].components[0].properties.filter — On the `object-kanban` block, this filter carries the combinator `$or`: … + ☐ TODO: 1 site(s) in 1 row(s) are left as stored — no conversion can rewrite them without changing what they mean, so no run of this pass will. … + ``` + + The same list is `rows[].todos` in `--json` and in the `POST /api/v1/meta/_migrate-stored` + report. A run with no TODO prints exactly what it printed before. + + **Outcome and exit code.** A row whose only finding is TODOs has nothing to persist and is now + reported `skipped` (it was `canonical`). Like every other skip class it does not change the + run's exit code: no run of this pass can clear it, because the conversion must not flatten a + combinator — it is the hand rewrite's to decide. A row that also converts something keeps the + outcome its conversion gives it, with its TODOs listed beside its notices. Measured through the + write path: on `--apply`, a row whose leftover sits in a block's `properties.filter` or + `properties.defaultFilters` is rewritten (its lossless filters persist; the metadata API's save + does not refuse block props by component type), while a leftover in `dataSource.filter` fails + the save, and the row's TODO says why. + + **For code calling the conversion layer.** `onTodo` and `reportTodo` are optional. Only the + stored-metadata pass passes a sink today; every other seam leaves the site as stored silently, + exactly as before. +- d7c0241: feat(spec): a stored record-form `filter` at the converged rule-array doors is converted to the rule array wherever the mapping is lossless — the ADR-0087 D2 conversion `page-component-filter-record-to-rule-array` (#17321, ruling B) + + The one-filter-orthography convergence moved every page-component `filter` door onto the + `ViewFilterRule` array and refused the MongoDB-style record it used to take — but converted + nothing at rest. A page saved with the old form kept rendering, and the next person to open it + in the builder and press Save was refused. This release adds the mechanical half. + + **What converts** — at `dataSource.filter` on any page component, at `properties.filter` on the + `object-grid`, `object-metric`, `object-kanban`, `object-calendar`, `object-map`, `object-gantt`, + `object-tree`, `object-timeline`, `element:number` and `element:record_picker` blocks, and at + `properties.defaultFilters` on `object-grid`: + + | Stored | Becomes | + | --- | --- | + | `{ status: 'active' }` | `[{ field: 'status', operator: 'equals', value: 'active' }]` | + | `{ amount: { $gt: 100 } }` | `[{ field: 'amount', operator: 'greater_than', value: 100 }]` | + | `{ amount: { $gte: 1, $lte: 9 }, owner_id: 'u1' }` | three rules — they AND | + | `[['owner_id', '=', '{current_user_id}']]` | `[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` | + | `{}` | `[]` | + + Values are carried verbatim, value placeholders and date macros included. The operator comes + from the two tables the doors already use — a declared FilterCondition operator (`$gt`, `$nin`, + `$notContains`, …) folded through `normalizeFilterOperator`, and an AST infix spelling (`=`, + `!=`, `>=`, …) lowered through `parseFilterAST` — and every produced rule must parse at the + door, so a converted page re-saves cleanly. + + **Where it runs.** On every stored-row read (`applyConversionsToStoredItem` replays it), so + the metadata API already serves such a page in the rule-array form; `os migrate meta --stored --apply` + persists it; `os migrate meta --from 17` lists the edits for author sources. It is retired from + the authoring load path, so `defineStack` / `os validate` still refuse an author who writes the + record form and teach the rule array — no accept-set change. + + **What is left exactly as stored.** A filter carrying `$and` / `$or` / `$not` is never + flattened: the rule array only ANDs, and flattening `$or` or `$not` changes which rows the page + selects. So is any filter with a part that has no lossless rule spelling — a `null` value (the + renderer skips that key today, where a rule would test IS NULL), `$null` / `$exists`, an AST + `like` / `ilike`, an array or object comparand in equality position, an AST `and` / `or` group. + Conversion is all-or-nothing per filter: converting the mappable keys and dropping the rest + would widen the filter. And every filter — the binding's included — of a component whose rows + are **inline** (`data: { provider: 'value', … }`, a `data` array, or `staticData`) is left as + stored: the `object-map`, `object-tree`, `object-calendar` and `object-gantt` renderers match + that filter against their own rows in an in-memory data source that reads the record form but + excludes every row for a rule array, so a rewrite there would empty the block. The same filter + on a block that queries an object converts. Such a page keeps loading and rendering unchanged, + and its `filter` door refuses the form: at `dataSource.filter` on the page's next save; at a + block's `properties.filter` / `properties.defaultFilters` only as the component-props gate's + advisory finding (`os validate`, `os build`, `os lint`) — a re-save through the metadata API is + not refused there, measured — and for a combinator record that refusal no longer renders the + combinator as a field (`{ field: '$or', … }`); it names the combinator and says why no rule + spells it. + `os migrate meta --stored` lists each such filter left as stored as a TODO under its row. +- 8271c81: **BREAKING** — a dataset-bound dashboard widget's `chartConfig` carries appearance only: `type`, `xAxis`, `yAxis` and `series` are refused by name, each refusal naming the dataset selection the intent belongs in. + + Clause-②: yes (narrowing) + + `DashboardWidgetSchema.dataset` is REQUIRED, so **every** dashboard widget is dataset-bound, and ADR-0021 already made the dataset the owner of the chart's structure: it decides which series exist and which column each one reads. `chartConfig` nonetheless declared `type` / `xAxis` / `yAxis` / `series`, and the two answers met with no rule between them. That was not merely inert. An authored `yAxis[].field` was a live MEMBERSHIP channel — the renderer synthesised a series from the authored axes when the chart declared none — so one authored axis could silently re-point a dataset-bound series at a different column while the chart still drew, which reads as a true statement about the data. Maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」, on options C+D together: state the ownership split in the protocol AND refuse the four keys by name. + + ## FROM → TO + + | you wrote inside `chartConfig` (17.4 and earlier) | write instead | + | --- | --- | + | `type: 'line'` | `type: 'line'` on the WIDGET, beside `dataset` — the widget's own `type` is the chart family and it always won; nothing on this face ever read the chart config's | + | `xAxis: { field: 'stage' }` | `dimensions: ['stage']` on the widget — the dataset dimension the category axis plots | + | `yAxis: [{ field: 'amount' }]` | `values: ['amount']` on the widget — the dataset measures, one entry per mark. A second axis is a second measure, not a second axis declaration | + | `series: [{ name: 'amount' }]` | `values` (plus a second `dimensions` entry to split) — series membership follows the selection; an entry naming a measure outside it was already being ignored | + + **The one-line fix:** delete the four keys; the widget's `type` and its `dimensions` / `values` are the chart's structure. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + + ## What is NOT retired + + The keys stay authorable on `ChartConfigSchema` itself, and that is the half a blanket refusal would have broken. A react-tier `` binds inline rows with no dataset behind them, so its axes are the author's and are unchanged — `react-blocks.ts` still publishes all four in that block's `dataProps`. `ReportChartSchema` keeps its own `xAxis` / `yAxis`, narrowed to its bound dataset's dimension and measure names. The refusal lives on a new per-carrier `DashboardWidgetChartConfigSchema` (`ChartConfigSchema.extend(…)`, the `ReportChartSchema` spelling) precisely so it cannot reach those two. + + ## What this costs, stated rather than discovered + + `xAxis` / `yAxis` / `series` carried presentation alongside the binding — axis titles, number formats, bounds, grid lines, log scale, and per-series labels, colours, stacking and mark types. Refusing the keys takes the presentation with the binding: a dataset-bound chart takes those from the dataset's own dimension and measure declarations, and `colors` on the chart config remains the palette channel. **The combo chart a dataset-bound widget could author through `series[].type` has no authoring channel on this face any more.** That capability loss is ruled, not incidental — the option that kept it was on the table and was not taken. + + ## Accept-set movement, both directions + + Narrowing, on a dataset-bound widget: the four keys move from accepted to refused. **And one widening, which is forced by the ruling rather than chosen:** `ChartConfigSchema.type` is REQUIRED, so before this change a `chartConfig` without a `type` was refused as incomplete. Refusing `type` while keeping the bag authorable for appearance — which ruling item 1 requires in as many words — means absence must now be legal. So `chartConfig: { title: 'Revenue' }` on a dashboard widget moves from refused to accepted. That is why the declaration reads `yes (narrowing)` rather than `no`. + + ## The retirement kit + + - **Four `retiredKey()` tombstones on the widget carrier**, registered as `ui/DashboardWidgetChartConfig:type` / `:xAxis` / `:yAxis` / `:series` under protocol 18. `tsc` types each key `never`, so every authoring site in a consumer's tree fails to compile before anything runs, and a value that reaches a parse raises the prescription rather than a bare unrecognized-key report. + - **The ADR-0087 pair.** The D2 conversion `dashboard-widget-chart-config-structure-removed` strips the four keys from stored dashboard widgets (dashboards only — reports and the react tier keep theirs); the D3 semantic entry `dashboard-widget-chart-config-structure-refused` carries the judgement, because moving what the keys MEANT into the dataset selection needs facts the widget does not hold — an authored axis field can name a dataset dimension the widget never selected. + - **The liveness rows stay and are regraded `dead`**, the `retiredKey` route's discipline: the tombstone keeps the key in the walked shape, so the row remains and records why. Three of them were graded `live` on their presentation half on 2026-09-12 and that measurement is recorded as overridden, not withdrawn. + - **`chart-config-missing` is withdrawn from `@objectstack/lint`.** It advised a `combo` widget with no `chartConfig` to declare `chartConfig: { series: [{ name, type }] }` — metadata the schema now refuses — and after the ruling there is nothing a `combo` author can do about the finding. The rule ID stays exported, so an existing `suppressWarnings: ['chart-config-missing']` entry keeps parsing. `chart-field-unknown` still fires on a legacy document and its hints now say delete-and-migrate instead of describing what the keys used to carry. + + ## What an operator with a STORED dashboard sees + + A `sys_metadata` `dashboard` row written before this release can carry any of the four. Nothing breaks at read: the conversion replays on rehydration and strips them, so the row is served canonical, and `os migrate meta --stored --apply` rewrites the rows. ⚠️ The strip is the mechanical half only. A widget whose authored axes AGREED with its selection renders identically afterwards — that is the expected case. A widget that renders differently was relying on the membership channel this removes, which is the case the ruling was made about. + + +- d285bf0: fix(spec)!: `multiple: true` is refused on every type outside the multi-capable set, and driver-sql derives JSON-column storage from the spec predicate (#17469) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition). + + Two definitions of "multi-valued" disagreed, and the user saw the disagreement as + a `400`. + + - `FieldSchema` accepted `multiple: true` on **any** type. + - `@objectstack/driver-sql`'s `isJsonField` read the flag raw — + `JSON_COLUMN_TYPES.has(type) || !!field.multiple` — and built a **JSON array + column** for it. + - `isMultiValueField` — the published spec predicate consumers shape queries from + — answered **"not multi-value"** for that same field, because `master_detail` / + `tree` / `text` are outside `MULTI_CAPABLE_TYPES`. + + So a related list composed `=` against a JSON array column, and the driver refused + the equality family there with a `400`. + + In business terms: `multiple` means "this cell holds several values at once", and + that has meaning only on multi-select, multi-record / multi-user and multi-file + fields — exactly what the spec already declares. A child record with several + masters, a tree node with several parents, or a text box holding several texts has + no meaning on any mainstream platform. The declaration was accepted silently, the + UI rendered a single value, the database built a JSON array column, and the + related list answered the user a 400. + + FROM → TO, for metadata that used to parse and now fails: + + ```ts + // FROM — parsed, stored a JSON array, rendered single, answered `=` with 400 + { type: 'text', label: 'Aliases', multiple: true } + { type: 'master_detail', label: 'Parents', reference: 'account', multiple: true } + { type: 'tree', label: 'Parents', reference: 'category', multiple: true } + + // TO — pick the type that actually holds several values… + { type: 'tags', label: 'Aliases' } // several free-form strings + { type: 'lookup', label: 'Parents', reference: 'account', multiple: true } // several related records + + // …or drop the key, if the cell really holds one value. + { type: 'text', label: 'Alias' } + { type: 'master_detail', label: 'Parent', reference: 'account' } + ``` + + The refusal names the field, its type and the alternative, on the `multiple` path. + `radio` keeps its own narrower 2026-08-22 message (#11437); the two never + double-fire. + + **`MULTI_CAPABLE_TYPES` and `isMultiValueField` are untouched**, deliberately: a + field that was already multi-valued by that predicate keeps its declaration, its + storage and its read path byte-identically. What moved is which declarations can + be newly authored, plus the storage decision for the shapes that are now refused. + + **Storage change (`@objectstack/driver-sql`)**: every site that asked + `field.multiple` the question "is this value multi-valued" now asks + `isMultiValueField` — **eighteen expressions across two files**, not one. The + file's own header already called `JSON_COLUMN_TYPES` membership "owned by + `@objectstack/spec`"; that sentence is now true for the `multiple` half too. + + - `sql-driver.ts` — the DDL writer (`createColumn`'s multi-value short-circuit), + the read-side deserializer (`isJsonField`, both limbs), the `varchar` width + mirror (`varcharColumnChars`), the cross-field comparison class + (`crossFieldComparisonClass`), the four scalar registries filled by BOTH + `registerObjectMetadata` and `registerExternalObject` (`mediaFields`, + `booleanFields`, `numericFields`, `numericValueFields`), and the two MySQL + temporal-widening candidate sets. + - `schema-drift.ts` — the differ's `fieldHasColumn`, its `declaresJsonColumn` + disjunct and its `declaresArray` test, which #15771 bound to the writer's + predicate and which a pin test holds equal to it. + + Only one of those was named in the ruling; aligning it and leaving seventeen + would have re-opened #11535 in reverse — the DDL writing a JSON column that the + read-side deserializer no longer recognises. A column whose field is multi-valued + by the spec predicate behaves exactly as before; the shapes that change are the + ones the schema now refuses at the entrance. + + ⛔ Three `field.multiple` reads are deliberately NOT aligned: the three that + interpolate `', multiple'` into an `uncompilableFieldReferenceError` message. + They echo what the author DECLARED back to them; they do not ask whether the + value is multi-valued (the verdict there comes from `crossFieldComparisonClass`, + which is aligned). + + ⚠️ **Two consequences worth reading before you upgrade.** + + 1. A **stored** field carrying `multiple: true` on a non-capable type has no + lossless conversion — its column was physically built as a JSON array. The + ADR-0087 semantic entry `field-multiple-non-capable-type-refused` emits the + structured TODO naming the object, field and type; migrating the data is the + author's judgment call, and the entry states how to prove it. + 2. `isMultiValueField` reads the **authorable** `FieldType` vocabulary. A driver + -internal column-type alias (`string` / `integer` / `int` / `float` — the + introspected-column spellings) is not a `FieldType`, so a hand-declared + external object that puts `multiple: true` on one of those no longer gets a + JSON column. Declare such a column as `object` or `array` (both are + `JSON_COLUMN_TYPES` members and unchanged), or as the authorable type it + really is. + 3. `multiple: true` on `boolean` / `toggle` / `number` / `currency` / `percent` / + `date` / `datetime` / `time` **ceases to be a supported shape end to end**, as + a consequence of the entrance refusal above. Such a column is no longer a JSON + column, so it is no longer excluded from the scalar read-coercion registries + and the declared-type text-operator gate (`isNonTextColumn`) applies to it: a + `$contains` against one answers the declared no-match rather than a JSON + membership test. Stored data in that shape is the ADR-0087 entry's subject. +- 2c1011b: fix(spec)!: a blank string in a flow node's predicate slot — a `decision` branch `expression`, a screen field `visibleWhen` — is refused at authoring (#17493) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on two authored flow-node slots, shipped as + `minor` under the launch-window convention (`check-changeset-no-major` refuses + `major` until GA; breaking-ness is carried by this banner and the ADR-0087 + disposition above, not by the level). + + **What changed.** A `decision` node's `config.conditions[].expression` and a + `screen` node's `config.fields[].visibleWhen` are declared bare CEL text. A string + that is blank after trimming (`''`, `' '`, a tab or a newline) used to be + accepted there by `FlowSchema.parse`, `AutomationEngine.registerFlow` and + `objectstack validate`, and was then read as "no predicate": the evaluator answers + a blank decision predicate `false`, so that branch was not taken, and nothing said + so. It is now refused at those doors — by `FlowSchema.parse` with a `custom` issue + anchored at the slot (for example `nodes.1.config.conditions.0.expression`), and + by `registerFlow` and `objectstack validate` through that same parse — with a + message that leads with the published `PREDICATE_SLOT_STRING_REFUSAL` sentence, + the one these slots already answered with for a non-string value. Where such a + value already sits, the whole flow is refused: registered from the metadata + registry or `sys_metadata` at boot, it is skipped with a + `failed to register flow` warn naming it while the flows beside it register; a + `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole + stack; an artifact file is refused whole at load. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `conditions: [{ label: 'high', expression: ' ' }]` on a `decision` node | the predicate you meant — `{ label: 'high', expression: 'record.amount > 10000' }` — or, to keep what the blank did, `expression: 'false'` | + | `fields: [{ name: 'reason', visibleWhen: '' }]` on a `screen` node | the predicate you meant — `visibleWhen: "status == 'rejected'"` — or, to keep what the blank did, drop the `visibleWhen` key | + + **One-line fix:** write the predicate, or keep what the blank did — `'false'` on + a decision branch (the value the blank evaluated to), no `visibleWhen` on a + screen field (a blank one was read as absent). ⚠️ Do not drop a decision's only + branch: the node then routes by its out-edges alone, and the out-edge that branch + labelled is no longer held back. A blank structural `condition` is another case — + see the `flow-edge-condition-evaluated-slot-source-required` migration entry. + + **Unchanged.** A non-blank predicate parses, registers and validates as before; + a non-string in these slots keeps its existing refusal at `registerFlow` and + `objectstack validate`; `edges[].condition` and a node's `config.condition` keep + their own rule and sentence (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`); and + `AutomationEngine.evaluateCondition` still answers a blank predicate `false` for + a caller that reaches it directly. The `PREDICATE_SLOT_STRING_REFUSAL` constant + keeps its name and now also names the blank string, so code matching the + constant rather than a copy of its text is unaffected. +- 12bb672: fix(spec)!: `groupByField` refuses a padded field name on kanban, gantt and timeline instead of handing the renderer a lookup that always misses (#17499) + + **BREAKING** — an accept-set narrowing on three published authoring keys. `KanbanConfigSchema.groupByField` (**required**), `GanttConfigSchema.groupByField` and `TimelineConfigSchema.groupByField` were bare `z.string()`, so `' stage'` was valid authored metadata; all three are now refused at parse. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings, the same as the sibling axis in #17360. Stored metadata carrying a padded `groupByField` now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `ui-list-view-groupbyfield-padded-refused`. + + ## What was wrong + + The padded name never failed anywhere. It failed to *group*. + + These three keys name a field the consumer looks up on **every row, by that name**. Measured in objectui at `dda8f3815`: the kanban board resolves its lane as `laneField = groupByField || groupField || detectStatusField(objectDef)` and buckets cards by `card[laneField]`; `ObjectGantt`'s `groupByAccessor` splits the name on `.` and walks the backing record (`resolvePath(task.data, field)`); the timeline groups its rows the same way. The server answers under the unpadded name, so a padded spelling reads `undefined` on every row and the board collapses into one `Uncategorized` lane — the gantt and the timeline into one ungrouped bucket — holding every record. + + That is a silent wrong answer that reads as a true statement about the data: a user looking at one giant lane cannot tell it apart from a dataset where the field genuinely is empty. Nothing weaker than a parse refusal is honest about it. + + `packages/lint`'s `validate-list-view-field-refs` already calls this consequence out for `kanban.groupByField` (*"collapses every card into the uncolumned bucket"*), and grades that position `error` — but that rule only runs where an app is validated against its object definitions. The producer accepted the value regardless, which is the hole this closes. + + ## What it does now + + Each of the three carries the **non-padded** pattern — no leading and no trailing whitespace — and the refusal is addressed to the offending key (`kanban.groupByField`, `gantt.groupByField`, `timeline.groupByField`), names the offending spelling verbatim so the whitespace an author cannot see in an editor is visible in the message, and carries the name to write instead. + + ⛔ **Not a `.trim()`.** A trimming schema makes `' stage'` and `'stage'` silently equivalent, which is the consumer-tolerance direction AGENTS.md #0.1 refuses: the padded spelling is a mistake the author should be told about, not a dialect the producer quietly normalises away. On the **required** kanban key this is sharper than on the sibling axis — an author cannot withdraw the value by omitting the key, so a normalising producer would be the author's only feedback channel and it would say nothing. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `kanban: { groupByField: ' stage' }` | `kanban: { groupByField: 'stage' }` | + | `gantt: { groupByField: 'owner ' }` | `gantt: { groupByField: 'owner' }` | + | `timeline: { groupByField: 'team\n' }` | `timeline: { groupByField: 'team' }` | + + The remedy is always the same: write the field name exactly as the object declares it and the server answers under. If a board has been silently showing one `Uncategorized` lane, re-authoring the name is also the fix for that. + + ## Scope — what is deliberately NOT narrowed + + - **The empty string is unchanged.** It still parses, exactly as before, on all three keys. This narrowing exists for the **silent** case; widening the pattern to catch `''` would be a second, undeclared narrowing riding on this one. + - **This is not the snake_case machine-name grammar.** `packages/spec` spells `/^[a-z_][a-z0-9_]*$/` inline for object, field and tool **names**, and these keys deliberately do not take it: a `groupByField` holds a field **reference**, and a dotted relationship path (`owner.name`) is an in-tree spelling of one — `packages/lint`'s `validate-list-view-field-refs.test.ts` carries `kanban: { groupByField: 'owner.name' }` in a case asserting no findings. + - **The sibling axis `grouping.fields[].field`** already landed this rule in #17360 / PR #17498; this change reuses that pattern rather than declaring a second one. + + ## Who is affected, measured + + Every `groupByField` spelling in this repo parses unchanged. Harvested across every `.ts` / `.tsx` / `.mdx` / `.json` / `.mjs` outside `node_modules`: **14 distinct literals, zero of them padded** (`'warning'` / `'error'` are severity-map values in `packages/lint` and `''` is prose inside a completeness hint, so neither is an authored name). Nothing in the tree reddens, and no fixture had to be rewritten to keep it green. + + Outside the repo, only metadata that was already grouping wrongly is affected: a padded `groupByField` has never produced a correct board, gantt or timeline on any renderer. + + Clause-②: no (narrowing) — no key is added, removed or renamed, no exported symbol moves (`check:api-surface` clean with no regeneration), and no registry row is added. The accept set narrows back to what the key's description already claimed. + + +- 97233b9: `dashboard.widgets[]` (17) and `dashboard.globalFilters[]` (10) — every authorable row property of these two repeaters now carries a JSON Schema `title`, so Studio's property-panel table prints an authoring label instead of the raw machine key (#17505). + + `Clause-②: yes` — no authorable key moves, but each row property gains a `title` node in the emitted JSON Schema, which is a published artifact. + + Studio renders a `type: 'repeater'` field as a table whose column headers read `items.properties[k].title ?? k` off the schema derived by `z.toJSONSchema(...)`. With no `title` the fallback arm runs in **every** locale, English included, so the maker saw `requiresService`, `filterBindings` and `optionsFrom` inside an otherwise translated panel. That is a missing authoring label in the contract, not a translation gap — the English default has to live on the schema, because `resolveMetadataFormSchemaTitles` only ever REPLACES a `title` that is already there. + + - **Mechanism unchanged** — this applies the one ruled in #16458 and already landed on `dashboard.header.actions` and on the `ai/skill`, `ui/report` and `ui/page` carriers: `.meta({ title })` on the zod item schema, beside the existing `.describe()` rather than in place of it. + - **The debt record is deleted, not suppressed.** `repeater-item-titles.test.ts` keeps an exact, shrink-only ledger: a carrier in it must still be untitled, so paying a debt and leaving the entry behind is as red as never paying it. Both `dashboard:*` entries are gone from that set; five remain (`field:options`, `object:fields.options`, `view:columns`, `view:sort`, `view:tabs`). + - ⛔ **No tombstone was titled.** The five `retiredKey()` keys on this row (`actionUrl`, `actionType`, `actionIcon`, `responsive`, `aria`) declare their keys unwritable; an authoring label would advertise them as writable. All five still emit `title: undefined` in both `io: 'input'` and `io: 'output'`, and the sibling control in `dashboard.test.ts` was re-pointed onto one of them so the rule is now pinned rather than assumed. + + Measured through the platform's own predicate (`z.toJSONSchema` over `getMetadataTypeSchema`, `io: 'input'`), not by regexing source: `dashboard:widgets` untitled 17 → 0 and `dashboard:globalFilters` untitled 10 → 0, with all twenty other repeater carriers unchanged in the same run. +- 1a2bb9e: Studio's property-panel repeater tables name their columns in the author's own language: every repeater enumerates its row properties in the owning `*.form.ts`, and all four platform catalogs carry a translated name for each one + + Clause-②: no + + A `type: 'repeater'` renders as a table whose column heads come from the form's declared row children when it declares any, and from the served JSON Schema `items.properties[k].title` when it does not. `os i18n extract` only emits a `metadataForms..fields['.']` key for a **declared** child, so a repeater that enumerated none had no localisation channel at all — #17232 (PR #17500) authored English titles on thirteen item schemas, #17505 and #17506 on four more, and every one of those column heads reached a Chinese, Japanese or Spanish author in English. + + Both halves land together, because either alone is a half-state: 112 row properties across fifteen repeaters are now enumerated, each with a `label` equal to the item schema's own `.meta({ title })`, and the `en` / `zh-CN` / `ja-JP` / `es-ES` catalogs gain a leaf for each. Nothing in the accept set moves — the same author input parses identically before and after, and no row child declares a `type`, so the row widgets stay schema-derived. + + Terms reuse the word each catalog already uses for the concept (`Label` → 显示名称 / 表示名 / Etiqueta, `Filter` → 筛选 / フィルター / Filtro, `Timeout (ms)` → 超时(毫秒)/ タイムアウト(ms)/ Tiempo de espera (ms)), and `field.options.*` mirrors its `object.fields.options.*` twin verbatim. + + `page.variables.source` is the one existing string that moves. Its children were enumerated without labels, so the extractor emitted the humanized path `"Source"` as the English source and the bundle overlay then wrote that over the schema's authored `"Written By"`. The form now declares the label, the `en` leaf becomes `Written By`, and its three translations are re-authored with it (写入组件 / 書き込み元 / Escrito por). + + `view.columns` / `view.sort` / `view.tabs` are untitled and enumerate no children — they are #17507's, and are untouched here. `object.fields.options` stays the curated four-key subset its reconciliation-ledger entry declares. +- eea7ccc: A package body now has a declaration at every stage it really passes through: `ArtifactStagePackageBodySchema` and `RecordStagePackageBodySchema` join `AssembledPackageBodySchema`, and the installed-package read rows are declared against the record stage instead of two `z.unknown()` holes (#17518). + + ADR-0130 D4 says an artifact is inert JSON — "a plugin written inside `packages[i].manifest` could never be constructed by a loader, so a reader that resolved it there would register garbage where it used to skip in silence". Of `AssembledPackageBodySchema`'s 55 members exactly two declare that they accept a callable: `functions`, whose entry union opens with `z.function()`, and `hooks`, whose `handler` carries a `z.custom()` branch. One unrepresentable member costs every embedder its whole JSON Schema, which is why `api/ListInstalledPackagesResponse` and `api/GetInstalledPackageResponse` could only carry the body with both keys written `z.unknown().optional()` — accepted without being checked, as that file's own docblock said. + + - **⛔ The assembled body is untouched, and that is the point.** Those callables are LIVE on the stage it declares itself for: `composeStacks(stacks, { manifest: 'preserve' })` builds exactly such a body and the load path registers it, and `stack.zod.ts` states the invariant that binds the two. Narrowing in place would refuse a published composition function's own output. The two JSON stages are declared BESIDE it instead. + - **Artifact stage** — what `objectstack build` writes: `functions` entries are the lowered spellings (a bare handler ref, or `FlowFunctionLoweredDeclarationSchema`), `hooks[].handler` is a string. **Record stage** — what `SchemaRegistry.installPackage` stores: the artifact stage with `functions[].handler` OPTIONAL, in both the map-record and the array form. That single difference is the whole distance between the two: `build` mints a ref for every callable, while `toRecordManifest`'s structural projection DROPS the callable and mints nothing in its place, so a record states what each function is named and what it declared with `handler` absent where the callable was. Measured: both bodies convert under `z.toJSONSchema` over the whole body, where the assembled body still does not. + - **`FlowFunctionLoweredDeclarationSchema` is exported** from `@objectstack/spec/automation`, with its `FlowFunctionLoweredDeclaration` / `…Parsed` aliases. It was a module-local `const`, and `export * from './flow-function.zod'` only re-exports what is already exported — so `unemitted-schemas.baseline.json`'s reason for `Automation.FlowFunctionDeclarationSchema`, which says the lowered record "is the serialisable half … and it publishes normally", pointed at a schema no consumer could reach. It publishes now: `automation/FlowFunctionLoweredDeclaration` is in the schema manifest. + - **`effect` is READ, not minted.** `FlowFunctionDeclarationSchema.effect` is `FlowFunctionEffectSchema.default('pure')` — a default, not a requirement — and the array member's is `.optional()` with no default. Both JSON stages inherit each form's optionality by deriving from it rather than restating it. + - **⚠️ What narrows, stated plainly**: on the two installed-package responses, `functions` and `hooks` move from `unknown` (accepts anything) to their declared JSON shapes. No row the doors really serve is withdrawn — measured through the real `SchemaRegistry.installPackage` on the shape `examples/app-showcase` ships, on the array form, and on the already-lowered body an artifact boot installs. Every other key, `objects` included, is checked exactly as before, and both stages still refuse an authoring glob and an unknown key. + - One correction in the same edit: `package-api.zod.ts` said those two members were also why `ArtifactPackageSchema` and `ObjectStackDefinitionSchema` publish no JSON Schema. They are not — `src/stack.zod.ts` is not one of the subpath namespaces `build-schemas.ts` walks, so neither is ever reached by the emit loop. +- 097d268: feat(spec)!: `manifest.id` enforces the reverse-domain rule its registry face already had (#17534) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the repo's convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition): + `ManifestSchema.id` was `z.string()` and accepted any string. It now enforces + reverse-domain notation — the same rule `PackageSchema.manifestId` has always + carried, now declared once and referenced from both sites so the two cannot + drift again. + + Two declarations named one identity and disagreed. The registry enforced the + shape; the key an author actually writes did not. So a package scaffolded, + validated, built and booted with an id the publish path would refuse, and the + author met the rule for the first time at the most expensive possible moment. + + FROM → TO, for metadata that used to parse and now fails: + + ```ts + // FROM — accepted by defineStack, refused at publish + defineStack({ manifest: { id: 'my_app', /* … */ } }); + defineStack({ manifest: { id: 'com.acme.my_app', /* … */ } }); + + // TO — dot-separated lowercase segments; hyphens inside a segment, never underscores + defineStack({ manifest: { id: 'com.example.my-app', /* … */ } }); + defineStack({ manifest: { id: 'com.acme.my-app', /* … */ } }); + ``` + + The refusal carries the repair rather than restating the rule: it names the key, + echoes the value, shows both documented examples, and — having first checked the + candidate against the pattern itself — suggests `com.example.blank` for a bare + word and `com.dogfood.flow-fixture` for a value whose only fault is an + underscore. A suggestion it cannot verify it does not make. + + ⚠️ **Changing an id is a republish, not an edit.** An id is an identity: the + registry addresses a package by `manifest_id`, an installed row is keyed on it + and a dependent declares it. Before renaming, confirm nothing still addresses + the old value. That is why this ships as an ADR-0087 **semantic** entry + (`manifest-id-reverse-domain-required`) with a structured TODO and no automatic + rewrite — `objectstack migrate meta` will not rename an id for you. + + `manifest.namespace` is unchanged and still admits underscores, so the two are + derived from a project name under different rules and neither is the other. Both + scaffolders were producing ids the new rule refuses and both now derive a + conforming one: the bundled `create-objectstack` template ships + `com.example.blank` and interpolates `com.example.` in kebab form, + and `os init` derives its id from the project name instead of interpolating the + snake_case namespace (`os init my-app` produced `com.example.my_app`). + + ## ⚠️ One consent path reverses direction: fail-OPEN → fail-CLOSED + + Narrowing `manifest.id` also narrows the **accept set of the artifact load + path**, and on one route that is a **fail-OPEN → fail-CLOSED reversal on a + consent/permission path**. Stating it explicitly because a reversal in that + direction is owed a named direction and a named population, however small the + population turns out to be. + + **What changed.** `AssembledPackageBodySchema` extends `ManifestSchema`, so the + artifact package entry schema now carries this rule too. An assembled package + whose `manifest.id` is `''` used to parse: `artifactPackageId` is + `manifest.id || manifest.name`, so such a package was carried under its `name`, + while an install-time `grantedPermissions` record keyed by `''` matched no + carried package and was registered nowhere. The package loaded **with no + consent record at all** — reported as unbound, warned about, and otherwise + allowed to run. That is the fail-OPEN half. Such an entry is now refused + outright (`INVALID_ARTIFACT_PACKAGE_ENTRY`, 422) and the artifact does not + materialize at all — fail-CLOSED. + + **Who is affected: artifacts carrying `manifest.id: ''`, and they were already + half-broken in both directions.** + + - They could never be **published**: the registry face + (`PackageSchema.manifestId`) has carried this exact pattern all along — the + same regex literal, now the shared `MANIFEST_ID_PATTERN` — so the publish path + has always refused them. + - Their granted-permissions **consent already did not apply**: a record keyed by + `''` bound to nothing, silently, on every load. + + ⇒ For that population this converts a silent, already-ineffective consent + binding into an explicit refusal that names `manifest.id`. Nobody who could + publish an artifact loses the ability to load it; what they lose is a shape that + only ever half-worked. + + ⛔ This is the **artifact package door** refusing a malformed id, **not** the + permission enforcer acquiring teeth. The install-time granted permission set is + still registered and not enforced (#17147) — nothing on the tree queries that + registry, and the repo-wide pin asserting so is unchanged and still green. +- 182bbde: the resume door's `repairable` is answered by the engine on the exits that stamp no status — `IAutomationService` declares the read-only `inspectConsumedSuspension` (#17541) + + Clause-②: yes (widening) + + The resume route's `400 FLOW_FAILED` details computed `repairable` as the single + expression `status === 'stranded'`. That word is stamped on exactly one exit — + the run that consumed its OWN pause and then threw downstream. The subflow + DELEGATION exit stamps nothing on purpose: a caller resumes the PARENT, the + signal is forwarded down, the child strands, and the parent frame answers + `{ success: false, error, durationMs }`, because nothing re-arms an ancestor by + resuming it and stamping `'stranded'` there would send an operator to retry a + recovery that cannot succeed. + + Since the nested-chain restore landed, that parent's consumed pause IS + journalled and one `restoreConsumedSuspension(parentRunId)` re-arms the whole + chain leaf-first. So the wire answered `repairable: false` about a run the + operator verb WILL repair, and a client written exactly as the reference page + instructs closed it as terminal. Measured through the HTTP route, before and + after, on the same parked delegation: + + ```json + before 400 { "error": { "code": "FLOW_FAILED", + "details": { "runId": "run_…", "repairable": false } } } + after 400 { "error": { "code": "FLOW_FAILED", + "details": { "runId": "run_…", "repairable": true } } } + ``` + + …while at that same instant the engine answered + `inspectConsumedSuspension(runId) → { repairable: true, witness: 'journal' }` + and `restoreConsumedSuspension(runId) → { restored: true, chain: [child, parent] }`. + + **`@objectstack/spec` — additive, `minor`.** `IAutomationService` declares the + optional read-only member `inspectConsumedSuspension(runId)`, which + `AutomationEngine` already implements publicly: would the restore verb have a + consumed suspension to put back for this run? It re-arms nothing and reads the + same two witnesses that verb reads, so what it calls repairable IS what that + verb restores. The declared result is deliberately narrower than the + implementation's, the way `restoreConsumedSuspension`'s already is — `reason` is + typed as the string the implementation answers, not as an enumeration this + contract would have to keep in step, and the engine's wider type satisfies it + under `implements`. `ResumeFailureDetailsSchema.repairable`'s `.describe()` is + rewritten to the truth and the generated reference page regenerated with it. No + key is added, renamed or retired on any wire schema. + + **`@objectstack/runtime` — the door.** On a `400 FLOW_FAILED` whose result + carries a `status`, that stamp still decides, and the engine is not consulted at + all. On a result that carries none, the door asks the declared member and relays + its `repairable`. Both ways of not getting an answer are FAIL-CLOSED: a service + that declares no inspection member answers `false` exactly as it did before, and + an inspection that REJECTS (a store it could not read) answers `false` and says + so once at `warn` — an unreadable store is UNKNOWN, not "nothing to restore", + and it is never allowed to replace the `400` the caller asked for with a `500`. + + ⛔ The fence is untouched: a cascade-failed ancestor is still never STAMPED + `'stranded'`. Its repairability is carried by the journal and REPORTED by the + inspection, which is exactly why the door asks instead of reading a word. ⛔ And + no new `AutomationResult.status` member is minted for this exit — there is + nothing new for a client to learn, and `details.repairable` is the member a + client was already told to branch on. +- 5ce3705: `DatasetSelectionSchema` — the ADR-0021 dataset selection is a Zod declaration now, and `POST /api/v1/analytics/dataset/query` parses the whole selection against it (#17551). + + `DatasetSelection` was a TypeScript **interface** with no Zod schema anywhere in the repo. PR #17548 doored that route, but only over the **seven** members the selection shares with `AnalyticsQuery`; the other **four** — `runtimeFilter`, `dateGranularity`, `compareTo`, `totals` — were declared in TypeScript, published in the api-surface, and enforced by nothing on the wire. The measured consequence is #17550: `compareTo: { kind: 'nonsense' }` came back as a previous-period comparison under an ordinary **200**, a number a dashboard renders and a person reads as fact. + + - **One declaration, in `packages/spec`.** `DatasetSelectionSchema`, `DatasetCompareToSchema` and `DatasetTotalsSchema` are authored in `api/analytics.zod.ts`, beside the `AnalyticsQueryRequestSchema` the sibling routes parse. `@objectstack/spec/contracts` now **re-exports** the `DatasetSelection` and `DatasetCompareTo` types from that schema instead of declaring interfaces of its own — the same move `AnalyticsQuery` made in #4538, taken here before a mirror could drift. + - **A transcription, not a new contract.** The seven shared members are read straight off `AnalyticsQuerySchema.shape`, so the claim that the two agree is structural rather than a hand-written list; the four dataset-only members are the already-published TypeScript members made executable. No member is added and nothing the interface permitted is refused. + - **Refusals carry a prescription.** An unrecognised `compareTo.kind` answers the sentence `datasetCompareKindRefusalMessage` builds — what arrived, the two windows the executor implements, what to do — and `@objectstack/service-analytics`' `shiftRange` now raises that same sentence with its own origin clause, so one condition keeps one wording. An unknown key is named, echoed and pointed at the canonical spelling (`where` → `runtimeFilter`, `granularity` → `dateGranularity`), and the retired `{ offset }` arm and the pre-#5011 bare-string form each carry their rewrite. + - ⚠️ **What narrows on the wire**, so an upgrading caller can look for it: a selection member whose value the published interface never permitted now answers `400 VALIDATION_FAILED` with `details.fields[]` instead of travelling into the executor. Measured against the sibling route spelling for spelling, `runtimeFilter` now behaves exactly as `/analytics/query`'s `where` does — three structurally-malformed filter spellings (`{ $or: 'x' }`, an `$or` branch that is not a filter object, `{ $not: 5 }`) are refused at the schema on both routes, and the four semantic ones (`{ stage: {} }`, `{ amount: { $between: [10] } }`, `{ $nor: […] }`, `{ $or: [] }`) still pass both and are answered deeper. The dataset route was the looser of the two; it is not any more. + - **No valid selection changes.** Every in-repo specimen and all five `@object-ui` call sites that build a selection today still pass, pinned in both packages; the route still forwards the caller's object to the service by identity, never a parse output, and the schema carries no default or transform that could override the engine's own timezone resolution chain. +- 24d622b: feat(spec, cli): an application contributes its own first-run credentials to the development boot banner — `devHint` / `devLogins[]` (#17556) + + Clause-②: yes (widening) + + ## What an operator sees + + `os dev` seeds a platform admin on an empty DB, and the banner prints it as the only + credential a first-run operator is handed. #17081 made that line honest about what the + account *cannot* see; it could not name an account that *can*, because the platform does + not know an application's audiences. Measured on a downstream app, of five personas the + four it seeds each rendered their navigation group and the one the banner printed + rendered none — and the operator read the empty shell as a broken product. + + Two new top-level keys on the stack definition close that. Declaring either adds a block + BENEATH the seeded-admin lines, on a development boot only: + + ``` + 🔑 Dev admin: admin@objectos.ai / admin123 + seeded on empty DB · dev only — do not use in production + platform admin — Setup, Studio and every record, but NO app-declared capability, so + an app that gates navigation on requiredPermissions may show it an empty menu; grant + it a permission set under Setup → Users, or sign in as an account your app seeds + + 👥 App logins: 2 declared by this app + Hiring admin — admin@quillstone.example / demo1234 + Job seeker — candidate01@mail.example / demo1234 + declared in this app's `devLogins` · dev only — the platform seeded none of them + + 💡 App hint: run `pnpm seed:demo` first, then sign in as the Hiring admin + ``` + + ## What is writable that was not + + The top-level stack door has been strict since #8687, so before this both spellings were + an `unrecognized_keys` refusal. The accept set gains exactly: + + - **`devHint?: string`** — one sentence printed under the credential block. Composes as + `'single'`: two stacks declaring different hints is a composition error naming the key, + never a silent last-wins. + - **`devLogins?: DevLogin[]`**, where `DevLogin` is `{ email: string; password?: string; + label?: string }`, closed against unknown keys from birth. Composes as `'concat'`, so + composing two applications keeps both publishers' personas. An artifact ENVELOPE key + like `plugins` / `devPlugins`: it stays at the top level and is refused inside + `packages[].manifest`, because the banner's only reader looks at the top level. + + `DevLoginSchema` / `DevLogin` / `DevLoginParsed` are exported from + `@objectstack/spec/system`. Nothing is renamed, nothing is retired, and no value that + parsed before is refused now. + + ## Three properties worth knowing before you author one + + - **Declaring is not seeding.** An entry CREATES NOTHING: it names an account the + application seeds by other means (`data` fixtures, `onEnable`, its own script) so the + banner can point at one that shows something. An entry naming an unseeded account + prints a credential that will not work, exactly as a README line would — which is why + the banner says the application declared it. + - **Additive, never a replacement.** The seeded-admin block still prints, unchanged and + first. An application-controlled key able to suppress a platform disclosure would let + an app hide a live credential the operator was just handed. + - **Development only, and scrubbed.** The block renders only under `os dev`, + `objectstack serve --dev` or `NODE_ENV=development`; any other boot is byte-identical + to one declaring nothing. The values are author-controlled text reaching a terminal, so + every C0/C1 control byte is replaced with U+FFFD before printing — an escape sequence + in a hint cannot erase the rows above it or repaint a forged `🔑 Dev admin` row. ⚠️ + Whatever is written here is committed to the application's repository and printed to a + terminal: it is a development fixture, never a real secret. +- 0252320: feat(service-analytics)!: `min` and `max` are judged by the aggregate × field-type table too — all 74 refused pairs answer `400 DATASET_INVALID` through one compile door (#17560) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, and the last + one this table owed. A dataset measure pairing `aggregate: 'min'` (or `'max'`) with any + of the **37** field types outside the numeric, temporal and boolean classes — for example + `text`, `select`, `lookup`, `autonumber`, `json`, `multiselect`, `file`, `location`, + `vector` or `formula`; the ADR-0087 entry registered below carries the full list — used to + compile and reach the backend; it is now refused by + `compileDataset` with `DATASET_INVALID` / **400** before any query is built. Shipped as + `minor` under the repo's launch-window convention for accept-set narrowings. + + ⛔ This changeset adds no rows to any table and restates none. The verdict is + `AGGREGATE_FIELD_TYPE_COMPATIBILITY`'s — the one table `@objectstack/spec` declared in + #16353 under the director ruling of decision batch #59 ("both legs, table in spec") — + read through `isAggregateCompatibleWithFieldType`. + + ## What was wrong + + The table refused these 74 pairs from the day it was declared, and **four declarations + gave three different answers about them**: + + | declaration | what it said about `min` × `text` | + |---|---| + | `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (spec) | refused | + | `dataset-compiler`'s compile leg | never judged — `if (!DERIVING_AGGREGATES.has(aggregate)) return;` | + | `measureResultType` (service-analytics, #15768) | a supported `'string'` result | + | two shipped test files, in prose | "ruled C — the table is to be AMENDED to accept it" | + + Driven through the real service door before anything was written, `min` / `max` over 13 + sampled refused pairs all compiled and emitted SQL, with `avg` × `datetime` as the + firing control (refused, `DATASET_INVALID` / 400, no SQL) — so the zero was a reading of + the tree rather than of a blind harness. + + The fourth row had nothing behind it. The card it cited (#17513) is closed as a + duplicate carrying zero rulings, and the one recorded ruling on this table says the + opposite. ⇒ The director ruling of decision batch #127 (2026-09-13) settled all three + sub-questions in one pass, because one shared fixture drove members of both halves: + + 1. **the string classes** (42 pairs) stay refused, as batch #59 ruled — ⛔ the table is + not amended; + 2. **the non-string classes** (32 pairs) are refused **and enforced**; + 3. **`formula`** is refused on the table's own storage ground — it is VIRTUAL in SQL + storage, no column is emitted, so no aggregate can be lowered to it whatever + `returnType` says. + + The divergence is real, and for these two aggregates it is the **ORDER** rather than the + arithmetic: string order is collation-dependent, so two backends answer two different + "smallest" values for one metadata document, and `min(jsonb)` does not exist on + PostgreSQL at all. + + ## What changed + + - **`dataset-compiler`**: the scope condition is gone. `assertAggregateFieldTypeCompatible` + judges all six `AggregationFunction` members against the table, through the same + `DATASET_INVALID` / 400 door. The refusal message names the divergence its own + aggregate class really has (`min` / `max` SELECT a stored value and diverge on order; + `sum` / `avg` DERIVE a number and diverge on arithmetic) and prescribes accordingly. + - **`measureResultType`** asks `isAggregateCompatibleWithFieldType` before it answers, so + the rule and the table agree **by construction**. Its `STRING_SOURCE_FIELD_TYPES` + branch and its `formula` branch are retired with them; `min` / `max` over the temporal + class still answers `'time'`, unchanged. + - **`AnalyticsServiceConfig.sourceFieldMeta`** no longer declares `returnType`. It was + carried (#16236) for one reader — the retired `formula` branch — and a declared input + nobody consumes is the declared-not-enforced shape Prime Directive #10 refuses. + + ⚠️ **That key was never released, so against every published version this removal is a + no-op.** #16236 is still a pending changeset in the same release window as this one; + the last published entry (17.4.0) says in as many words that `FieldSchema.returnType` + "is not on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape". The key was + therefore added and removed inside one window and no published tarball ever carried it. + + **Host fix, one line:** drop `returnType` from whatever your `sourceFieldMeta` returns. + You do not have to — the hook is a function RETURN position, so an extra key is not an + excess-property error and is simply ignored at runtime — but keeping it declares an + input nothing reads. Hosts on `AnalyticsServicePlugin` need no change at all: the plugin + stopped relaying the key in this same change. + + ## FROM → TO, and the one-line fix + + | you wrote | write instead | + |---|---| + | `{ aggregate: 'min' \| 'max', field: }` | `count` / `count_distinct` if you were counting; a **sort** on the list/report if you wanted the first or last RECORD | + | `{ aggregate: 'min' \| 'max', field: }` | store the quantity you meant as a numeric or temporal field and aggregate that | + | `{ aggregate: 'min' \| 'max', field: }` | a formula emits no column; aggregate the stored field the formula reads, or persist the computed value | + + ⚠️ **Untouched:** those field types used as a **DIMENSION** (grouping, labelling, + bucketing, filtering), `count` / `count_distinct` over any type, `min` / `max` over the + numeric, temporal and boolean classes, and every `sum` / `avg` row #16778 and #16099 + already settled. The refusal also still stands down rather than guessing wherever the + declared type cannot be resolved: no `sourceFieldMeta` wired, an unknown field, or a + `relationship.field` path whose column lives on a joined object. + + ⚠️ The hand-migration prescription ships as the ADR-0087 semantic TODO registered above, + which names the measure and the field type per affected pair — no lossless conversion + exists, because nothing can compute "the smallest text value" in a way every backend + agrees on. +- e04a0af: `$contains` on a multi-valued / JSON column is a MEMBERSHIP test, compiled per dialect so SQLite, MySQL and PostgreSQL answer the same rows. + + `$contains` is the membership spelling on a `multiple: true` field or a `JSON_COLUMN_TYPES` member — the one operator that kept working on a JSON column after the scalar-comparison family was refused there, and the spelling that refusal's own message prescribes. It was lowered like any other text operator, so each backend was asked about the SERIALIZATION rather than about the members, and the three answered three different things: SQLite matched a substring of the stored array text, MySQL coerced its `json` column for `LIKE` and matched the same substring, and PostgreSQL raised SQLSTATE 42883 (`operator does not exist: json ~~ text`) — a `DATABASE_ERROR` 500 for a filter the spec accepts. + + `driver-sql` now compiles a real membership construct per dialect: `jsonb` containment on PostgreSQL, `JSON_CONTAINS` on MySQL, a `json_each` scan on SQLite. `$notContains` moves with it as its exact complement. + + **Behaviour change on SQLite and MySQL, in the narrowing direction.** Where the substring reading matched ACROSS element boundaries it no longer does: `{ tags: { $contains: 'red' } }` stops answering a row whose only tag is `redwood`, and `{ nums: { $contains: '1' } }` stops answering a row holding `[10, 21]`. Those rows were wrong answers, not a contract — a filter that needs the old reading is asking for a substring search over a serialization and should be written against a scalar column. On PostgreSQL the same filters change from a 500 to the member rows. + + Unchanged: `$contains` on a scalar string column is still the case-sensitive substring test, and the rest of the text family (`$startsWith`, `$endsWith`, `$icontains`, `$like`, `$ilike`) keeps the lowering it had on every column. + + `packages/spec`'s `StringOperatorSchema` docblock — published source — now states the membership reading and records, per face, which runtimes answer it. +- 75237a9: fix(spec)!: `timeDimensions[].dateRange`'s array arm is exactly two string bounds, and each refusal ORIGIN gets a true sentence (#17598; ruling A, decision batch #117 item 3) + + + + **BREAKING** accept-set narrowing at `timeDimensions[].dateRange` — shipped as + `minor` under this repo's launch-window convention for breaking changes + (`scripts/check-changeset-no-major.mjs`), above the `patch` floor the `fix` + commit type sets, and the same grade the one comparable precedent took: the + STRING-arm closing on this same schema is #16041, and it shipped + `"@objectstack/spec": minor` (`packages/spec/CHANGELOG.md` 17.4.0, under Minor + Changes). ⚠️ Its driver half #16322 declares `"@objectstack/spec": patch`, but + that entry is — in that changeset's own words — "a `PROVENANCE_WAIVERS` row + only", not an accept-set narrowing, so it is not a grade this one is measured + against. The maintainer + ruling calls it a "major changeset"; under the launch window that phrase maps to + the protocol MAJOR the migration registers against (18), not to the changeset's + bump level, which `scripts/check-changeset-no-major.mjs` reserves. The semantic + prescription is registered under protocol major 18 as + `analytics-date-range-array-two-bounds-required`. + + ### What changed + + `AnalyticsDateRangeSchema`'s array arm was `z.array(z.string())` with **no length + constraint**, so `['2026-01-01']`, `[]` and `['a', 'b', 'c']` were schema-valid. + It is now `z.tuple([z.string(), z.string()])` — a tuple rather than a length + refinement, so the arity is stated to the author's compiler before any parse runs. + Preset names, two-bound windows and an absent `dateRange` parse byte-identically + to before. + + `analyticsDateRangeRefusalMessage(input)` becomes + `analyticsDateRangeRefusalMessage(input, origin)`, where `origin` is `'schema'` or + `'runtime'` and is **required** — there is deliberately no default. + + ### Migration: FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `dateRange: ['2026-01-20']` | `dateRange: ['2026-01-20', '2026-01-20']` — a single day is that day as both bounds, the shape the shipped #16322 table already prescribes | + | `dateRange: []` | no conversion. An empty array names no window: write the two bounds the widget was meant to show, or omit `dateRange` (it is optional, and absent means the query is not time-bounded) | + | `dateRange: ['a', 'b', 'c']` | no conversion. Decide which two bounds you meant and write them | + | `analyticsDateRangeRefusalMessage(value)` | `analyticsDateRangeRefusalMessage(value, 'schema')` at a parse door, `…(value, 'runtime')` past one | + + `os migrate meta --from 17` emits the first three as a structured TODO rather than + rewriting them: rewriting a one-element array to the same day twice at load would + be the platform deciding, silently, that the author meant one day rather than a + window whose end they forgot, and for the other two shapes there is nothing to + decide from. + + ### Why it is not a new class of breakage + + Since PR #17593 all four analytics faces (`ObjectQLStrategy`, `NativeSQLStrategy`, + the draft-preview evaluator, `DatasetExecutor.runCompare`) already refused anything + that is not exactly two bounds with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`, so + every stored range this narrowing refuses was **already failing at query time**. + The contract door was looser than every reader behind it; this moves the refusal + to authoring time and states it accurately. Blast radius is the WIDGET, not the + page: a stored dashboard carrying a now-refused range loses that widget with the + refusal shown and still loads. + + ### The wording half + + The shared sentence ended `"Refused at the schema"` and described every refused + array as `"received an array with a non-string bound"`. For a one-element window + refused by a face **both clauses were false** — every bound present is a string, + and it was refused past the schema, not at it — which is why + `@objectstack/service-analytics` had to overwrite the message rather than reuse it, + leaving one condition with two wordings. The origin is now a parameter and the + `received …` clause names the arity and the bad bound separately, so the sentence + is true for each origin both before and after the arm narrows. + + The same rule reaches the WIRE. Narrowing the arm to a tuple gave the union a + second voice: its arm answers `Too small: expected array to have >=2 items` for + the very arity the prescription just prescribed, and the ADR-0114 union + expansion emitted both as `fields[]` entries on `POST /analytics/query` and + `POST /analytics/dataset/query`. `fieldsFromZodIssues` (`@objectstack/types`), + the one mapper both doors report through, now drops the branch issues that land + at the union's OWN path for this refusal — recognised structurally through + `isAnalyticsDateRangeRefusalIssue`, never by message prose. A refusal that names + a DEEPER position keeps it: `dateRange: ['2026-01-01', 3]` still reports + `timeDimensions.0.dateRange.1`, because WHICH bound is not a string is a + location the prescription does not carry. Every other union expands exactly as + before. Client-visible effect: one `fields[]` entry for an arity refusal instead + of two, with the prescriptive one kept. +- ada7012: feat(spec): the `/packages` doors declare the query parameters they execute, and stop declaring the two they never did (#17667) + + `GET /api/v1/packages` diverged from its own declared request contract in BOTH + directions, on the same door, with the same `200`. This aligns the declaration + with the reads, per the maintainer-approved ruling of 2026-09-13 (decision batch + #126 item 1, route 2 of three). + + **BREAKING** — `limit` and `cursor` no longer parse on + `ListInstalledPackagesRequestSchema`, and `limit`'s `.default(50)` is gone with + them. Both were declared here and read by nothing: the serving door filters on + `status` / `type` / `enabled` and then returns every remaining row, so no page + was ever withheld and no continuation token was ever minted. The response half's + `nextCursor` has never been emitted, so a caller looping "until the cursor runs + out" re-read the first and only page forever, with no error and no `400`. + + ``` + FROM ListInstalledPackagesRequestSchema.parse({}) + -> { limit: 50 } // a cap the server has never applied + ListInstalledPackagesRequestSchema.parse({ limit: 1, cursor: 'x' }) + -> { limit: 1, cursor: 'x' } // both dropped on the wire, 200, every row + + TO ListInstalledPackagesRequestSchema.parse({}) + -> {} // no window is declared, because none exists + ListInstalledPackagesRequestSchema.parse({ limit: 1 }) + -> throws: '`limit` / `cursor` were removed from GET /api/v1/packages in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) …' + ``` + + **Read the removed default, not just the removed key.** `limit` carried + `.default(50)`, so a reader of the published schema — an SDK, codegen, an AI + client — was entitled to believe an unparameterised list is capped at 50 rows. + It has never been capped at all. Nothing parses a query string through this + schema, so that default has never been stamped onto anything; there is nothing + to send instead and nothing to restore. **A client that sized a buffer or a + page control to the declared 50 should size it to the installed set instead** — + which is a bounded table of tens of rows, which is also why paging was removed + rather than implemented. + + Both keys are `retiredKey()` tombstones rather than deletions: the schema is not + `.strict()`, so a bare deletion would have made Zod silently strip whatever a + generated client kept sending — a clean parse and a parameter that never takes + effect, which is this defect re-created one layer down (ADR-0104). Writing + either key is now a `tsc` error and a parse error carrying the prescription. + + **The other direction, and nothing on the wire changes for it.** Three query + parameters the doors already executed were declared by no request schema, so + they were invisible to anything generated from the contract: + + | door | parameter | now declared on | + |---|---|---| + | `GET /api/v1/packages` | `type` — exact match against `manifest.type` | `ListInstalledPackagesRequestSchema` | + | `GET /api/v1/packages/:id` | `version` — exact installed-version scope; `latest` reads the installed row | `GetInstalledPackageRequestSchema` | + | `DELETE /api/v1/packages/:id` | `keepData` — keep object tables, remove metadata only | `UninstallPackageApiRequestSchema` | + + No accept set moves: the doors served all three before and serve them + identically now. `overwrite`, the fourth parameter the ruling named, was already + declared on `PackageInstallRequestSchema` and needed nothing. + + **`hasMore` stays the constant `false` it already was, and is now true by + construction rather than by coincidence**: with no `limit` and no `cursor` to + ask with, nothing can request a page, so there is never a next one to announce. + + Clause-②: yes + + +- 3a9ad22: docs(spec): `FormField.colSpan` and `FormField.span` describe their measured behaviour — the two claims browser measurement falsified are gone (#17670) + + Both `.describe()` strings ship inside the published package (`src/**/*.zod.ts`, `dist`, `json-schema`) and they generate the public `content/docs/references/ui/view.mdx` tables, so what they assert is what every reader of the API reference — human or AI — is told the renderer does. Two of those assertions were measured false in Chromium at all three surface widths (#17328, `absolute-colspan-discouraged` withdrawn on the same evidence): + + - `colSpan` was described as "fragile … a fixed span only lines up at the width the author imagined". It is not. The renderer clamps the span to the form grid's column count, so the cell starts at a real column boundary at every width; rendered overflow was 0px in every configuration measured, including `colSpan: 4` in a 3-column section — the case that would overflow if the clamp did not work. The old text contradicted its own next sentence, which already stated the clamp. + - `span: 'full'` was described as "whole row at any column count". It is not. It resolves to the form grid's full column count, and at the `.objectui-sha` pin `53ded82bf7` the renderer emitted only the widest tier's class (`@2xl:col-span-3` for a 3-column grid — the identical class `colSpan: 4` emits), so at the 2-column modal width it took one cell of two, not the row — in the single 3-column section #17328 measured, pixel-identical to authoring nothing at all. + + Each key now states what it actually does. **The preference between the two keys is removed, not reversed** — `[legacy — prefer `span`]`, `Prefer `span`.` and `Prefer this over the absolute `colSpan`.` are gone, and nothing replaces them. Both spellings rest on the falsified claim, and the measurement puts the recommended one on the wrong side of it; the renderer question behind it — `span: 'full'` not spanning the row at intermediate container widths — was answered on the objectui side by objectui#9253 (commit `bd09957380`, 2026-09-12, part of objectui#9244), which emits one clamped col-span class per multi-column tier. That fix is unreleased at this repo's pin (`@object-ui/components` 17.6.0 at both, 0 tags contain the commit), so the text above anchors the pin state and this PR does not move `.objectui-sha`. + + Nothing an author writes moves. Both keys are unchanged, both still parse, every stored form view keeps its shape and its rendering, and no validation, default or emitted class changes. This is a correction to what the package says about itself. +- 6d2571f: **BREAKING** — `CONCURRENT_LIMIT_EXCEEDED` is removed from the closed `StandardErrorCode` catalogue (#17707). + + A `major`-class change, recorded as `minor` under the launch-window convention. ADR-0049 enforce-or-remove applied to the ADR-0112 error catalogue: ruling A on #17707, narrowed on 2026-09-24 to this code alone. + + **Why.** A catalogue member is the list callers branch on exhaustively, and a member with no producer teaches a branch that cannot fire. `CONCURRENT_LIMIT_EXCEEDED` had no producer behind it when the ruling was recorded, so it leaves the catalogue. Its neighbour `QUOTA_EXCEEDED` stays, unchanged, as the narrowing ruled. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `CONCURRENT_LIMIT_EXCEEDED` (`StandardErrorCode`, 429) | nothing — delete the branch. For request pacing branch on `RATE_LIMIT_EXCEEDED` (HTTP 429; wait `retryAfterSeconds` before retrying). A service that enforces its own concurrency limit registers a code for it in its own error-code ledger. | + + **The one-line fix: delete every branch on `CONCURRENT_LIMIT_EXCEEDED`.** A comparison against a value typed `StandardErrorCode` or `ErrorCode` no longer compiles (`TS2367`). At runtime the spelling now fails `StandardErrorCode`, `ErrorCode` / `ApiErrorSchema.code` and `makeApiErrorSchema(...)` parse, and the failure message is the removal prescription itself. + + **What stays.** The other `StandardErrorCode` members, `QUOTA_EXCEEDED` included, are unchanged. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** Inside this repository the code occurred only in the enum declaration, the hand-written error catalogue page, the generated reference pages and the unpinned-status baseline, and the pinned objectui checkout does not name it; `@objectstack/spec` is published, so readers elsewhere were not measured. + + The ADR-0087 D3 semantic entry `standard-error-code-concurrent-limit-exceeded-retired` carries the judgement: an error code is wire vocabulary, not a metadata key, so there is no authored source for a D2 conversion to rewrite. + + Clause-②: no + + +- 2bf6ef1: **BREAKING** — remove `aria` from the chart config, and answer its two alias spellings with the retirement instead of renaming an author onto a tombstone. + + `ChartConfigSchema` declared a nested ARIA block that **no chart renderer has ever applied**. Measured first-hand at this checkout's own `.objectui-sha` pin `53ded82bf7a4` and re-confirmed at objectui HEAD: `AdvancedChartImpl` declares no `aria` prop; `chartConfigPresentation` names it nowhere — its own docblock calls it *"the one declared key with no reader at all"*; `SchemaRenderer`'s ARIA injection reads flat node props and never a nested `aria` object; and `ui/react-blocks.ts` omits it from ``'s thirteen `dataProps`, the one `ChartConfigSchema` key missing from that list. Every objectui hit on the chart paths is a **negative** pin asserting nothing reads it. So a chart could declare accessibility work that had measurably not happened. + + It is the third and last member of the `aria` family retired for exactly this: `dashboard.aria` went at the audit close-out and `dashboard.widgets[].aria` at the widget drill. This one survived both sweeps by **depth**, not by evidence — it sits inside the widget's `chartConfig`, a container no drill had reached until the per-key pass recorded in `liveness/dashboard.json`. + + **Removed rather than enforced**, which is the less usual ADR-0049 answer and is the whole of the ruling (maintainer decision batch #118 item 2, 2026-09-12 — recommendation C, 「其他同意」 to judging the protocol wrong for this one key). The same chart config already carries a **working** accessible-name channel in `description`, which the chart renderer lowers onto the chart graphic as `role="img"` + `aria-label`, pinned in the DOM. Wiring `aria` as well would put two accessible-name sources on one element and demand a precedence rule nobody has written. One node, one accessibility vocabulary. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `chartConfig: { aria: { ariaLabel: 'Orders by month' } }` on a dashboard widget | `chartConfig: { description: 'Orders by month' }` — the renderer announces it as the chart graphic's accessible name | + | `chart: { aria: { … } }` on a report, or on a report block | the same: `description` on that chart config | + | `chartConfig: { accessibility: { … } }` (an alias for `aria`) | the same — the alias is now a refusal carrying this retirement, and it never accepted the key anyway | + | `chartConfig: { ariaProps: { … } }` (the other alias) | the same | + | `ariaLabel` / `ariaDescribedBy` / `role` on a surface that renders DOM | unchanged — the shared `AriaProps` block stays live on `page.aria`, `page.components[].aria` and the list view `aria` | + + **The one-line fix:** delete `aria` from the chart config; move an accessible name into the sibling `description`. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + + ## The retirement kit + + - **A `retiredKey()` tombstone, not a bare deletion** — even though `ChartConfigSchema` **is** a `strictObject`. A bare delete would still be loud, but only as a generic unrecognized-key report that cannot carry the prescription; the tombstone types the key `never` for `tsc` and raises the upgrade text at parse. The key therefore stays in the walked shape, which is why its liveness row stays (regraded with a `REMOVED` note, the `rls.priority` precedent) and why the authorable-surface baseline marks it `[RETIRED]` rather than losing the line. + - **Two registered keys from one tombstone.** `ReportChartSchema` is a `ChartConfigSchema.extend(...)`, and an extension copies the retired property into its own walked shape, so the retirement registers `ui/ChartConfig:aria` **and** `ui/ReportChart:aria`. Nothing radiates from the base. + - **The two alias entries are gone from `aliases` and present in `guidance`.** This narrows nothing: an alias table runs only from the `unrecognized_keys` path, so `accessibility:` and `ariaProps:` were *already refused* — the entries only decorated the rejection, and after the retirement they would have decorated it by pointing at the one key the shape is now guaranteed to reject. Leaving them is not a style choice: `shared/alias-integrity.test.ts` refuses an alias whose target accepts nothing, by name. + - **No form input and no locale bundle move.** Unlike its siblings this key never reached a `*.form.ts`, so there is no false-compliant UI half to remove; the generated `chart` / `report` references regenerate with the prescription in place of the old nested-shape table. + + ## What an operator with a STORED dashboard or report sees + + A `sys_metadata` `dashboard` or `report` row written before this release can carry the key at any of its three coordinates — `widgets[].chartConfig.aria`, `chart.aria`, `blocks[].chart.aria`. Nothing breaks at read: the ADR-0087 conversion `chart-config-aria-removed` (protocol 18) replays on rehydration and strips it, so the row is served canonical. `os migrate meta --stored --apply` rewrites the rows; the next save through the metadata door heals one row the way it heals any pre-protocol shape. + + The strip is the **whole** of it — there is no paired semantic entry, and that is a statement, not an omission. The key never had an effect to lose, so deleting it changes no behaviour and closes no hole. It stops an unkept promise from being made. + + +- 09e16a5: feat(spec)!: a metric-family dashboard widget declares exactly ONE measure — `values` is bounded above on `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` (#17779; objectui#8894 ruling D, decision batch #119 item 4) + + Clause-②: yes (narrowing) — this diff BOTH narrows and widens, which is the shape this arm exists for. The accept set NARROWS (that is the change). What makes the value `yes` is the other axis: the published surface GAINS one exported symbol, `checkDashboardWidgetMetricMeasureArity`, and a new exported symbol is the mechanical floor for in-seat contract review. + + + + **BREAKING** accept-set narrowing at `dashboard.widgets[].values`, shipped as + `minor` under this repo's launch-window convention for breaking changes + (`check-changeset-no-major` refuses `major` outright while the window is open, so + breaking-ness is carried by this banner and by the ADR-0087 disposition above, + never by the bump level). The mechanical prescription is registered under + protocol major 18 as `dashboard-widget-metric-family-multi-measure-refused`. + + **What was wrong.** `DashboardWidgetSchema.values` was + `z.array(z.string()).min(1)` with **no upper bound on any widget type**, so a + `metric` tile could declare three measures. Measured on this tree before the + change: `{ type: 'metric', values: ['a','b','c'] }` returned `success: true`, + and so did `kpi`, `gauge`, `solid-gauge` and `bullet`, with `bogusProp` refused + by name on the same call as the lit control. The dataset query then **selected + and computed all three** and the tile rendered `values[0]` — the other two were + queried and dropped on the floor (objectui#7293 defect 1). objectui PR #8887 + landed a sub-caption that says so, which makes the tile honest about dropping + them; it does not make the document legal. + + The maintainer ruled **D** on objectui#8894 (decision batch #119 item 4, + 2026-09-12 「同意」) under the standing rule 「协议不正确的应该先修改协议。」 — + judge the protocol wrong rather than invent display semantics for `values[1..]`. + A metric tile answers one number; `ChartTypeSchema` groups these five under + *"Performance (single value)"* in its own words. Several numbers is a different + visual, not a variant of this one. + + ### Write N tiles for N measures + + | wrote | write instead | + |---|---| + | `{ id: 'sales', type: 'metric', values: ['amount_sum', 'count'] }` | `{ id: 'sales', type: 'metric', values: ['amount_sum'] }` **and** `{ id: 'sales_count', type: 'metric', values: ['count'] }` | + | several numbers wanted in ONE widget | a different visual: `type: 'table'` renders a row of measures, and `bar` / `line` / `area` / `combo` render one mark per measure — all keep the unbounded `values` they have always had | + + Splitting is not done for you and no conversion could do it: N tiles need N ids + and N boxes on a 12-column grid, which is a layout decision about a dashboard + the registry has never seen. The refusal lands at `widgets[N].values` with one + `custom` issue naming the widget's `id`, the number of measures it declared and + the authored `type`, and prescribing one measure per tile. + + **Exactly one is a conjunction, not one rule.** The field's own `.min(1)` still + owns the empty array (`too_small`, unchanged, and the new check deliberately + adds no second issue there); the new upper bound is + `checkDashboardWidgetMetricMeasureArity`, exported so objectui's `.shape` mirror + can re-attach it. A widget that declares no `type` is refused too — `type` + defaults to `metric` and zod applies defaults before object-level checks — and + the message says so rather than claiming the author wrote it. + + **Nothing else moves.** All fifteen other `ChartTypeSchema` members — `bar`, + `horizontal-bar`, `column`, `line`, `area`, `pie`, `donut`, `funnel`, `scatter`, + `treemap`, `sankey`, `combo`, `radar`, `table`, `pivot` — keep accepting three + measures, byte for byte; `ReportSchema.values` is a separate declaration and is + untouched; and `dashboard.zod.ts` has no other `.min(1)` **array** key at all + (its one other `.min(1)` is `dashboard.columns`, a number bound, unchanged). + Fleet census over every tracked `.ts` / `.tsx` / `.json` / `.mdx` / `.md` / + `.yaml` at the branch point: **187** brace-local literals carrying a + `values: [...]`, **39** of them on a metric-family `type`, and **0** of those + carrying more than one measure. Both counts are lit controls on the scan. +- 98bd798: feat(spec)!: the three `kernel/plugin-lifecycle-advanced.zod.ts` duration keys carry their unit in the key name (#17780, ruling A on #15939) + + + + **BREAKING** — the health-check period, the health-check deadline and the hot-reload debounce + now carry `Ms` in the key name. + + | | before | after | + |:--|:--|:--| + | `PluginHealthCheck` | `interval: 30000` | `intervalMs: 30000` | + | `PluginHealthCheck` | `timeout: 5000` | `timeoutMs: 5000` | + | `HotReloadConfig` | `debounceDelay: 1000` | `debounceDelayMs: 1000` | + | values, defaults, min bounds | ms; 30000 / 5000 / 1000; min 1000 / 100 / 0 | **unchanged** | + + ## Migration + + ```diff + const health = PluginHealthCheckSchema.parse({ + - interval: 30000, + - timeout: 5000, + + intervalMs: 30000, + + timeoutMs: 5000, + }); + + hotReload.registerPlugin('my-plugin', { + - debounceDelay: 1000, + + debounceDelayMs: 1000, + }); + ``` + + Rename the keys. Every value is the same number of milliseconds it always was, and the + 30000 / 5000 / 1000 defaults are unchanged; nothing else on either def moves. + + ## Why + + Each key named milliseconds in a source JSDoc — "Health check interval in milliseconds", + "Timeout for health check in milliseconds", "Debounce delay before reloading (milliseconds)" — + and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is. + Measured by the `check:duration-unit-keys` census on this tree, all three read + `[name: -] [prose: -]`: no unit in the name and none in the published prose either. + `interval` was the sharpest of the three — its describe carried one unit-shaped token, the + parenthetical "(default: 30s)", naming SECONDS for a value the schema bounds and defaults in + MILLISECONDS. Executes director-seat ruling A on #15939 (2026-09-11, maintainer 「同意」, + decision batch #115), the per-file remediation of the #14478 rule. + + The suffix is the family's own spelling, counted on this tree: 100 key-position `*Ms` + declarations across `packages/spec`, `timeoutMs` 29 of them and `intervalMs` 3. + `debounceDelay` takes the plain suffix rather than a shortened form because it is the only + debounce-shaped key spelling in the repo (no `debounceMs` variant anywhere) while the + Delay-plus-`Ms` pairing is already attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, + `delayMs`) — so unlike the `Ttl`-versus-`TTL` question the sibling round settled, there was no + competing family spelling to choose between. + + ## The kit + + - a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value + reaching the parse raises the rename prescription instead of being silently stripped — + neither `PluginHealthCheckSchema` nor `HotReloadConfigSchema` is `.strict()`, and here the + stripped value would land on a `setInterval` period, a race deadline and a `setTimeout` delay + - the ADR-0087 D3 semantic entry `kernel-health-check-and-hot-reload-durations-unit-in-key` and + three `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: neither def is an authorable + surface — both are library parameters a host passes to `PluginHealthMonitor` / + `HotReloadManager` in TypeScript — so the chain has no seam that runs on them, the same + reading `plugin-auto-restart-never-reinitialised` and `hot-reload-watch-placeholder-retired` + recorded for keys on these two defs + - `@objectstack/core` moves with the rename: `PluginHealthMonitor` and `HotReloadManager` read + the suffixed keys, and each class's registration-time refusal table gains a row so a host + still passing an old spelling is answered with an ADR-0112 `VALIDATION_ERROR` / 400 naming + the rename, rather than getting `undefined` where a duration belongs + - pin tests on both schemas and both classes: the refusal carries the rename prescription, the + suffixed keys parse at the magnitude the retired ones carried with the same defaults, and the + describes publish the unit. The two minimum-bound pins were rewritten rather than left: spelled + through the bare keys they would have stayed green off the tombstone's refusal instead of the + bound, so they now assert the `too_small` issue code on the suffixed keys + - `HotReloadConfig.shutdownTimeout` is deliberately NOT renamed with them — its JSDoc reads + "Graceful shutdown timeout" and names no unit anywhere, so it is the unit-nowhere shape the + #14478 gate leaves outside its verdict, not part of this row set +- cbcae14: feat(spec)!: the fifth `kernel/plugin-security-advanced.zod.ts` duration — `RuntimeConfig.resourceLimits.timeout` — carries its unit in the key name (#17781, ruling A on #15939) + + + + **BREAKING** — the execution timeout on a plugin sandbox's runtime block carries its unit in the + key name. + + | | before | after | + |:--|:--|:--| + | authored key | `resourceLimits.timeout: 60000` | `resourceLimits.timeoutMs: 60000` | + | published describe | `Maximum execution time` | `Maximum execution time in milliseconds` | + | value + bound | milliseconds, `int().min(0)` | **unchanged** | + + ## Migration + + ```diff + resourceLimits: { + maxMemory: 1073741824, + - timeout: 60000, + + timeoutMs: 60000, + } + ``` + + Rename the key. The value is the same number of milliseconds it always was and the `int().min(0)` + bound rides along with it; nothing else on `RuntimeConfig` moves. + + ## Why + + This is the key #15678 deliberately left alone, and this changeset closes it. `#15678` renamed the + four other plugin-security durations on this same file and recorded, accurately, that this one was + out of its scope: `resourceLimits.timeout` named its unit only in the JSDoc above it — "Execution + timeout in milliseconds" — a channel `check:duration-unit-keys` does not read (it reads + `.describe()` and `.meta({ description })`), and its describe said "Maximum execution time" and + named no unit at all. So the gate listed the key among the duration-shaped keys without judging it, + neither an offender nor an exemption, and the reader who most needs the unit — the reader of + `content/docs/references/kernel/plugin-security-advanced.mdx`, who never sees the source JSDoc — + got a bare integer and could not tell 60000 milliseconds from 60000 seconds. That JSDoc-channel gap + was filed as #15939 and is now ruled: director-seat **ruling A** (2026-09-11, maintainer 「同意」, + decision batch #115) remediates the population per file. Under the #14478 rule, moving the unit + into the describe alone is itself a violation — unit in prose, none in the name — so the key is + renamed and the describe is corrected in one stroke. + + Spelled `Ms`, the same token `SandboxConfig.process.timeoutMs` on this very file already carries: + counted on this tree, the suffixed family spells it that way in every member (29 key-position + `timeoutMs` declarations across `packages/spec/src/**/*.zod.ts`, 40 distinct `*Ms` keys), and no + `timeoutMillis`, `timeout_ms` or `timeoutMS` variant exists anywhere in `packages/spec/src`. + + ⚠️ Two keys on this one file spelled `timeout` and both now retire to a key spelled `timeoutMs`: + `RuntimeConfig.resourceLimits.timeout` (this one) and `SandboxConfig.process.timeout` (#15678). + They are different keys on different shapes, so each refusal names its own shape — check which + block you are editing. + + ## The kit + + - a `retiredKey()` tombstone on the old spelling, so `tsc` types it `never` and a value reaching + the parse raises the rename prescription instead of being silently stripped (the nested + `resourceLimits` object is not `.strict()`) + - the ADR-0087 D3 semantic entry `kernel-runtime-config-timeout-unit-in-key`, which states + explicitly that it completes what #15678 left alone so the two read as a sequence, and the + `RETIRED_KEYS_BY_MAJOR[18]` row `kernel/RuntimeConfig:resourceLimits.timeout`. No D2 conversion: + a `RuntimeConfig` is the engine block of the `SandboxConfig` a host or a plugin security manifest + constructs, `stack.zod.ts` declares no sandbox, security-policy or runtime-config collection, and + it is not a stored `sys_metadata` row — so the chain has no seam that runs on it. That is the + same reading #15678 recorded for the four keys it renamed. + - the pin test that asserted this key stays bare is **replaced, not removed**: it now pins that the + bare spelling is refused with the rename prescription, that `timeoutMs` parses at the same + magnitude beside its siblings, that the describe publishes the unit, and that the two same-named + `timeout` retirements on this file name their own shapes apart + - `content/docs/references/kernel/plugin-security-advanced.mdx` regenerated by `gen:docs`: three + rows move and the tombstone prescription renders in place of the old describe + - no authorable-surface row moves — that ratchet records top-level keys per def, and this key is + nested under `resourceLimits` (measured: `kernel/RuntimeConfig:` carries exactly + `engine`, `engineConfig` and `resourceLimits` across `authorable-surface/` and + `authorable-surface.base.json`, and `check:authorable-surface` is green without regeneration) +- 8261ff7: feat(spec)!: the four `system/logging.zod.ts` duration keys carry their unit in the key name (#17782, ruling A on #15939) + + + + **BREAKING** — the HTTP log destination's batch flush, retry backoff start and request deadline, + and the logging buffer's flush, now carry `Ms` in the key name. + + | def | before | after | + |:--|:--|:--| + | `HttpDestinationConfig` | `batch.flushInterval: 5000` | `batch.flushIntervalMs: 5000` | + | `HttpDestinationConfig` | `retry.initialDelay: 1000` | `retry.initialDelayMs: 1000` | + | `HttpDestinationConfig` | `timeout: 30000` | `timeoutMs: 30000` | + | `LoggingConfig` | `buffer.flushInterval: 1000` | `buffer.flushIntervalMs: 1000` | + | values, defaults, bounds | ms; 5000 / 1000 / 30000 / 1000; positive int | **unchanged** | + + ## Migration + + ```diff + const destination = HttpDestinationConfigSchema.parse({ + url: 'https://logs.example.com/v1/logs', + - batch: { maxSize: 500, flushInterval: 10000 }, + - retry: { maxAttempts: 3, initialDelay: 1000 }, + - timeout: 30000, + + batch: { maxSize: 500, flushIntervalMs: 10000 }, + + retry: { maxAttempts: 3, initialDelayMs: 1000 }, + + timeoutMs: 30000, + }); + + const logging = LoggingConfigSchema.parse({ + name: 'app_logging', + label: 'App logging', + destinations: [], + - buffer: { enabled: true, size: 5000, flushInterval: 2000 }, + + buffer: { enabled: true, size: 5000, flushIntervalMs: 2000 }, + }); + ``` + + Rename the keys. Every value is the same number of milliseconds it always was, and the + 5000 / 1000 / 30000 / 1000 defaults are unchanged; nothing else on either def moves. + + ## Why + + Each key named milliseconds in a source JSDoc — "Flush interval in milliseconds", "Initial retry + delay in milliseconds", "Timeout in milliseconds" — and the JSDoc above a key is not what + `content/docs/references/**` renders; `.describe()` is, and **none of the four carried one at + all**. Measured by the `check:duration-unit-keys` census on this tree before the change, all four + read `[name: -] [prose: -]`: no unit in the key, and no published prose to supply it either. So + `content/docs/references/system/logging.mdx` printed a bare `5000` / `1000` / `30000` / `1000`, + and nothing on the page decided milliseconds from seconds. Under the #14478 rule, moving the unit + into the describe alone would itself be a violation (unit in prose, none in the name), so each key + is renamed and given the describe it never had in the same stroke. Executes director-seat ruling A + on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the + #14478 rule. + + ⚠️ `flushInterval` was declared **twice** on this file, in two different defs and with two + different defaults — 5000 on the HTTP destination's `batch`, 1000 on the logging `buffer`. They are + two keys, not one; each gets its own tombstone, its own registered row, and a prescription that + names its def, so an author who lands on one is not sent to the other. + + The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` + declarations in `packages/spec/src` against 75 `*Seconds:`. The only competing unit spellings are + 3 `*MS:` and 9 `*Millis:`, and every one of them mirrors a name fixed outside this repo — MongoDB's + `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and + `connectionTimeoutMillis` on `PoolConfigSchema` — so unlike the `Ttl`-versus-`TTL` question a + sibling round had to settle, there was no in-repo alternative to choose between. All three target + spellings were already attested as key-position `*.zod.ts` declarations before this change: + `flushIntervalMs` 1 (on `kernel/events/integrations.zod.ts`, at the same 1000 default), + `initialDelayMs` 5, `timeoutMs` 30. + + ## The kit + + - a `retiredKey()` tombstone on each of the four old spellings, so `tsc` types it `never` and a + value reaching the parse raises the rename prescription instead of being silently stripped — none + of the four enclosing objects is `.strict()` (`HttpDestinationConfig` itself and its nested + `batch` and `retry`; `LoggingConfig`'s nested `buffer`) + - the ADR-0087 D3 semantic entry `logging-durations-unit-in-key` and four + `RETIRED_KEYS_BY_MAJOR[18]` rows, one per key. No D2 conversion: `stack.zod.ts` declares no + logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is + referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no + rehydration seam that runs on an authored logging document — the same reading + `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key + - pin tests per key: the refusal carries the rename prescription and names the def, the suffixed + key parses at the magnitude the retired one carried with the same default, and the describe + publishes the unit + - exactly one authorable-surface row pair moves, and it is the one that should: that ratchet records + top-level keys per def (`build-schemas.ts` reads `schema.properties` one level deep), and + `HttpDestinationConfig.timeout` is the only top-level key of the four — + `system/HttpDestinationConfig:timeout` becomes `[RETIRED]` beside a new + `system/HttpDestinationConfig:timeoutMs`, and the `authorable-defaults/` row is renamed with it. + The three nested keys move neither file, which is correct and not an omission + - the pinned objectui checkout is untouched by this rename: at `.objectui-sha` pin + `53ded82bf7a494f54e344e19099dbf00854b8694` it spells `flushInterval` 0 times, `initialDelay` 0, + `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 6409 tracked files, against lit + controls `useState` 2304 and `timeout` 702 on the same corpus +- 24489f1: feat(spec)!: the five `system/metrics.zod.ts` durations carry their unit in the key name (#17783, ruling A on #15939) + + + + **BREAKING** — the five metrics durations whose unit was stated only in a source JSDoc now carry + it in the key name, and each published `.describe()` states it too. + + | def | before | after | + |:--|:--|:--| + | `MetricDefinition` | `summary.maxAge: 600` | `summary.maxAgeSeconds: 600` | + | `ServiceLevelObjective` | `errorBudget.burnRateWindows[].window: 3600` | `errorBudget.burnRateWindows[].durationSeconds: 3600` | + | `MetricExportConfig` | `interval: 60` | `intervalSeconds: 60` | + | `MetricsConfig` | `collectionInterval: 15` | `collectionIntervalSeconds: 15` | + | `MetricsConfig` | `retention.period: 604800` | `retention.durationSeconds: 604800` | + + Every value is seconds, exactly as before, and every default (600, 3600 as authored, 60, 15, + 604800) is unchanged. + + ## Migration + + ```diff + summary: { + - maxAge: 600, + + maxAgeSeconds: 600, + } + + errorBudget: { + - burnRateWindows: [{ window: 3600, threshold: 14.4 }], + + burnRateWindows: [{ durationSeconds: 3600, threshold: 14.4 }], + } + + exports: [{ + type: 'prometheus', + - interval: 60, + + intervalSeconds: 60, + }], + - collectionInterval: 15, + + collectionIntervalSeconds: 15, + retention: { + - period: 604800, + + durationSeconds: 604800, + }, + ``` + + Rename the keys. Nothing else on these four defs moves, and the three same-named objects on this + file — `MetricAggregationConfig.window`, `ServiceLevelIndicator.window` and + `ServiceLevelObjective.period` — are untouched. + + ## Why + + Each key named its unit in a source JSDoc — "Max age of observations in seconds", "Window size in + seconds", "Export interval in seconds", "Collection interval in seconds", "Retention period in + seconds" — and nowhere else. Four of the five carried no `.describe()` at all and the fifth read + "Window size", so the text `content/docs/references/system/metrics.mdx` publishes named no unit: + 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of + milliseconds, and nothing on the page decided between them. Executes director-seat ruling A on + #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the + #14478 rule — under that rule, moving the unit into the describe alone is itself a violation (unit + in prose, none in the name), so each key is renamed and its describe corrected together. + + Three of the five new names are deliberately **not** the mechanical suffix, and this file supplied + the reason for each: + + - `burnRateWindows[].window` → **`durationSeconds`**, not `windowSeconds`. It is the fourth window + length on this file, and #15679 already settled that a window length here reads `durationSeconds` + so the measurements read alike. `windowSeconds` would stutter against the enclosing + `burnRateWindows` array — the same objection #15679 recorded against `window.windowSeconds` — and + on this tree `windowSeconds` is not an authorable key at all: its only key-position occurrence is + an alias-map entry in `ServerRateLimitConfigSchema` that maps the spelling *away* to `windowMs`. + - `retention.period` → **`durationSeconds`**, not `periodSeconds`. `period` is calendar vocabulary + elsewhere in this spec (`ServiceLevelObjective.period.type` selects rolling or calendar, + `PluginRegistryEntry.pricing.billingPeriod` is monthly or yearly), so `periodSeconds` would have + kept the ambiguous half of the name — the same objection #15679 raised against `sizeSeconds`. + - `collectionInterval` → **`collectionIntervalSeconds`**, keeping the qualifier, because + `MetricExportConfig.intervalSeconds` is a different cadence one def over that this same change + creates. + + The two mechanical spellings are attested: `maxAgeSeconds` is the token + `AccessControlConfig.maxAgeSeconds` already carries after this same rule renamed it on + `system/object-storage.zod.ts`, and it keeps the `age` stem that the sibling `ageBuckets` counts + buckets of; `intervalSeconds` is the token four seconds-valued cadences already carry. Counted in + key position across `packages/spec/src` at `fc28c1d38`, the base of this change, the seconds + suffixes run `Seconds` 40, `Sec` 1 (`maxExecutionTimeSec`) and `S` 0 — the two bare `*S` keys on + that corpus, `maxCommitTimeMS` and `enableRLS`, are a millisecond spelling and a boolean. This + change takes `Seconds` to 45 at `9b62f54671`. + + ## The kit + + - a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value reaching + the parse raises the rename prescription instead of being silently stripped (none of the five + enclosing shapes is `.strict()`) + - the ADR-0087 D3 semantic entry `system-metrics-jsdoc-durations-unit-in-key` and five + `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: `stack.zod.ts` declares no metrics collection + and none of these defs is a stored metadata row — the reading + `system-metrics-window-durations-unit-in-key` already recorded for this file + - pin tests per key: the refusal carries the rename prescription and is not an `unrecognized_keys` + issue, the suffixed key parses at the magnitude the retired one carried with the same default, + and each describe publishes the unit + - two authorable-surface rows move, three do not: that ratchet records **top-level** keys per def, + so `MetricExportConfig:interval` and `MetricsConfig:collectionInterval` become `[RETIRED]` beside + their suffixed rows (and their `authorable-defaults` rows move with them), while + `summary.maxAge`, `burnRateWindows[].window` and `retention.period` are nested and move nothing +- fc28c1d: feat(spec)!: the `system/tenant.zod.ts` schema-cache TTL key carries its unit in the key name (#17784, ruling A on #15939) + + + + **BREAKING** — the schema-cache TTL on the `isolated_schema` tenant isolation strategy carries + its unit in the key name. + + | | before | after | + |:--|:--|:--| + | authored key | `performance.schemaCacheTTL: 3600` | `performance.schemaCacheTtlSeconds: 3600` | + | published describe | `Schema cache TTL` | `Schema cache TTL in seconds` | + | value + default | seconds, `3600` | **unchanged** | + + ## Migration + + ```diff + performance: { + - schemaCacheTTL: 3600, + + schemaCacheTtlSeconds: 3600, + } + ``` + + Rename the key. The value is the same number of seconds it always was, and the `3600` default is + unchanged; nothing else on `SchemaLevelIsolationStrategy` moves. + + ## Why + + The key named its unit in a source JSDoc — "Schema cache TTL in seconds" — and nowhere else. The + `.describe()` that `content/docs/references/system/tenant.mdx` renders said "Schema cache TTL" and + named no unit at all, so the one reader who most needs it, the reader of the published reference + page, was the only reader who never saw it: `3600` is a plausible number of seconds and a plausible + number of milliseconds, and nothing on the page decided between them. Executes director-seat ruling + A on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the + #14478 rule — under that rule, moving the unit into the describe alone is itself a violation (unit + in prose, none in the name), so the key is renamed and the describe is corrected together. + + The new spelling is `Ttl`, not `TTL`: counted on this tree, every member of the suffixed family + already spells it that way — `cacheTtlSeconds` (11), `ttlSeconds` (3), `defaultCacheTtlSeconds` (1). + + ## The kit + + - a `retiredKey()` tombstone on the old spelling, so `tsc` types it `never` and a value reaching the + parse raises the rename prescription instead of being silently stripped (the nested `performance` + object is not `.strict()`) + - the ADR-0087 D3 semantic entry `tenant-schema-cache-ttl-unit-in-key` and the + `RETIRED_KEYS_BY_MAJOR[18]` row `system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL`. + No D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is + not a stored metadata row, so the chain has no seam that runs on it — the same reading + `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file + - pin tests on `SchemaLevelIsolationStrategySchema`: the refusal carries the rename prescription, the + suffixed key parses at the magnitude the retired one carried with the same `3600` default, and the + describe publishes the unit + - no authorable-surface row moves — that ratchet records top-level keys per def, and this one is + nested under `performance` (measured: 0 hits for the key across `authorable-surface/` and + `authorable-surface.base.json`, against 4 for the `system/MigrationPlan:` control) +- 6d64785: feat(spec)!: the four `system/tracing.zod.ts` duration keys carry their unit in the key name (#17785, ruling A on #15939) + + + + **BREAKING** — the OTel exporter deadline, the batch processor's two knobs and the background + span-export period now carry `Ms` in the key name. + + | | before | after | + |:--|:--|:--| + | `OpenTelemetryCompatibility.exporter` | `timeout: 10000` | `timeoutMs: 10000` | + | `OpenTelemetryCompatibility.exporter.batch` | `exportTimeout: 30000` | `exportTimeoutMs: 30000` | + | `OpenTelemetryCompatibility.exporter.batch` | `scheduledDelay: 5000` | `scheduledDelayMs: 5000` | + | `TracingConfig.performance` | `exportInterval: 5000` | `exportIntervalMs: 5000` | + | values, defaults, bounds | ms; 10000 / 30000 / 5000 / 5000; `int().positive()` | **unchanged** | + + ## Migration + + ```diff + const otel = OpenTelemetryCompatibilitySchema.parse({ + exporter: { + type: 'otlp_grpc', + - timeout: 10000, + + timeoutMs: 10000, + batch: { + - exportTimeout: 30000, + - scheduledDelay: 5000, + + exportTimeoutMs: 30000, + + scheduledDelayMs: 5000, + }, + }, + resource: { serviceName: 'api-server' }, + }); + + const tracing = TracingConfigSchema.parse({ + name: 'default_tracing', + label: 'Default Tracing', + - performance: { exportInterval: 5000 }, + + performance: { exportIntervalMs: 5000 }, + }); + ``` + + Rename the keys. Every value is the same number of milliseconds it always was, the + 10000 / 30000 / 5000 / 5000 defaults are unchanged, and nothing else on either def moves. + + ## Why + + Each key named milliseconds in a source JSDoc — "Timeout in milliseconds", "Export timeout in + milliseconds", "Scheduled delay in milliseconds", "Background export interval in milliseconds" — + and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is. + Measured on this tree: all four carried **no `.describe()` at all**, so the published reference + row for each was a bare integer with no unit anywhere on the page. That is a strictly worse + channel than the unit-in-prose shape #14478 already refuses — here the reference reader had no + prose to misread. All four magnitudes read plausibly in both units (10000, 30000, 5000, 5000), + and an operator who reads seconds sets an exporter deadline 1000x short. Executes director-seat + ruling A on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file + remediation of the #14478 rule, and closes the last of that ruling's seven cards. + + The suffix is the family's own spelling, counted in key position across `packages/spec/src`: + 281 `*Ms` declarations over 42 distinct names, `timeoutMs` 65 of them and `intervalMs` 14, + against **0** key-position `timeoutSeconds`. The Delay-plus-`Ms` pairing is likewise already + attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, `delayMs`, `debounceDelayMs`) with no + competing `scheduledDelay` spelling anywhere. This file is milliseconds throughout and its own + landed precedent is `Span.duration → durationMs` (#15679) — the opposite of the sibling metrics + card, whose rows were seconds. + + `exporter.timeoutMs` and `exporter.batch.exportTimeoutMs` deliberately sit one nesting level + apart. The pair pre-exists the rename: the `batch` sub-object is the OpenTelemetry batch span + processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) + beside the exporter's own request deadline. Renaming either to something more distinctive would + depart from the vocabulary this shape mirrors, and the nesting already disambiguates every read + point — `exporter.timeoutMs` versus `exporter.batch.exportTimeoutMs`. + + ## The kit + + - a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value + reaching the parse raises the rename prescription instead of being silently stripped. Neither + `OpenTelemetryCompatibilitySchema` nor `TracingConfigSchema` nor any object nested inside them + is `.strict()`, so `unrecognized_keys` was never the alternative — a bare deletion would have + landed a default on an exporter deadline and a background export period + - the ADR-0087 D3 semantic entry `system-tracing-otel-exporter-durations-unit-in-key` and four + `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: `stack.zod.ts` declares no tracing + collection, no metadata-type binding or manifest embed carries either def, and a tracing + configuration is never a stored `sys_metadata` row — so the chain has no seam that runs on + them, the same reading `system-tracing-span-duration-unit-in-key` recorded for the other key + on this file + - pin tests: a refusal pin per row asserting the issue **code** (never a bare `toThrow()`) and + the FROM → TO prescription, an acceptance pin at each retired key's magnitude with the same + default, a bounds pin, and a describe pin proving the unit now reaches the published channel + - the `authorable-surface` / `authorable-defaults` ratchets move **nothing**, and that is the + correct outcome rather than an omission: those artifacts record top-level keys per def + (`build-schemas.ts` reads `schema.properties` one level deep) and every one of these four is + nested + - `Span.duration → durationMs`'s own entry is untouched — a predecessor's scoped record stays + true, and this round's entry opens by saying how it relates to it +- b3b43b6: fix(spec): four lookup folds no longer hand out `Object.prototype` members for an off-vocabulary key (#17818) + + `normalizeFilterOperator` (`/ui`), `resolveDiscoveryEnvironment` (`/api`), and + `pluralToSingular` / `singularToPlural` (`/meta-spelling`, re-exported from + `/shared`) each read a module-level lookup table with a runtime key through a + bare index. Every one of those tables is an ordinary object, so a key that is + not in the vocabulary resolved a member of `Object.prototype` instead of + falling through — and the `?? fallback` each function already writes never + fired, because the inherited member is truthy. + + Measured on Node v22.22.2, before and after — each fold evaluated at this + change's implementation and again at its merge base, against the TypeScript + sources that the build and the test run both consume: + + | call | before | after | + |:--|:--|:--| + | `normalizeFilterOperator('constructor')` | the `Object` function | `'constructor'` | + | `normalizeFilterOperator('toString')` | `Object.prototype.toString` | `'toString'` | + | `normalizeFilterOperator('valueOf')` | `Object.prototype.valueOf` | `'valueOf'` | + | `normalizeFilterOperator('__proto__')` | `Object.prototype` | `'__proto__'` | + | `resolveDiscoveryEnvironment('constructor')` | the `Object` function | `'development'` | + | `resolveDiscoveryEnvironment('__proto__')` | `Object.prototype` | `'development'` | + | `pluralToSingular('constructor')` | the `Object` function | `'constructor'` | + | `singularToPlural('__proto__')` | `Object.prototype` | `'__proto__'` | + + Each function's declared refusal value is what it now answers — the same value + each already gave for an ordinary unknown word such as `nope`. ⛔ No new + fallback was invented. `resolveDiscoveryEnvironment` is the sharpest case: its + own docblock promises "a value guaranteed to satisfy + `DiscoveryEnvironmentSchema`", and for `constructor` it returned a `Function`. + + ⚠️ **Why `minor` and not `patch`.** The level is carried by this change's + declared contract-review status, ⛔ not by a widening — the guard only NARROWS. + An off-vocabulary key that previously resolved an inherited member now gets each + function's own declared refusal value, and nothing that answered before answers + differently. Nothing in the declared vocabulary moves: every canonical operator, + every `EnvironmentType` bucket, both operator shorthands and every manifest + collection spelling answers byte-identically to before, and the only inputs + whose answer changes are the four prototype-member spellings above, which no + signature ever admitted. + + The guard is the `Object.prototype.hasOwnProperty.call(table, key) && table[key]` + shape already landed in `src/data/type-compat.ts`, and carries that site's two + recorded rejections: ⛔ not a null-prototype table (it does not type-check + against the `Record` annotation, and the spelling that does compile silently + costs the exhaustiveness check), and ⛔ not a list of prototype member names + (which the next prototype member defeats). +- b1d3945: **BREAKING** for authored metadata — `ObjectSchema.fields` refuses a key named `__proto__`, `constructor` or `prototype`, and `AssignmentConfigSchema.assignments` (the `assignment` flow node's variable map) refuses a key named `__proto__` — both refused with a named, located error at parse time, rather than silently accepted and then silently mishandled (objectstack#17852, objectstack#18847). + + ## Why + + zod's `z.record()` skips a `__proto__` own key entirely, above its own key schema — the record parser's `if (key === "__proto__") continue;` runs before `def.keyType._zod.run`, so no key grammar (a regex, `.refine()`, `.superRefine()`, even a key schema that rejects every string) can ever see that key. A document whose `fields` (or `assignments`) carried a `__proto__` own key — which `JSON.parse` produces routinely — used to parse as SUCCESS with that key silently missing from the output: the validator accepted a document and handed back a *different* document. `os build` writes the release artifact from that returned document, so the failure shape is success, silent, and irreversible into the shipped artifact. + + Two independent mechanisms close this, one per name class, because they are not reachable the same way: + + - `__proto__` is refused by a **pre-parse guard** that reads the raw input's own keys before the record ever parses, at both `ObjectSchema.fields` and `AssignmentConfigSchema.assignments`. + - `constructor` and `prototype` — which, unlike `__proto__`, DO reach the key schema unskipped — are refused by `ObjectSchema.fields`' own key grammar (they were ordinary lowercase words its regex already admitted). They are **not** refused at `AssignmentConfigSchema.assignments`: that slot's key type carries no grammar at all (`z.string().min(1)`), both names are legal flow-VARIABLE names measured to survive parse intact today, and no ruling narrows that slot's accept set for them — only its `__proto__` half moves. + + Measured: zero authored use of any of the three names as a `fields` key or an `assignments` variable name, across this repo, `examples/` and `objectui`. + + ## Known gap, left open on purpose + + The guard runs at parse time only. It does not project into the published JSON Schema (`packages/spec/json-schema/**`) — the general gap that closes is tracked separately (objectstack#18670) and stays open after this change. + + Clause-②: yes (narrowing) + + +- 134b410: The artifact-ingestion door no longer replays the **default-flip** class of ADR-0087 conversion, so an artifact carrying `defineApp({ hidden: true })` is registered with `hidden: true` — not as an unpublished app (#17885, #4829). + + `app-hidden-to-unpublished` rewrites `app.hidden: true` into `app._unpublished: true`. Both keys are live and they mean opposite kinds of thing: `hidden` is navigation presentation and *"never an access gate"* (`ui/app.zod.ts`), while `_unpublished` is the machine-managed publish gate `filterAppForUser` drops the app on for every user without `studio.access` / `setup.access`. Measured before the change, on an artifact declaring `engines.protocol: ^17.0.0` — the range `create-objectstack` stamps — against a 17.4.0 runtime: the door emitted the `app-hidden-to-unpublished` notice and the object that reached registration carried `hidden: undefined`, `_unpublished: true`. So an author who asked for "keep this out of the App Switcher" got "nobody but a builder can see this" — the incident the `_unpublished` split was introduced to end, arriving through the conversion layer. + + - **The entry is not withdrawn and no key moves.** It still fires where its precondition is a fact — the stored-row rehydration seams (a pre-split `hidden: true` row can only have come from the materialization path) and `os migrate meta`, where the operator asserts the source's age. What changed is that the artifact door, whose evidence is the artifact's **declared `engines.protocol` floor** rather than its age, no longer treats that guess as sufficient for a rewrite that reinterprets a live authorable key. + - **The retired window stays open.** Closing it wholesale would fix this and re-break #12772: an artifact built by 17.1.0 tooling carrying `allowRestore` / `allowPurge` would again be refused at the tombstone with no operator remedy. The door refuses one named class by id, with its reason written beside it, and the pin drives a retired conversion and a non-retired one through the same window to prove it. + - **New seam option, no new export.** `applyConversions` accepts `excludeConversionIds` — the seat-level spelling of "my evidence cannot carry this entry". `retiredFromLoadPath` cannot express it: that flag's jurisdiction is the authoring funnel and nothing else. + - ⛔ **The consumer is unchanged.** `filterAppForUser` withholding on `_unpublished` is correct; the defect was who writes `_unpublished`. + + Deployments whose apps were being served as unpublished purely because of a permissive `engines.protocol` range will see those apps again, for every user, on the next boot. No artifact file changes and no stored row is rewritten. +- 84e6b05: `@objectstack/plugin-approvals` is now registered as a second emitter of the already-registered `RESUME_FAILED` in `ERROR_CODE_LEDGER`, so the only correct implementation of `ResumeFailureReport.code` stops being refused by `check:error-code-provenance`. + + **The contradiction this closes.** `ResumeFailureReport` (`contracts/approval-service.ts`) declares `code: ErrorCode` as **required** — "a success answer has no envelope `code` to fall back on" — and its docblock prescribes `RESUME_FAILED` for a run that could not be advanced. But the ledger listed that code only under `@objectstack/rest`, so the first producer to fill the slot stamped a registered code its own owner key did not list, which the provenance gate refuses. The declaration shipped in a state where satisfying it tripped a sibling gate. + + **Measured, not derived.** With PR #17908's stamp site present and the ledger unchanged, the guard answers exit 1 and names it: `@objectstack/plugin-approvals stamps 'RESUME_FAILED' (objlit) at packages/plugins/plugin-approvals/src/approval-service.ts:3370 — not listed under its own owner key`. With this row, the same tree answers exit 0 with the site counted as listed. + + **A row, not a waiver — the precedent's own predicate decides it.** The `EXTERNAL_IMPORT_ERROR` waiver records "the door stamps this code itself for every throw and never reads the producer's declaration". Both halves fail for `resumeFailure`: it rides a **success** answer, which the REST approvals door serves with `res.json(out)` verbatim, and `packages/rest/src` spells `resumeFailure` nowhere. The producer's literal *is* the wire value, so the door names no vocabulary to waive it under. + + **One code, not the three the docblock names.** `RESUME_TARGET_LOST` is a thrown message prefix mapped by rest's catch and stays under rest's row; `RESUME_IN_PROGRESS` is compared and never constructed in this package, and is emitted by `@objectstack/service-automation`, which carries its own row. A row for a code the package does not stamp would be the dead weight this file's gate refuses. + + ⛔ **No wire byte moves and no accept set widens.** `RESUME_FAILED` was already in the registered union, so no response can now carry a code it could not carry before; the per-package rows are provenance, not identity. No exported symbol is added and no published payload gains a key. +- cb1f274: fix(automation): a `wait` node must say what resumes it — the config block is required at the contract, and the executor stops defaulting to a duration-less timer (#17928) + + **BREAKING** — a `type: 'wait'` flow node with no `waitEventConfig` block, and a + `type: 'boundary_event'` node with no `boundaryConfig` block, no longer parse. + Under `eventType: 'timer'`, `timerDuration` is now required and may not be blank + — and that half sits on the `waitEventConfig` BLOCK, not on the node type, so it + bites on ANY node carrying the block: a `start` node spelled + `waitEventConfig: { eventType: 'timer' }` parsed before and is refused now. It is + still a narrowing in every direction (no shape starts parsing that did not), and + the block is inert on a node type no executor reads it from, so the practical + reach is `wait`. + + `eventType` has been required *inside* each block since protocol 17, so + `waitEventConfig: {}` was already a loud parse error. The block itself was + optional — so "omit the key" and "omit the block" were two documents with two + verdicts, and the accepted one was the silent one. It is also the state a + freshly created node is in, which is what made it reachable from a designer's + default screen rather than only by hand-authoring. + + What that document did, measured through a real `engine.execute()` run rather + than read off the source: + + ``` + FROM { id: 'pause', type: 'wait', label: 'Wait' } // parses clean + -> { success: true, suspend: true } // run status: paused + scheduled jobs: [] <- with a job service ANSWERING + variables: no `pause.waitUntil` <- cold boot cannot re-arm + log lines: 0 at any level <- warn, error, info, debug + + TO FlowNodeSchema.safeParse(...) + -> { success: false, + issues: [{ code: 'custom', path: ['waitEventConfig'], + message: 'a `wait` node requires a `waitEventConfig` block saying + what resumes it … `waitEventConfig: { eventType: 'timer', + timerDuration: 'PT1H' }` … or `{ eventType: 'signal', + signalName: 'order_paid' }` …' }] } + ``` + + The control — the same node with `{ eventType: 'timer', timerDuration: 'PT1H' }` + — armed the one-shot job and persisted the deadline, so the zeros above are a + reading of this path and not of a dead harness. + + **The executor follows the contract.** `wait-node.ts` carried + `(node.waitEventConfig ?? {})` and `String(wec.eventType ?? 'timer')` under a + comment declaring the second one deliberate — "a wait node without one is a + VALID TIMER WAIT". Both fallbacks are retired. A node that still reaches + `execute` without the block (a stored pre-migration document on a path that + skipped the parse) is now a **guard refusal** — `errorClass: 'guard'`, so a + `fault` edge cannot route a metadata defect into a handler that reports success + — and it **logs**, naming the node and the remedy, because the defect being + closed was silence. It never suspends with `success: true` again. Two smaller + corrections ride along in the same return: the timer branch stops answering + `output` as a present key holding `undefined` (it is absent when no deadline was + computed), and the reversed comment is deleted rather than left describing a + behaviour that is gone. + + **`screen.mode` now declares the default the executor applies; `http.method` + still declares none.** Both were read by running the executors with the key + absent, not by reading the Zod: + + | key | absent ⇒ the runtime applies | declared | + | --- | --- | --- | + | `ScreenConfig.mode` | `'create'` (object-form branch; the flat `fields` branch never reads it) | `.default('create')` | + | `HttpConfig.method` | `GET` inline, **`POST`** when `durable: true` | ⛔ none — two values, no single default | + + Declaring `.default('GET')` on `method` would materialise `GET` at parse time, + the durable arm's own `?? 'POST'` would never fire again, and every stored + durable callout that omits the method would silently change verb. That is the + defect this card exists to end, pointed the other way. + + **Migration.** A stored `wait` node with no block has no lossless conversion — + the missing value is an intent no artifact records, and the old runtime's pick + (`'timer'` with no duration) was not a wait at all — so this is an ADR-0087 D3 + semantic entry rather than a D2 conversion: `os migrate meta --from 17` names + each node to edit. Declare the resume condition and re-publish the flow. ⚠️ + Behaviour the fix deliberately changes: a run that used to park forever now + waits the duration you declare or the signal you name. + + **`boundary_event` gets the contract half only.** The runtime registers no + executor for that node type at all — a flow reaching one fails with + `NO_EXECUTOR` before any config is read, identically whether the block is + present or absent — so there is no silent executor branch behind it. The + refusal fixes the authoring surface; `try_catch` (ADR-0031) remains the native + construct for error handling. + + +- b0eb9a5: Approval nodes gain a fourth empty-slate policy — `onEmptyApprovers: 'fallback'` with a sibling `fallbackApprovers` list — so a rung that expands to nobody opens the request on people you named instead of on a slot nobody can act on. + + Until now an approval node whose approvers resolved to nobody had three endings, and none of them named anyone: `admin_rescue` (the default — the request opens on a dead `type:value` slot and waits for a privileged admin), `fail` (the run dies) and `auto_approve` (the record is waved through). All five graph approver types reach that dead end, and `{ type: 'manager' }` reaches it without anybody authoring a wrong value: `manager` omits `value`, so the literal the expansion falls back to is `manager:undefined`. + + ```ts + { + approvers: [{ type: 'manager' }], + onEmptyApprovers: 'fallback', + fallbackApprovers: [{ type: 'org_membership_level', value: 'owner' }], + } + ``` + + - **`fallbackApprovers` is the approver shape you already write** — the same entries as `approvers`, resolved by the same expansion, so every approver type, OOO delegation and `per_group` tagging behaves identically on it. It is not a second, reduced approver dialect. + - **The pairing is enforced in both directions.** `'fallback'` without a list is refused; a list under any other policy is refused too, because nothing would ever read it — a node that declares a rescue slate and silently ignores it is the failure this config shape is `.strict()` against. Both messages name both keys. + - **A fallback that itself resolves to nobody degrades to `admin_rescue`.** The run is never killed and the record is never waved through by a policy whose author only asked for different people; the log says both that the fallback fired and that it found nobody. + - **This is on the node, not on the `manager` rung** — the node is already where emptiness is decided, and a fallback is wanted for every approver type, not one of them. + - **`os lint` names the new escape and keeps firing without it.** `approval-approvers-may-resolve-empty` still reports a manager-only slate even when a fallback is declared: the rule reads shape, and a static check can no more prove a `fallbackApprovers` list resolves than it can read `sys_user.manager_id`. A seeded manager chain remains the one silencer. +- e233db9: feat(spec): declare `navigation` on the standalone `object-kanban` / `object-calendar` element faces, and give `object-timeline` the `ComponentPropsMap` row it never had (#17987) + + Clause-②: yes (widening) — one new optional key on two published element faces plus one new row, so the accept set grows. Nothing previously admitted is refused, nothing is renamed or retired, and no producer is required to write anything. + + **What changes for an author.** A record-click navigation block written on a + STANDALONE element node is now declared where it is read. Before this, the same + document ran correctly in objectui's renderer and was refused by name at the + authoring door: + + ``` + FROM ComponentPropsMap['object-kanban'].safeParse({ objectName: 'task', + navigation: { mode: 'drawer' } }) + -> success: false, unrecognized_keys: ['navigation'] + TO -> success: true, navigation: { mode: 'drawer', preventNavigation: false, + openNewTab: false, size: 'auto' } + ``` + + `object-calendar` moves identically. The value is `NavigationConfigSchema` — + the same def `ListViewSchema.navigation` already declares, taken by reference, + so a standalone element and a list view speak one vocabulary and the retired + `navigation.view` key (17.5.0) stays retired on every face that carries it. + + **`object-timeline` gains a row.** It was registered in objectui and reachable + through the component type union's open string arm with no entry in + `ComponentPropsMap`, so the authoring gate skipped it entirely: a real key and + a typo rode through alike. The row declares the key set measured from the + renderer's own read points at the `.objectui-sha` pin this repo builds against + — `objectName`, `timeline`, `filter`, `sort`, `limit`, `data`, `items`, + `variant`, `dateFormat`, `rowLabel`, `minDate`, `maxDate`, `descriptionField`, + `mapping` and `navigation` — and refuses everything else, the flat `startDateField` / + `titleField` / `scale` handoff spellings with a prescription pointing at the + `timeline` config block that owns them. + + **What does NOT change.** The view-level `navigation` on `ListViewSchema` is + untouched: the element key is an ADDITIONAL carrier for the standalone + placement, not a replacement, and both faces keep judging the same block. The + parse is unchanged for every document that did not author these keys, and the + component type union is not narrowed — an `object-timeline` node reaches + `PageComponentSchema` through the open string arm exactly as it did before. + + Executes the objectui#8652 maintainer ruling (verbatim `B`). +- 176b035: **BREAKING for authored metadata** — a `$between` range now requires two endpoints that are present and non-empty. A blank bound (`''` or an absent `undefined` bound, at either side) is refused at the authoring door, and the refusal names the blank side (#18012). + + Clause-②: yes + + Maintainer ruling A on decision batch #146 item 5, 2026-09-17 「146 同意」. + + ## What changed, and why it is a new rule rather than a repair + + `FieldOperatorsSchema.safeParse({ $between: [1, ''] })` answered `success: true` — measured on the card against spec 17.4.0 and re-measured on `main` before this change. That acceptance was **conformant**: the endpoint contract shared by both bounds says verbatim that "Each endpoint is a number, a Date, or a string", and the empty string is a string. So this narrows a published face by adding a rule to it, rather than pulling code back to a declaration it was already violating. + + What made the acceptance wrong is the other half of the same contract — "Closed interval [min, max]" — which no backend can honour against a blank. `driver-sql` binds the blank into `whereBetween`; the JS matchers compare it as a value. Either way the range stops bounding on that side **while still reading as a complete two-element range**, so the query runs with one meaningless boundary and no signal at any layer. The reference matcher was already taught to survive the `null` form of exactly this (a bounded range answered every valued row, because both of the arm's comparisons are false against a missing bound); the door that admitted it was never addressed. + + The only producer ever measured is a UI builder padding a **half-typed** pair so a length-based completeness check passes it. Nobody writes a blank bound on purpose — which is why it is refused rather than given a published meaning. + + ``` + FROM FieldOperatorsSchema.safeParse({ $between: [1, ''] }) + -> { success: true } // a half-filled range, green all the way + // to the driver + + TO FieldOperatorsSchema.safeParse({ $between: [1, ''] }) + -> { success: false, + issues: [{ code: 'custom', path: ['$between', 1], + message: 'A blank value is not a valid $between endpoint at index 1 + (the MAX bound). …' }] } + ``` + + ## Migration — FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `{ $between: [1, ''] }` | `{ $between: [1, 100] }` — the upper bound you meant, written out | + | `{ $between: ['', '2026-12-31'] }` | `{ $between: ['2026-01-01', '2026-12-31'] }` — the lower bound you meant | + | a range that was only ever bounded on ONE side | `{ "$gte": min }` or `{ "$lte": max }` — a one-sided bound is not a range | + + **The one-line fix: write the bound that is missing, or — if only one side was ever meant — drop `$between` and write that side as a scalar comparison.** ⛔ Not mechanically convertible: the bound the author did not type is not recoverable from the one they did, so this ships as an ADR-0087 D3 structured TODO and **no D2 conversion**. Both of the two readings a conversion could take are wrong — dropping the operator deletes a constraint the author wrote and silently WIDENS the result set, and treating the blank side as unbounded invents a filter nobody authored. + + + + ## What does NOT change + + - **Arity.** A one-element or three-element `$between` was already refused, and still is, by the tuple's own contract. This rule is about a two-element range one of whose elements means nothing. + - **`null` bounds.** Already refused since 2026-08-31, and they keep **their own** message, which prescribes the null predicate — an author who wrote `null` was reaching for absence, not for a bound. Two blank spellings, two intents, two remedies. + - **Falsiness.** `{ $between: [0, 100] }` and `{ $between: ['0', '9'] }` parse exactly as before. The rule is blankness, not falsiness. + - **Whitespace-only endpoints** are deliberately **not** judged. The ruling is the empty string; widening the refusal past it would narrow a published face further than the ruling did. + - **The set slots.** `{ $in: ['', 'won'] }`, `{ $nin: [''] }`, `{ $eq: '' }` and `{ $gte: '' }` are untouched — an empty string is a legitimate stored VALUE, and only an interval ENDPOINT is judged here. + - **Stored documents.** The read path does not re-validate stored rows, and the stored-row conversion pass neither validates nor drops anything, so no stored view becomes unreadable. What changes is that **re-saving** one is refused, at the endpoint's own path, with the blank side named. + - **The published export surface.** No export is added, removed or renamed; the refusal rides the existing endpoint factory that both the documentation copy (`RangeOperatorSchema`) and the enforced copy (`FieldOperatorsSchema`) already share, so the two cannot drift. +- 51297e9: `package-registry` is a platform capability of its own, and an always-on one: the `sys_packages` container and the boot hydration that replays it no longer hide behind the `marketplace` token, which is left naming only the optional catalogue / browsing half (#18053, director ruling A′ on #17676). + + A package is a first-class persistent entity whether or not a deployment has a store — an admin-created package does not depend on the marketplace existing. Until now the only way to get the persistence was `requires: ['marketplace']`, so a stock boot had no `sys_packages` at all and `protocol.installPackage` / `updatePackage` fell back to their in-memory branches: an admin-created package did not survive a restart, under a token advertising a store that was not there. + + - **`PLATFORM_CAPABILITY_TOKENS` gains `package-registry`** — one new token, none removed, so `marketplace` keeps working exactly as before for anyone who declares it. The vocabulary is a closed set validated by `defineStack`, so this widens what an app may write, and nothing it already writes stops parsing. + - **`PLATFORM_ALWAYS_ON_CAPABILITIES` gains `package-registry` at the tail.** The slate's ordering contract is a role, not a count: the entry binds into nothing on the slate (its one hard requirement is the ObjectQL engine, which is not a capability token), so it joins after every bind target like any other reader. `--preset minimal` still opts out of the whole slate. + - **`PLATFORM_CAPABILITY_PROVIDERS` gains a row naming `@objectstack/service-package`, `open` edition** — the same package `marketplace` names today, because that package ships exactly one plugin and everything it does is the persistence half. The catalogue surface `marketplace` is left naming ships in `@objectstack/cloud-connection` and is mounted off a resolved marketplace URL, never through the token; repointing the `marketplace` row at it moves the runtime's own resolver with it and is the engine-lane half of the same ruling (#17676 items 2/3/5). + - ⚠️ **Declaration first, runtime second — measured, not assumed.** `objectstack serve` mounts a slate entry only when `Serve.CAPABILITY_PROVIDERS` keys the token, and that registry keys `marketplace`. Until the engine-lane half lands, appending `package-registry` mounts nothing under the standalone CLI: a stock boot is exactly as capable as before, no more and no less. This package is the single list both the CLI and cloud's per-tenant runtime read, which is why the declaration is the half that goes first. +- 2d892dd: docs(spec): scope the email-template locale-floor claims to a call that NAMES a locale (#18056) + + Clause-②: yes — no accept set moves (no key is added, removed or revalidated), + but what a PUBLISHED package states about its own resolution contract is + corrected, which is a contract act in substance. + + `packages/spec` stated two different rung counts for one resolution. + `EmailTemplateDefinitionSchema.locale`'s `describe` and the + `EMAIL_TEMPLATE_FLOOR_LOCALE` docblock published **one** retry rung and an + explicit "no fallback floor at all"; `SendTemplateInput.locale` in + `contracts/email-service.ts`, same package, documents a **three-rung** ladder + whose third rung is reachable exactly on the path the first says cannot exist. + + Measured against the runtime rather than reconciled by preference — + `EmailService.resolveAndRenderTemplate` and `createSysEmailTemplateLoader` in + `@objectstack/plugin-email`, and the CI pins in + `template-locale-resolution.test.ts` — the three-rung text is the correct one: + + 1. the named locale, matched exactly (no language-subtag folding); + 2. the literal `en-US`, which is also where a call naming no locale starts; + 3. **only for a call that named no locale**, and only when the bundle carries + no `en-US` row: the bundle's lowest locale tag. + + So a bundle with no `en-US` row dead-letters (`TEMPLATE_NOT_FOUND`, permanent) + for every recipient whose locale was NAMED, and silently renders whichever + language sorts first for every call that named none. The shipped declaration + promised the loud permanent refusal on the path where the runtime performs the + silent fill; an author reading it was told a missing locale always + dead-letters. Both call shapes are now named wherever the floor is claimed, and + the ladder itself is stated in one place only. + + Also corrected: `SendTemplateInput.template` said the service "picks the + best-matching locale row", which the resolver has never done — there is no + best match and no folding, only the ladder above. + + `defineStack`'s `warnEmailTemplateLocaleFloor` gains a declaration of the two + shapes it deliberately does NOT examine (a stack whose `i18n.supportedLocales` + is absent or empty; a bundle whose tags all fall outside `supportedLocales`) — + both can still ship a floorless bundle. Its control flow is unchanged — the same + bundles warn, once each, and the warning stays advisory — but the emitted warning + TEXT did change, and now names BOTH call shapes: it says the bundle has no + fallback floor *for a send that names a locale*, and adds that a send naming NO + locale does not fail but drops to that bundle's lowest tag and renders it + silently. A test asserting on the old wording needs updating. Whether either + undeclared shape should warn is the ADR-0049 enforce-or-remove question and is + not answered here. Both shapes are now pinned against a warning discriminator so + neither can change without a test saying so. +- 156792e: The package-install request contract now names the door that actually serves it, declares the two body forms that door accepts, and the door honours `enableOnInstall` instead of ignoring it (#18058). + + `PackageInstallRequestSchema` was declared, published and bound to `POST /api/v1/packages/install` — a path the composed runtime mounts nowhere: the dispatcher answers `handled=false` and `@objectstack/rest`'s registrar mounts only `POST /api/v1/packages/publish`. Meanwhile `POST /api/v1/packages`, the door that answers `201`, had no declared request contract at all, so the read contract was strictly more truthful than the write contract producing the rows it describes. + + Clause-②: yes (widening) + + **What moved on the published surface** + + - `PackageApiContracts.installPackage.path` — `'/api/v1/packages/install'` → `'/api/v1/packages'`. A caller that read the constant to build a URL was building one nothing serves; a caller that hard-coded the old string gets a `404` today and should send `POST /api/v1/packages`. The method (`POST`) is unchanged and is what distinguishes this entry from `listPackages`. + - `PackageApiContracts.installPackage.input` — `PackageInstallRequestSchema` → the new `PackageInstallBodySchema`. The wrapped schema is still exported and still parses the wrapped form; the new export is a union that also parses a bare manifest. + - `PackageInstallRequestSchema` gains **`overwrite?: boolean`**. This is a declaration of behaviour that already shipped: the door reads `overwrite` from the body (or `?overwrite=true`) to opt back in to replacing an already-installed id instead of answering `409 Conflict`, the first-party SDK sends it, and no schema declared it — so any parse at that door would have silently stripped it and turned a deliberate re-install into a conflict. + - **`PackageInstallBodySchema`** / `PackageInstallBody` / `PackageInstallBodyParsed` are new. The door reads `body.manifest || body`, and first-party callers really do post a bare manifest as the whole body, so the contract declares both forms as a union — every parse is a full parse of one coherent form, never a tolerant shape. The two branches are disjoint, but only the BARE one is CLOSED: `PackageInstallRequestSchema` is a plain `z.object`, so an unknown key on the wrapped form is DROPPED (`{ manifest, bogus: 1 }` parses and `bogus` is gone) while the same key on a bare manifest is refused by name. That asymmetry matches the door, which reads four keys off the wrapper and ignores the rest — closing the wrapped branch would refuse bodies the door answers `201` to. The bare form carries no install options: `settings`, `enableOnInstall` and `overwrite` are not manifest keys and the manifest surface is closed, so a bare-form caller reaches `overwrite` through the query string alone. + + **What moved at the runtime** + + `POST /api/v1/packages` now honours `enableOnInstall: false` in the wrapped body: the package installs `disabled`, through the same registry flip and durable state write `PATCH /packages/:id/disable` uses, so a restart does not re-enable what the caller switched off. `true` and absent install enabled, which is the declared default. Previously the key was declared in three schemas, sent by the SDK, and read by no handler at all. + + The durable write happens on **both** arms, not just the disable. `POST /packages` is a create that an already-installed id reaches through `overwrite`, and `DELETE /packages/:id` does not clear this record either, so an install could answer `201` with `enabled: true` while the state file still listed the id as disabled — and `SchemaRegistry.installPackage` reads that file at boot, re-installing the package DISABLED one restart later with nothing red in between. The mirror of that risk is why the write follows the ROW this door returned rather than the request's intent: `SchemaRegistry.installPackage` lands an id in the boot-seeded `initialDisabledPackageIds` DISABLED whatever the request says, and `enableOnInstall` defaults to `true`, so persisting the request would clear an operator's earlier disable off disk on the SDK's default call while the row being served says `enabled: false`. A flag-absent install of a seeded id therefore answers `enabled: false` and records it disabled — wire, registry and disk agree, and the next boot reads the same. Every install now persists the state it returned. + + **What the declaration does NOT cover — the measured residual** + + This is a subset description of the live door, deliberately, and it is recorded rather than implied. Measured through `HttpDispatcher.handlePackages`, the door also answers `201` to: a manifest missing `type` and/or `version` (both of the runtime's own door drives post one); unknown keys on either form (refused by name on the bare branch, dropped on the wrapped one, `201` either way); a string-typed `enableOnInstall` / `overwrite`, which is compared against `true`/`false`/`'true'` and therefore treated as absent — `enableOnInstall: 'false'` installs ENABLED; and install options spelled on the bare form, which are ignored. In the opposite direction the door answers `400` to a whitespace-only `id` this declaration admits. `ManifestSchema` is not relaxed to close any of that. + + **Documentation** + + `packages/client`'s README install example could not parse against the manifest contract — no `id`, no `type`, and a `label` key the closed manifest surface refuses by name — and the live door answered it `400 Package id is required`. It is now a manifest that parses, and the example names the `overwrite` opt-in beside it. +- 5ba2ec3: feat(spec,core,objectql,driver-sql,driver-turso): a transport can declare it has no transactions, and every transaction gate reads the declaration instead of method presence (#18063) + + Maintainer ruling, decision batch #148 item 3, letter B, 「同意」 2026-09-17, verbatim and untranslated: + + > `packages/spec`: the driver contract gains a way for a transport to **declare 「no transactions」** (the dev picks the smallest spelling the existing capability/contract surface already has — a capability bit is preferred over a new key), and the engine's transaction gating reads the declaration instead of method presence. + + **`DriverCapabilities` gains one live bit, `transactionsUnsupported`.** A transport sets it to say that a handle it issued would be a FALSE SUCCESS rather than a missing feature: the caller gets a handle, the writes execute and are already durable, `rollback()` resolves and undoes nothing. Absence means `false`, exactly like `batchSchemaSync`, so a driver that declares nothing keeps the behaviour it has today. + + **⛔ This is not `DriverCapabilities.transactions` un-retired, and the difference is not cosmetic.** That key was tombstoned in 17.0.0 under ADR-0049 enforce-or-remove and STAYS tombstoned — writing it is still a compile error and still a parse refusal carrying its prescription. It claimed "I support transactions" and nothing read it; this one declares "my transport cannot honour one" and the engine dispatches on it. Reviving the name would have inverted the record's own `absence = false` convention into a tri-state, turned a documented refusal into silent acceptance of a value whose meaning had changed underneath it, and made the tombstone's published text ("no code in any repository ever read it") false. A new key costs one bit; the name costs all of that. + + **Adding a bit to a record enforce-or-remove has pruned SATISFIES that ADR rather than reversing it.** The audit removed thirty-one bits for one stated reason — no code anywhere read them — and kept the three where method presence provably cannot carry the signal. This change is the creation of the missing reader: `driverSupportsTransactions()` (exported from `@objectstack/spec`) is the one definition of the gate, and all FOUR places that used to spell `typeof driver.beginTransaction === 'function'` ask it — `ObjectQL.transaction()`, `ScopedContext.transaction`, the `ScopedContext` begin/commit/rollback trio, and `@objectstack/core`'s `engineCanRollBack`. The bit arrives WITH its reader, in the same change, which is the honest order the ADR asks for. + + **Why method presence could not carry it.** `TursoDriver extends SqlDriver`, whose `beginTransaction()` opens a real knex transaction, so the inherited method reported the libSQL REMOTE transport as transactional. It is not — `RemoteTransport`'s data methods take no `options` argument at all, so a handle cannot reach the statement that would have to join it. A subclass cannot opt out of a door it did not open. This is the mirror of `batchSchemaSync`, which exists because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot. + + **What changes for a caller.** On a datasource whose driver declares the bit, `engine.transaction()` now takes the DECLARED non-transactional path (ADR-0119 D1) instead of opening a transaction it cannot honour: the degrade warns once per datasource — naming the declaration, not a missing method — and `{ require: true }` throws `TransactionUnsupportedError` before the callback writes anything. `ScopedContext.transaction` and the discrete begin/commit/rollback trio read the same predicate; the trio's `begin` returns `null`. Both are the answers a driver with no `beginTransaction` already received. + + **`driver-turso`.** The remote face declares `transactionsUnsupported: true`; local and embedded-replica inherit `false` from the base and are untouched. `TursoDriver.beginTransaction()` publishes the inherited declaration instead of `Promise` — the annotation the earlier `any` was masking an LSP violation to avoid, dissolved rather than widened: the remote arm returns `never` (it refuses), so the only arm that still returns is the base's. `SqlDriver.beginTransaction()` keeps its narrow `Promise`; nothing in the base was widened. + + **`@objectstack/core`.** `engineCanRollBack()` — the ADR-0119 D4 gate that `@objectstack/metadata-protocol` uses for `batchData` / `updateManyData` / `deleteManyData` under `options.atomic`, and that `runMigrationJournal()` uses to decide whether to start at all — reads the same predicate. It has to: it does not open the transaction itself, it vouches that `engine.transaction()` will, and on a driver that declares the bit the engine now takes its non-transactional path. A gate still reading method presence would vouch for a runtime that is about to run the callback with no transaction, so the atomic batch would answer `rollback` over writes that stayed on disk and the journal would write `chunk_done` rows its own contract says mean "committed". What a caller sees on such a datasource instead: `batchData({ atomic: true })` refuses with `501 NOT_IMPLEMENTED` — retry without `atomic`, or probe `capabilities.transactionalBatch` on `/discovery` first — and `runMigrationJournal()` refuses with `MigrationJournalRefusal('NOT_IMPLEMENTED')` before writing a single journal row. Both are the answers a driver with no `beginTransaction` already received. + + **`RemoteTransport` loses `beginTransaction()`, `commit()` and `rollback()`.** They are a published surface, and this is **minor** rather than major on the ruling's own stated ground: that transport never honoured a transaction, so no working behaviour is withdrawn. They had already become unreachable from every caller in the repository when the driver started refusing them; they are now gone, and the declaration keeps them gone by design rather than by audit. +- e64ae15: A `reference` carrier that no reader can read is now **REFUSED** where it is read, instead of coming back as `undefined`. The source-level gate that guarded the same shape (`check:reference-carrier-shape`) is retired in the same change (#18095, executing a maintainer ruling). + + `FieldSchema.reference` is `z.string().optional()`, so `ObjectSchema.safeParse` already refuses an object- or array-valued carrier at the contract door with a located `invalid_type` issue. Measured on the pre-change tree: + + ``` + ObjectSchema.safeParse({ fields: { invoice: { type: 'lookup', + reference: { object: 'shop_invoice' } } } }) + -> success = false, issue invalid_type at path ["fields","invoice","reference"] + control: the same object with reference: 'shop_invoice' + -> success = true (so the refusal is about the carrier's SHAPE) + ``` + + What was missing was the other door — the one a value reaches only when it never went through parse at all. #13053's fixture spelled `reference: { object: … }` inside `fields:`, and the rule reading it answered `undefined`: refused where it was written, read as absent where it was consumed, reported nowhere. The fixture passed, and would have kept passing. + + **New export — `referenceCarrierOf(def, reader?)` in `@objectstack/spec/data`.** It answers the carrier as the string the contract declares, and throws a `TypeError` naming the shape and the fix when the key is present in any other shape. `null`, `undefined` and `''` are ABSENCE, not a wrong shape, and still answer `undefined` — a field is allowed to name no target. + + **`referenceTargetOf` reads through it**, so the single arbiter of "what does this field expand into" refuses rather than answering "no target". Every consumer that already asks the arbiter — `$expand`, the record-title deriver, the dangling-reference audit, the analytics dimension labeller — inherits the refusal with no edit. + + **`@objectstack/lint`** routes its own target readers through the same accessor: `refOf` in `validate-security-posture.ts` (the reader in the #13053 incident) and in `data-model-rules.ts`, plus the object-graph slice every other rule downstream reads. + + Upgrading: nothing conformant changes. A non-string `reference` could not be authored, stored or parsed before this release either; what changes is that a hand-built fixture or a raw registry entry carrying one now fails loudly at the read instead of being silently treated as targetless. If a test asserted the old silence, assert the refusal instead — `packages/cli/test/data-model-rules.test.ts` is the worked example. +- 66abef3: `@objectstack/spec/data` publishes the case-insensitive-contains **text-comparand door** — `isRefusedTextComparand(target)` and `textComparandRefusalReason(field, operator, target)` — so every face reads one implementation of a refusal the package already declared as data (#18113, objectui#9048 ruling D). + + `FILTER_TEXT_CASES` has carried two REJECTION rows for that operator since #5701 — an empty comparand and a non-string one, both `code: 'INVALID_FILTER'`, both `mustMention: ['$icontains']` — but only as cases a backend is *checked against*. Every face that honoured them wrote its own copy of the discrimination and its own wording, which is how the same authored filter came to be refused in one dialect and lowered onto the wire in another. The rule now lives with the producer of the rule. + + - **`isRefusedTextComparand(target)`** answers `true` for exactly those two shapes. It answers `true` for `undefined` as well: a vocabulary with an "absent" the `$` dialect does not have (a stored view rule whose operator takes no comparand) must test for absence **before** this door — that carve-out is the caller's, not a third row. + - **`textComparandRefusalReason(field, operator, target)`** returns the CONTRACT half of the message: **no leading capital, no trailing period, no envelope**, so each face seats it in its own sentence — a matcher that has a row to exclude logs it, a producer that has none throws it. ⛔ No new error code: `INVALID_FILTER` is declared and already in the ADR-0112 ledger. + - **`operator` is the spelling that ARRIVED** (`$icontains` from a `$`-dialect filter, `icontains` from the infix/view vocabulary), never a canonical substitute — telling an author about a key their dialect cannot contain is the misdirection this door exists to end. + - ⚠️ **Consequence for the infix dialect**: `mustMention` is spelled `$icontains` because the published rows' filters are, so for an arriving `icontains` the reason names what arrived and does **not** carry the `$`-dialect token. The face serving that vocabulary names the `$` twin in its own tail. Pinned in both directions in `filter-text-comparand.test.ts`. + - **The message bytes are the contract, not prose.** They are the bytes two shipped faces already emit byte for byte; `mustMention` is what makes a reword a different failure to honour the same row, and a transcription pin catches the reword `mustMention` cannot. ⛔ Change them only by changing the rows they answer. + + Additive: no existing export changes, no behaviour moves. `describeComparand` — the guard that keeps a BigInt or a cyclic comparand from making `JSON.stringify` throw *inside* the refusal — travels with the reason as a module-internal helper and is deliberately not published; exporting it is a published-surface decision for the PR that needs it. +- 25c9a83: Ten wall-clock instants now declare their unit through the shared `EpochMs` schema (`@objectstack/spec/shared`) instead of a bare `z.number()`. No key is renamed and no key is added or removed. + + `EpochMs` is `z.number().int()` with the describe "Unix timestamp in milliseconds (epoch)". Adopting it moves each key's published JSON Schema from `{"type":"number"}` to `{"type":"integer"}` and puts the millisecond unit on the contract itself, where a reader of the reference page, the JSON Schema or the TypeScript surface all see the same answer. Before this, the unit lived in a JSDoc block (invisible in every published artifact), in prose that named only the epoch and not the unit, or nowhere at all — the ×1000 ambiguity a `timestamp: number` key carries by default. + + The keys, by schema: + + - `Data.DocumentVersion.createdAt`, `Data.Document.access.expiresAt` + - `System.SupplierSecurityAssessment.assessedAt`, `.validUntil`, `.remediationItems[].deadline` + - `Identity.Account.expiresAt` + - `Kernel.PluginLoadingEvent.timestamp`, `Kernel.PluginLoadingState.startedAt`, `.completedAt` + - the shared connector OAuth2 auth shape's `tokenExpiry` + + **What an author must change: nothing, unless they were writing a fractional millisecond.** Seven of the ten previously accepted any `number` and now accept integers only; `Date.now()` — the value every one of these keys is documented to carry — is already an integer. The three `Kernel.PluginLoading*` keys already declared `.int().min(0)`; they keep that floor (`EpochMs.min(0)`), so their accepted set is byte-for-byte what it was and only their description is new. + + `timestamp`, `tokenExpiry`, `deadline` and `validUntil` deliberately keep their names. `EpochMs`'s own docblock recommends spelling an instant `*At`, but a rename of a published key is a retirement with its own ADR-0087 entry and is not part of this change. +- ee5812a: **BREAKING** — retire the CEL predicate arms of `ServiceLevelIndicator.successCriteria` + and `TraceSamplingConfig.composite[].condition`, the two observability predicates nothing + ever evaluated. + + Both slots were `z.union([, ])`. The + expression arm parsed, normalized a bare string to `{ dialect: 'cel', source }`, + registered, and was served back — and **nothing anywhere evaluated it**. An identity scan + over the whole tree finds every hit for `successCriteria`, `ServiceLevelIndicatorSchema` + and `TraceSamplingConfigSchema` outside `packages/spec/src` to be a generated artefact or + prose; inside it the only readers are the schemas' own unit tests and the two census tests + that enumerate expression slots. No service, plugin, runtime or CLI path reads either key. + So an author — very often an AI reading the generated reference page (ADR-0033) — who + wrote `successCriteria: 'p95 < 300ms'` got a green parse and no signal, indistinguishable + from a predicate that ran and answered. + + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18 (director decision batch #160 + item 3, letter A). By the standing criterion that a declared-but-unread capability is kept + only when mainstream platforms in the domain have it: application platforms do not carry + SLI success criteria or trace-sampling conditions as authorable application metadata — + that lives in observability infrastructure (SLO products, OTel sampling policy) and is + structured there, not a free expression. The `cron-declared-unwired` family was retired + outright under the same ADR after the same measurement. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `successCriteria: 'p95 < 300ms'` | `successCriteria: { threshold: 300, operator: 'lt', percentile: 0.95 }` — the structured rule this slot has always carried | + | `successCriteria: { dialect: 'cel', source: 'p95 < 300ms' }` | the same structured rule; the envelope spelling goes with the bare-string one | + | `condition: 'record.amount > 10'` on a composite sampling branch | `condition: { service: 'api', attributes: { 'http.route': '/v1/orders' } }` — a structured filter object carrying no `dialect` key | + | `condition: { dialect: 'cel', source: 'record.amount > 10' }` | the same structured filter; an object carrying `dialect` is refused as an expression attempt | + + **The one-line fix:** delete the predicate and write the structured shape the slot already + carried. A criterion or a sampling rule the structured shape cannot express has no home in + application metadata at all — it belongs in the SLO product or the OpenTelemetry sampler + configuration that actually evaluates it. ⛔ Do not translate a predicate into a threshold + by guessing the number: nothing was evaluating it, so there is no behaviour to preserve and + a wrong number is worse than an absent one. + + ## The retirement kit + + - **Neither KEY is retired — one ARM of each key's union is.** `successCriteria` and + `condition` both survive with their structured arm intact, so `retiredKey()` and an + ADR-0087 D2 strip are both the wrong tool: they retire a key. The prescription hangs on + the surviving schema's own `error` map, dispatched on `issue.input` — the + `HookBodyCapability` / `object.managedBy: 'system'` pattern for a narrowing a key + survives. + - **Where the prescription reaches, measured on zod 4.4.** A schema's `error` map is + consulted for the top-level `invalid_type` a NON-OBJECT raises, and not for the child + issues a wrong-shaped OBJECT raises. So on `successCriteria` the bare-string spelling + carries the prescription and the `{ dialect, source }` envelope is refused by the + structured arm's own missing-key issues (`threshold`, `operator`). On `condition` both + spellings carry it, because the structured arm is a record whose aborting `dialect` + refine sees the object itself. Pinned both ways in the schemas' unit tests, the negative + included: a value refused for a reason that is NOT the retirement must not borrow its + sentence. + - **ADR-0087 disposition: a D3 SEMANTIC entry**, `observability-cel-predicates-retired`, + not a D2 conversion. A predicate is an intent that no threshold/operator pair or + attribute filter records; a mechanical strip would delete what the author meant and leave + no trace of which SLI or which sampling branch lost it — and it would not even be lossless + in the weak sense, because `successCriteria` is REQUIRED (a strip leaves an SLI that no + longer parses) and a composite branch stripped of its `condition` declares no condition at + all. That is the one place this retirement parts company with the two precedents it copies + its MECHANISM from: `crypto.hash` on `HookBodyCapability` and `managedBy: 'system'` both + ALSO registered a D2 conversion, because for each of them a mechanical rewrite existed. + Here none does, which is what makes D3 the right disposition rather than merely an + available one. The prescriptions therefore carry **no** `os migrate meta` sentence — that + sentence is owed only where a conversion covers the surface. + - **The same-major D3 record is absorbed, per the playbook's 「同 major 记账」.** The + `evaluated-expression-slots-source-required` entry landed into this same unpublished step, + and it enumerated these two slots among its 36 declaring positions while instructing the + upgrader to give a sampling `condition` a dialect and a non-blank `source` — the exact + envelope this head now refuses. Both entries first ship together, so the composite of the + two changes is the retirement alone: that entry now reads 34 positions, names the two + absentees and why, and routes them to this retirement instead of to its own repair. + - **The surviving accept sets are pinned beside the refusals.** `successCriteria` still + takes `{ threshold, operator, percentile? }`; a composite `condition` still takes any + filter object carrying no `dialect` key — `{ source: 'x' }` included, because `source` + alone is an ordinary filter key and the retirement narrowed the `dialect` door only. + - **FOUR published JSON Schemas change projection direction**, and it is mechanical rather + than chosen: the retired arm held the last `.transform()` in each of these subtrees, so + each def now projects in output mode instead of falling back to the input shape. All four + lose `x-io: input`, and what each gains differs: + + | published schema | gains | + | --- | --- | + | `system/MetricsConfig` | `default: []` on `slis`, plus 8 `required` members | + | `system/TracingConfig` | `default: {"type":"always_on","rules":[]}` on `sampling`, plus 4 `required` members | + | `system/ServiceLevelIndicator` | one `required` member, `enabled` | + | `system/TraceSamplingConfig` | one `required` member, `rules` | + + Only the first two carry a `default` move, so only those two are declarable in + `DEFAULT_CHANGES_BY_MAJOR` — the nested pair's `required` growth has no ratchet row to + live in and is stated here instead. A `required` that lists defaulted keys is this repo's + existing output-mode convention, not a new one, and the same-category control + `system/CacheConfig` is untouched. The reference pages show the same signature: the nested + type cells of both pages lose the `?` from their default-bearing keys. **No runtime default + moves** — measured twice, by byte-identity of the untouched `.default(…)` and by parsing a + minimal config on the built package. + + ## What is deliberately NOT in this change + + - **The structured arms.** `{ threshold, operator, percentile }` and the sampling filter + record are equally unread today. The ruling says so and leaves them to their own card: + they carry no dialect and are outside the expression ledger's remit. + - **`skills/objectstack-formula/SKILL.md`**, which still lists `metrics` / `tracing` under + `structured | cel`. The ruling assigns that correction to the skills lane, at tier, and + this diff does not touch it. + - **`packages/spec/src/shared/expression.zod.ts`.** `EvaluatedExpressionInputSchema` is + untouched and stays the schema of every remaining evaluated slot; what left is two + references to it. + + Shipped as `minor` under the repo's launch-window convention, in which `major` is refused + by `check-changeset-no-major` and breaking-ness is carried by the banner above plus the + ADR-0087 disposition rather than by the level. + + Clause-②: yes (narrowing) + + +- 68fea8b: spec(shared): closed duration types `DurationMs` / `DurationSeconds` beside `EpochMs` (#18122) + + Two new schemas and their type aliases, reachable on the **`@objectstack/spec/shared`** subpath — the same published surface `EpochMs` reaches consumers on, and the reason this is a `minor`: the entry gains exported symbols. The root `.` entry is deliberately untouched, because `EpochMs` is not on it either and mirroring the precedent means mirroring its width. + + ```ts + import { DurationMs, DurationSeconds } from '@objectstack/spec/shared'; + + // the unit rides on the VALUE; the default stays at the site + updateAge: DurationSeconds.default(60 * 60 * 24).describe('Session update frequency'), + ``` + + Both are `z.number().int().nonnegative()`. Author state and parsed state coincide — no `.default()` and no `.transform()` on the type itself — so there is deliberately no `DurationMsParsed` / `DurationSecondsParsed`, and the isomorphism is pinned (ADR-0122). + + **Why a type and not a longer name list.** `check:duration-unit-keys` (#14478, ruling B) reads one channel: a unit token in the key NAME, cross-checked against the `.describe()` prose. It deliberately declines to judge a key whose prose names no unit at all, because judging those by name alone was measured to fire 44 times and mostly on counts wearing a duration's vocabulary — `contextWindow`, `backoffMultiplier`, `snapshotInterval` ("every N events"). Ruling A on #18115 adds a second declaration channel instead: a duration declares its unit either on its value (one of these types) or as a token in its key name, and the 25-token name list retires from judge to hint. + + **Why this refinement**, measured against the six genuine duration rows the ruling derives the unit set from — `shutdownTimeout`, `cors.maxAge`, `slideInterval`, `session.updateAge`, `meta.duration` and `FileValue.duration`. Three of the six already declare `.int()`, and both rows that carry a default default to an integer (`30000`, `60 * 60 * 24`). One declares `.min(0)` and one `.positive()`; none declares a negative floor, so `.nonnegative()` is the weakest floor every declared floor implies — and `.positive()` would be too strong, since a zero timeout means "do not wait" and one of the six already accepts it. + + **Nothing else moves, on purpose.** This is step ① of three. No key is converted to the new types (#18124, step ③), and no gate behaviour changes (#18123, step ②): `check:duration-unit-keys` recognises exactly one identifier root today, `EpochMs`, so a key typed `DurationMs` is outside its population rather than exempted by it — the gate learns to read the new channel in step ②. `DurationMinutes` / `DurationHours` / `DurationDays` are deliberately absent: the unit set is derived from the conversion population, never declared ahead of it, so a third unit arrives in the PR that converts the row needing it. + + Nothing an author can write today is removed, renamed or refused: the six rows still declare exactly what they declared before this landed. +- c049e74: spec: the genuine duration rows declare their unit — `DurationMs` / `DurationSeconds` and two `externalVocabulary` mirrors (#18124) + + **BREAKING** — three keys that accepted any `number` now accept whole, non-negative numbers only. No key is renamed, added or removed, and no exported symbol moves. + + Step ③ of ruling A on #18115. Step ① added the closed duration vocabulary and step ② taught `check:duration-unit-keys` to read it; this converts the rows the census found carrying a genuine duration with its unit written down in no channel a reader can reach. + + **Six rows declare the unit on the value**, by adopting `DurationMs` / `DurationSeconds` (`@objectstack/spec/shared`) and stating the unit in the describe the reference page renders: + + - `API.BaseResponse.meta.duration` — milliseconds + - `Kernel.HotReloadConfig.shutdownTimeout` — milliseconds + - `System.MetricAggregationConfig.window.slideInterval` — seconds + - `System.MetricsConfig.retention.downsampling[].resolution` — seconds + - `System.MetadataLoadResult.loadTime`, `System.MetadataSaveResult.saveTime` — milliseconds + + **Two rows declare it by mirror**, with `.meta({ externalVocabulary })` plus the unit in the describe, because the key name is fixed outside this repo and renaming it would break the correspondence that makes it readable: + + - `Kernel.KernelSecurityPolicy.cors.maxAge` — seconds, per CORS `Access-Control-Max-Age` (WHATWG Fetch). This is the same declaration its twin `CorsConfig.maxAge` already carried. + - `System.AuthConfig.session.updateAge` — seconds, per better-auth `session.updateAge`. Its sibling `session.expiresIn` already carried the marker; this closes the pair. + + **What an author must change: nothing, unless they were writing a fraction or a negative span.** Only `meta.duration`, `loadTime` and `saveTime` change what they accept — each was a bare `z.number()` and is now `z.number().int().nonnegative()`. `shutdownTimeout` declared `.int().min(0)` and `slideInterval` / `resolution` declared `.int().positive()`; all three keep their floor, so their accepted set is byte-for-byte what it was and only their description is new. The two mirror rows keep their types untouched. + + Every unit is a measurement of the row's producer, printed in the PR body per row, never a reading of the key name. + + Clause-②: no (narrowing) + +- d402e32: `record:details`, `record:highlights` and `record:related_list` accept `enforceFieldSecurity` and `redactFields` — the two field-security keys objectui's detail renderers have been honouring on documents this contract refused by name (#18159). + + Clause-②: yes (widening) + + All three blocks are `strictObject`s that declared neither key, while `@object-ui/plugin-detail` reads both off each of the three. An author who wrote either was refused at publish, and the same document was honoured on the raw-node path — a contract that could not be satisfied by writing it down. Both keys are declared here, optional, with no schema default, so an absent key stays absent rather than becoming "the author asked for off". + + - **`enforceFieldSecurity`** (boolean) folds the block's field list — the detail body's fields and sections, the highlight chips, the related list's `columns` — through the caller's field-read permissions before rendering, so a field the permission set denies leaves no empty row behind. + - **`redactFields`** (string array) drops the names it lists outright. On `record:related_list` it also reaches the columns the list derives for itself when none are authored. + - **The claim is held to what the render path does.** Both are presentation filters, applied in the browser after the record is fetched: the values are in the page either way, so neither is a data-access control and neither is the object's `publicSharing.redactFields`, which removes them server-side. Each `describe()` says that in the text an author reads, rather than leaving the key names to imply it (Prime Directive #10). The gates that do keep a value from a caller are the field's own `requiredPermissions` / `maskingRule` (ADR-0066 D3) and the permission set. + - **⚠️ On `record:details`, `redactFields` neighbours the already-declared `hideFields`** and on a well-formed field list the two remove the same rows: `hideFields` is the dedupe channel the renderer also writes to (live `record:highlights` registrations, the page-title field), `redactFields` is the author's deliberate omission and the arm that participates in the renderer's fail-closed fold. Converging them is a contract question this change did not open. + - **The third key the same three renderers read — `requiredPermissions` — is declared in the same release, by its own entry.** It is the block-level ADR-0066 capability gate, not a member of this pair. + + ⚠️ **Not measured here**: the runtime behaviour of either declared key in a browser, and whether any authored document anywhere writes them. "The schema refused it" is not "nobody writes it"; only the first is measured. +- 63a8eb4: `record:details`, `record:highlights` and `record:related_list` accept `requiredPermissions`, the block-level capability gate objectui's detail renderers read, with the same shape and the same describe as `record:quick_actions` — whose published describe changes in this release (#18159). + + Clause-②: yes (widening) + + - **Additive.** All three blocks are `strictObject`s that refused the key by name. It is now declared optional, `z.array(z.string())`, with no schema default, so an absent key stays absent and nothing that parsed before stops parsing. + - **One key, one meaning, one text, on all four record blocks.** The names are ADR-0066 capabilities (what permission sets grant through `systemPermissions`), not object actions. The user must hold all of them; otherwise the block renders an insufficient-permissions notice in place of its content. It is presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities renders the block as if they were held (fails open). To keep data from a user, gate the object, the field or the action. + - **⚠️ Published text changes: the describe of `record:quick_actions.requiredPermissions`.** It read "Hide the whole bar unless the current user holds every named permission on this object." Against the renderer the pinned console ships, "on this object" is false — the gate reads the user's capability set and is not object-scoped — and the sentence named no fail-open case. The shape is unchanged; only the text moves. If a page writes object actions there (`read`, `update`), the console reads them as capability names. +- 9a910c4: `@objectstack/spec/data` now exports the typed hook `ctx.api` face — `HookApi`, `HookObjectApi`, `HookQuery`, `HookCountQuery`, `HookUpdateDoc`, `HookUpdateOptions`, `HookDeleteOptions`, `HookDoc` and `HookDriverPassthroughOptions` — so a metadata app's `*.hook.ts` imports the platform's type instead of hand-declaring one (#18163). The same entry additionally re-exports `EngineTransactionInfo` and `EngineTransactionOptions`, which its public declarations reference structurally: without them a consumer that imports only `@objectstack/spec/data` and emits declarations answers `TS2883: The inferred type ... cannot be named without a reference to ...`. Type-only re-exports of the declarations `@objectstack/spec/contracts` already publishes, not second declarations. + + ```ts + import type { HookApi } from '@objectstack/spec/data'; + + const api = ctx.api as HookApi | undefined; + if (!api) return; + const owner = await api.object('user').findOne({ where: { id: ctx.input.owner } }); + ``` + + The platform already implemented this surface; it just never published a type an app could import, so every app re-derived the engine's option vocabulary in a copy that drifts the moment the engine moves. The reference third-party app carried ~2,358 authored tokens of one in a single file, imported by 17 hook files. + + - **The query shape is `where`-only — there is no `filter` key, deliberately.** `RPC_QUERY_ALIAS_SLOTS` declares `filter` as the alias of `where` (and `top` as the alias of `limit`); every engine entry point folds the `where` slot, collapsing redundant identical spellings and REFUSING the slot when the two spellings carry different values. So `{ where, filter }` is silent when they happen to agree and a runtime throw when they do not. Omitting the alias keys makes it neither: `TS2353: 'filter' does not exist in type 'HookQuery'`, at the authoring site. + - **Not a second dialect of `IScopedContext`.** `contracts/scoped-context.ts` stays the CHECKED IMPLEMENTATION contract ObjectQL's `ScopedContext` and `ObjectRepository` carry `implements` clauses against, with its deliberately loose `Record` bags. This is the authoring half of the same seam: `HookApi` is assignable to `IScopedContext`, so `ctx.api as HookApi` stays a direct cast, and nothing about the older contract changes. + - **Every option shape is DERIVED, not transcribed.** Each is an `Omit`/`Pick` over the `Engine*Options` schemas that the engine's own per-method legal-key sets are pinned against, so a key added to a schema reaches the published type in the same run it reaches the engine's accepted set. `count` is the one shape without the driver pass-through keys, because the engine forwards no bag on that method and rejects them there — engine behaviour no document states, and exactly what a hand-written copy gets wrong. + - **What is deliberately absent, each for a stated reason**: `context` (the repository injects it and discards a caller's), the `cursor` / `distinct` / `upsert` tombstones, `sudo()` (the #5945 exclusion stands — `Hook.runAs: 'system'` is the declared way to run elevated), and `aggregate` / `execute` / `create` / `deleteById`. + + Additive only: eleven new exported names from `./data` (nine new declarations plus two type-only re-exports), no removal and no signature change, so nothing an existing consumer imports moves. + + Clause-②: yes (widening) +- adabccf: **BREAKING for authored metadata** — `BulkActionParamSchema` is strict, matching its single-record twin `ActionParamSchema`, and declares `dependsOn` (#18177, decision batch #146 item 4, letter A). + + Clause-②: yes (narrowing) + + + + A list view's `bulkActionDefs[].params[]` entry was `.passthrough()`, so **the shape examined nothing** — and that is the whole finding, not the framing. Measured against installed spec 17.4.0, three parses per schema in one process: + + | | positive control (minimal valid) | negative control (nonsense key) | subject (`dependsOn`) | + | --- | --- | --- | --- | + | `BulkActionParamSchema` | parses | **ACCEPTED** | accepted | + | `ActionParamSchema` | parses | refused `unrecognized_keys` | refused `unrecognized_keys` | + + It accepted `zzz_nonsense_key_that_no_producer_emits_8755` in the **same run** that it accepted `dependsOn`. ⇒ "the bulk schema accepts it" was never evidence that a key was licensed, in either direction: a shape that examines nothing can neither authorise `dependsOn` nor refuse a typo. Both control legs are now pinned in `src/ui/bulk-action.test.ts` in their post-close form, together, so a future re-opening of the shape cannot pass as a green `dependsOn` assertion. + + The maintainer's ruling: 「Breaking for authored metadata」, one-shot — no grace window, no dual spelling. + + ### `dependsOn` is DECLARED, not refused — and needs no edit + + It was already live on this surface and the renderer honours it, so this half is a contract catching up with behaviour. `bulkParamToField` does not destructure it out, so it rides the adapter's spread onto the field metadata, where **both** widget families read it: the option family (`SelectField` / `MultiSelectField` / `RadioField` / `CheckboxesField`) gates and refreshes the offered set through `useCascadingOptions`, and the reference-bearing pickers (`LookupField`, and `UserField` through it) lower it into a hard candidate filter. Retiring it was measured off the table — an ablation removing it from that spread reddens 7 of 12 cases in the consuming repo. + + Shape and description mirror **`FieldSchema.dependsOn`**, which is the single-record twin *for this key*: `ActionParamSchema` declares no `dependsOn` at all, because the single-record dialog reaches it through the field-backed route this surface does not have. One vocabulary, two doors. + + ```ts + params: [ + { name: 'account', type: 'lookup', object: 'showcase_account' }, + { name: 'contact', type: 'lookup', object: 'showcase_contact', dependsOn: ['account'] }, + { name: 'owner', type: 'lookup', object: 'sys_user', + dependsOn: [{ field: 'account', param: 'account_id' }] }, // remote key differs + ] + ``` + + On a bulk param the "record" a binding resolves against is the dialog's own in-progress param values — a bulk run holds a selection, not a row — so a binding names a **sibling param of the same def**. + + ### Migration — FROM → TO + + Every rejection names the surface, echoes the key and carries its own fix. Nothing below is mechanical, which is why this registers as an ADR-0087 **D3 structured TODO** rather than a D2 conversion: an arbitrary unknown key has no mapping target, and deleting it automatically is the silent data loss ADR-0078 bans. + + | You wrote on a bulk param | Write instead | + | --- | --- | + | `helpText: '…'` | `help: '…'` | + | `defaultValue: x` | `default: x` | + | `reference: 'sys_user'` | `object: 'sys_user'` | + | `displayField: 'name'` | `labelField: 'name'` | + | `field: 'owner'` (field-backed param) | declare it inline — `name` + `type`, plus `object` for a picker. The bulk surface has no field-backed route: `resolveActionParams` consults the object's field definitions for the single-record dialog, `toBulkParam` never does | + | `visible: '…'` on the param | move the predicate to the DEF (`bulkActionDefs[].visible`), which gates the button and narrows the run per record | + | `visibleWhen: '…'` on the param | it is a per-**option** key — write it inside `options[]` | + | `carryOver` / `defaultFromRow` / `requiresFeature` / `objectOverride` | ACTION-param contracts with no bulk equivalent: a bulk dialog runs over a selection and holds no row. Use `default` for a fixed prefill, or the def's `patch` for a value the user must not see; gate the button with the def's `visible` / `requiredPermissions` | + | `min` / `max` / `step` / `precision` / `scale` / `rows` / `accept` / `maxSize`, or the picker knobs `lookupFilters` / `lookupColumns` / `lookupPageSize` / `descriptionField` / `picker` / `subtitle` / `avatarField` / `idField` / `allowCreate` | remove the key — see the warning below | + + ### ⚠️ The widget-config family really was honoured, and really is refused now + + This is the half of the narrowing that costs something, so it is stated rather than buried. Those keys rode the same `...extra` spread `dependsOn` rides, and whichever widget read one honoured it (`min`/`max`/`step` at NumberField / SliderField / CurrencyField / PercentField, `accept`/`maxSize` at FileField / ImageField, `rows` at TextAreaField / RichTextField, the picker knobs at LookupField). They are refused now, with one prescription naming `FieldSchema` as the shape they are real on. + + ⛔ **Do not read that prescription as "declare it on the object's field instead"** — the bulk surface has no field-backed param route, so the value does not reach this dialog either. If a bulk param genuinely needs one of these keys, it has to be declared on `BulkActionParamSchema`; open an issue rather than working around it. They were not declared here because the census below found no author writing one, and a declared key is published contract whose removal costs a full retirement. + + **Census, with its boundary.** Taken at authoring time over the two repositories reachable from that session: `objectstack@176b03582e` (7 authored bulk-param literals) and `objectui@3e4f6324f7` (3) — **zero** carrying a key this shape does not declare. ⚠️ **hotcrm was NOT REACHABLE and is UNMEASURED, not clean.** If you keep your own metadata corpus, run `objectstack validate` before upgrading rather than inheriting this result. + + ### What is deliberately NOT closed + + `params[].options[]` stays `.passthrough()`, on its own measurement rather than by symmetry with its parent: `bulkParamToField` spreads every option entry into the field metadata, and the option widgets read `color` / `icon` / `disabled` / `visibleWhen` beyond the declared `{ label, value }`. Closing it would delete widget config the renderer honours — the exact defect this change closes one level up. The declared pair is still type-checked. + + ⛔ No renderer is edited and no key is removed from any other shape. `BulkActionDefSchema` was already strict and is untouched. +- 99fcb4a: `FlowRuntimeState` now declares `reason` — the optional sentence saying WHY a flow is not armed — and the automation engine populates it, so `GET /automation/_status` can tell a policy-disabled flow apart from a broken binding (#18235). + + Ruling G item 6 on #17396 names three surfaces that must each carry a DISTINCT reason for a flow left unarmed because package-authored scheduled work is switched off, and must never read as "binding failed". Two of them shipped: `getTriggerBindingAudit()` and the CLI startup summary. The third — a console — could not be built: Studio's only status door answers `FlowRuntimeState` rows, and that shape had no field a reason could travel in, so on the wire a policy-disabled flow was `enabled: true, bound: false, triggerType: 'schedule'`, byte-identical to one whose trigger is missing. + + **Clause-②: yes (widening)** — one new key on an already-published payload, so the shape a consumer reads against grows. Nothing previously emitted is removed or renamed, and no producer is required to write it. + + - **Optional, and additive by measurement.** Every producer of these rows — the engine, and the test doubles in `packages/runtime`, `packages/cli` and `packages/qa/dogfood` — writes `{ name, enabled, bound }` at minimum; a required key would have broken all of them and would demand a reason from rows that have none. The key is absent (not `undefined`-valued) on any row that is bound, disabled, or declares no trigger. + - **One vocabulary, not a new one.** The sentence is the one `getTriggerBindingAudit()` already answers for the same flow: both doors now read a single private `describeUnboundReason()` on the engine, so Studio and the boot summary cannot drift. A free-form string, matching the two surfaces that already carry this reason; ⛔ consumers render it, they do not parse it. + - **Read from the RECORD, never re-derived.** The policy sentence comes from the engine's recorded refusal (`policyDisabledFlows`, cleared the moment a flow gets past the gate), never from a live `resolveScheduledWorkPolicy()` read at call time. `_status` is served on demand, arbitrarily long after the bind — re-deriving would report a binding failure for a trigger that was never called, the defect the implementing round of #17396 already caught once. + - **Wire, not rendering.** `SCHEDULED_WORK_DISABLED_REASON`'s docblock is corrected: Studio's door now carries the reason, while displaying it distinctly remains objectui#9217's card. Declared is not delivered, and reaching the wire is not being shown. The published prose carrying the same claim moves with it — `content/docs/automation/flows.mdx`'s callout said the status door "has no field to say why", which this change makes false; both carriers are corrected in one landing, and neither now claims a console *renders* it. +- 55095cc: fix(spec): `composeStacks` refuses a stack whose `objects` is not an array, with the ADR-0112 envelope + + **BREAKING** — `composeStacks`, a public root export, now refuses a class of input it used to crash on, skip in silence, or compose by accident. + + Step 2 of `composeStacks` (`mergeObjects`) iterated each input's `objects` with no shape guard. The strict `defineStack` parse already rejects a non-array `objects`, so the reachable population is an input that bypassed it — a hand-built stack object, or `defineStack(config, { strict: false })`. Measured before this change, composing a well-formed stack with such an input: + + | the second stack's `objects` | before | after | + | :--- | :--- | :--- | + | a map (`{ b_item: {…} }`) or a number | bare `TypeError: … is not iterable`, `code` and `status` both `undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422` | + | `null`, `''`, `0`, `false` | composed, the stack's objects silently absent | refused, `STACK_SCHEMA_INVALID`, `status: 422` | + | a `Set` of objects | composed as if it were an array | refused, `STACK_SCHEMA_INVALID`, `status: 422` | + + A composed artifact is complete or it is refused: skipping a stack's objects composes an artifact that silently lacks them, so no non-array `objects` is skipped. An absent `objects` (`undefined`) is not malformed and composes as before. The refusal carries the code the strict parse raises for the same authored mistake — one code for one defect, whichever door catches it — with the zod issue on `issues` (`path: ['objects']`, `expected: 'array'`) and a message naming the stack by manifest id and position. The map form is an authoring spelling `defineStack` normalizes before any check runs; a stack that reaches composition without passing through `defineStack` never had it normalized, and is refused like any other non-array. + + A non-object entry inside an array `objects` (`null`, a number) is skipped and reported once through the composer's malformed-collection warning, the shape step 3 gives a non-array collection; before, it raised a bare `TypeError` reading `name` off it. The artifact cross-reference pass skips such an entry too. + + No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`, and the error class stays module-local. + + + + Clause-②: no (narrowing) +- a3d4c59: `ComponentPropsMap` declares `object-map`, `object-gantt` and `object-tree` — the three object-bound SDUI blocks #7751 enumerated past — with each row's key set derived from the objectui renderer's own read points (#18305). + + **Clause-②: yes (widening)** — three new declared rows on a published surface, so the accept set a consumer writes against grows. Nothing previously admitted is refused, and nothing is retired. Contract-review tier. + + Until now the `object-*` family carried six rows, `object-chart` carried a written note saying its key set is not derivable with this section's confidence, and these three carried neither: they were not ruled out, they were never measured. The cost was the one #7751 exists to remove — the `@objectstack/lint` props gate had no schema to dispatch on, so every authored key inside `properties` on one of these nodes parsed clean, stored, shipped and was ignored by the renderer with a success receipt. It also left objectui's own `@object-ui/types` mirror standing in as the authority for `object-map.data` and `object-gantt.data`, and left `object-tree`'s record-source read undeclared on every published face (objectui#8348, PR objectui#9234). Executing the ruling 「8348 以协议为准」 (decision batch #83, 2026-09-08) and batch #136 item 3 (Q1-C). + + Key sets measured from `plugin-map/src/ObjectMap.tsx`, `plugin-gantt/src/ObjectGantt.tsx` and `plugin-tree/src/ObjectTree.tsx` at the `.objectui-sha` pin `53ded82b`, with per-key read-point citations in each schema's header: + + - **`object-map`** — `objectName`, `data`, `staticData`, `filter`, `sort`, `map`, `mapStyle`, `navigation`, `enableClustering`. + - **`object-gantt`** — the same record-source and query keys, plus `gantt`, `navigation`, `label`, `skipWeekends`, `holidays`, `persistLayout`, `viewName`, `markers`, `criticalPath`, `showBaselines`, `readOnly`, `mobileReadOnly`. + - **`object-tree`** — `objectName`, `data`, `staticData`, `filter`, `tree`, `navigation`. No `sort`: this renderer's fetch carries `$filter`, `$top` and `$expand` and no `$orderby`, so a `sort` door here would publish a key with no read site. + + Three things the derivation decided rather than assumed, each pinned: + + - **`data` is the `ViewData` object arm on all three**, because rung 1 of the shared record-source ladder returns the authored value verbatim as a `ViewData`. For map and gantt that agrees with objectui's mirror — verified from the read points first and read back as a check, never as the source. For **`object-tree` it does not**: the mirror declares no `data`, no `staticData`, no `filter` and no `navigation` at all, while the renderer reads all four (`data` on two sites). The row follows the read points, which is what 「以协议为准」 resolving for this block means. + - **The flat top-level config spellings stay unauthorable.** `ObjectView` / `ListView` build these nodes by spreading `options.map` / `options.gantt` / `options.tree`'s CONTENTS at the top level; that is an internal transport form, not a second authoring surface (maintainer ruling objectui#5018, 2026-08-17, inherited by objectui#6469). Writing one now gets a wrong-layer prescription naming the config block instead of a bare unknown-key refusal — the channel `object-calendar` already uses for its own flat field spellings. + - **`filter` and `sort` are the family's one orthography from birth** — `ViewFilterRule[]` and `SortItem[]`, not the `z.unknown()` the original six carried before #15449 and objectui#8221 pulled them back. + + Nothing about the parse of a page changes: `PageComponentSchema.type` already accepted all three through its open string arm, and it still does. What changes is that an authored props bag on one of them is now judged instead of skipped. +- 1aa5026: `ListMapConfigSchema` now declares `style` — optional `z.string()`, the map style URL the renderer already reads and the schema refused by name (#18406). In the same stroke `object-map`'s `map` prop points at `ListMapConfigSchema` again, retracting the `z.unknown()` that the missing key had forced. + + `ListMapConfigSchema` is a `strictObject`, and `style` was the one member of the renderer's own documented config surface it omitted. Measured at the `.objectui-sha` pin `53ded82b`: objectui's `ObjectMapConfigSchema` (`packages/types/src/zod/objectql.zod.ts:562`) declares all eight keys, `getMapConfig` reads `schema.mapStyle || schema.map?.style` (`packages/plugin-map/src/ObjectMap.tsx:365`), and objectui's own `content/docs/plugins/plugin-map.mdx:131` documents `style` inside the block. `ListMapConfigSchema.safeParse({ style: 'https://tiles.example/style.json' })` answered `success: false`, so a map style could not be declared through the spec's list-view face at all. Declared here under the director seat's decision batch #153 item 4 letter 1, confirmed by the maintainer verbatim 「其他同意」. + + **Clause-②: yes (widening)** — one new declared key on a published, strict accept set, so the set a consumer writes against grows. Nothing previously admitted is refused, and nothing is retired. Contract-review tier. + + - **`style`, not `mapStyle`, and not both.** Mapbox and MapLibre both call a style URL `style`, and that is the name the renderer reads inside the config block. The competing spelling — objectui#5017's dev warning teaching `map: { mapStyle }` — is corrected on the objectui side rather than learned here, and no alias is declared: an alias would be a permanent obligation for a key nobody has written yet. + - **Not the node-level `style`.** A component node's `style` is `BaseSchema.style`, an inline CSS record; the renderer stopped reading a top-level `style` as a map style at objectui#5017. The component-level `mapStyle` prop is unchanged and still wins when both are present. + - **`object-map.map` stops being `z.unknown()`.** That posture existed only because pointing the door at a schema missing `style` would have refused a value the renderer honours. With the gap closed, the door takes the spec's own block — so a misspelling inside an authored `map` block is now refused at `map`, by name, instead of passing through an open value. The two pins that recorded the divergence are inverted in the same change. + - **The generated projections move with it** — `authorable-surface/ui.json` gains `ui/ListMapConfig:style`, and `content/docs/references/ui/view.mdx` plus `content/docs/references/ui/component.mdx` gain the key; the `object-map.map` row in the component reference changes from `any` to the block's real shape and gains a nested-shape table. +- b9d5422: `UserSchema.image` and `OrganizationSchema.logo` are declared `z.string().url().nullish()` — a URL string, `null`, or the key absent are all accepted — so the user and organization bodies this platform serves parse against the schemas it publishes (#18509). + + Both were `z.string().url().optional()`: a URL string or the key's absence, and `null` refused. Both columns are better-auth-owned and nullable — `sys_user.image` and `sys_organization.logo` are each `Field.url({ required: false })`, reaching SQLite as `varchar(255)` with `notnull=0` — and better-auth SELECTs them and serialises them present-and-null for a user who never set an avatar and an organization created without a logo. + + Measured through a real `AuthManager` (better-auth 1.7.3) over a real `ObjectQL` on a real `SqliteWasmDriver`, with the platform's own `sys_user` / `sys_organization` object definitions: + + ``` + /auth/sign-up/email -> user.image = null + /auth/get-session -> user.image = null + /auth/organization/create -> logo = null + /auth/organization/list -> [0].logo = null + /auth/organization/get-full-organization + -> logo = null + -> members[].user.image = null + + UserSchema.safeParse() + -> [{ path: ["image"], code: "invalid_type", + message: "Invalid input: expected string, received null" }] + OrganizationSchema.safeParse() + -> [{ path: ["logo"], code: "invalid_type", + message: "Invalid input: expected string, received null" }, … ] + ``` + + Those two paths now parse. + + - **Measured, not inferred.** #18509 exists because PR #18501's contract review named these two siblings as *not measured* rather than folding them into the `SessionUserSchema.image` ruling it had. The verdict here comes from the probe above, run the way that ruling's own evidence was taken; the analogy was only ever a reason to look. + - **The declaration was the thing that was wrong.** Prime Directive #12's default — fix the producer, never widen the consumer — rests on the premise it states out loud, that we own both ends. We do not: the nullable columns belong to a third-party model, so PD #12's own exit clause is the operative sentence. + - **A pure widening.** `.nullish()`, not `.nullable()`: the key's ABSENCE is a legal shape today, so `.nullable()` would retire a live shape as the price of admitting `null`. Every body legal before this change is still legal. + - **`.url()` is kept, and it does not fight `null`.** These two declarations carry `.url()`, which `SessionUserSchema.image` did not, so the question had to be answered rather than copied. `.nullish()` wraps the whole `z.string().url()`: `null` and `undefined` are separate branches the URL check never sees, while a present string is still required to be a well-formed URL. Of six inputs — absent, `null`, `''`, a URL, a non-URL, a number — exactly one row moves, and it is the ruled one. `''` and `'not-a-url'` are still refused. + - **No key is added or removed** — both keys were already authored and already published, so no authorable surface moves and nothing is retired. + - **`OrganizationSchema` is not made whole by this.** The same probe found `metadata` served present-and-null and `/auth/organization/create` omitting the required `updatedAt`. Those are separate defects with their own reasoning, filed separately rather than folded in; #18509 asked about `logo`. +- 627382b: Publish the object-permission VERB vocabulary and the effective-entry reader from `@objectstack/spec/security`. + + `Clause-②: yes` — new exported names on a published surface. Purely additive: no export is removed, renamed or narrowed, and no schema changes shape. + + **New exports** + + - `OBJECT_PERMISSION_VERBS` — the closed verb → `allow*` bit table. Derived from the bare verbs of the object-permission key aliases (`read`, `create`, `edit`/`update`/`write`, `delete`/`remove`, `export`, `transfer`) plus one row that is not derivable and is recorded as a deliberate choice: `import` → `allowCreate`, because importing rows is creating rows. `restore` / `purge` are absent, as they are on the alias table since their bits were retired. + - `OBJECT_PERMISSION_VERB_NAMES` — the same vocabulary, sorted, for a refusal message to name in full. + - `resolveObjectPermissionVerb(verb)` — the only supported read of the table. Use it rather than indexing the record: a direct index answers `toString` with a function, which a truthiness check reads as a grant. + - `objectPermissionGrants(permission, target)` — whether one `EffectiveObjectPermission` entry grants a bit, folded the way the enforcement path folds it: `viewAllRecords` or `modifyAllRecords` grants read; `modifyAllRecords` grants edit, delete and transfer but never create; `export` is `grant ∧ read`. An absent entry and an all-`false` entry both answer `false`. + - `ObjectPermissionVerbTarget` — the `allow*` bit type a verb can resolve to. + + **Why they are published**: `@objectstack/formula`'s new `current_user.can(object, verb)` predicate reads a `/auth/me/permissions` map, and a client rendering the same capability reads the same map. One table and one fold, published once, so the predicate an author writes and the 403 the server returns cannot answer differently. +- c23cfb3: feat(spec)!: split the assembled-stage package API declarations off `@objectstack/spec/api` into the new `@objectstack/spec/api-assembled` entry (#18576) + + **BREAKING** — five Package API declarations, with their types, are no longer exported from `@objectstack/spec/api`. They are exported, unchanged, from the new entry `@objectstack/spec/api-assembled`. + + A `major`-class change — an existing import path stops resolving for these names — recorded as `minor` under the launch-window convention. Maintainer ruling on #18576, batch #145 item 1, letter B, 「同意,其他也同意」. + + **Why.** These five declarations embed the ASSEMBLED package body, which reaches the whole metadata vocabulary and, behind it, the datasource declaration and the driver-config validators. While they were declared inside `@objectstack/spec/api`, that tree was part of every bundle of the entry, and the entry ships as one self-contained bundle that a consumer's tree-shaking can recover little of. A browser module that imports two string constants from `@objectstack/spec/api` paid for all of it. Measured on the splitting PR (esbuild 0.28.2, `platform: browser`, conditions `browser` + `import`, minified, gzip -9), for objectui's `@object-ui/core` `column-sortability.ts`, which imports only those two constants: **311,124 → 166,529 bytes gzipped (−46.5%)**. The `./api` entry bundle itself goes from 612,813 to 469,795 bytes gzipped, and its module graph no longer reaches `stack.zod`, the datasource declaration or any driver-config module. `./api` also no longer needs a `browser` export condition, since nothing in its graph links the server-only pg URL grammar any more; the condition moves to `./api-assembled`. + + ### FROM → TO + + | removed from `@objectstack/spec/api` | import instead from | + | --- | --- | + | `AssembledInstalledPackageSchema`, `AssembledInstalledPackage`, `AssembledInstalledPackageParsed` | `@objectstack/spec/api-assembled` | + | `InstalledPackageAtEitherStageSchema`, `InstalledPackageAtEitherStage`, `InstalledPackageAtEitherStageParsed` | `@objectstack/spec/api-assembled` | + | `ListInstalledPackagesResponseSchema`, `ListInstalledPackagesResponse`, `ListInstalledPackagesResponseParsed` | `@objectstack/spec/api-assembled` | + | `GetInstalledPackageResponseSchema`, `GetInstalledPackageResponse`, `GetInstalledPackageResponseParsed` | `@objectstack/spec/api-assembled` | + | `PackageApiContracts` | `@objectstack/spec/api-assembled` | + + **The one-line fix: change the import path.** + + ```ts + // before + import { ListInstalledPackagesResponseSchema } from '@objectstack/spec/api'; + // after + import { ListInstalledPackagesResponseSchema } from '@objectstack/spec/api-assembled'; + ``` + + The compiler finds every site: `TS2305` ("Module '"@objectstack/spec/api"' has no exported member …"), or `TS2724` with a did-you-mean when a similarly named export exists — measured on the splitting PR, `ListInstalledPackagesResponseSchema` from `/api` answers `TS2724 … Did you mean 'InstallPackageResponseSchema'?`, which is NOT the name you want. Nothing else changes: every schema parses and refuses exactly what it did, `PackageApiContracts` keeps its four entries, and the JSON Schema ids are the same (`json-schema/api/AssembledInstalledPackage.json` and its three siblings are still published under `api/`, and still documented in the API reference). Every other Package API declaration — the two read doors' request schemas, the install / uninstall / upgrade / rollback shapes and `PackageApiErrorCode` — stays on `@objectstack/spec/api`. If you only use those, or any other `/api` contract, you need to do nothing. + + ⚠️ **Out-of-repo consumers are NOT MEASURED beyond objectui.** Inside this repository the moved names had four importers (the client's type import, one runtime conformance test, the client's return-type pins and the spec's own unit test), all moved in the same PR. objectui at the pinned `.objectui-sha` imports none of the moved names from anywhere; its six browser-shipped files that import `@objectstack/spec/api` keep resolving every name they use, from `/api` itself. `@object-ui/types` re-exports `@objectstack/spec/api` as a type-only `API` namespace, which loses the moved names with this release; objectui itself references none of them through it. The `cloud` repository was not measured. + + The ADR-0087 D3 semantic entry `api-assembled-entry-split` carries the judgement: an import path is TypeScript source, not metadata, so there is no source a D2 conversion could rewrite. + + Clause-②: yes (narrowing) + + +- 596090e: `enableOnInstall` is declared in three published schemas; each one now says which of the three governs it, and the two that are not the authority say what they are (#18605). + + The install door already honours the key — `POST /api/v1/packages` moves the registry row through the same verbs `PATCH /packages/:id/enable` and `PATCH /packages/:id/disable` use: `true` enables, `false` disables, and an ABSENT key makes no lifecycle call at all, so the row the registry returned stands (#18058). What was left was three declarations that looked identical (`z.boolean().default(true)`, same description) with nothing saying which one an author should read. + + Clause-②: yes + + **The authority** + + `PackageInstallRequestSchema` (`api/package-api.zod.ts`) is the one authority, because it is the request contract of the door that honours the key. Its published description now says so, naming the door that honours the key and the three states it honours. Its doc block carries the map to the other two, so a reader never has to guess which of three identical-looking declarations governs. + + **`kernel/InstallPackageRequest.enableOnInstall` — a COPY of the request key** + + Same type, same optionality, same meaning, restated on the in-process protocol primitive `ObjectStackProtocol.installPackage`. Its published description now records what this layer does with it: the implementation honours the key on the registry row (`true` enables, `false` disables, an ABSENT key makes no lifecycle call at all, tested `=== true` / `=== false` so absence is never collapsed into either), and the HTTP door does not forward the key down that seam — it calls `installPackage({ manifest, settings })` and performs the enable/disable flip itself, because the durable half must follow the row that door returned rather than the request's intent. + + The copy is held to the authority by a **parity pin** rather than by a structural reference. The structural spelling is not available in this direction: the authority is built from `ManifestSchema` and `InstalledPackageSchema`, both declared in `kernel/package-registry.zod.ts`, so `PackageInstallRequestSchema.shape.enableOnInstall` spelled there is an import cycle, and under `OS_EAGER_SCHEMAS=1` — the mode `gen:schema` and `check:authorable-surface` run in — it dies with `ReferenceError: Cannot access 'InstalledPackageSchema' before initialization`. `api/package-install-one-authority.test.ts` parses both declarations over one matrix (absent, `false`, `true`, a string, `null`) and reds on any cell where they disagree. + + **`marketplace/MarketplaceInstallRequest.enableOnInstall` — not this key at all** + + It stays, and its published description says what it is: the marketplace channel's own install option. That request's subject is a listing (`listingId`, `version`, `licenseKey`, `tenantId`), not a manifest; its door is the control plane's `POST /api/v1/marketplace/install`, of which a runtime mounts only a read-only proxy; and the channel resolves the artefact and validates the licence before mapping what it holds into a platform install. It is one translation upstream of the door key, owned by a different party on a different release cadence, so folding it would let a narrowing at the platform door silently narrow a control-plane contract. + + **What does not move** + + No key is added, removed, renamed or retyped, and no default changes: the accept set of all three schemas is byte-for-byte what it was, and `api-surface`, `authorable-surface` and `authorable-defaults` are all unchanged. What moves is the published description text of three keys and the reference pages generated from it. The `Clause-②` declaration is `yes` as the conservative arm, because three published declarations' stated meaning moves. +- 5380daa: **BREAKING** — retire `CubeJoin.sql` and `CubeJoin.relationship`. A cube join declares + WHICH object it reaches; the ON clause is derived from the declared relationship between + the two cubes' objects and is never authored. + + `CubeJoin.sql` was **required** and described itself as the `ON` clause, and nothing ever + read it. Both analytics strategies synthesise the join: `NativeSQLStrategy` emits + `LEFT JOIN ON ""."" = ""."id"` from the dotted member + path alone, and `ObjectQLStrategy` resolves the join through `cube.joins?.[alias]?.name` and + lowers it to a relationship traversal with no `ON` clause at all. So an authored join + condition was not ignored — it was **replaced**, under a `200`, by an equality the author had + not asked for, with a plausible number attached. `relationship` is the same shape one key + over: it carried a `.default('many_to_one')`, nothing dispatched on the cardinality, and + `one_to_many` parsed, changed no SQL and kept the many-to-one arithmetic. + + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18 (director batch #154 item 4, + letter 2). The ruling declined the other remedy — executing the author's SQL — as a new + capability whose first design question is an injection boundary, for zero authors today. A + custom join condition, if a customer needs one, is a capability card with that boundary + decided first. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `joins: { account: { name: 'crm_account', relationship: 'many_to_one', sql: '${orders}.account = ${crm_account}.id' } }` | `joins: { account: { name: 'crm_account' } }` — delete both keys | + | `joins: { a: { name: 'b', relationship: 'one_to_many' } }` | `joins: { a: { name: 'b' } }` — the cardinality was never read; declare it on the object's own relationship field | + | `joins: { a: { name: 'b', on: '…' } }` | `joins: { a: { name: 'b' } }` — `on` was the curated alias for `sql` and is retired with it | + + **The one-line fix:** delete `sql` and `relationship` from every `joins` entry; keep `name`. + + Nothing regresses by deleting them: neither key ever reached a query. What decides the join + is `name` (the joined object, which is also what the per-object RLS/tenant read scope is + computed for) and the declared relationship the runtime derives the equality from. + + ## The retirement kit + + - **Strict deletion plus a `guidance` prescription, not a `retiredKey()` tombstone.** Every + cube shape is a `strictObject`, so the key leaves the walked shape entirely and the + refusal carries the upgrade: writing `sql`, `relationship` or `on` on a join is an + `unrecognized_keys` rejection whose message names the key and states that the `ON` clause + is DERIVED from the declared relationship between the two cubes' objects. Same route + `MetricSchema.filters` took one shape over in this same file. + - **`on` is no longer an alias.** It pointed at `sql`; an alias naming a key the shape + cannot accept answers an author with a second rejection, so it became a `guidance` entry + of its own and the rename suggestion is gone. Pinned in both directions. + - **ADR-0087: a D2 conversion AND a D3 semantic entry**, plus the two exact-key + registrations `data/CubeJoin:sql` and `data/CubeJoin:relationship` in + `RETIRED_KEYS_BY_MAJOR[18]`. The conversion is + `cube-join-sql-and-relationship-removed` (`toMajor: 18`, + `retiredFromLoadPath: true`), chained into step 18: it strips both keys from every + `analyticsCubes[].joins.*` wherever the chain is replayed, one notice per stripped site, + each naming the cube that lost the key. It is owed because the removal is measured + against **metadata at rest**, not only against sources: `sql` was required and + `relationship` was defaulted, so every cube artifact ever written from the old schema's + own parse output carries both keys, and the boot door + (`ObjectStackDefinitionSchema` → `analyticsCubes: z.array(CubeSchema)`) would otherwise + refuse it with no remedy short of hand-editing JSON. The strip is lossless in the only + sense that applies: a key that never had an effect has none to lose. The D3 entry + `cube-join-sql-and-relationship-retired` stays as the human-facing record — the strip + removes the key, the entry says why an author who wrote a non-FK `sql` should re-read the + numbers that join produced. + - **The `os migrate meta --from 17` sentence** closes all three prescriptions, which is what + a covered surface owes. + - **The `joins` record KEY is documented.** `name`'s describe now states that the key a join + is declared under is the FOREIGN-KEY FIELD on the cube's own base object — the column the + derived `ON` reads — not a second spelling of the object the join reaches. + - **The liveness ledger rows went WITH the keys** (`liveness/analytics_cube.json`), which is + the strict-deletion route's disposition — the opposite of the tombstone route, which keeps + the row because `retiredKey()` keeps the key in the walked shape. `analytics_cube` drops + from 12 `dead` to 10. + - **The one in-repo producer is fixed in the same diff.** `examples/app-showcase`'s + `DeliveryCube` authored both keys, including an `ON` clause the runtime was replacing; + `dataset-compiler.ts` minted them as two constants no reader consulted. Its join was also + keyed `showcase_project` — the object it reaches — while `showcase_task`'s foreign key is + `project`, so the derived `ON` named a column the base object does not have and the join + never resolved. It is re-keyed `project` here and pinned against the object's own field + map. + + Clause-②: yes (narrowing) + + +- 7056ca5: `record:related_list.columns` now declares the SAME union the saved-view key declares — `z.union([z.array(z.string()), z.array(ListColumnSchema)])` — so a saved view's per-column decoration reaches the related list instead of being refused at the block door (#18639, the upstream half of objectui#9593). + + **Clause-②: yes (widening)** — one published accept set grows: the key admitted `string[]` and now also admits `ListColumn[]`. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write the new arm. Contract-review tier. + + Two published declarations disagreed about one key. `RecordRelatedListProps.columns` (`ui/component.zod.ts`) was `z.array(z.string())`, while `listViews[].columns` (`ui/view.zod.ts`) was already the union — and objectui composes a saved view's `columns` onto this block **verbatim** (`dataSource.view` → `composeElementDataSource` → `savedViewColumns`). A view whose columns carried `label` / `width` / `hidden` / `summary` therefore arrived at a block that declared it could not carry them. + + - **The same union, by reference — not a lookalike.** `ListColumnSchema` is imported from the view face rather than re-spelled, so the object arm is one def with two carriers. The pin asserts reference identity on both sides and then asserts block and saved view return the same verdict for every fixture: two spellings of one key is the defect this closes, so a second spelling would not have fixed it. + - **The arms are exclusive, and the description says so because the schema enforces it.** `['name', { field: 'amount' }]` matches neither arm and is refused. The decoration also survives the parse — a description promising keys a parse strips would be the same defect one layer up, so the pin asserts the parsed value, not merely `success`. + - **Unchanged, by ruling and by measurement.** `field.relatedListColumns` stays child field-name STRINGS only and still refuses a column object with its derivation prescription, and the `field-column-lists-canonicalized` conversion still folds an object entry on that key to its identity string. Both are pinned next to the widening so the fences cannot erode quietly. + + No migration: authors writing `string[]` are unaffected, and the new arm is opt-in. +- 731f020: feat(spec)!: `FileValue.duration` and `CompatibilityMatrixEntry.estimatedMigrationTime` carry their unit in the key name (#18669, ruling A) + + + + **BREAKING** — two duration-shaped `z.number()` keys are renamed. No value type moves, no key is + removed from the contract, and nothing already stored is narrowed. + + | def | before | after | + |:--|:--|:--| + | `data/FileValue` | `duration: 12` | `durationSeconds: 12` | + | `kernel/CompatibilityMatrixEntry` | `estimatedMigrationTime: 8` | `estimatedMigrationTimeHours: 8` | + + Maintainer ruling A on #18669 (2026-09-17, decision batch #151 item 4): rename each key, with an + ADR-0087 conversion-layer entry each — ⛔ no new closed type, ⛔ no narrowing of stored data. + + ## Why each bare name was worth a rename + + `FileValue.duration` declared its unit in **no channel at all** — no `.describe()`, no JSDoc, no + unit token in the key — so the published reference page printed a bare number and the authoring + site printed nothing. The company it kept is what makes it a trap rather than an omission: the + only other number on `FileValue` is `size`, a **byte** count, so the one member that measured + time was indistinguishable from a count at the site an author (very often a model, ADR-0033) + writes it. + + `CompatibilityMatrixEntry.estimatedMigrationTime` said *"Estimated migration time in hours"* in a + source **JSDoc** and carried no `.describe()` — the #15939 shape, one def over. The JSDoc stops at + the source file; `.describe()` is what `content/docs/references/**` renders, so the published page + printed a bare number directly beside `migrationComplexity`, whose scale *is* named + (`trivial`/`simple`/`moderate`/`complex`/`major`). A reader comparing `major` with `40` could not + tell minutes from hours from days. + + ## What an author must change + + Rename the key. **Nothing else** — both values keep the type they had. + + ```diff + // an expanded file/image/avatar/video/audio value + { + url: 'https://cdn.example.com/files/clip.mp4', + - duration: 12.34, + + durationSeconds: 12.34, + } + + // a plugin compatibility-matrix entry + { + from: '1.9.0', to: '2.0.0', compatibility: 'breaking-changes', + - estimatedMigrationTime: 8, + + estimatedMigrationTimeHours: 8, + } + ``` + + ## Deliberately NOT narrowed + + Both keys stay `z.number().optional()`. `durationSeconds: 12.34` still parses — a fractional + second is the ordinary shape of a media length — so the closed `DurationSeconds` type + (`z.number().int().nonnegative()`, #18122) was **refused** by the ruling, and so was a bare + `.int()`. `FileValue` is one of the six rows #18122 derived its unit set from; it is the one that + takes a **name** instead of a type. `estimatedMigrationTimeHours` keeps `hours` rather than + converting to seconds, for the same reason: the value does not move. + + The hours key also gains `.describe('Estimated migration time in hours')`, and that half is not + cosmetic — renaming alone would leave the key name and a source comment agreeing about a unit the + published page does not print, which `check:duration-unit-keys` refuses as + `unit-in-jsdoc-not-in-describe` (ruled an offence 2026-09-18, decision batch #158 item 5, letter + A). `FileValue.durationSeconds` gains `.describe('Media duration in seconds')` for the same + reader. + + ## The kit + + - a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value reaching + the parse raises the **rename prescription** instead of vanishing. Neither enclosing shape is + `.strict()`: `CompatibilityMatrixEntrySchema` is a plain `z.object` and would have stripped the + old spelling in silence, and `FileValueSchema` is the one deliberate `z.looseObject` in + `field-value.zod.ts` and would have waved it through as an unrecognised extra + - two ADR-0087 D3 semantic entries and two `RETIRED_KEYS_BY_MAJOR[18]` rows. **No D2 conversion** + for either: `FileValueSchema` is the ADR-0104 D3 wave-2 *expanded read* form, derived at read + time from a `sys_file` id (the stored form is `FileReferenceIdValueSchema`, an opaque string), + and a plugin compatibility matrix is a published version manifest that `stack.zod.ts` declares + no collection of — neither is ever a stored `sys_metadata` row, so the chain has no seam that + would see one + - both authorable-surface rows move: each becomes ` [RETIRED]` beside its renamed row, since + both are **top-level** properties of their def + + Clause-②: yes +- 5eebc9e: **BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the banned-key rule the tracing sampling filter enforces, so a validator reading the published files stops answering PASS on `{ "dialect": "cel" }` at `TraceSamplingConfig.composite[].condition` — the card's own worked instance of a published file saying yes to metadata the runtime refuses (#18670 item 2, the fourth of the ruling's named arms). + + Clause-②: yes (narrowing) + + One named pattern joins the closed list, and only one: + + - **`banned-keys` — "no document may carry any of these keys"**, emitted as `propertyNames` with a `not` over the banned names. `TraceSamplingConfig.composite[].condition` is a structured filter of match criteria that refuses an object carrying `dialect`, because such an object is an expression attempt and this slot's expression arm was retired in 17.5.0. The published file now says so. + + **The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json` goes from 202 entries / 553 sites to **200 entries / 551 sites**: + + | row | before | after | + |:---|:---|:---| + | `system/TraceSamplingConfig` | `sites: ["composite.element.condition"]` | **deleted** — the schema drops nothing now | + | `system/TracingConfig` | `sites: ["sampling.composite.element.condition"]` | **deleted** — the same node, reached through the parent | + + 2 sites closed, **0 sites added anywhere**, and the ledger diff is deletions only. Generator census after: 551 dropped across 200 published schemas, **357 projected** — 224 `non-blank-string`, 129 `required-one-of`, 2 `dependent-required`, **2 `banned-keys`** — 9 undecidable. + + **⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** The arm is EXACT rather than approximate: a JSON object's properties are exactly its own enumerable string-keyed ones and `propertyNames` judges exactly those names, so "none of the banned names is an own property" and "no property name is one of the banned names" are one sentence read from two ends. It is presence and never value — a banned key present with a `null` value is present on both sides. The accept set at the slot is **unchanged in both directions**: every document the runtime takes (`{}`, `{ "service": "api" }`, any filter carrying no `dialect` key) the file still takes, and every document the runtime refuses the file now refuses too — a `dialect`-bearing object of any shape, the CEL envelope included, since that arm is retired and nothing here revives it. Across the published tree, **1528 of the 1530 per-schema files are byte-identical**; the two that move gain the ban and lose the matching `x-dropped-refinements` row, and nothing else in either file changes. + + **The list stays CLOSED.** `packages/spec/src/shared/refinement-projection.ts` declares the vocabulary and builds each predicate from its own declaration — the key list is read once and used by both the published keyword and the enforced rule — so the two cannot name different keys. The predicate judges OWN properties and never `key in value`: `in` walks the prototype chain, so a ban on a name `Object.prototype` carries would refuse `{}` itself while `propertyNames` accepts it, and that is a disagreement about a JSON document rather than an edge outside the domain. A ban over an OPEN set of names — every key starting with `$`, which is what `data/filter.zod.ts`'s normalized field condition refuses — is deliberately not this arm: its keys are a finite list, and a list that merely sampled an open set would be wider than the rule, so those sites stay unprojected — and because the detector reads them `undecidable` rather than `dropped`, they carry NO annotation and hold NO ledger row: published yet unratcheted. + + +- 72c1640: **BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the cert/key pairing rule on SSL driver configuration, so a validator reading the published files stops answering PASS on a half-configured client certificate the platform then refuses (#18670 item 2, the third of the ruling's four named arms). + + Clause-②: yes (narrowing) + + One named pattern joins the closed list, and only one: + + - **`dependentRequired` — "whenever this key is present, those keys must be present too"**, emitted as JSON Schema's own `dependentRequired`. `SSLConfig`'s rule that a client certificate and its private key are provided together is precisely `dependentRequired { cert: ['key'], key: ['cert'] }`, so the file now states it. + + **The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json` goes from 201 entries / 553 sites to **200 entries / 551 sites**: + + | row | before | after | + |:---|:---|:---| + | `data/SSLConfig` | `sites: [""]` | **deleted** — the schema drops nothing now | + | `data/SQLDriverConfig` | `sites: ["", "sslConfig"]` | `sites: [""]` — the `sslConfig` site closed | + + 2 sites closed, **0 sites added anywhere**, and the ledger diff is deletions only. `data/SQLDriverConfig`'s remaining `""` site is its own separate rule — "`sslConfig` is required when `ssl` is **true**" — which judges a VALUE rather than key presence, is `if`/`then` rather than this arm, and stays dropped and annotated as `x-dropped-refinements`. + + **⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** The arm is EXACT rather than approximate: a key absent from a JSON object is the only way for its value to read `undefined`, and `dependentRequired` triggers on presence, so a key present with any JSON value — `null` included — arms its dependency exactly as the predicate's `!== undefined` does. Measured over a 10,368-document corpus across both affected schemas: the runtime verdict vector is byte-identical before and after (lit control — weakening the dependency map to one direction moves 96 documents), and of the 36 documents the published files stop accepting, **zero** are documents the runtime accepts. Across the whole published tree, 1530 of 1532 files are byte-identical; the two that move gain `dependentRequired` and lose the matching `x-dropped-refinements` row. + + **The list stays CLOSED.** `packages/spec/src/shared/refinement-projection.ts` declares the vocabulary and builds each predicate from its own declaration — the dependency map is read once and used by both the published keyword and the enforced rule — so the two cannot name different keys. A refinement outside the list stays unprojected and keeps its annotation. `propertyNames` / `not` for banned keys remains untaken: the tree carries no candidate whose rule is mechanically derivable, so no arm was constructed for it. + + **Two mechanism repairs ship with it**, both invisible in the published output and both load-bearing from this arm onward. The detector's verdict was reached per NODE while refinements are per CHECK, so a node carrying a declared arm beside an undeclared rule read `projected` outright and the undeclared rule reached neither the ledger nor the annotation; `projected` now requires every check on the node to be declared, and the generator reports partially-stated sites on their own line. And the generator and the detector each passed the projection `override` for themselves — dropping it on the generator side alone left every site reading `projected` behind a green ledger while the published file silently went wide — so both now reach `z.toJSONSchema` through one shared call with no argument left to forget. + + +- 5e5ec9f: **BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states two of the rules it used to leave entirely to the runtime, so a validator reading the published files stops answering PASS on metadata the platform then refuses (#18670 item 2). + + Clause-②: yes (narrowing) + + `z.toJSONSchema()` has no arm for a `custom` check: on zod 4.4.3 a plain record, the same record with a `.refine()`, and the same record with an **aborting** `.refine()` all project byte-identically. Every rule written as a refinement was therefore enforced by the runtime and absent from the published file — the direction in which an author's, or an AI's, validator says yes right up to the moment the platform says no. + + Two named patterns now project, and only those two: + + - **at least one of these keys is present** — emitted as `anyOf` of one `required` per key. `shared/Expression.json` states the source-or-ast rule, so `{ "dialect": "cel" }` is refused by the published file exactly as the runtime already refused it. + - **a string with at least one non-whitespace character** — emitted as `minLength: 1` plus the pattern `\S`. Every evaluated and typed expression slot states it, so a whitespace-only `source` is refused at the door. + + **⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** Both patterns are EXACT rather than approximate: a key absent from a JSON object is the only way for its value to read `undefined`, and `String.prototype.trim` removes exactly the ECMA-262 whitespace set that `\S` is the complement of. Both equalities are pinned over their whole input space in `packages/spec/scripts/refinement-projection.test.ts`, including every ECMA-262 WhiteSpace and LineTerminator code point. No refinement was weakened, removed or added; the runtime accepts and refuses exactly what it did before. + + **The list is CLOSED.** `packages/spec/src/shared/refinement-projection.ts` declares the vocabulary and builds each predicate from its own declaration, so the rule the runtime enforces and the keywords the file publishes cannot name different things. A refinement outside that list stays unprojected and keeps its `x-dropped-refinements` annotation. Adding an arm is a public-contract decision with its own measurement, never a refactor — and ⛔ never an open-ended zod-to-JSON-Schema translator over the whole population. + + **Proof of work, in the shrink-only ledger.** `packages/spec/dropped-refinements.baseline.json` reads 201 published schemas / 553 dropped sites, from 246 / 750: 45 rows deleted, 75 rows shrunk, 197 sites closed, zero sites added anywhere. The generator now prints the closed population per pattern on every run (137 `required-one-of`, 60 `non-blank-string`), and reports a site that projects with no declared pattern on its own line. + + +- 170fd83: **BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the `$`-prefix key ban a normalized field condition enforces, so a validator reading the published files stops answering PASS on `{"$and":[{"$bogus":{"$eq":1}}]}` at `data/NormalizedFilter` — a document the runtime refuses by name (#18670 item 2, the fifth arm). + + Clause-②: yes (narrowing) + + One named pattern joins the closed list, and only one: + + - **`banned-key-pattern` — "no document may carry a key matching this pattern"**, emitted as `propertyNames` with a `not` over a `pattern`. `NormalizedFilter`'s `$and` / `$or` members and its `$not` operand each admit a field condition whose keys are field names (`amount`, `account.name`) and never `$`-prefixed operators. The published file now says so at all three nodes. + + **Scoped, and the scope is mechanical.** The ban is over an OPEN set of names, which is why the existing `banned-keys` arm cannot express it — a finite list that merely sampled the set would be wider than the rule. The pattern arm that can express it is bounded by a second closed list: `BannedKeyPattern` is a union of the pattern strings this package publishes, exactly one today (`^\$`), so a call site cannot invent a regex because there is no `string` to pass, and widening it is the same reviewed decision that adding an arm is. That is what answers the standing objection to a regex-shaped declaration — its over-reach cannot be read off the declaration the way a key list's can, so the bound is on how few declarations exist rather than on trusting the next caller. + + **The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json`, entry `data/NormalizedFilter`: + + | row | before | after | + |:---|:---|:---| + | `lazy.$and.element.options[0]` | dropped | **deleted** — reads `projected`, arm `banned-key-pattern` | + | `lazy.$or.element.options[0]` | dropped | **deleted** — reads `projected`, arm `banned-key-pattern` | + | `lazy.$not.options[0]` | dropped | **deleted** — reads `projected`, arm `banned-key-pattern` | + + **⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** The arm is EXACT rather than approximate. A JSON object's properties are exactly its own enumerable string-keyed ones and `propertyNames` judges exactly those names; JSON Schema specifies `pattern` as an ECMA-262 regular expression evaluated as a SEARCH, which is `RegExp.prototype.test` and nothing else — so the same source text decides the same set of names on both sides. It is presence and never value: a matching key present with a `null` value is present to both. Measured with ajv 8 (draft 2020-12) on the generated file, the verdict vector moves in one direction only: the three `$`-prefixed specimens go `true` → `false`, and every document the runtime accepts — `{}`, the empty combinators `{"$and":[]}` / `{"$or":[{}]}` / `{"$not":{}}`, a nested group, an ordinary field condition — is accepted before and after. Across the published tree, **1530 of 1535 files are byte-identical**; one file changes what it accepts, two change annotation only, and the remaining two are the bundle and the build-input hash. + + **The predicate and the keyword are ONE string.** `bannedKeyPattern` compiles its `RegExp` from the declared pattern, so the keyword the file publishes and the rule the runtime enforces cannot come to mean different things — the construction `requiredOneOf`, `dependentRequired` and `bannedKeys` already use, and the reason this arm needs no drift pin either. The `RegExp` carries no flags, which is part of the equality rather than a style choice: a JSON Schema `pattern` has none to carry, and `g` would make `test` stateful through `lastIndex` so a key's verdict would depend on which keys were judged before it. + + **A ratchet repair ships with it, and it is what made the rows exist to delete.** The detector decided `dropped` vs `projected` on a two-rung projection ladder while the generator publishes on a three-rung one — a node whose every io direction refuses over an unrepresentable member still reaches its file when that member sits in a union position, because the emit loop drops the branch and publishes the rest. Nine PUBLISHED sites therefore read `undecidable`, the one verdict the ledger does not count: they held no row, carried no `x-dropped-refinements`, and no repair of them could ever have deleted a row. The three nodes this arm closes were three of the nine. The detector now carries the generator's third rung and reports which rung answered, so a differential can never compare a pruned projection with an unpruned one; and a published site that still cannot be adjudicated fails the build by name, so the blind spot cannot reopen in silence. + + ⚠️ **The ledger therefore GREW before it shrank, and the growth is the point.** Six sites became visible that were previously uncounted — `data/FieldOperators` and `data/RangeOperator` gained `$between.items[0]` / `[1]`, `data/NormalizedFilter` gained the same pair under `$not`, and `data/RangeOperator` entered the ledger as a published schema that had been holding no entry at all — then this arm deleted three. Net across the change: **204 entries / 560 sites → 205 / 566**, with the census at **566 dropped / 205 published schemas / 360 projected** (224 `non-blank-string`, 129 `required-one-of`, 3 `banned-key-pattern`, 2 `dependent-required`, 2 `banned-keys`) and **0 undecidable**, down from 9. Those two files gain annotation only: `x-` keywords are ignored by every validator, so the set of documents they accept is unchanged. + + ⭐ **Superseding a sibling entry in this same release.** `18670-project-banned-keys.md` records that the `$`-prefix sites 「stay unprojected … they carry NO annotation and hold NO ledger row: published yet unratcheted」. That was a correct reading of its own tree and is no longer true of this one: the sites are projected, the blind spot is closed, and the population it described is empty. The earlier entry is left as the record of what it landed. + + +- 2cac363: feat(spec): one declaration per version grammar — eight regex carriers of "the version of a package or plugin" now reference three exported constants + + Clause-②: yes (widening) + + **No accept set moves, and that is the whole point of this change.** Eight sites + spelled a version regex out as a literal of their own. Five of those spellings + were byte-identical to each other, two more were byte-identical to each other, + and the eighth stood alone — three accept sets written eight times, growing on + their own: three of the eight were published schema declarations with no parse + caller at all, added by authors who copied a neighbour's literal. Each site now + references the constant carrying the pattern it already enforced, byte for byte. + A ninth in-repo carrier of the same concept spelled no regex at all: + `PackageManifestSchema.version` is a bare `z.string()`, and it stays one here. + + `@objectstack/spec/kernel` gains three exported patterns: + + - `MAJOR_MINOR_PATCH_VERSION_PATTERN` — three numeric segments and nothing + else. Referenced by `ManifestSchema.version`, + `MetadataPluginManifestSchema.version`, `PluginRegistryEntrySchema.version`, + `PluginMetadataSchema.version`, and the `PATCH /api/v1/packages/:id` door in + `@objectstack/runtime`. + - `SEMVER_SHAPED_VERSION_PATTERN` — `major.minor.patch` with an optional + `-prerelease` and an optional `+build` suffix, identifiers in either ASCII + case. Referenced by `PluginSchema.version` and by + `PluginLoader.isSemverShapedVersion` in `@objectstack/core`. Those two + converged on one spelling under the widen-never-narrow ruling and were held + equal by hand until now; they reference one declaration and can no longer + drift apart. + - `SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN` — the same with the suffix + identifiers restricted to lowercase ASCII. Referenced by + `PackageVersionSchema.version`. + + ⛔ **The three are not interchangeable** — they are three different accept sets, + and referencing the wrong one moves a published accept set. None of the three is + a SemVer 2.0.0 conformance check and none is named as one: two accept forms + SemVer forbids (leading zeroes in the numeric core, empty and leading-zero + identifiers), one refuses forms it requires. For ordering or precedence, + `dependency-resolver.ts` in `@objectstack/core` is still the module to extend. + + **Nothing an author can write changes.** Every regex is byte-identical to the + literal it replaces — verified per carrier by sha256 over the extracted literal + — and every existing suite passes unedited. Those two together are the + neutrality proof, and they are the whole of it. `PackageManifestSchema.version` + keeps its bare `z.string()`; it is deliberately untouched here. No `.describe()` + text, refusal message or JSON Schema `pattern` moves. Regenerating the spec's + artifacts moved `api-surface/kernel.json` and `export-origins/kernel.json` and + nothing else, each gaining the three constant names — ⛔ read that as a check + that nothing unexpected regenerated, never as evidence about the accept set: the + artifacts that stayed byte-unchanged do not record a `.regex()` pattern in the + first place. A new pin, + `src/kernel/version-grammar.test.ts`, records each grammar's verdict on twelve + witness strings so the next deliberate move to any of them is one visible edit + to one matrix. +- fc91239: feat(spec)!: the canon for "the version of a package or plugin" is SemVer 2.0.0 — nine carriers, one grammar + + Clause-②: yes (narrowing) + + + + **BREAKING** — four published accept sets converge on one, and the fringe each + of them carried outside SemVer 2.0.0 is refused. The widening half needs no + action from anyone; the narrowing half is listed per carrier below, with its + FROM → TO. + + One concept was judged by four different grammars across ten carriers in two + repositories, and the strictest refused `2.0.0-beta.1` — the exact string a + sibling declaration documented as an example of itself. The disagreement was + observable between doors on the same resource, not merely between schema files: + `os plugin build` refused a prerelease the publish door accepted, the Studio + form refused it twice over, the `PATCH` door answered `400`, and the install + door parsed nothing at all. An earlier change collapsed the eight regex literals + onto three exported constants, which removed the drift but not the disagreement. + + `@objectstack/spec/kernel` now exports ONE grammar — + `SEMVER_2_0_0_VERSION_PATTERN`, semver.org's own published expression — and + every carrier references it. + + ## What every author gains, with no edit + + Prerelease and build suffixes are accepted on the five carriers that demanded a + bare three-segment core, so `2.0.0-beta.1`, `17.0.0-rc.5`, `1.0.0+20230101` and + `1.0.0-rc.1+exp.sha.5114f85` now pass a key that refused all of them. Identifiers + are case-preserving everywhere, as the standard requires. This repository cuts + prereleases of its own packages while the key describing a package could not + express one; that ends here. + + ``` + FROM ManifestSchema.parse({ id: 'com.acme.crm', version: '2.0.0-beta.1', … }) + -> throws // and `os plugin build` exits 1 + + TO ManifestSchema.parse({ id: 'com.acme.crm', version: '2.0.0-beta.1', … }) + -> parses + ``` + + ## What stops being accepted, per carrier + + Eight strings, all of them forms SemVer 2.0.0 forbids and none of them a valid + prerelease. What they have in common is that no precedence order exists for any + of them — `dependency-resolver.ts` can place none in an order — so a package + versioned this way could be published and never compared against its own + successor. + + ``` + FROM version: '01.1.1' TO version: '1.1.1' // §2 no leading zero in + FROM version: '1.01.1' TO version: '1.1.1' // a numeric identifier + FROM version: '1.1.01' TO version: '1.1.1' + FROM version: '1.0.0-0123' TO version: '1.0.0-123' // §9 no leading zero in a + // numeric prerelease id + FROM version: '1.0.0-alpha..1' TO version: '1.0.0-alpha.1' // §9 no empty + FROM version: '1.0.0-alpha..' TO version: '1.0.0-alpha' // identifier + FROM version: '1.0.0-.' TO version: '1.0.0' + FROM version: '1.0.0+.' TO version: '1.0.0' // §10 no empty build id + ``` + + ⛔ Each repair above is one defensible reading and not the only one, which is + why they ship as ADR-0087 D3 semantic TODOs rather than as mechanical D2 + conversions: a version is how a release is addressed, so rewriting one + re-points whatever already resolved the old string. Run + `objectstack migrate meta --from ` for the per-site list. + + Per carrier: + + - `ManifestSchema.version` and its three sibling declarations + (`MetadataPluginManifestSchema`, `PluginRegistryEntrySchema`, + `PluginMetadataSchema`), plus the `PATCH /api/v1/packages/:id` door: gain the + whole prerelease and build space; lose a leading zero in the numeric core. + - `PluginSchema.version` and the plugin boot path in `@objectstack/core`: lose + those eight and **nothing else**. ⭐ Every valid prerelease and build form the + loader accepts today it still accepts, which is what keeps the widen-never- + narrow ruling on that path honoured rather than reversed; both halves of that + bound are pinned in `plugin.test.ts` and `plugin-loader.test.ts`. + - `PackageVersionSchema.version`: gains case-preserving identifiers + (`1.0.0-Beta.1`, `1.0.0+Build.5`), which the boot path has always accepted and + this key alone refused; loses the same eight. + - `PackageManifestSchema.version`: was a bare `z.string()` constraining nothing, + so it is the one carrier where the grammar is entirely new. `latest`, + `v1.0.0`, `1.0`, the empty string and `2.0.0-beta.1extra!` were accepted and + frozen into a published manifest snapshot; each is refused now. A dist-tag + becomes the version it pointed at, a `v`-prefix drops, a two-segment string + gains its patch. + + ## The prose moved with the grammar + + Every `.describe()` names SemVer 2.0.0 and the nine generated reference-doc rows + follow; the `PATCH` door's refusal says so; `manifest.test.ts`'s + 「should enforce semantic versioning」 case stops listing `1.0.0-beta` among the + invalid versions. `PluginLoader.isSemverShapedVersion` becomes `isSemverVersion` + — a predicate named for a standard it does not implement gets misused by the + next caller whatever its docblock says, and the name is true now. + + Three exported constants are retired, each replaced by the one canon: + + ``` + FROM import { MAJOR_MINOR_PATCH_VERSION_PATTERN } from '@objectstack/spec/kernel' + FROM import { SEMVER_SHAPED_VERSION_PATTERN } from '@objectstack/spec/kernel' + FROM import { SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN } from '@objectstack/spec/kernel' + TO import { SEMVER_2_0_0_VERSION_PATTERN } from '@objectstack/spec/kernel' + ``` + + ⛔ They are not interchangeable with what they replaced — each named an accept + set that no longer exists, which is why they are retired rather than aliased. A + consumer that referenced one to REPRODUCE a verdict gets the canon's verdict + now; one that referenced it to match a foreign grammar owns that grammar itself. + + The accept set is pinned witness by witness in `version-grammar.test.ts`: move a + cell there and you have moved a published accept set on nine carriers at once, + in one visible edit. +- e6c34f6: The identity read routes now serve what `@objectstack/spec/identity` declares: `metadata` arrives DECODED on every organization route that reads the row back, and `updatedAt` is declared optional on `Organization` / `Member` / `Invitation` — the shape better-auth's own serializer documents (#18728). + + Clause-②: yes (widening) — `updatedAt` moves from required to optional on three published schemas, so the set a consumer may hand to `OrganizationSchema` / `MemberSchema` / `InvitationSchema` grows by exactly one shape: the key being absent. Nothing previously admitted is refused, nothing is renamed, and no producer is required to write it. Contract-review tier. + + Three published schemas could not parse a served response. `OrganizationSchema` declared `updatedAt` required and `metadata` an object; the four organization read routes (`setActive`, `get`, `delete`, `list`) carried no `updatedAt` at all and served `metadata` as the stored JSON text. `@objectstack/client` had recorded that as three 「not relayed」 notes rather than as a defect, and with zero in-repo consumers nothing went red — the audience was entirely external. Maintainer ruling C (batch #158 item 4) fixed the producer and made the one remaining key conditional on a measurement, which is what decided each half: + + - **`metadata` is decoded at the producer, unconditionally** — it is our column. plugin-auth's data adapter decodes `sys_organization.metadata` out of its stored JSON text on its READ verbs, so all four routes serve the object the spec declares, and an unset column is OMITTED rather than sent as `null`. ⛔ The write verbs are deliberately untouched: better-auth's own organization adapter decodes the `create` / `update` echoes itself and discriminates on the value still being a string, so decoding there would fold the create echo's `metadata` to `undefined`. Both directions are pinned. + - **`updatedAt` aligns to the documented wire** — ruling C's own fallback A, and its two conditions were measured against the installed better-auth 1.7.3 rather than assumed. The routes are better-auth's endpoints mounted through a single catch-all, each answering `ctx.json(...)` with no ObjectStack post-processing; and the vendor's `organization`, `member` and `invitation` models declare no `updatedAt` field, while its adapter factory's output transform iterates the declared fields only, so an undeclared column is dropped before any route sees it. Control, in the same file: the vendor's `team` and `organizationRole` models DO declare `updatedAt`, so the absence is a reading. For `member` and `invitation` there is additionally no column to serve — `sys_member` and `sys_invitation` are `managedBy: 'better-auth'`, the one disposition under which the platform injects no audit family, and neither declares `updated_at` itself. + - **`@objectstack/client` relays the schemas.** `OrganizationWire` is the spec's `Organization`, `OrganizationMemberWire` is `Member`, and `OrganizationInvitationWire` is `Invitation` with `status` narrowed per route plus the three members the platform adds on top (`teamId` and the two ADR-0105 D8 placement fields, which the non-strict schema strips). The three 「not relayed」 notes are gone. + - **The negative controls are the point.** "The client relays the spec schemas" and "the client stopped validating" look identical from a green positive test, so every accepted body is paired with a refused one — a required field genuinely missing, `metadata` still arriving as the stored JSON TEXT, and a `createdAt` or `updatedAt` present but not a datetime. `.optional()` widened the accept set by absence ONLY; a value that is there is still held to `z.string().datetime()`. + + **Not declared breaking, and the reason is the repo's own criterion** rather than the level being convenient. AGENTS.md binds the breaking class to removing or renaming something an author can write, and to the `(narrowing)` arm of the clause-② pair. Neither holds here: nothing is removed, renamed or retired; the one `packages/spec` edit only widens an accept set; and the `metadata` half is a producer brought into line with a contract this package has published all along — `OrganizationSchema.metadata` has declared an object since it was written, and the client's own comment called the served text 「not relayed」 rather than a shape anyone was promised. No ADR-0087 disposition is claimed because no breaking change is declared: no authored metadata moves, so `objectstack migrate meta` has nothing to visit, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. These three schemas are not metadata types — not in `DEFAULT_METADATA_TYPE_REGISTRY`, no authorable surface. ⚠️ Stated here rather than assumed silently, because it is the one judgement in this diff that the contract review the `Clause-②: yes` declaration commissions should confirm. + + **What a consumer notices**, and where it is delivered: `organization.metadata` was the stored JSON text and is now the decoded object, so a caller that decoded it itself drops that step. + + ```ts + // before — the caller decoded what the route sent + const meta = JSON.parse(org.metadata ?? '{}'); + // after — the producer decoded it; the key is ABSENT when unset + const meta = org.metadata ?? {}; + ``` + + The channel that reaches that caller is the compiler, on the line that used to work: `JSON.parse` no longer accepts the value. `updatedAt` needs nothing in either direction — it was never on this family's wire, so no caller can have been reading a value, and the declaration now says so out loud instead of promising one. +- 5d8319f: fix(spec): `rowColor`'s own prescription stops handing authors the one spelling the renderer drops (#18791) + + Clause-②: yes + + `RowColorConfigSchema.colors` advertised `Map of field value to color (hex/token)`. + The only renderer — objectui `plugin-grid`'s `useRowColor` — hands a `bg-`-prefixed + literal through untouched, otherwise lower-cases and trims the value and resolves it + through its own closed vocabulary of colour NAMES, and returns `undefined` for + everything else. A hex is not a key, and Tailwind v4 has no runtime, so no class can + be fabricated from one. + + The `view/row-color-without-colors` diagnostic checks PRESENCE only, so every link in + the chain was shipping code except the author's step: the gate fires, **the gate + itself hands the author a hex**, the hex parses, publishes, turns the gate green, and + colours nothing. A control whose own prescription switches it off. Measured, not + argued: #18787's reverse-verification leg B swapped four colour names for the four + hexes the `priority` field already declares — the app-local resolvability arm went red + naming all four while the presence arm stayed green. + + Three things change, none of which moves an accept set: + + - **The describe** now names the two spellings that actually reach a class, and names + a hex only as the thing that does not. An author who comes to ask "can I paste the + option colours in?" now finds the answer instead of an invitation. + - **The `fix` string** the presence diagnostic emits prescribes a resolvable colour + name. `token` went with the hex: read as the renderer's colour names it was still + standing beside hex as an equal alternative, and putting a bad option first is as + harmful as offering only the bad option. The string is pinned by feeding the value + it suggests back through `checkViewCompleteness`, so the prescription can only ever + name something the new rule below accepts. + - **A new author-time warning, `view/row-color-unresolvable-value`**, reports values + the resolver drops. This is the half presence-only structurally cannot see: a hex + map CLEARS the `!config.colors` guard, which is exactly what silences the older + rule. + + The new rule judges the SHAPE a value has, and deliberately does not transcribe + objectui's 23-entry map. Two structural facts about the resolver are enough and + neither depends on what the map contains: the `bg-` branch tests the raw value, and + every key is a bare lower-case word matched after `toLowerCase()` and `trim()`. So a + value that is neither `bg-`-prefixed nor a bare alphabetic word once normalised cannot + be a key, whatever the map holds. That makes the rule **sound** — it never accuses a + value the renderer would have resolved, including `'RED'` and `' red '` — and + deliberately **incomplete**: an unknown colour name such as `chartreuse` is shaped + like a key and is passed, pinned as a NON-rule. A hand-copy of another repo's + vocabulary is a second opinion that drifts silently in both directions, and where the + vocabulary should be declared so the two sides cannot drift is a cross-repo question + this change deliberately does not answer. + + Not breaking, and measured rather than assumed: the finding is `warning` severity, + like its sibling. `@objectstack/lint`'s `splitBySeverity` sorts everything that is not + `error` into advisories, so `os build` / `os validate` / `os lint` still exit 0 on their + DEFAULT paths, and the registration-time twin in `@objectstack/objectql` is field-only — + it calls `checkFieldCompleteness` and never the view predicate — and warns without ever + throwing. Nothing that builds today on a default run starts failing, and nothing authored + today is refused. Under `os lint --strict` / `os validate --strict` a warning IS a + failure — that is what the flag is for — so a stack carrying an unresolvable + `rowColor.colors` value, typically a hex, starts failing those strict runs on upgrade; + the fix is the one the finding prescribes: a resolvable colour name (`red`) or a complete + Tailwind background class (`bg-red-200`). + + Blast radius measured over this repo, the five example apps and objectui at the pinned + `.objectui-sha` `53ded82bf7a494f54e344e19099dbf00854b8694`: **zero** authored `colors` + maps reach this rule carrying an unresolvable value — the one shipped map, + `examples/app-showcase`'s task grid, spells all four values as colour names and resolves + clean. The pinned sibling does hold three hex `colors` literals, and they are named here + so the zero is checkable rather than asserted: all three are objectui's OWN React test + fixtures (`ObjectView.rowColorRelay-7218.test.tsx`, in `app-shell` and in `plugin-view`), + they assert a relay by `toEqual`, and they never traverse `checkViewCompleteness` — so + this rule does not judge them and does not change their verdict. +- 021755a: fix(spec)!: `scale` is bounded at the renderer ceiling of 100 (#18972) + + Clause-②: no (narrowing) + + `FieldSchema.scale` — and the inline grid column's own `scale` — were declared as + any non-negative integer with no upper bound. Every renderer that turns a declared + `scale` into fraction digits reaches one of two platform primitives, and both of + them refuse above 100: `Number.prototype.toFixed` throws `RangeError: toFixed() + digits argument must be between 0 and 100`, and `Intl.NumberFormat` throws + `RangeError: maximumFractionDigits value is out of range.` So a spec-valid + declaration was unrenderable by any conforming consumer, and its author got no + signal at publish time — the failure arrived as a render-time crash in someone + else's repository. Both live readers are objectui's: the grid's `computeRow` rounds + a computed cell with `Number(v.toFixed(column.scale))`, and the number cell renderer + passes a field's `scale` straight into `maximumFractionDigits`. + + Both declarations now carry an upper bound of 100, and the refusal says **why** — + it names both primitives, the `RangeError` and the legal maximum — so an author + reads a platform limit they can verify rather than a cap somebody chose. The bound + is the platform's own: at 100 both primitives are measured to succeed, at 101 both + are measured to throw, and a unit test re-measures that boundary on every run + rather than trusting the literal. + + **BREAKING** — a declaration above 100 that parsed clean before is refused at + authoring now. This is a deliberate narrowing of a published accepted set, priced + as such rather than as a tidy-up. The declarations it refuses could only ever have + crashed a renderer: there is no value above 100 that any conforming consumer can + render, which is why the bound is the platform's limit and not a policy number. + `packages/objectql` already carries the consumer-side half of the same fact and + skips its formula rounding past 100, so no read is newly affected. + + Unchanged in both directions: `scale: 100` still parses, `scale: 0` still parses, + absence is still absence, and the malformed-declaration refusals from #8321 + (`scale: -1`, `scale: 2.5`) keep their existing codes and their existing wording. + `precision` is untouched — it is a total digit count that reaches neither + primitive, so the renderer-ceiling argument does not carry to it. + + Shipped as `minor` under the repo's launch-window convention, in which + `check-changeset-no-major` refuses `major` and breaking-ness is carried by this + banner plus the ADR-0087 disposition rather than by the level. + + +- b929e0a: feat(connectors): a connector's declared `retryConfig` and `requestTimeoutMs` are executed, not just parsed (#18975) + + Clause-②: yes (widening) + + `ConnectorSchema.retryConfig` (eight sub-keys) and the two timeouts beside it + parsed, stored, and reached nothing. An author who wrote a retry policy — the + one `packages/spec/docs/SYNC_ARCHITECTURE.md` points at for a rate-limited + upstream, whose `retryableStatusCodes` default includes `429` — got + configuration that looked applied and did nothing, with no error and no + warning. ADR-0049 owed these keys a decision and ruled **implement**. + + **Where it landed: one wrapper, not a gateway.** `resilientFetch` + (`@objectstack/spec/shared`) already was the platform's outbound-HTTP call for + connectors — it gave every attempt a 30s timeout and a fixed exponential + backoff. What it could not express was the declared policy, so it gains exactly + the knobs that were missing (`strategy`, `backoffMultiplier`, `maxDelayMs`, + `jitter`, `retryOnNetworkError`), each defaulting to the behaviour it already + had. One new function, `connectorFetchOptions()` + (`@objectstack/spec/integration`), is the single mapping from a connector's + declared policy onto those options — one execution site, not one per connector + package. + + **How the authored value gets there.** `ConnectorProviderContext` gains + `retryConfig` and `requestTimeoutMs`, read-only and + resolved from the entry (the automation service parses `retryConfig` so a + factory reads real values instead of re-deriving the schema's defaults), so a + custom provider that does its own I/O can honour them. The built-in HTTP + providers — `rest` and `openapi` — honour them by construction. + + What an author now gets from each key: `strategy` picks the growth shape + (`exponential_backoff` / `linear_backoff` / `fixed_delay` / `no_retry`); + `maxAttempts` bounds the calls (it counts TOTAL attempts with the first + included, the contrast `content/docs/automation/flows.mdx` already draws against + `maxRetries`, and `maxAttempts: 0` still makes the one call and never retries); + `initialDelayMs` and `backoffMultiplier` shape the delay; `maxDelayMs` caps it, + applied after jitter so the declared ceiling is a real one — and an upstream + `Retry-After` longer than that ceiling ends the retry loop and returns the + response, rather than sleeping past a maximum the author declared; + `retryableStatusCodes` both widens and narrows what is retried; + `retryOnNetworkError` governs a thrown attempt; `jitter` can now be turned off; + `requestTimeoutMs` becomes the per-attempt deadline. + + **Two behaviour changes to know about.** A connector that declares a policy now + retries per that policy where it previously did not retry at all — that is the + fix, and a connector that declares none is on exactly its prior behaviour. + Separately, `connector-openapi`'s generated actions went through a naked + `fetch`: unbounded, never retried, and the one built-in HTTP path an authored + policy could never reach. They now go through the same wrapper as + `connector-rest` and `connector-slack`, which gives them the 30s per-attempt + timeout and bounded retry those two already had. + + **⚠️ `connectionTimeoutMs` is NOT made live, deliberately, and is the one thing + the ruling assumed that measurement refused.** A connector's call is a WHATWG + `fetch`, whose only cancellation surface is one `AbortSignal` over the whole + operation; nothing in that interface observes the connection phase separately. + Bounding time-to-response with it would kill a slow-but-connected upstream the + author meant to allow with a large `requestTimeoutMs` — breaking the very + promise the key makes. So this change leaves it unenforced, with the reason + recorded at the mapping and in `packages/spec/liveness/connector.json`, whose + row for it stays `dead`. That left it owed a second, narrower ADR-0049 + decision, and this same release takes it: `connector.connectionTimeoutMs` is + **retired**, and its own entry in this release says what to write instead. The + key never reaches `ConnectorProviderContext` in any release. + + Nine of the ten ledger rows flip `dead` → `live` with the consumer site named; + the tenth is `connectionTimeoutMs`, above. This change itself moves no + declaration: it leaves every key, every bound and every default on the + connector schema as it found them. +- 14a762f: fix(spec): `spec-changes.json`'s aggregate export diff declares the release pair it really spans (#18978) + + Clause-②: yes (widening) — one new OPTIONAL key on a published artifact (`aggregate.surfaceScope`) + and one new optional field on `SpecChangesSchema`. Nothing is renamed, retired or reshaped: the + schema still ACCEPTS a record without it, every existing key keeps its spelling and meaning, and + `perMajor` and the `release` section are byte-identical. Contract-review tier. + + `aggregate.added` / `aggregate.removed` are not registry-derived. A release-time api-surface diff + fills them by comparing the artifact being published against the previously **published** one, so + they span **one release** — while the record they sit in is keyed by protocol major (`from: 10, + to: 17`) and every entry carries only `since: 17` / `removedIn: 17`, with + `perMajor[16 → 17].added` at `0` beside it. Nothing in the file distinguished one minor's slice + from the whole major-boundary delta. + + Measured on the published `@objectstack/spec@17.4.0` Release asset: `aggregate.added` = **225**, + `aggregate.removed` = **51**, every entry `since`/`removedIn` = 17 — and set-identical to a + recomputed `17.3.0 → 17.4.0` diff of the two tarballs' own `api-surface/` snapshots. It was the + minor's delta wearing a major's label. + + **What ships now.** A record whose export arrays are non-empty carries the version pair they were + diffed between: + + ```bash + jq '.aggregate | {from, to, surfaceScope, added: (.added | length), removed: (.removed | length)}' \ + node_modules/@objectstack/spec/spec-changes.json + ``` + + - `surfaceScope: { fromVersion, toVersion }` present ⇒ `added`/`removed` span exactly that + published-version pair. ⛔ They are **not** the `from` → `to` major delta, and never were. + - `surfaceScope` absent ⇒ the record carries no export diff at all and `added`/`removed` are + empty. ⛔ Read that as "this record does not say", never as "nothing was added between `from` + and `to`" — the same rule the `release` section already states for itself. + - `from` / `to` still answer the major-boundary question for `converted` / `migrated`, which are + registry-derived and unaffected. + + **Refused at the producer and at the publish gate, in both directions.** The generator reads the + previous version off the previous artifact's own `package.json`, omits the arrays loudly when it + cannot read one, and refuses outright to write a non-empty unlabelled array. + `scripts/check-release-spec-changes.mjs` — which until now checked the `release` section and not + the aggregate — recomputes the aggregate's claim from the two tarballs and refuses an absent, + mislabelled or untrue scope. Its self-test roster grows from 15 batteries to 23. + + **Nothing previously honest moved.** The committed registry-only projection and every `perMajor` + record carry no new key at all; the committed `spec-changes.json` changes on its `$comment` line + and nowhere else. The published schema is deliberately not narrowed — every manifest published so + far carries an unscoped diff and must keep parsing. +- 9bb059d: **BREAKING for authored metadata** — the `object-grid` page-component door now refuses a page size of `0`, a negative page size and a non-integer page size, at all three of its spellings: `pagination.pageSize`, every `pagination.pageSizeOptions[]` entry, and the flat `pageSize` shorthand (#19046). + + Clause-②: yes (narrowing) + + The accept set shrinks to the one the VIEW arm has ruled all along. `PaginationConfigSchema` (`view.zod.ts`) declares `pageSize: z.number().int().positive()` and pins its refusals by name; `MetadataQuery` and the two marketplace request schemas say `z.number().int().min(1)`, each with its own throwing pin. The `object-grid` door said `pagination: z.unknown()` and `pageSize: z.number()` — the only page-size declaration in the package that accepted `0`, and the one renderers read. + + **It was not theoretical.** Measured at objectui#9853: an authored `pagination.pageSize: 0` reached `ObjectGrid`, went out on the wire as `$top: 0` and rendered ZERO ROWS, with no grouping needed to trigger it — through this arm, with a `success: true` receipt from this schema. The view arm would have refused the same value. objectui#9896 repaired the consumer half (a resolver at every read point, fail-soft, one loud diagnostic); this is the declaration half and is not a prerequisite for it. + + ``` + ✗ pagination.pageSize: Too small: expected number to be greater than 0 + ✗ pageSize: Invalid input: expected int, received number + ``` + + ### Migration — FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `pagination: { pageSize: 0 }` | `showPagination: false` and no `pagination` bag — the bag's PRESENCE is what enables paging, so `pageSize: 0` never meant "no paging" | + | `pagination: { pageSize: 0 }` (meaning "all rows on one page") | the page size you actually want (`{ pageSize: 100 }`); `0` reached the wire as `$top: 0` and returned nothing | + | `pagination: { pageSizeOptions: [0, 25, 50] }` | `{ pageSizeOptions: [25, 50] }` — drop the `0` entry; selecting it set the fetch window to zero rows | + | `pageSize: 25.5` | `pageSize: 25` — a fractional page size was truncated or forwarded verbatim, depending on the read point | + + The one-line fix is always the same: **write a positive integer, or delete the key and take the renderer's default.** + + + + **⛔ What this deliberately does NOT narrow: the `pagination` bag stays OPEN.** The card's defect is that the two arms disagreed about a page SIZE — not that the bag should become a closed shape. `pagination` is now a `z.looseObject` that validates the two members whose value is a page size and passes every other key through unvalidated, so a sibling key that parsed before still parses and still survives the parse byte-identically (pinned in `component-object-grid-pagination-accept-set.pin.test.ts` §3). Reusing the view arm's `PaginationConfigSchema` here would have refused every sibling key this door has accepted since it was written — the `…` in its own describe says authors write them — which is a wider narrowing than the measured defect and a different decision. `PaginationConfigSchema` itself is unchanged and stays closed; §4 of that pin states both the agreement and the deliberate asymmetry. + + **One second axis, named rather than left to be discovered.** `pagination` moves from `z.unknown()` to an object type, so a non-object value (`pagination: true`) is refused where it used to parse. Measured before narrowing: zero non-object `pagination` values exist on an `object-grid` node in either repository's corpus, the objectui registry has published this input as `type: 'object'` all along (`plugin-grid/src/index.tsx`), so the html tier already answered `type-mismatch` on one, and the renderer reads the key for PRESENCE (`schema.pagination !== undefined`) — which means an authored `pagination: false` used to turn paging ON. That value now gets a located refusal instead of the opposite of what it says. +- 07c6f82: spec(ui): a navigation entry may omit `label` — it then inherits its target's CURRENT label at render time (#19049) + + Clause-②: yes (widening) + + `BaseNavItemSchema.label` is `.optional()`. An `app.navigation` entry written without a `label` now parses, and the semantic it parses into is declared on the key itself: **absent means the entry inherits, at render time, the current label of whatever it opens** — the view's label when it names a view and that view is labelled, else the object's / dashboard's label. A label the author *did* write renders verbatim and is never overwritten. + + This executes the maintainer's cloud#2021 ruling (「2021 可以接受有些修改刷新才生效」) as letter **A** on objectui#9868: sync by render-time inheritance, no stored state. The spec moves first because the console reads its navigation contract from here — until now an unnamed entry was not *representable*, so the promise "an unnamed entry shows its target's name" had nowhere to be declared. + + - **Accept-set widening only, on eight branches at once.** `BaseNavItemSchema` is spread (`...BaseNavItemSchema.shape`) into the `object`, `dashboard`, `page`, `url`, `report`, `action`, `component` and `group` nav-item declarations, so the one-line relaxation reaches all eight. The ninth branch, `separator`, spreads nothing and has never carried a `label`. Nothing that parsed before stops parsing: a present `label` is accepted exactly as before, and every other key on the item is untouched. + - **Nothing is stored for the absent case.** There is no new member and no `inherited` flag — the parse adds no key the author did not write. That is the whole point of resolving at render: a target renamed after the entry was authored shows its new name on the next render, where a label materialised at authoring time would be a stale snapshot. Consumers must resolve an absent `label` at render, not at ingest. + - **The rule this relaxes still holds.** *Every real destination must have identity and text* — identity is the target, text is inherited at render. That sentence is recorded in the key's `describe`, so it ships to the reference page and to any tool reading the JSON Schema. + - **The three sibling `label` declarations in this file are unchanged and still required**: `NavigationArea.label`, `AppContextSelector.label` and `App.label`. Each names a container the author is creating rather than a target it could inherit from, so there is nothing for an absent label to resolve against. The ruling covers navigation entries only. + + Downstream, in order: objectui#9868 relaxes its own `packages/types` validator to match, resolves the absent label in the nav renderer, and stops writing `label || pageName` for an unnamed entry; then cloud#2021 stops materialising an inherited label in `apply_blueprint`. +- 502f179: **BREAKING** — retire `object.tenancy.organizationField`, the stamp-only column + declaration the whole protocol declared exactly once, on a table this platform ships. + + The key answered "which column says who this platform row is ABOUT", where + `tenancy.tenantField` answers "what is this object WALLED by". The spec's own docblock + stated the consequence: *"For ordinary objects the two coincide and `organizationField` + is never needed."* Measured on `main` before this change, the entire repository declared + it **once** — `packages/platform-objects/src/identity/sys-api-key.object.ts`, the + better-auth credential table — and zero business objects declared it anywhere. Its + readers were three platform-row writers, scope-pinned **by name** (audit stamping, the + approval-row writer, the automation-run recorder), so an application declaration was + inert by construction while still being authorable on every object, which made every + future piece of organization logic owe the question "what if somebody set this?". + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18, verbatim and untranslated: + 「organizationField 撤出可授权面 同意你的建议」. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `tenancy: { enabled: false, organizationField: 'active_organization_id' }` | `tenancy: { enabled: false }` — delete the key. Nothing read it on an application object | + | `tenancy: { enabled: true, organizationField: 'about_org_id' }` on an object whose tenant column really is `about_org_id` | `tenancy: { enabled: true, tenantField: 'about_org_id' }` — the surviving key both walls the object and stamps its platform rows | + | you declared it to make one platform table's rows stamp differently | nothing to write. That divergence is a platform fact now, not a knob | + + The `tenancy` block is `.strict()`, so the key is **refused** with its prescription + rather than stripped, and `os migrate meta --from 17` lists the mechanical edits for + existing sources. + + ## What does NOT change + + The `sys_api_key` divergence is intact, and that is the point of the shape this takes. + The credential table is `managedBy: 'better-auth'`, so `resolveInjectedSystemColumns` + bails before tenancy is consulted and no `organization_id` is ever injected; the column + it really carries is better-auth's `active_organization_id`. Its audit, approval and + automation-run rows still stamp that column. What moved is only where the fact is + written: `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, one row, + keyed by object name and read by the STAMP face alone. The WALL face + (`resolveRecordWallOrganizationField`) never read the key and is untouched, so the + stamp/wall divergence pin stands unchanged. + + ⛔ The column is **not** renamed to `organization_id` and must never be: in this platform + "has an `organization_id` column" IS the wall, so the rename would wall the credential + table on an equality that excludes NULL and every pre-existing key would vanish from its + own owner's key list. + + ## For `@objectstack/metadata-core` consumers + + `resolveRecordOrganizationField` and `createRecordOrganizationResolver` keep their + signatures and their four-limb precedence. Limb 0 is now keyed by the object's + registered NAME against the platform table instead of by a declaration on the definition: + the engine-bound resolver passes the name it was asked about, and the two-argument + function reads `objectDef.name` when the definition carries one. A caller that fed it a + hand-built definition carrying `tenancy.organizationField` — only reachable by + reimplementing a platform writer — now gets limbs 1 to 4. + + The retirement kit, in the shape the playbook prescribes: + + - the key is DELETED from `TenancyConfigSchema` (the block is a `strictObject`), and a + `TENANCY_RETIRED_KEY_GUIDANCE` row carries the prescription beside the two v15.0 + precedents (`tenancy.strategy`, `tenancy.crossTenantAccess`) + - D2 conversion `object-tenancy-organization-field-removed` (`toMajor: 18`, + `retiredFromLoadPath: true`) strips the key from authored sources and stored + `sys_metadata` rows; D3 wires it into the protocol-18 chain step, and + `RETIRED_KEYS_BY_MAJOR[18]` declares `data/TenancyConfig:organizationField` + - the `authorable-surface/data.json` row is deleted in this same commit — the strict + route's tripwire — with the build computing the guidance-route proof for itself + - the liveness ledger row is deleted, since the key leaves the walked shape entirely + - pin tests: the authored shape is refused with its prescription, and the `sys_api_key` + stamp is pinned end to end beside the closed-set control (the same shape under any + other object name takes the ordinary limbs) + + Clause-②: no + + +- f20fe29: chore(spec)!: the metadata migration chain is supported from protocol 16 — `MIGRATION_SUPPORT_FLOOR` 10 → 16, and `step11`–`step16` retire with it (#19056) + + Clause-②: yes (narrowing) + + + + **BREAKING** for a consumer still authored against protocol **10, 11, 12, 13, 14 + or 15**. Landing in the launch window as `minor` under the lockstep convention. + + Maintainer ruling, 2026-09-18, verbatim and untranslated: + + > 升级只需要支持从 16.0版本开始。 + + 「16.0」reads as protocol major 16 — the same unit as the constant + (`PROTOCOL_VERSION` is `17.0.0`, so the package version `17.x` and the protocol + major are not the same number). That reading was put back to the maintainer and + was not contradicted. + + ## What changes for you + + `MIGRATION_SUPPORT_FLOOR` — a published export of `@objectstack/spec` — moves + from `10` to `16`. Two consequences, both at the boundary: + + | you call | before | after | + | --- | --- | --- | + | `applyMetaMigrations(stack, N)` for N ∈ 10..15 | replays the chain from N | throws `MigrationFloorError` | + | `os migrate meta --from N` for N ∈ 10..15 | migrates | refuses, naming the floor | + | `applyMetaMigrations(stack, N)` for N ≥ 16 | unchanged | unchanged | + | `MIGRATION_SUPPORT_FLOOR` as a TS literal type | `10` | `16` | + + The fix, and the only one there is: **reach protocol 16 by another path first, + then re-run.** The refusal says so itself — `Cannot migrate from protocol N: the + chain's support floor is 16 (ADR-0087 D3). Upgrade to protocol 16 by another + path first, then re-run.` A stack already at 16 or above is unaffected, and the + 16 → 17 and 17 → 18 hops are untouched. + + If you pin `MIGRATION_SUPPORT_FLOOR`'s literal type (`const f: 10 = …`), that + annotation stops compiling. The value was always a release-policy knob, so read + the constant rather than restating it. + + ## What this is NOT + + It is **not** a slimming change, and the measurement is the reason to say so. + Counted on `src/migrations/registry.ts` at `e6a03e649` (17,718 lines): + + | block | lines | share | + | --- | ---: | ---: | + | `step11`–`step16` — what leaves | 328 | 1.9% | + | `step17` | 4,699 | 26.5% | + | `step18` | 7,565 | 42.7% | + | the registration map + the two retirement tables | 5,077 | 28.7% | + | file header | 49 | 0.3% | + + Everything but the first row stays. What the raise buys is a **narrower support + promise**: six permanently-replayable chains no longer have to be maintained, + and the CI replay shrinks to the range the project actually promises — 10 of the + 98 conversion fixtures leave the chain-replay gate, because the chain no longer + reaches the major that graduated them. + + ## What was deliberately NOT removed + + `RETIRED_KEYS_BY_MAJOR` and `RETIRED_DEFS_BY_MAJOR` live in the same file and + are keyed by protocol major, which makes them look like chain state. They are + not, and both are kept whole: + + - the chain never reads either table (`chain.ts` imports the steps and the floor + and nothing else); + - their one non-test reader, `packages/spec/scripts/build-schemas.ts` + (`check:authorable-surface`), folds every major into one set and never + mentions `MIGRATION_SUPPORT_FLOOR`. + + So a row below the floor is still the live proof that its retirement was + declared. Measured by ablation: a row planted under major **11** — a major whose + step this change deletes — was still read and judged, reported as *"(registered + at major 11)"*. Both facts are pinned in + `src/migrations/retired-tables-not-floor-scoped.test.ts` so the next floor move + reads them first. Dropping such a row errors nowhere at the moment it is + dropped; the declared retirement simply stops being declared. + + The D2 conversion registry is untouched for the same reason: every rehydration + seam replays the **full** conversion chain over stored `sys_metadata` rows, + retired entries included, so the protocol-11/13/14/15 conversions keep + converting rows at rest long after the source-side chain stops reaching them. + + ## `@objectstack/cli` + + `os migrate meta --help` advertised `--from 10`, `--from 10 --step`, + `--from 11 --to 12` and `--from 10 --out …`. Every one of those refuses after + this change. The examples are now derived from `MIGRATION_SUPPORT_FLOOR`, so the + next floor move cannot leave them advertising commands that throw. +- 362035c: React-tier ``: the `onNavigate` declaration becomes + `(recordId, action: 'view' | 'new_window') => void` — a declared value **no branch ever + emitted** is removed, and the value **two reference call sites do emit** is added. + + `REACT_BLOCKS`' ListView overlay declared the second argument as `'view' | 'edit'`. That + sentence was false in both directions. `'edit'` is emitted by no call site in the + reference implementation and read by no branch; `'new_window'` — what a Cmd/Ctrl- or + middle-click, and an authored `navigation: { mode: 'new_window' }`, actually send — was + not declared at all. An author reading this contract wrote a handler with one dead arm + and one missing arm. + + The second argument is a navigation-MODE token with a **closed vocabulary**, and the + declaration now says so. That closedness is not new: the protocol's own retirement note + for `view.list.navigation.view` (removed in 17.5.0, ADR-0049) records that anything + outside the mode vocabulary "matched no branch". What this change corrects is the + membership of the vocabulary, not its closedness. + + ## FROM → TO + + | you wrote | write instead | + | --- | --- | + | `onNavigate={(id, action) => { if (action === 'edit') … }}` | delete that arm — nothing ever called it | + | a handler with no `'new_window'` arm | handle `'new_window'`: open the record in a new browser tab. Omitting the arm leaves the modifier-click path doing nothing | + | `onNavigate={(id) => …}` (one argument) | unchanged — the arity is untouched | + + **The one-line fix:** replace the `'edit'` arm with a `'new_window'` arm. + + Scope: this moves a **declaration**, not a type or a runtime check. `REACT_BLOCKS` types + this prop as a documentation string (`ReactBlockDef[]`), so no `.d.ts` signature moves + and nothing that compiles today stops compiling. The behaviour it describes is the + reference implementation's, which already emits exactly these two values; the sibling's + four declaration faces are corrected under objectui#9547 and its bump to + `@objectstack/spec` >= 17.5.0. + + Clause-②: yes +- 32b5831: **BREAKING for callers** — `parseFilterAST` now refuses a blank `$between` endpoint, exactly as the authoring schema already does. An empty-string or absent (`undefined`) bound, at either side, is refused with `INVALID_FILTER` / 400, and the refusal names the blank side — MIN or MAX, plus the index (#19071). + + Clause-②: no + + ## What changed, and why it is the implementation catching up rather than a new rule + + `RANGE_ENDPOINT_DESCRIPTION` — the published endpoint contract shared by both of `$between`'s bounds — has stated since 2026-09-17 that "BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side". That rule shipped at the authoring schema only. The runtime door disagreed with it: `parseFilterAST({ at: { $between: ['', ''] } })` returned the filter unchanged, same object reference, measured on `origin/main` before this change and re-measured after. + + One published sentence therefore had two truth values, decided by which door a caller came through — and the door that passed it is the one that matters most here. A caller that lowers a filter with `parseFilterAST` and hands it straight to a driver (an embedder; this repo's own driver conformance suites) never meets the schema. At every backend a blank bound stops bounding on that side while the range still reads as a complete two-element range, so the query runs with one meaningless boundary and returns rows outside the window its filter names, with no signal at any layer. + + ``` + FROM parseFilterAST({ at: { $between: ['2026-01-01', ''] } }) + -> { at: { $between: ['2026-01-01', ''] } } // unchanged, same reference, + // straight on to the driver + + TO parseFilterAST({ at: { $between: ['2026-01-01', ''] } }) + -> throws INVALID_FILTER / 400: + 'Operator "$between" on field "at" requires two non-blank bounds. + Received an empty string at where.at.$between[1] (the MAX bound). …' + ``` + + ## Migration — FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `{ $between: ['2026-01-01', ''] }` | `{ $between: ['2026-01-01', '2026-12-31'] }` — the bound you meant, written out | + | `{ $between: ['', 100] }` | `{ $between: [0, 100] }` — the lower bound you meant | + | a range that was only ever bounded on ONE side | `{ "$gte": min }` or `{ "$lte": max }` — a one-sided bound is not a range | + + **The one-line fix: write the bound that is missing, or — if only one side was ever meant — drop `$between` and write that side as a scalar comparison.** The same prescription the authoring door already gives, now given at the door an embedder actually reaches. + + ## What does NOT change + + - **`null` bounds** keep their own message, refused since 2026-08-31. It prescribes the null PREDICATE, because an author who wrote `null` was reaching for absence and an author who left a bound empty was reaching for a bound — two blank spellings, two intents, two remedies. `null` is also checked first, so a pair that is blank on one side and null on the other keeps the message it has always had. + - **Whitespace-only endpoints** are still accepted, at BOTH doors. The 2026-09-17 ruling is the empty string; the authoring door pins `{ $between: [' ', 'M'] }` as parsing green on purpose, and trimming here would re-open the very split this change closes, in the opposite direction. + - **Falsiness.** `{ $between: [0, 0] }` and `{ $between: ['0', '9'] }` lower exactly as before. The rule is blankness, not falsiness. + - **`$in` / `$nin` members.** A falsy or empty-string MEMBER is a value, not an absence (2026-08-31, `filter-comparand-shape.test.ts`). Only a range ENDPOINT is judged here, and only the `$between` row of that pin moves. + - **Arity**, which was already refused with its own message, and every legal range: numbers, Dates, ISO days, UTC instants, clock times and non-temporal text all lower byte-identically, same object reference. + - **The published export surface.** No export is added, removed or renamed; the refusal rides the existing `$between` arm of the shared comparand-shape door, so the engine's delegating wrapper inherits it unchanged. + + +- 74554a3: `field.relatedListFilter` and `object.validations` are authorable in the metadata form. Both keys were **declared** by the served schema and offered by **no** form in `METADATA_FORM_REGISTRY`, so the generic metadata form never rendered a row for either and an author's only door was the Source tab — free-text JSON, where a mis-spelled sibling key is written, stored, and refused by the runtime later. + + Measured on the tree before the change: zero rows for either key across every `*.form.ts` in `packages/spec/src`, with a lit control (`maskingRule`, offered twice) and a dark control (a name no form carries) in the same read — so the zero is a reading, not a dead probe. + + **The face each row gets is a measurement, not a preference.** Both keys serve as JSON-Schema **pointer rows**, which is the shape a generic renderer cannot be assumed to resolve: + + - **`field.relatedListFilter` → `widget: 'filter-condition'`.** The served node is `{ $ref: '#/$defs/…' }` onto the recursive Query-DSL `FilterCondition`, whose derivation is `allOf: [open record, { $and/$or/$not }]` with **no top-level `type`** — there is nothing for the generic renderer to derive a control from. `filter-condition` names the FilterCondition wire, and this file already uses it one section down for `summaryOperations.filter`, the sibling `FilterConditionSchema` key. What the hint renders as **today**, measured at the pinned `.objectui-sha`, is the announced **raw-JSON editor carrying the hint** — not a criteria builder: the renderer that consumes this registry is the metadata-admin `SchemaForm`, whose own `WIDGETS` map registers no `filter-condition` (the `FilterConditionField` of that name lives in `@object-ui/fields`, on the ComponentRegistry path `ObjectForm` uses), and with the pointer unresolved neither structural fallback applies, so `resolveFieldFace` lands on `{ kind: 'raw-json', hint }` — the same face `summaryOperations.filter` gets. That editor hands `JSON.parse` output through verbatim and the save door judges it, so the wire is exact either way; the hint is the forward-looking half. ⛔ Deliberately **not** `filter-builder`: that widget consumes a rule **ARRAY** (what `view.filter`, `dataset.filter` and `page.filterBy` store), so routing this key there would write metadata the runtime refuses — the authoring trap this row exists to close, re-created one layer up. `visibleWhen` mirrors the key's own contract text (`lookup` / `master_detail`), a meaningfulness gate rather than a parse gate: `FieldSchema` accepts the key on every type, but the related-list derivation only ever reads it on the child-side FK. + - **`object.validations` → `widget: 'json'`.** The served node is an array whose items are a **double-hop** pointer (`items.$ref` → `$defs/__schema1` → `$defs/__schema2`) landing on a `oneOf` over the six `ValidationRule` members. A repeater would have to resolve both hops **and** pick a union branch before it could render a row; neither half is measured for this node, and a repeater that resolves neither renders an empty row whose values never land — the offer-vs-door defect the reconciliation gate beside it exists to catch. The Zod parse still refuses a malformed rule loudly at publish. Precisely: `json` is in that renderer's passthrough set, but the set is consulted **after** the structural fallbacks, not instead of them — so this row reaches the raw-JSON editor because the unresolved double-hop pointer derives nothing, not because the hint suppresses derivation. Once the pin moves past objectui's pointer resolution the same hint derives an `object-rows` repeater over the first `oneOf` branch; that is the renderer's precedence, not this repo's contract. Same treatment as the sibling structured-array rows `permission.rowLevelSecurity` and `email_template.variables`. Upgrading it to a structured control is a form-face addition, ⛔ not a reconciliation. + + A new pin (`metadata-form-declared-rows.pin.test.ts`) keeps both rows and both faces, and adds a registry-wide assertion — every row of every form, at every depth — that **no** form routes a `FilterCondition`-typed key to the rule-array builder, with a lit control proving the walk reaches both keys before it reports an empty misrouted set. + + ⛔ **No wire byte moves and no export changes.** `check:api-surface` is green with no regeneration: `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so the row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles (44 additive lines; the new `en` entries are source text, the translated locales still need translating). +- e56112c: fix(spec)!: the form-row `scale` is bounded at the renderer ceiling of 100 (#19088) + + Clause-②: no (narrowing) + + `FormFieldBaseSchema.scale` — the per-field override a form row carries — was declared as + any non-negative integer with no upper bound. It is the third declaration of `scale` to + reach the same platform ceiling #18972 bounded on the two in `data/field.zod.ts`: every + renderer that turns a declared `scale` into fraction digits reaches one of two platform + primitives, and both refuse above 100. `Number.prototype.toFixed` throws `RangeError: + toFixed() digits argument must be between 0 and 100`, and `Intl.NumberFormat` throws + `RangeError: maximumFractionDigits value is out of range.` So a spec-valid declaration was + unrenderable by any conforming consumer, and its author got no signal at publish time — + the failure arrived as a render-time crash in someone else's repository. The route from + this row to that reader, measured in the sibling checkout at the `.objectui-sha` pin + `53ded82bf7`: plugin-form copies the row's constraint keys onto the runtime field + (`packages/plugin-form/src/sectionFields.ts:220`, `if (fd.scale != null) base.scale = + fd.scale;`), and the number cell renderer hands that value straight to `Intl.NumberFormat` + (`packages/fields/src/index.tsx:661-667`, `maximumFractionDigits: scale ?? 20`). That one + route carries the premise on its own. + + The row now carries that upper bound, and the refusal says **why** — it names both + primitives, the `RangeError` and the legal maximum — so an author reads a platform limit + they can verify rather than a cap somebody chose. The bound is the platform's own: at 100 + both primitives are measured to succeed, at 101 both are measured to throw, and a unit + test re-measures that boundary on every run rather than trusting the literal. + + **BREAKING** — a form-row `scale` above 100 that parsed clean before is refused at + authoring now. This is a deliberate narrowing of a published accepted set, priced as such + rather than as a tidy-up. The declarations it refuses could only ever have crashed a + renderer: there is no value above 100 that any conforming consumer can render, which is + why the bound is the platform's limit and not a policy number. + + Unchanged in both directions: `scale: 100` still parses, `scale: 0` still parses, absence + is still absence, and the malformed-declaration refusals from #8321/#12174 (`scale: -1`, + `scale: 2.5`) keep their existing codes and their existing wording. `precision` is + untouched on this row as on the object-field row — it is a total digit count that reaches + neither primitive, so the renderer-ceiling argument does not carry to it. + + The number itself moves into `src/shared/scale-ceiling.ts`, a spec-internal leaf module + that no package entry re-exports, so no published export moves and `check:api-surface`, + `check:export-origins` and `check:declaration-map` all stay green. `data/field.zod.ts` + keeps the module-private copy #18972 minted; a pin asserts the two sites refuse with + byte-identical text, so the duplication is held equal rather than left to drift, and that + file can adopt the shared module later as a pure delete-and-import. + + Shipped as `minor` under the repo's launch-window convention, in which + `check-changeset-no-major` refuses `major` and breaking-ness is carried by this banner + plus the ADR-0087 disposition rather than by the level. + + +- 43460b9: **BREAKING** — `PackageApiContracts` loses its three entries that named routes nothing serves: `upgradePackage`, `resolveDependencies` and `uploadArtifact` (#19116). + + A `major`-class change, recorded as `minor` under the launch-window convention. Maintainer ruling 2026-09-23, director seat decision batch #217 item 4, letter A, 「217 同意」; ADR-0049 enforce-or-remove. + + **Why.** Each entry bound a path the composed runtime mounts nowhere — `POST /api/v1/packages/upgrade`, `POST /api/v1/packages/resolve-dependencies` and `POST /api/v1/packages/upload`. The package dispatcher has no route for any of them and `@objectstack/rest` mounts only `/packages/publish` under `/packages`, so a request to any of the three was never answered, while the generated API reference printed all three as live endpoints. Unlike `installPackage`, which was rebound onto the serving `POST /api/v1/packages`, there was no serving door to rebind these onto, and mounting three new capabilities nobody has asked for was ruled out. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `PackageApiContracts.upgradePackage` (`POST /api/v1/packages/upgrade`) | nothing — delete the read and any URL built from it. No route serves a package upgrade. | + | `PackageApiContracts.resolveDependencies` (`POST /api/v1/packages/resolve-dependencies`) | nothing — delete the read and any URL built from it. No route serves dependency resolution. | + | `PackageApiContracts.uploadArtifact` (`POST /api/v1/packages/upload`) | nothing — delete the read and any URL built from it. No route serves an artifact upload. | + + **The one-line fix: delete every read of the three keys, and every request to the three paths.** The compiler finds the reads (`TS2339: Property 'upgradePackage' does not exist`); a hard-coded path has to be searched for. No behaviour is lost — none of those requests was ever answered. + + **What stays.** The four entries whose doors serve — `listPackages`, `getPackage`, `installPackage`, `uninstallPackage` — are unchanged. The per-route request/response schemas (`PackageUpgradeRequestSchema`, `PackageUpgradeResponseSchema`, `ResolveDependenciesRequestSchema`, `ResolveDependenciesResponseSchema`, `UploadArtifactRequestSchema`, `UploadArtifactResponseSchema`, with their types) stay published, now bound to no route; their docblocks no longer name a route. If the platform later serves a package upgrade, dependency-resolution or upload route, its contract entry is declared in the same change that mounts it. + + ⚠️ Runtime behaviour is deliberately **unchanged**: nothing ever mounted the three paths or built a route, client or SDK method from the entries, so every request answers exactly as before. The removal retracts a false claim, not a capability. **No deprecation window** (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」). + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** Inside this repository the three paths occurred only in the declaring file, its unit test and the generated reference page, and the pinned objectui checkout names none of the keys, none of the paths and not `PackageApiContracts`; `@objectstack/spec` is published, so readers elsewhere were not measured. + + The ADR-0087 D3 semantic entry `package-api-contracts-unmounted-entries-retired` carries the judgement: a contract-map entry is not metadata, so there is no source for a D2 conversion to rewrite. + + Clause-②: no + + +- 488f4f5: **BREAKING** for authored metadata — an `assignment` flow node's config refuses a **top-level** key named `__proto__`, with a named, located error at parse time, instead of accepting the document and silently returning one without that key (objectstack#19151). + + ## Why + + `AssignmentConfigSchema` is deliberately open at the top level: an `assignment` node is exempt from `registerFlow()`'s undeclared-key walk by design, because its top-level keys may themselves be flow variables (the bare legacy `{ : }` config), and the descriptor declares `additionalProperties: true`. That openness is spelled `.catchall(z.unknown())`. + + zod has two open-key branches and both skip a `__proto__` own key before anything author-facing can judge it. `z.record()`'s branch skips it above the key schema — that is objectstack#17852, fixed for the `assignments` map one level down. `handleCatchall` skips it above the **catchall** schema, one function over in the same file. So a flow variable named `__proto__` declared at the top level of an `assignment` node config parsed as SUCCESS and came back missing: the contract accepted a document and handed back a different one, on a surface whose own keys are author-named by design. + + `JSON.parse` is what produces `__proto__` as an own key, so stored flow metadata reaches this door routinely; an object literal's `{ __proto__: … }` sets the prototype instead and never reaches either loop. + + ## What is refused, and what is not + + `__proto__` only, in this position as in the sibling one. `constructor`, `prototype` and every other reserved-looking name reach the catchall unskipped and round-trip intact — measured — so they remain legal top-level flow-variable names and nothing narrows for them. The refusal is a `z.preprocess` guard on the raw input (`refuseCatchallProtoKey`, a sibling of `refuseRecordProtoKey` sharing one mechanism), because that is the only place the key is still visible: declaring it in the object's own shape was measured to refuse *every* config, since zod reads a declared key through `input["__proto__"]` and `"__proto__" in input`, which on an ordinary object both answer through the inherited accessor. + + The `assignments` map keeps its own guard. The two are different parsers at different depths and neither covers the other. + + Measured: zero authored use of `__proto__` as a top-level key on an `assignment` node config, across this repo, `examples/` and `objectui` — against a lit control of 100 authored `assignment` node declarations in 26 files here and 11 files there. + + ## Known gap, left open on purpose + + Like the sibling guard, this runs at parse time only and does not project into the published JSON Schema (`packages/spec/json-schema/**`) — the general gap tracked as objectstack#18670, which stays open after this change. + + Clause-②: yes (narrowing) + + +- 61dd96f: spec(ui): `ActionEngineFacade.find` no longer accepts a `context` on its query envelope + + Clause-②: no (narrowing) + + `ctx.engine.find(object, query)` takes `Omit` — the + engine's query envelope with exactly one key subtracted. Every other key is + unchanged and still read off the engine's own type by reference. + + **Why.** The action facade is trusted and context-less by design: the runtime + stamps its own elevated `ExecutionContext` last, so a caller-supplied `context` + was overridden, never honoured. The key was nonetheless *declared* on the + parameter, which made this a declared-but-unenforced key on the one thing + `context` carries — identity and tenant. A handler could write + `context: { tenantId: 'org_acme' }`, type-check clean, and get the facade's + context instead: a read its author believes is tenant-scoped, silently broader + than intended. ADR-0049 admits enforce or remove; removal is the exit that + changes no runtime behaviour. + + **Migration.** Delete the key. There is nothing to replace it with, because it + never did anything: a `find` that carried one returned exactly the rows it + returns without one. To scope a read, put the scope in `where`. + + | You wrote | Write instead | + | --- | --- | + | `ctx.engine.find('task', { where: { … }, context: { tenantId } })` | `ctx.engine.find('task', { where: { … } })` | + | `ctx.engine.find('task', { where: { … } })` | unchanged | + + `tsc --noEmit` over a consumer's handlers finds every occurrence, because the + key is now an excess property on a fresh literal. ⚠️ Only where the handler is + annotated with the published `ActionHandlerContext`: an untyped handler (a JS + config body, a local copy of the context type, `(ctx: any)`) still passes the + key and still has it overridden, silently, exactly as before. The runtime arm is + deliberately unchanged — refusing an identity key there is a runtime behaviour + change, not a declaration narrowing. + + +- 6afa59d: **BREAKING for authored metadata** — `undoable: true` on a registered `action` is now legal only on a shape some runtime actually fulfils, and refused at parse time everywhere else. + + Clause-②: yes + + The accept set narrows. `undoable` was a plain optional boolean that no refinement read, so it parsed clean on every action shape while only two of them ever produced an Undo — the declared-but-inert case the spec refuses at author time (ADR-0078). + + **The two fulfilled shapes, and which runtime fulfils each** + + | shape | who takes the snapshot | + | --- | --- | + | `operation: 'update'` (with a `patch`) | the framework runtime — the prior value of every field in the merged write bag, `patch` UNDER the collected `params` | + | `type: 'api'` | the pinned console — it builds the undo envelope from `undoable` alone | + + Both stay accepted, byte-identically. Naming the console in the contract is deliberate: the spec is the contract for every runtime including the console, and a closed table of fulfillable combinations is what the declared-is-delivered rule asks for. + + **What is refused** + + `undoable: true` on `type: 'script'` (the default route) or `type: 'url'`, and on the dormant `type: 'flow'` / `'modal'` / `'form'`, in each case without `operation: 'update'`. Nothing reads the flag on those shapes, so it promised an Undo that never appeared. + + ``` + ✗ undoable: `undoable: true` has no runtime that can fulfil it on this action. An Undo is + captured on exactly two shapes: `operation: 'update'`, where the framework runtime snapshots + the prior value of every field the write bag touches, and `type: 'api'`, which the console + snapshots. … + ``` + + **⛔ What is deliberately NOT refused, because it was measured wrong.** Requiring `operation: 'update'` — the obvious repair — would refuse the published `ReassignLeadAction` skill example (`type: 'api'` + `undoable: true`, no `operation`) at import time, since `defineAction` IS `ActionSchema.parse`, and every console api action with undo along with it. The console's two readers gate the undo envelope on `action.undoable` alone with zero reads of `action.operation`, and those same two files are the entire recorded evidence for this package's own liveness verdict `action/undoable: live`. `undoable` absent or `false` is untouched on every type, and the rule lives on `ActionSchema`'s refine chain alone — an inline action is not a registered action. + + ### Migration — FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `{ type: 'script', body, undoable: true }` | `{ operation: 'update', patch: { … }, undoable: true }` if the action is a single-record field write, or drop `undoable` and keep the handler | + | `{ type: 'url' \| 'flow' \| 'modal' \| 'form', undoable: true }` | the same action without `undoable` — those routes never had a capture, so behaviour is unchanged | + + ⛔ Not mechanically convertible, so this ships as an ADR-0087 D3 structured TODO rather than a D2 conversion: which of the two fulfilled shapes an author meant is an intent no artifact records — a `script` action with an inline handler and an api action calling an endpoint are different dispatches, not two spellings of one — and dropping the flag automatically would remove an Undo the author asked for. + + + + **Published surface.** No export is added, removed or renamed; `ActionType` still carries all six types. The `undoable` `.describe()` and the comment above it are corrected in the same change: both claimed that an action with no `operation` has nothing anchoring the capture, which is false against the pinned console. +- 8f6d831: fix(plugin-security): the `sys_permission_set` duplicate-name refusal carries `UNIQUE_VIOLATION`, and the packaged-set lock answers first (#19307) + + Clause-②: yes + + Two halves of one defect on the data door's insert leg for `sys_permission_set` + (`permission-set-projection.ts`), both measured live on `examples/app-showcase` + with a seeded admin over a cookie session. + + **1. The refusal carried no machine-readable code.** It threw a bare `Error` + with `.status = 409` and no `.code`, and the flat `{ error, code }` responder + invents nothing for a producer that declared nothing, so the client got prose: + + ``` + POST /api/v1/data/sys_permission_set {"name":"dev_local_set"} + → 409 {"error":"[Security] permission set 'dev_local_set' already exists","object":"sys_permission_set"} + ``` + + ADR-0112's 2026-08-17 amendment closed `error.code` at the flat door too, so a + 409 with no code is that contract unhonoured — and a UI that has to branch on + the refusal was pushed back to string-matching. The same request now answers + `409 … "code":"UNIQUE_VIOLATION"`, message byte-identical. + + ⚠️ `UNIQUE_VIOLATION` is REUSED, not minted. `sys_permission_set` declares + `{ fields: ['name'], unique: 'organization' }`, so this very collision already + answers `409 UNIQUE_VIOLATION` when the index catches it instead of this + pre-check; a second spelling would make one condition answer two envelopes + depending only on which layer got there first. The ledger gains a provenance + row for `@objectstack/plugin-security` — the union, its casing and every other + package's rows are unchanged, and no schema shape moves. + + **2. It ran BEFORE the packaged-set lock, so the most likely path answered the + less useful of two true refusals.** A package-declared set has a projected row, + so its name is duplicate AND locked at once. An admin who opened the Clone + dialog on a packaged set and typed the base set's own name — the single most + likely thing to type — got `already exists`, which names no remedy, and never + reached `NOT_OVERRIDABLE`, which names the clone path. The lock now runs first: + + ``` + POST /api/v1/data/sys_permission_set {"name":"showcase_manager"} + → 403 {"error":"[Security] Permission set 'showcase_manager' is declared by package + 'com.example.showcase' and is locked … Choose a different name for your set, or clone + 'showcase_manager' …","code":"NOT_OVERRIDABLE","object":"sys_permission_set"} + ``` + + **What did NOT move**, measured on the same runtime: an ordinary + (non-package-declared) duplicate **whose provenance the lock can resolve** still + answers the duplicate refusal and not `NOT_OVERRIDABLE` — that qualifier is + load-bearing, and the corner below is the case it excludes; an unauthenticated + write on the same resource still answers `401 UNAUTHENTICATED`; and an `update` + targeting a packaged set answers `403 NOT_OVERRIDABLE` exactly as before. + + ⚠️ **One corner moved with the order**: an ordinary duplicate attempted while no + artifact source can answer now takes the lock's fail-closed `unknown` refusal — + `403` `NOT_OVERRIDABLE` (`PackagedPermissionSetProvenanceUnknownError`, "retry + once the metadata layer is readable") — instead of the 409. Both are refusals and + neither writes; it is pinned so the behaviour is declared rather than incidental. + + ⚠️ **And the order has a cost, stated rather than discovered**: the lock's probe + (`protocol.getMetaItemLayered`) used to be evaluated only AFTER the duplicate + check passed, so a duplicate insert never paid for it. It is now evaluated + unconditionally, ahead of that check. Two consequences, both deliberate: every + **duplicate** insert on `sys_permission_set` costs one extra metadata round trip + (the accepted path's cost is unchanged — it always paid this probe), and the + duplicate path is now COUPLED to metadata-layer reachability, where before it + answered from the record alone. That coupling is the mechanism behind the corner + above, and it is the price of putting the refusal that names the remedy first. +- adbdbc5: `Clause-②: yes (widening)` + + A `percent` field's declared `scale` is the number of decimal places of the **percentage-point** value as displayed and entered; the **stored** allowance now derives from it. For a fraction-stored percent the record validator's `max_scale` branch accepts `scale + 2` decimal places in the stored fraction (#19320). + + Maintainer ruling batch #161 item 3 letter B (2026-09-18) settles what one word means: `scale: 2` on a percent field is two displayed decimals, so the edit widget offers `12.34` and writes the fraction `0.1234`. The branch compared those four places against the raw declaration and refused the write — an author could declare two displayed decimals and then not write two displayed decimals. + + - **Which fields move**: only a **fraction-stored** percent, i.e. one whose `percentScaleOf` is `fraction` — no declared `max`, or a `max` at or below 1. A **whole-percent** field (`max` above 1) stores the displayed number itself and keeps the declared `scale` exactly, as do `number`, `currency`, `slider` and `rating`. The split is read from the spec's `percentScaleOf`, not re-decided at this seam. + - **Direction, measured in both**: over a 1,950-cell corpus of declaration x written value, **36 cells move from refused to accepted and 0 move the other way**. Nothing that writes today stops writing; no stored value is re-read or re-judged; no migration is implied. + - **`FieldSchema.scale`'s describe states both meanings**, which is the half of the ruling that makes the derivation legible to an author: what the number counts (displayed percentage points) and what it permits in storage (`fraction` ⇒ `scale + 2`, `whole` ⇒ `scale`). The generated field reference page carries the same sentence, and `percentScaleOf`'s docblock points at it rather than restating it. + - **The refusal envelope names the allowance that was applied.** On a fraction-stored `scale: 2` field, `0.12345` is still refused and reports `constraint: { scale: 4, actual: 5 }` — previously it would have read `{ scale: 2, actual: 5 }` on a field that accepts four places, a true refusal described by a false constraint. A consumer asserting the raw declaration back out of a percent field's `max_scale` envelope reads the derived number instead. +- 408ca2e: 45 declared-but-unoffered scalar metadata keys are authorable in the metadata form. Each was **declared** by an object-rooted metadata schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY` — so the generic metadata form rendered no row for any of them and an author's only door was the Source tab: free-text JSON, where a mis-spelled sibling key is written, stored, and refused by the runtime later. + + **The population was re-derived, not inherited.** The reconciliation gate's own helper block (`packages/spec/src/system/metadata-form-zod-reconciliation.test.ts`, lines 104-469 verbatim) was run over the live registry with a lit control (`name`, offered by 17 of 17 forms) and a dark control (a fabricated key, 0 forms and 0 schemas) asserted in the same probe. Readings on the tree this change starts from: **142** top-level zod-only keys across the 17 forms once the ADR-0010 provenance overlay is skipped, **94** of them on the 11 object-rooted types (`view` is union-rooted and contributes the other 48), **87** of those graded `live`, and **49** of those resolving to a scalar schema node. After the change the same probe reads 4, which are the four rows deliberately not landed. + + **Four keys are deliberately still unoffered**, each because a control for it would be an authoring trap rather than an offer: + + - `object.displayNameField` — `[DEPRECATED → nameField]`. Its canonical replacement `nameField` lands here; offering the alias beside it would teach an author the retired spelling. + - `app._unpublished` — the schema's own text says `Never authored`: a machine-managed publish gate written by the AI materialization path and cleared by publish-drafts. + - `field.system` — the auto-injected/system-column marker the platform stamps (`applySystemFields`, the search companion). It is read widely on the write path — the record validator skips required and multi-value checks for a flagged column — so a control for it lets an author assert a false provenance that silently disables validation for that field. + - `field.format` — **one `z.string()` key carrying three vocabularies**, so no help text can be written for it until someone rules which one it has. The engine reads it as an **autonumber pattern**: `resolveAutonumberFormat` (`packages/spec/src/data/autonumber-format.ts:196-202`) falls back from `autonumberFormat` to `format`, and `packages/objectql/src/engine.ts:5043-5051` calls it for every `autonumber` field — as does the SQL driver. objectui reads it as a **date display style**, `short` / `relative`, pinned at the `.objectui-sha` this repo builds against by `packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx` and `packages/plugin-detail/src/__tests__/DetailSection.dueLikeReachesTheCell-9729.test.tsx`. The published `describe` names a third — `email`, `phone` — that **nothing measured honours**: an author who follows it on an autonumber field gets the literal string `email` rendered as their number. + + **The control follows the scalar type and the copy states what the runtime enforces**, including what ABSENCE resolves to, which is the half an author cannot read off an enum: `object.sharingModel` says a custom object that omits it resolves to `private`; `field.step` says the write path does not reject a value off the step grid; `action.undoable` says an action with no `operation` has no write set to capture. Nineteen rows carry a `visibleWhen` MEANINGFULNESS gate mirrored from the same key's row in the object designer's quick-add grid — the schema accepts each key whatever the sibling value is, but only some field types, page kinds or action operations ever read it. + + Three enums (`object.managedBy`, `action.execution`, `action.openIn`) deliberately carry **no** inline `options` list: `FormSelectOptionSchema.value` is a system identifier (`^[a-z][a-z0-9_.]*$`), so members such as `system-data`, `engine-owned` or `perRecord` cannot be spelled as option values at all. Those rows derive their enum from the served JSON Schema, which carries every member verbatim, and the meanings ride the help text. + + ⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles, whose 90 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills, because those three catalogs are ratcheted against undecided echoes. + + ⛔ **The gate that would notice a missing row is NOT landed here.** The top-level `zodOnly` direction of the reconciliation gate stays unwired: turning it on today would turn the remaining absences into red lines with no offers behind them, which is the shape the census round explicitly refused. This change lands offers; the assertion is a separate card. +- ec292cf: Clause-②: no + + Sixteen live structured metadata keys are authorable in the metadata form: eleven on the field form — `accept`, `currencyConfig`, `dependsOn`, `lookupColumns`, `lookupFilters`, `readonlyWhen`, `relatedListColumns`, `requiredPermissions`, `requiredWhen`, `storage`, `visibleWhen` — and five on the action form — `bodyExtra`, `description`, `errorMessage`, `patch`, `requiredPermissions`. Each was **declared** by its schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each now has exactly one row, whose control copies a row a registered form already carries for the same node shape: + + - `visibleWhen`, `readonlyWhen`, `requiredWhen` — `type: 'code'`, `language: 'expression'`, the object designer's per-field rows for the same three keys. + - `lookupFilters` — `widget: 'json'`, the object designer's per-field row for the same key; no inline operator list, because `notIn` is not a spellable option value. + - `lookupColumns`, `dependsOn` — `widget: 'json'`, **never** `string-tags`: each is an array of a union (a field name, or an object entry), and the tag widget is a chip input for strings only, which cannot show or edit a stored object entry. + - `accept`, `relatedListColumns`, and both `requiredPermissions` — `widget: 'string-tags'`, the app form's `requiredPermissions` row: a chip input over a plain `string[]`. + - `currencyConfig`, `storage` — a `composite` with declared sub-rows (`currencyMode` as a `dynamic` / `fixed` select, `defaultCurrency`; `notNull`), the shape of the object form's `access` row. + - `patch`, `bodyExtra` — `widget: 'json'` over a string-keyed record. + - `description` — `widget: 'textarea'`, the page form's `description` row, over the same `I18nLabel` node; `errorMessage` — a plain row, the twin of `successMessage`. + + Each type-specific row is gated to the types its runtime reader serves: the media types for `accept`, `currency` for `currencyConfig`, `lookup` / `master_detail` for the picker and related-list rows, those two plus the four option types for `dependsOn`, `operation: 'update'` for `patch` (the parse refuses it anywhere else), and `type: 'api'` for `bodyExtra`. The help text states what the runtime does with each value, including what absence resolves to. The four field-name lists (`relatedListColumns`, `lookupColumns`, `lookupFilters[].field`, `dependsOn`) are free text: no authoring door judges their names today — not the schema parse, not the publish door and not `os validate` — so the help text claims no such refusal. + + ⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles, whose 38 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. + + ⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers only. +- dc0ab6a: Clause-②: no + + Two live structured object keys are authorable in the metadata form: `fieldGroups` and `indexes`. Each was **declared** by `ObjectSchema`, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each is now a `type: 'repeater'` row on the object form whose sub-rows are declared by hand rather than derived from the schema: + + - `fieldGroups` (Basics, beside `highlightFields`) — six sub-rows, one per canonical group key: `key` and `label` (required text), `icon` (text), `description` (textarea), `collapse` (a `none` / `expanded` / `collapsed` select) and `visibleWhen` (`type: 'code'`, `language: 'expression'`, the `fields` grid's predicate rows). The three `[DEPRECATED → collapse]` aliases (`defaultExpanded`, `collapsible`, `collapsed`) are **not** offered; the metadata-form reconciliation ledger records a nested `omit` row for each. The parse still accepts them and derives `collapse` from one only when `collapse` is absent, so a stored entry keeps its meaning, and a `collapse` set in the form outranks any alias it carries. + - `indexes` (Advanced, beside `datasource`) — three sub-rows over the keys the SQL driver reads: `name` (text), `fields` (`widget: 'string-tags'`, required) and `unique`, a select offering **only** `global` and `organization`. The deprecated bare `unique: true` is never offered: a schema-derived control would take the union's first arm and render a switch that writes it. An edit merges into the stored entry, so an index that already carries `true` or `false` keeps it until the author picks a scope, and the select can write only the two values the parse accepts. `type` and `partial` are tombstones and have no row. + + The help text states what the runtime does with each value. `indexes[].fields` is free text, and the schema parse, so a draft save, does not judge its names; `os validate`, `os build`, `os lint` and the publish door refuse a name that is not a field of the object (`object-field-ref-unknown`, #20479, in the same release). A name that is not a stored column, a `formula` field say, makes the SQL driver skip the whole index at sync with an error in the server log, and the help text says exactly that; `os migrate plan` reports the skipped index too (#20432, in the same release). A field group has no field-name list: a field joins a group through its own `group` key. + + The two row schemas also carry a JSON Schema `title` on every property, as every repeater row schema must: `IndexSchema` on `name`, `fields` and `unique`, and `ObjectFieldGroupSchema` on its nine keys, the three deprecated aliases included. A property panel that reads the served schema's titles therefore shows a named column instead of a raw key. Each title is a `.meta({ title })` call and nothing more. + + ⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves (its rows, and the titles above in its JSON Schema) and the translation keys `os i18n extract` walks, hence the regenerated `platform-objects` metadata-form bundles. Their 22 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. + + ⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers and three nested ledger rows only. +- 19e58e2: Clause-②: no + + Four more live structured keys are authorable in the metadata forms: `activityMilestones`, `publicSharing` and `userActions` on the object form, and `inlineColumns` on the field form. Each was **declared** by its schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each is now a row whose sub-rows are declared by hand rather than derived from the schema: + + - `activityMilestones` (object form, Advanced, beside `validations`) — a `type: 'repeater'` over the milestone's four keys: `field` (`widget: 'text'`, required), `value` and `summary` (text, required) and `type` (text). `field` pins its widget because the console turns a string sub-row named `field` into a field picker whose catalogue an object draft never fills. + - `publicSharing` (object form, Advanced, after `requiredPermissions`) — a `type: 'composite'` over all six keys of the share-link policy: `enabled` (switch), `allowedAudiences` and `allowedPermissions` (`widget: 'multiselect'` over their enum members), `maxExpiryDays` (number, at least 1), `redactFields` (`widget: 'string-tags'`) and `eligibility` (`type: 'code'`, `language: 'expression'`). + - `userActions` (object form, Advanced, under `managedBy`) — a `type: 'composite'` over the five affordance keys. `create`, `import`, `edit` and `delete` are each a boolean **or** a `{ enabled, visibleWhen, disabledWhen }` object, so they take `widget: 'json'`: the console renders a switch for a new entry or a stored boolean, and the object's own keys for a stored object, and never writes one arm over the other. `exportCsv` is a switch. + - `inlineColumns` (field form, Configuration, beside `inlineTitle`, shown on `master_detail` fields) — a `type: 'repeater'` over a **curated subset** of the twenty keys an inline grid column accepts: `name` (required), `label`, `width` and `defaultHidden`. The metadata-form reconciliation ledger records the nested `subset` row and names what is left to source and why: `type` opts a column out of hydration from the child field, the type-specific keys cannot be gated on a type the column takes from the child field at render, and the rules are copies of the child field's own. + + The help text states what the runtime does with each value, read from its consumer, and claims a refusal only where one exists. A misspelt `publicSharing.redactFields` entry is refused at publish and by `os validate`. `activityMilestones[].field`, a `{token}` in its `summary`, and `inlineColumns[].name` are judged by no authoring door, and their help texts say so and name what happens instead: the milestone never fires, the token renders empty, the column renders as plain text. + + The two new repeaters' row schemas also carry a JSON Schema `title` on every property, as every repeater row schema must: the four keys of an `activityMilestones` entry, and all twenty keys of `InlineGridColumnSchema`. Each title is a `.meta({ title })` call and nothing more. + + ⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves (its rows, and the titles above in its JSON Schema) and the translation keys `os i18n extract` walks, hence the regenerated `platform-objects` metadata-form bundles. Their 46 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. + + ⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers and one nested ledger row only. +- b3615f1: **BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the `constructor` / `prototype` field-name ban that `ObjectSchema.fields` has always enforced, so a validator reading the published files stops answering PASS on `{"constructor":{"type":"text","label":"R"}}` at `data/Object.properties.fields` — a document the runtime refuses by name (#19346; #18670 item 2, through the `banned-keys` arm). + + Clause-②: yes (narrowing) + + **No arm joins the closed list.** The ban is over a FINITE list of two names, which is exactly what the existing `banned-keys` arm expresses, so this is a call site moving onto a declared pattern rather than a new public-contract decision. What moved is WHERE the refusal is written: from a `.refine()` on the record's KEY schema to a record-level `bannedKeys(['constructor', 'prototype'])` inside the existing `refuseRecordProtoKey(...)` wrapper. A key-schema `.refine()` is a `custom` check, and `z.toJSONSchema()` has no arm for one, so that rule reached the runtime and never the file. + + **The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json` goes from 204 entries / 569 sites to **204 entries / 560 sites** — nine site deletions, no entry deletions (every one of the nine schemas keeps other rows), and **0 sites added anywhere**: + + | ledger entry | row deleted | + |:---|:---| + | `api/AssembledInstalledPackage` | `manifest.objects.element.fields.out.keyType` | + | `api/GetInstalledPackageResponse` | `data.options[1].manifest.objects.element.fields.out.keyType` | + | `api/InstalledPackageAtEitherStage` | `options[1].manifest.objects.element.fields.out.keyType` | + | `api/ListInstalledPackagesResponse` | `data.packages.element.options[1].manifest.objects.element.fields.out.keyType` | + | `api/ObjectDefinitionResponse` | `data.fields.out.keyType` | + | `data/Object` | `fields.out.keyType` | + | `system/ChangeSet` | `operations.element.options[3].object.fields.out.keyType` | + | `system/CreateObjectOperation` | `object.fields.out.keyType` | + | `system/MigrationOperation` | `options[3].object.fields.out.keyType` | + + Generator census after: **560 dropped across 204 published schemas, 366 projected** — 224 `non-blank-string`, 129 `required-one-of`, **11 `banned-keys`** (2 before), 2 `dependent-required` — 9 undecidable. Across the published tree, **1524 of 1535 files are byte-identical**: the nine carriers above each gain the ban and lose their matching `x-dropped-refinements` row, and the remaining two are the bundle (`objectstack.json`) and the build-input hash. + + **⛔ The set of documents the runtime accepts does not move.** The arm is EXACT rather than approximate: a JSON object's properties are exactly its own enumerable string-keyed ones and `propertyNames` judges exactly those names, and `bannedKeys` reads OWN properties and never `key in value` — which is what the key schema judged too, since a record's key loop only ever visits own keys. It is presence and never value: a banned key present with a `null` value is present on both sides. Measured with ajv 8 (draft 2020-12) on the generated `data/Object.json`, before and after, the verdict vector moves in one direction only — `{"constructor": …}` and `{"prototype": …}` go `true` to `false`, while an ordinary document and the near-miss controls `{"constructors": …}` and `{"to_string": …}` are accepted on both sides. + + **⚠️ What DOES move is the refusal's location, and a consumer will see it at BOTH layers** — the raw zod issue, and the published `{field, code, message}` envelope every REST / data-API client reads (ADR-0114, built by `api/zod-issues-to-fields.ts`). Measured on this tree by parsing `{"name":"lead","label":"Lead","fields":{"title":{…},"constructor":{…}}}` with the schema before and after: + + | layer | | before | after | + |:---|:---|:---|:---| + | raw zod issue | `path` | `['fields', '']` | `['fields']` | + | raw zod issue | `code` | `invalid_key` | `custom` | + | raw zod issue | where the reason text sits | nested one level down, under zod's fixed "Invalid key in record" | the issue's own `message` | + | published envelope | entries | **2** | **1** | + | published envelope | `field` | `fields.constructor` on both entries | `fields` | + | published envelope | `code` | `invalid_shape` (zod's "Invalid key in record") **and** `invalid_value` (the reason) | `invalid_value` alone | + + ⚠️ `invalid_shape` is a member of the published `FieldErrorCode` vocabulary and it no longer appears for this refusal at all. A client that branched on `invalid_shape` to detect a rejected field NAME must branch on `invalid_value` at `field: "fields"` instead, and must stop expecting two entries where it now receives one. + + The message text is unchanged and still names both reserved words in full. The fix for a consumer that keyed on the old shape: match the issue at path `fields` with code `custom` — `field: "fields"`, `code: "invalid_value"` in the envelope — and read its `message` directly, instead of descending into an `invalid_key` issue's nested `issues[0]`. This is the cost of the projection: the closed list can only publish a RECORD-level predicate, and `.refine()` carries no per-key path, so a located-per-key refusal and a published refusal cannot both be had from one rule. The ban list is closed and two names long, so the slot is still named and the two candidate keys are both named in the message. + + **⛔ `__proto__` is untouched, and it is a third name rather than a third case.** Its guard is `refuseRecordProtoKey`'s `z.preprocess` on the raw input, because zod's record parser skips that one name with an unconditional `continue` ABOVE the key schema — no schema, and therefore no projection, can ever see it. It holds no ledger row and gains no published keyword here. This change reaches two of the three names, never three. + + +- 0b4022b: feat(automation): `GET /automation/:name/runs` retires `cursor` and computes `hasMore` (#19543) + + This door declared a pagination parameter it never spent and then reported, as a + literal, that there was nothing more to fetch. Both halves are closed here, per + the maintainer-approved ruling of 2026-09-21 (decision batch #204 item 2, + letter C of three). + + **BREAKING** — `cursor` no longer parses on `ListRunsRequestSchema`, its slot + is gone from `IAutomationService.listRuns`, and `@objectstack/client` no longer + declares or sends it on any of the three run-list surfaces + (`automation.runs.list`, `automation.listRuns`, + `client.environment(id).automation.listRuns`). It was declared on the wire, + *validated* at the boundary, forwarded into the service contract, appended by + the SDK, and read by no implementation. No emit site has ever written the + response half `nextCursor`, and the only ordering this door has is a required + but non-unique `startedAt` timestamp that nothing ever minted a resume point + from — so a caller looping "until the cursor runs out" re-read the first and + only window forever, with no error. + + ``` + FROM ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' }) + -> { name: 'f', limit: 20, cursor: 'n_007' } // forwarded, then dropped + + TO ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' }) + -> throws: '`cursor` was removed from GET /api/v1/automation/:name/runs in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) …' + ``` + + `cursor` is a `retiredKey()` tombstone rather than a deletion: the request + schema is not `.strict()`, so a bare deletion would have made Zod silently strip + whatever a generated client kept sending — a clean parse and a parameter that + never takes effect, which is this defect re-created one layer down (ADR-0104). + Writing the key is now a `tsc` error and a parse error carrying the + prescription. + + **The SDK is retired in the same stroke, and that is what makes the sentence + above true.** Retiring the key in the schema alone would have left the one + generated client this repo ships typing it `string` and sending it into a route + that no longer reads it — the exact ADR-0104 shape the tombstone exists to + prevent, re-created one layer down, for the channel most callers actually reach + this door through. So the option is gone from all three surfaces and no + `?cursor=` is appended on any of them; an untyped caller cannot smuggle it past + the retired schema either, which is pinned. Same call as when #6361 retired the + notifications `cursor`: the client dropped the option and recorded the removal + in its docblock. + + ``` + FROM client.automation.runs.list('f', { limit: 5, cursor: 'abc' }) + -> GET …/automation/f/runs?limit=5&cursor=abc // the key is dropped server-side + + TO client.automation.runs.list('f', { limit: 5 }) + -> GET …/automation/f/runs?limit=5 + // `{ cursor }` is now a TS2353 excess-property error; widen `limit` + // (1..100) and read `hasMore` instead. + ``` + + **⛔ `limit` is NOT retired, and its `.default(20)` stays.** The sibling + `/packages` door retired *its* `limit` alongside `cursor` (#17667) because + nothing read it. That does not transfer, and the ruling says so explicitly: here + `limit` is read end to end — the HTTP boundary enforces the declared `1..100` + range read off the schema itself, the service takes it as an option, and the + engine spends it as the run store's history window. Retiring it would have been + a regression, not a narrowing. + + **`hasMore` is now computed, and this is a behaviour change callers can see.** + The door shipped `{ runs, hasMore: false }` with the `false` written as a + literal, beside a list the engine had already cut with `.slice(0, limit)`. A + caller asking for one row of a thousand was handed one row and told that was all + of them. A request whose window is shorter than the matching run set now + receives `hasMore: true` where it previously received `false`; a caller that + read `false` as "this is the whole history" was always wrong and is now told so. + `nextCursor` stays absent — nothing mints one. + + Read the new `false` with **one qualification**: unfiltered it is exact, but + under `?status=` it means "no further match inside the window that was scanned" + rather than "none exists", because the durable history source has no status slot + and the window is taken before the filter is applied. Pushing the filter down is + a `RunStore` contract change this card did not scope. The published + `RunListResult.hasMore` docblock and the response schema's own description both + carry that qualification, so a consumer meets it where they meet the field. + + **How truncation is established, because the obvious signal is wrong.** + `runs.length === limit` cannot tell a flow holding exactly `limit` runs from one + holding ten thousand; the two windows are byte-identical. So + `AutomationEngine` over-reads its history source by exactly one row and compares + the merged, filtered, ordered set against the caller's window. + `RunStore.listHistory`'s signature is deliberately unchanged — over-reading is + expressible in the `limit` it already takes. + + **New:** `IAutomationService.listRunsPage`, an optional member returning + `{ runs, hasMore }` (the shape `IExportService.listExportJobs` already uses, + minus the cursor nothing mints), plus the exported `RunListResult`. The engine + implements it and `listRuns` is its `runs` half, so there is one implementation + and no second copy to rot. A deployment whose automation service does not + implement it answers `501` naming the member, never a `200` carrying a guessed + `hasMore`. + + **One strictness regression, stated because it reverses a recorded decision.** + `?cursor=a&cursor=b` used to answer `400 VALIDATION_FAILED` and now answers + `200` with the key ignored, like any other unrecognised query name. #7300 + validated the key rather than deciding it, so that a future cursor + implementation would not be the one to discover the type was unenforced; this + ruling decides it instead — there will be no cursor implementation on this + door — so the refusal would be validating a key the contract no longer has. + This route declares no closed query-parameter set, so an unrecognised name has + never been refused here on its own account. + + Clause-②: yes + + +- a60c913: **BREAKING for callers** — `parseFilterAST` now refuses a `{ $field }` column reference as a `$between` endpoint, exactly as the authoring schema has since 2026-08-11. A reference at either bound is refused with `INVALID_FILTER` / 400, and the refusal names the side — MIN or MAX, plus the index (#19377). + + Clause-②: yes + + ## What changed, and why it is the implementation catching up rather than a new rule + + `RANGE_ENDPOINT_DESCRIPTION` — the published endpoint contract shared by both of `$between`'s bounds — has stated verbatim since 2026-08-11 that "A { $field } reference is NOT an endpoint shape: no backend resolves one inside a list". The ruling that wrote it (ADR-0049 enforce-or-remove) removed `FieldReferenceSchema` from both endpoint unions, and it shipped at the authoring schema alone. The runtime door disagreed with it: `parseFilterAST({ f: { $between: [{ $field: 'a' }, 'M'] } })` returned the filter unchanged, same object reference, measured on `origin/main` before this change and re-measured after. + + One published sentence therefore had two truth values, decided by which door a caller came through — and the door that passed it is the one an embedder reaches by handing a lowered filter straight to a driver. There, nothing resolves the reference: the in-memory matchers compare the raw reference OBJECT and the range silently matches nothing, while both SQL faces refuse the position. A filter that names a window and answers no rows, or 400s one layer down, is what a caller got instead of a refusal they could act on. + + ``` + FROM parseFilterAST({ close_date: { $between: [{ $field: 'contract.start' }, '2026-12-31'] } }) + -> the same object, unchanged, straight on to the driver + + TO throws INVALID_FILTER / 400: + 'Operator "$between" on field "close_date" does not accept a { "$field": … } + reference as an endpoint (at where.close_date.$between[0], the MIN bound). …' + ``` + + ## Migration — FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `{ $between: [{ $field: 'contract.start' }, '2026-12-31'] }` | `{ $between: ['2026-01-01', '2026-12-31'] }` — the literal bound the range was meant to stop at | + | a range that was genuinely meant to be column-to-column | `{ "$gte": { "$field": "a" }, "$lte": { "$field": "b" } }` — two scalar bounds, the position that compiles on every face | + + **The one-line fix: write the literal bound, or — if the range really was column-to-column — drop `$between` and write the two bounds separately as `$gte` / `$lte`.** Nothing was evaluating the old filter, so treat the replacement as a new one and test it: at every backend the reference range either matched nothing or was refused. + + ## What does NOT change + + - **A `{ $field }` reference as the WHOLE comparand of `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`.** That is #5222's shipped column-to-column capability, it is the alternative this refusal prescribes, and it lowers exactly as before — pinned by a lit control in the same file. + - **Every legal range.** Numbers, Dates, ISO days, UTC instants, clock times and non-temporal text all lower byte-identically, same object reference. + - **The three older endpoint carve-outs.** Arity, `null` (2026-08-31) and blank (2026-09-17) are checked first, so a pair carrying one of those keeps the message and the prescription it already had — an author who wrote `null` is still sent to the null predicate, not to a scalar comparison. + - **A plain object that is not a reference** keeps the comparand-TYPE door's own sentence, one step further on. + - **`$in` / `$nin` members.** The same 2026-08-11 decision rules a reference out of those positions too and `SET_MEMBER_DESCRIPTION` publishes it, but that is a second split over a different published sentence; it is measured and filed separately, and this change deliberately does not move it. + - **The published export surface.** No export is added, removed or renamed; the refusal rides the existing `$between` arm of the shared comparand-shape door, so the engine's delegating wrapper inherits it unchanged. + + +- 3f9e2ea: `ERROR_CODE_LEDGER['@objectstack/plugin-security']` now lists the three codes the package stamps as class fields and ships in `dist`: `INVALID_STATE` (`PermissionSetOverlayStateError`, 409), `NOT_FOUND` (`PermissionSetNotFoundError`, 404) and `NOT_OVERRIDABLE` (`PackagedPermissionSetLockedError` and `PackagedPermissionSetProvenanceUnknownError`, 403) (#19441). + + Clause-②: yes + + Provenance, not identity: each code was already registered under another package (`@objectstack/rest`, `@objectstack/metadata-protocol`), so the `ErrorCode` union, the wire, and every other package's rows are unchanged. What widens is the per-package face a consumer reads from `ERROR_CODE_LEDGER['@objectstack/plugin-security']`. The `NOT_FOUND` synonym waiver's `reason` text now names plugin-security among its emitters; its `code` and `shadows` are unchanged. Nothing to migrate. +- 77f54bf: **BREAKING for authored metadata** — a delegated-admin scope's `businessUnit` (`AdminScopeSchema`, reached as `adminScope.businessUnit` on a permission set) must now name a business unit. An empty or whitespace-only value is refused at parse, at the key's own path, with a message naming what a valid anchor is: the `sys_business_unit.name` of the root of the delegated subtree (#19461). + + Clause-②: yes + + Maintainer ruling A on decision batch #217 item 1, 2026-09-23 「217 同意」. + + ## What changed, and why + + `businessUnit` is the scope's one required key, and every other key of the scope is scoped to it. It was declared as a bare string with no minimum, so `{ businessUnit: '' }` and `{ businessUnit: ' ' }` parsed green: the requirement was satisfied by a value that names no business unit. The delegated-admin gate looks the anchor up by exact name, so a blank anchor resolved to an empty subtree. No escalation was measured; the defect is a declaration that did not enforce what it declared. The likeliest author of a blank anchor is an AI that knew the key was required and did not yet know the unit, and until now the platform answered "accepted". + + ``` + FROM AdminScopeSchema.safeParse({ businessUnit: '' }) + -> { success: true } + + TO AdminScopeSchema.safeParse({ businessUnit: '' }) + -> { success: false, + issues: [{ code: 'custom', path: ['businessUnit'], + message: 'A blank businessUnit is not a delegation boundary: businessUnit is + the sys_business_unit.name (machine name) of the business unit at + the root of the subtree this scope delegates, …' }] } + ``` + + The refusal is a non-transforming refinement: nothing is trimmed. The metadata save path stores the submitted body as written, so a trimming schema would validate one string and store another. A real name parses byte-identical. The published JSON Schema states the same rule (`minLength: 1` and a non-whitespace `pattern`). + + ## Migration — FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `adminScope: { businessUnit: '' }` | `adminScope: { businessUnit: 'north_america' }`, using the machine name of the unit at the root of the subtree | + | `adminScope: { businessUnit: ' ' }` (or a tab or newline) | the same: the root unit's `sys_business_unit.name` | + | a blank-anchored scope on a set that should not delegate at all | remove `adminScope` from the permission set | + + **The one-line fix: set `businessUnit` to the `sys_business_unit.name` of the business unit at the root of the delegated subtree, or remove `adminScope` if the set should not delegate administration.** This cannot be converted automatically, because a blank names no unit and the intended root cannot be inferred. So this ships as an ADR-0087 D3 structured TODO with **no D2 conversion**. + + + + ## Stored permission sets + + - **Stored scopes are not rewritten.** Reads do not re-validate stored rows, so no stored permission set becomes unreadable. A stored blank anchor loads and resolves exactly as before. + - **The next write refuses it.** A Setup or data-door edit of a permission set whose stored scope has a blank anchor answers `422 INVALID_METADATA` naming `adminScope.businessUnit`, until the anchor is named or the scope removed. + - **The boot reconciliation backfill reports it.** A legacy `sys_permission_set` record with no metadata definition and a blank anchor is not backfilled. It is reported on every boot through the existing ADR-0094 D4 durability `ERROR`, which names the record and the offending key, until the record is fixed or deleted. Restoring a trashed blank-anchored set brings the record back and reports the missing definition at `ERROR` the same way. No path skips the row. + - **A clean boot is not a completed sweep.** A definition already stored in `sys_metadata` says nothing until it is written again, so search stored permission sets and the `admin_scope` column for a blank `businessUnit`. + + ## What does NOT change + + - **An absent `businessUnit`** is refused exactly as before, with its own `invalid_type` issue. + - **A real name with surrounding whitespace** is not judged by this change. It parses and is stored byte-identical. + - **The other keys of the scope** (`includeSubtree`, `manageAssignments`, `manageBindings`, `authorEnvironmentSets`, `assignablePermissionSets`) are untouched. + - **The published export surface.** No export is added, removed or renamed, and the `AdminScope` / `AdminScopeParsed` types are unchanged. +- ccccdcc: **Clause-②: yes (widening)** — a new member (`type: 'doc'`) on the published, strict navigation-item union, and a new exported schema (`DocNavItemSchema`), so the accept set an app author writes against grows. Nothing previously admitted is refused: the `docs/nav-target` build rule judges only `doc` items, which no stack could carry before. Contract-review tier. + + A documentation entry on the app menu: the new `type: 'doc'` navigation item (`DocNavItemSchema`, ADR-0046) targets a `book` and/or a `doc`, and at least one is required. + + ```ts + { id: 'nav_help', type: 'doc', label: 'Help Centre', book: 'crm_manual' } // opens the book + { id: 'nav_guide', type: 'doc', doc: 'crm_lead_guide' } // opens that page + { id: 'nav_both', type: 'doc', book: 'crm_manual', doc: 'crm_lead_guide' } // that page, in that book + ``` + + - **`book` alone** opens the book at its first readable page with the book sidebar. Membership is derived by the book's group rules, so a doc added later that matches a rule appears under the entry with no navigation edit. The package id also names a book — the package's implicit book. + - **`doc` alone** opens that page; its book context is the doc's own book, else the package's implicit book. `doc` is a doc NAME (the source filename stem, lowercase snake_case): `crm_lead_guide.md` or `docs/crm_lead_guide` is refused. + - **Neither** is refused when the app is parsed, with a message naming both keys. The rule also reaches the published JSON Schema (`json-schema/ui/DocNavItem.json`) as an `anyOf` of `required`, so a validator reading the schema refuses the same shape. + - **Audience**: the entry has no gate of its own — it inherits the docs audience gate. A `book` entry shows the member only the pages they may read, and is not shown to a member who may read none; a `doc` entry the member may not read is not shown. `visible` / `requiredPermissions` can only narrow that further. + - **`os build` / `os validate` / `os lint`** refuse a `doc` entry whose `book` or `doc` names nothing in the package (new rule `docs/nav-target`, with a did-you-mean). This runs in the docs step because that is where docs from `src/docs/*.md` join the artifact. It checks app `navigation`, `areas[].navigation` and `manifest.navigationContributions`. + - Near-misses are answered: `docName` → `doc`, `bookName` → `book`, and `book` / `doc` written on another item type points at `type: 'doc'`. + + The console renders the new entry in a later objectui release; until then a `doc` item parses and publishes, but the menu does not show it. +- 2b52a5b: fix(spec)!: the filter doors refuse the three shapes they already declared refused — a scalar operator's array, an `icontains` comparand the conformance table rejects, and an ungated `defaultFilters` (#19514) + + **BREAKING** — three accept-set narrowings on published authoring surfaces, each pulling the door back to what this package already declared somewhere an author's parse never reached. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying any of these shapes keeps loading and keeps rendering exactly as it does today; what changes is that RE-SAVING it is refused, at the key that carries the mistake. The hand-migration prescriptions are registered under protocol major 18 as `view-filter-rule-scalar-operator-array-refused`, `filter-icontains-comparand-refused-at-parse` and `object-grid-default-filters-rule-array`. + + Direction set by objectui#9050's ruling C′, quoted untranslated: 「the differences are the protocol's to close」. + + ## 1. A scalar operator carrying an ARRAY is refused + + `ViewFilterRuleSchema.value` has carried this sentence in its published description since the operator/value coupling landed: *the accepted SHAPE depends on the operator: `in` / `not_in` take an array, `between` takes exactly [min, max], **every other operator takes a scalar**.* The refinement that implements the coupling returned early for every operator that was neither a list operator nor `between`, so from the day the coupling landed until this change the entire scalar class was declared and not judged. + + ⚠️ **This reverses a reading the code recorded**, and the reversal is the substance. The scalar-operator array was listed as deliberately accepted because it *"lowers to a bare `{ field: value }` deep-equality comparand, which every backend answers"*. The backends a lowered view rule reaches at this release do not agree — so each is named, in the present tense (how each cell was measured, and which were not, is stated under the table): + + | backend | what it does with the lowered `{ tags: ['a'] }` | + |:--|:--| + | the SQL family: `driver-sql`, the `driver-turso` / `driver-sqlite-wasm` drivers built on it, and turso's remote transport | **REFUSES** — the bare `{ field: value }` loop asserts the comparand against its own scalar-operator set, an array is none of the six accepted comparand types (`a string, number, bigint, boolean, null or Date`), and it comes back as the withheld `INVALID_FILTER` / 400 envelope | + | `driver-memory` | **REFUSES** — the same shape in the same envelope | + | `driver-mongodb` | **ANSWERS** — `translateFilter` passes the array through unchanged and the engine's shared comparand doors pass the shape, so the server applies MongoDB's equality rule for an array operand: a row matches when its stored array **equals** `['a']` **or holds `['a']` as an element**, and a row storing the scalar `'a'` does not (mingo 7.2.4, over `['a']`, `'a'`, `['a', 'b']`, `['b', 'a']`, `[['a'], 'x']`, `[['a']]` and `'b'`, selects `['a']`, `[['a'], 'x']` and `[['a']]`); a live `mongod` is NOT MEASURED | + + How each cell was measured. Run for this change on the lowered `{ tags: ['a'] }`, each beside a scalar and an `$in` control: `driver-sql` on SQLite, `driver-memory`, `driver-mongodb`'s `translateFilter`, and mingo 7.2.4 for MongoDB's rule. ⚠️ NOT MEASURED: MySQL, a live Turso server, and a live `mongod` — the MongoDB row is read at the driver's compile face, at the engine's shared comparand doors and through mingo. + + **None reads the array as the scalar the operator declares.** `driver-mongodb` returns rows — but for a different predicate, and only on an array-valued field, so it reads as a true statement about data the rule never asked for (a live `mongod` is NOT MEASURED). Earlier releases are a separate question, and only partly measured: `driver-memory` refuses the shape from 17.4.0, while its published 17.3.0 returned the row stored as `['a']` (run in this change's review; which other rows it selected, nested arrays included, is NOT MEASURED); whether any earlier SQL-family release answered the shape is NOT MEASURED. + + Two carve-outs are kept and pinned, because a narrowing that runs past the query path is the mirror-image defect: an **omitted** value still parses (`value` is optional), and the four **valueless** operators (`is_empty` / `is_not_empty` / `is_null` / `is_not_null`) still accept anything in the value position — they take their direction from the operator NAME, the lowering discards the value, and the ObjectUI client deliberately sends a truthy placeholder there. + + ## 2. The `icontains` comparands the platform's own table declares refused + + `@objectstack/spec/data`'s `FILTER_TEXT_CASES` declares two REJECTION rows for the case-insensitive contains operator — an **empty** comparand and a **non-string** one, each `code: 'INVALID_FILTER'`. All five driver packages run both rows in their own suites, and the drivers re-run for this change — `driver-sql` on SQLite, `driver-memory`, `driver-mongodb`'s `translateFilter` — each refuse both comparands with `INVALID_FILTER` / 400; the formula matcher does not refuse them, it answers `false` for every row. Nothing applied them at parse, on either vocabulary, so the protocol declared the refusal and then admitted the document that would hit it. Both doors now refuse: the `$` dialect's `FilterConditionSchema` and the view vocabulary's `icontains` arm. + + The predicate is **derived from the table, not transcribed beside it** — both doors call the published `isRefusedTextComparand` and `textComparandRefusalReason`, so a row added to `FILTER_TEXT_CASES` reaches both doors with no edit at either, and the reason an author reads at authoring time is byte-identical to the one three shipped consumer faces already show at query time. `$contains`, `$startsWith`, `$endsWith`, `$like` and `$ilike` are untouched, because widening by analogy is the table's decision and not a door's. + + One asymmetry between the two vocabularies, and it is a fact about them rather than an extra rule: a view rule's `value` is optional, so an **absent** comparand is left unjudged there; the `$` dialect has no absent, so an explicit `undefined` in a comparand slot is the refused non-string shape. + + ## 3. `object-grid`'s `defaultFilters` carries `filter`'s declaration + + The key is described as *"Legacy base-filter fallback, read only when `filter` is absent"* — the same value in the same role as `filter`, read through the same lowering sink. `filter` converged on the `ViewFilterRule` array with the rest of its family; this key was not named by that ruling and kept `z.unknown()`, so the block had one declared door and one undeclared door onto one seam, and the parse receipt said nothing about what the grid would then do with the value. In the objectui version this release pins (`.objectui-sha` pin `87af769e9a`), `ObjectGrid` lowers `defaultFilters` through `toFilterNode` whenever `filter` lowers to nothing, and what that does depends on the shape: + + - the **record form** and the **AST tuple array** are lowered and **applied** as declared; + - a **bare string** or a **number** is **dropped** without a word, so the grid sends no filter and lists its rows unfiltered; + - a **list of malformed rules** is **refused** — on the wire with 400 `INVALID_FILTER`, or by the client before any request for the value shapes it judges itself. + + ⛔ **Narrowed, not retired.** Refusing the key outright is a removal of an accepted shape and needs its own ruling. The deprecation already stated in the description is unchanged: prefer `filter`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ field: 'tags', operator: 'equals', value: ['a'] }` | `{ field: 'tags', operator: 'equals', value: 'a' }` — or `operator: 'in'` if membership was meant | + | `{ field: 'name', operator: 'icontains', value: '' }` | delete the condition — every value contains the empty substring | + | `{ field: 'name', operator: 'icontains', value: 42 }` | `value: '42'`, if a substring match on those two characters was really meant | + | `{ name: { $icontains: '' } }` | delete the condition | + | `{ name: { $icontains: 42 } }` | `{ name: { $icontains: '42' } }` | + | `defaultFilters: { status: 'active' }` | `defaultFilters: [{ field: 'status', operator: 'equals', value: 'active' }]` — better, move it to `filter` and delete the key | + | `defaultFilters: [['owner_id', '=', '{current_user_id}']]` | `defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` | + + What the rewrite changes on the page differs by row, so read them apart: + + - **The scalar-operator array.** How releases before this one answered it is set out in § 1 and is only partly measured. Re-check what each view is supposed to show rather than assuming the old result set was correct. + - **The two `icontains` comparands.** At this release each of the five driver packages answers both with a 400 and the formula matcher excludes every row; how earlier releases answered them is NOT MEASURED. + - **`defaultFilters`.** The record form and the AST tuple array were applied as declared in the pinned objectui, so for them the rewrite is a spelling change. A bare string or a number was dropped, so that grid has been listing its rows unfiltered — decide which rows it should show before writing the rule. Beside a non-empty `filter`, deleting `defaultFilters` is the whole migration; beside `filter: []` the grid reads `defaultFilters`, so move its rules onto `filter` rather than deleting them. + + The one to read closest is a one-element array: its two corrected spellings — `value: 'won'` on `equals`, and `operator: 'in'` with `value: ['won']` — select the same rows, so the result set cannot tell you which the metadata meant, and only the author knows. + + ## Who is affected, measured + + Nothing in this repository authored any of the three shapes. Two fixtures pinned the old accept set and were re-judged rather than rewritten by rote: one asserted that a scalar-operator array parses (it pinned the reading paragraph 1 reverses), and one parsed an ObjectQL AST tuple array on `defaultFilters` to prove the key is HONOURED — that subject survives, on the rule array, with the tuple array's refusal pinned beside it. The full `@objectstack/spec` suite is green, and `check:api-surface` reports no export moved: no symbol is added, removed or renamed by this change. + + Clause-②: no (narrowing) — no key is added, removed or renamed, no exported symbol moves, and no new published vocabulary is introduced (the two operator sets the refusals name are the ones already exported, and the two the checks needed for themselves are deliberately module-private). Every one of the three accept sets narrows back to what this package had already declared: a published `.describe()` for the first, a published conformance table for the second, and the sibling key's own declaration for the third. + + +- 3875ae6: feat!: retire the `GET /api/v1/automation` flow list in favour of `GET /api/v1/meta/flow`; `ListAiConversationsResponse` declares `hasMore` (#19543) + + **BREAKING** — two sibling list doors that declared paging nobody honoured. + + **The flow list is retired, with no alias and no transition window** (maintainer + ruling: 「退役,统一走 /meta/flow」). Its contract described a capability no build + ever delivered: the request declared `status`, `type`, `limit` (default 50) and + `cursor`, and the route read none of them; the response declared `FlowSummary` + rows with `total`, `nextCursor` and `hasMore`, and the route answered bare flow + names beside a literal `hasMore: false`. Measured before removal on the main branch + of this repository and cloud, and on objectui at its pinned commit and at main: + zero callers of the route or of `client.automation.list` outside their own tests, + while the Console flow-runs page and the Setup packaged-automation page already + read `GET /api/v1/meta/flow`. + + FROM → TO, per surface: + + - `GET /api/v1/automation` (and its environment-scoped twin) → no longer mounted + for `GET`. `POST /api/v1/automation` (create a flow) still lives at that path, so + on the default Hono host a `GET` there answers the host's standard method + mismatch — `405 METHOD_NOT_ALLOWED` with `Allow: POST` — the same answer any + POST-only path gets. A transport that forwards every automation path to the + dispatcher (the `@objectstack/hono` catch-all) is told the domain does not handle + it and answers its own not-found `404`. Fix: read `GET /api/v1/meta/flow`; + flows are metadata (ADR-0106), and it answers full definitions, so map each item + to its `name` if you only need names. Per-flow runtime enablement and trigger + binding is `GET /api/v1/automation/_status`, unchanged. + - `client.automation.list` (`@objectstack/client`) → removed; calling it is a + compile error. Fix: `client.meta.getItems('flow')`, or + `client.automation.getRuntimeStatus()` for the enabled/bound state. + - `ListFlowsRequestSchema`, `ListFlowsResponseSchema`, `FlowSummarySchema` and the + types `ListFlowsRequest`, `ListFlowsRequestParsed`, `ListFlowsResponse`, + `ListFlowsResponseParsed`, `FlowSummary` (`@objectstack/spec/api`) → removed, + no replacement export (TS2305 on import). Fix: delete the import; the flow + definition type is `Flow` from `@objectstack/spec/automation`. + - `AutomationApiContracts.listFlows` → removed; the map has eight entries, none of + them a `GET` at the bare path. Every other automation route is unchanged. + + **`ListAiConversationsResponseSchema` gains a required `hasMore`** (the spec half + of the same card; the server half is objectstack-ai/cloud#2426). The list is + declared **newest first** and pages by keyset: `cursor` is the `id` of the last + conversation the caller already holds, and `hasMore` says whether another page + follows. `hasMore` is required rather than optional so a server that does not + compute it is off-contract instead of silently spec-valid; no `nextCursor` is + declared, because the next cursor is the last conversation's id, already on the + page. Who notices: code that constructs a `ListAiConversationsResponse` must now + set `hasMore`, and a response parsed with the schema is refused without it. + `client.ai.conversations.list()` is unchanged — it still resolves to the + conversation array. + + Breaking ships as `minor` per the launch-window convention + (`scripts/check-changeset-no-major.mjs`). + + **Clause-②: yes (narrowing)** — the conversation list's response surface gains a + declared `hasMore`; a route, an SDK method, three published schemas with their five + types and a contract entry are removed, and a conversation-list response without + `hasMore` is now refused. + + +- 1c16889: **`ISecurityService` gains the effective-object-permission reader.** + + `getEffectiveObjectPermissions(context?)` answers the server-resolved effective object-permission + map for a caller — object name -> `EffectiveObjectPermission` — which is the `objects` slot of the + published `/auth/me/permissions` response (`GetEffectivePermissionsResponseSchema`): the caller's + permission sets merged most-permissively, the super-user folds applied, each entry annotated with + its effective API-operation set. Purely additive: the member is OPTIONAL, nothing is renamed, + narrowed or removed, and a security service that omits it still satisfies the contract. + + Clause-②: yes (widening) + + **Why it is a reader on the service rather than a merge each consumer does.** The existing + `resolvePermissionSetsForContext` deliberately leaves the merge to the caller, because two consumers + legitimately project *different* subsets of the same sets. This map is the projection two consumers + need to be *identical*: the effective map `/auth/me/permissions` serves is also the map the + permission predicate `current_user.can(object, verb)` reads through `EvalContext.permissions`, and + that consumer cannot tell a wrong map from a right one. `@objectstack/formula` already states the + hazard at its own door — a hand-built permission map "has no shape of its own to be wrong against: + it parses, `can()` answers from it, and the answer is a confident silent denial". One producer + removes the second copy before it is written. + + **Three properties of the contract, each load-bearing:** + + - **The WHOLE map, with no object parameter.** An entry the map omits reads as "no grant" and + answers `false`, which is indistinguishable from a measured denial — so a caller may not narrow + the map to the objects it expects to be asked about. A predicate names its objects in its own + source; the site assembling the context does not know them. + - **It THROWS on resolution failure and never degrades to `{}`.** An empty map is a *real* answer + here (this subject holds nothing), so a failure returning it would publish a denial of everything + as a measured fact. Callers fail closed on the throw, exactly as they must for + `resolvePermissionSetNames` and `resolvePermissionSetsForContext`. + - **Absence is a defined state.** Consumers feature-detect + (`typeof svc.getEffectiveObjectPermissions === 'function'`), and the fallback is NOT an empty map + and NOT a locally merged one: a caller that cannot get this answer passes no permission data at + all, leaving a permission-gated predicate loudly unevaluable instead of quietly denied. + + Request-scoped: resolve it once per request, never per evaluation (the map is pinned data an + evaluator re-reads for free) and never cached across requests (a grant may since have been revoked). + + **This is the declaration only.** The engine-side threading — populating `EvalContext.permissions` + from this reader at each `current_user`-bound predicate evaluation site, and the + `packages/objectql` / `plugin-security` wiring — lands separately under the same maintainer ruling, + which orders a census of those sites first. +- 1912237: feat(spec): the protocol declares what an ABSENT `scale` means per field type — `percent` ⇒ 0 (#19579) + + **Clause-②: yes (widening)** — two new exported symbols on the `@objectstack/spec/data` index (`resolveFieldScale`, `FieldScaleMeta`), so a published public surface grows purely additively. Graded `minor` for that act, per the repo's level rule. ⛔ Nothing narrows: no key is added, removed or retyped on any `z.object`, no `.default()` is introduced, and a field's parse output is byte-identical to before — see "Why a resolver" below for why that last point is deliberate rather than incidental. + + `FieldSchema.scale` is optional with **no declared meaning for its absence**, so every face that renders a decimal width invented one. Measured on the pinned sibling checkout: the read-only percent cell, the grid summary footer, the detail summary chip and the dashboard metric widget each resolved an absent `scale` to `0`, while the percent EDIT widget resolved it to `2`. One stored `0.25` therefore read **`25%`** on one face and **`25.00%`** on another — two magnitudes for one record, out of a single empty declaration, and a difference users report as a data bug rather than a formatting one. + + Maintainer ruling (director seat, summon 25, batch 194 item 1, letter A′, 「同意」), quoted rather than paraphrased: + + > `@objectstack/spec` declares the default decimal places for an **absent** `scale` per field type, and consumers read it from the protocol — ⛔ no `?? N` in any consumer. **percent ⇒ 0** in this card. + + **What lands.** `resolveFieldScale(field)` in `data/field-scale.ts` answers the effective decimal width: the field's declared `scale` when it has a well-formed one, the platform's declared value for an absent `scale` on that type otherwise. `percent` is the one type with a declared value, and it is `0`. `FieldSchema.scale`'s `.describe()` now states the rule in words an author can read and names the resolver as the single source, so the generated field reference page carries it too. + + **Nothing to migrate.** The key keeps its type, its optionality and its bounds; an authored `scale` round-trips unchanged; a field that declares none parses to output that still omits it. Adopting the resolver is what removes a consumer's private fallback, and the consumer half of that is a separate landing in the sibling repo. + + **Why a resolver, and not a Zod default — measured, ⛔ not assumed.** `FieldSchema` is a flat `strictObject`, so a key-level `.default(0)` cannot see `type` and would land on every numeric type at once: that is the plain letter the ruling refused by name, because an undeclared currency would fall from `$25.00` to `$25`. A type-conditional materialization in the schema's `.overwrite()` tail — the instrument `unique` and `deleteBehavior` use, which CAN see `type` — is wrong for a second reason, outside presentation entirely: `packages/objectql`'s record validator arms its write-time `max_scale` REFUSAL only when `def.scale !== undefined`. Materializing a `0` would start refusing writes the platform accepts today, on every percent field whose author declared nothing — a stored-data change bought for a display ruling, and one no author could read off their own metadata. The absent value therefore stays absent on the parsed field and is resolved at the moment of display; a pin asserts a bare `percent` field parses to output carrying no `scale` key. + + **`number` and `currency` are deliberately NOT declared here.** The ruling scoped them to a consumer census, and the census came back inconsistent for both, so under its own instruction each takes its own card with the readings instead of a guessed default. `number`'s faces disagree by design — the cell renderer resolves absence to "no fixed width" while the summary footer and the metric widget resolve it to `0` — and the display-grouping policy keys on the very distinction a default would erase: a DECLARED `scale: 0` marks a discrete integer (a year, a fiscal period, an ordinal) and renders ungrouped, while an absent `scale` means "decimals unknown" and keeps its separators, so declaring `number ⇒ 0` would print `2026` where the platform shows `2,026`. `currency`'s money faces do not read this key at all: they resolve fraction digits from the currency's own ISO 4217 minor-unit count, with a different surface's `precision` as the authored override. +- fc29c74: feat(spec)!: retire `connector.connectionTimeoutMs` — declared, bounded, defaulted, served back, and never applied as a deadline + + **BREAKING** — `connector.connectionTimeoutMs` is removed. ADR-0049 + enforce-or-remove; maintainer ruling 2026-09-22, letter A. It is the narrower + **second** decision this key was owed: the earlier ruling that made its nine + liveness siblings live (`retryConfig.*`, `requestTimeoutMs`) left this one dead + on a stated reason rather than by oversight, and `packages/spec/liveness/connector.json` + has been asking for this decision since. + + The key was bounded (`min(1000).max(300000)`), defaulted (`30000`), + `.describe()`d, authorable on both carriers and served back by + `/meta/connector`. Every signal an authoring surface can give said it worked. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `connector.connectionTimeoutMs` (on `Connector` and on `DeclarativeConnectorEntry`, so `stack.connectors[]` and `PUT /meta/connector/:name`) | `requestTimeoutMs` — the deadline the platform keeps, applied as `resilientFetch`'s per-attempt timeout. For a connect-only bound, configure it at a connector provider or upstream gateway on a transport that can separate the phases. | + | `ConnectorProviderContext.connectionTimeoutMs` (handed to every `ConnectorProviderFactory` — added after `@objectstack/spec@17.4.0` and never in a release, see below) | `ctx.requestTimeoutMs`, or the factory's own `providerConfig` where the provider owns the vocabulary. | + | The `ZodObject` combinators on `ConnectorSchema` and `DeclarativeConnectorEntrySchema` — `.extend()`, `.omit()`, `.pick()`, `.partial()`, `.merge()`, `.strict()`, `.keyof()`, `.safeExtend()` | Both exports are now `z.preprocess` **pipes** (the residue stage below), so those methods no longer exist on them. **Build on the object and re-wrap:** `acceptRetiredDefaultResidue(, { connectionTimeoutMs: 30000 })`, the `EffectiveObjectPermissionSchema` route. ⚠️ `.superRefine()` still *exists* on a pipe but returns a schema with no read-through `shape`, so refine before wrapping, not after. Parsing, `z.input` / `z.infer`, and the read-through `.shape` are unchanged. | + + **The one-line fix: delete the key.** `os migrate meta --from 17` lists the + mechanical edits for existing sources; apply them by hand. + + The three interface members withdrawn with it were **never in a release**: + `ConnectorProviderContext.connectionTimeoutMs`, + `RestConnectorOptions.connectionTimeoutMs` and + `OpenApiConnectorConfig.connectionTimeoutMs` all entered with `b929e0a662`, + after the `@objectstack/*@17.4.0` tag, and leave in this same release. A factory + or caller built against a released version never saw them; only code written + against an unreleased `main` in between can read them, and it stops. + + ⚠️ Runtime behaviour is **unchanged for every shipped provider**, because none + ever applied the value: a connector that authored `connectionTimeoutMs: 1000` + made exactly the same calls, with exactly the same deadlines, as one that did + not. What does change is observable and intended: the def served by + `GET /connectors` no longer echoes a connect deadline nobody keeps. + + ### ⭐ This is NOT the zero-mention retirement shape + + Measured with `git grep -n connectionTimeoutMs SHA -- . ':!packages/spec'` at + `e07843b5a6`, the tree this retirement landed on: **thirteen** non-test source + occurrences over seven files in five + packages — **six reads** (`openapi-connector.ts:242`, `openapi-provider.ts:193`, + `rest-connector.ts:134`, `rest-provider.ts:64`, `plugin.ts:307`, + `plugin.ts:1589`), **four type declarations**, and **three** surviving hardcoded + `30000` writes. Reading the retirement as "nothing referenced it" loses the + finding. Measured across all six reads, every one is a **pass-through**: the + value's only termini were the def `GET /connectors` echoes and the fingerprint + that decides whether to re-materialize. `connectorFetchOptions()` — the one + mapping from authored policy onto the platform's outbound `fetch` — was handed + `{ retryConfig, requestTimeoutMs }` only. Carrying a number is not honouring it, + and ADR-0049 forbids the parsed-unmarked-unenforced state whether the inert + value travels or sits still. + + Nor was the `实现` arm available. A connector's outbound call is a WHATWG + `fetch`, whose only cancellation surface is ONE `AbortSignal` covering the whole + operation; nothing in that interface observes the connection phase. Bounding + "time until the response arrives" with this key would kill a slow-but-connected + upstream the author meant to allow with a large `requestTimeoutMs` — breaking + the very promise the key makes. (undici's `connectTimeout` needs a custom + dispatcher: Node-only, and a new subsystem underneath every connector, which the + ruling that made the siblings live forbids.) + + ### The retirement kit + + - The **authorable key** is a `retiredKey()` tombstone on `ConnectorSchema`, + registered as `integration/Connector:connectionTimeoutMs` and + `integration/DeclarativeConnectorEntry:connectionTimeoutMs` in + `RETIRED_KEYS_BY_MAJOR[18]`. The schema is not `.strict()`, so a bare deletion + would strip an authored key in silence (ADR-0104): the tombstone is audible in + both channels — `tsc` (input type `never`) and the parse, which raises the + prescription itself. `DeclarativeConnectorEntrySchema` carries it too — both + published carriers wrap the same private `ConnectorBaseSchema` — so + `stack.connectors[]` and the `/meta/connector` door refuse it too: every value + but the retired default `30000`, which the residue stage below strips first. + - **A D2 conversion, `connector-connection-timeout-ms-removed`** — one strip per + `connectors[]` entry, a pure lossless delete. ⭐ The ruling left whether one was + owed to be **measured** ("a D2 conversion only if a stored connector row can + carry the key"). It can, and both legs were measured before the tombstone + landed: `getMetadataTypeSchema('connector')` — what `PUT /meta/connector/:name` + validates against — parsed a body carrying the key and its output **retained** + the authored value, so the number reached `sys_metadata`; and + `applyConversionsToStoredItem('connector', …)` is live for this type. Rows + written on 17.x therefore replay clean. + - **A D3 semantic entry, + `connector-provider-context-connection-timeout-ms-retired`**, for the withdrawn + `ConnectorProviderContext` member (never in a release, above). A provider + factory is code: there is no authored source and no `sys_metadata` row for a + conversion to rewrite, so the removal reaches a factory author who read it — + possible only against an unreleased `main` — as a `tsc` error and as that + entry. + - **No def leaves.** The key was a bare `z.number()`, never a `ConfigSchema` + shape, so `RETIRED_DEFS_BY_MAJOR[18]` gains nothing — and `api-surface/` and + `json-schema.manifest/` are byte-identical, which is the correct reading for a + key-only tombstone rather than a missed regeneration. + - `authorable-surface/integration.json` gains two `[RETIRED]` rows; + `authorable-defaults/integration.json` loses the two `= 30000` rows. + - The liveness row **stays** `dead` with a `REMOVED` note, because `retiredKey()` + keeps the key in the walked shape. Its previous note claimed "every occurrence + outside `packages/spec` is a WRITE". That reading was **correct at the SHA the + card cited and dated** (`0870fb5418` — exactly five non-spec source hits, all + five `connectionTimeoutMs: 30000,`) and was superseded by `b929e0a662`, the PR + the card itself flagged as pending. It is **stale, not false**, and the row now + carries both readings with their trees rather than one undated claim. + - **An `acceptRetiredDefaultResidue` stage** (#12840), `{ connectionTimeoutMs: 30000 }` + on both carriers. The key was `.optional().default(30000)`, so a 17.x parse + materialized it into **every** connector — measured on both sides of the + retirement: the released + `@objectstack/spec@17.4.0` emits `connectionTimeoutMs: 30000` for an entry that + authored only `name`/`label`/`type`, and the tombstone **without the stage** + refuses that exact object at `connectionTimeoutMs`. With the stage, as it + ships, that object is **accepted and the key stripped** before the tombstone + reads it — on `ConnectorSchema`, `DeclarativeConnectorEntrySchema`, the + `/meta/connector` schema and `stack.connectors[]` alike. + The D2 does **not** discharge the obligation, and the precedent shows it: + `ObjectPermission:allowPurge` carries a D2 **and** the residue stage, for its + own reason (a released toolchain materialized its default into every built + artifact's entries). The reason *here* is a different one — this schema has a + second door: `AutomationEngine.registerConnector` parses `ConnectorSchema` for + a def a plugin or provider factory builds **in code**, where no conversion + ever runs, and in 17.4.0 all four shipped connector packages put that `30000` + straight into the def literal. So the emitted `30000` is accepted-and-stripped, + while every other value (`15000`, `1000`, the string `"30000"`) keeps the + tombstone's refusal — at `connectionTimeoutMs`, or at + `connectors.0.connectionTimeoutMs` inside a stack — and nothing is un-retired: + `z.input` stays `never` and the `[RETIRED]` row stays. + - **No deprecation window** (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」), + and no staged retirement. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no download, dependent or source + telemetry was consulted for. The pinned sibling checkout **was** measured: zero + occurrences of the name at objectui `87af769e`, against a lit control on the same + command and scope, so no sibling fix or pin bump rides with this. + + `Clause-②: yes (narrowing)` — a published authorable key is removed on two + carriers, so the accept set a consumer writes against narrows. Nothing is + widened and nothing is renamed. Contract-review tier. + + +- 4ec3987: **BREAKING for runtime-authored `translation` items** — the registered `translation` metadata type no longer declares `settings`: platform settings copy is platform-only at BOTH application doors (#19620) + + Clause-②: no + + `TranslationItemSchema` — one `translation` metadata item, authored with + `defineTranslation`, in Studio, or through the metadata API — now takes the same + ten groups as a per-app bundle entry (`TranslationData`). `settings`, and its + singular `setting`, are refused by name with the platform-only prescription, + exactly as the per-app bundle has refused them since #15178. The file door and + the item door are two authoring surfaces for one app metadata type, so they + accept one shape. + + ### Migration — FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `defineTranslation({ locale: 'zh-CN', settings: { mail: { title: '邮件投递' } } })` | delete the `settings` group — there is no application-side replacement key | + | a `translation` item saved through the metadata API or Studio carrying `settings` | delete the `settings` group; the save answers `422 INVALID_METADATA` until you do | + | `const t: TranslationItem = { locale: 'en', settings: … }` | move the copy to the PLATFORM bundle (`PlatformTranslationData`), or delete it | + + **The one-line fix: delete the `settings` group from the item.** Settings copy is + not application-authorable — `settings` is keyed by `SettingsManifest.namespace` + and only platform code declares a manifest. `settingsCommon` is **not** affected: + the Settings UI shell strings (the source badges, under + `settingsCommon.sourceLabels`) stay on both application faces. + Run `os migrate meta --from 17` to list the mechanical edits for existing + sources; apply them by hand. + + ### Rows already stored are converted, not refused + + A `translation` row saved before this change keeps loading. The runtime + translation sync (`@objectstack/core`'s `authored-translation-sync`) reads + `sys_metadata` itself and used to merge the RAW stored payload; it now replays + the ADR-0087 conversion chain over each row before merging it, the same policy + as every other stored-metadata read seam. `translation-per-app-settings-removed` + has learned the item shape, so a stored row's `settings` is dropped there, the + rest of the item (`objects`, `apps`, …) still loads, and the server logs one + warning per row naming the row, the group and the conversion. Run + `os migrate meta --stored --apply` to persist the canonical rows. + + ### What changes on screen, which is not nothing + + On the item door the group was STRONGER than on the bundle door. A published + item is loaded into the runtime-authored layer, which both i18n adapters read + **over** the shipped bundles — so an item's `settings` overrode the platform's + own Settings copy for its locale, rather than only filling gaps. After + upgrading, re-read the Settings screens in each locale such an item covered: + where it overrode a platform string, **the platform's string renders again**; + where it filled a gap the platform bundle leaves, the **manifest's own literal + renders, which is English**. If a platform string is wrong or missing for your + locale, correct it in the platform bundle (`@objectstack/service-settings`'s + `settingsBuiltinTranslations`). + + No deprecation window: the item door refuses the key by name from this major. + + ### Unchanged + + The platform face — `PlatformTranslationDataSchema`, `settingsBuiltinTranslations`, + and `GET /api/v1/i18n/translations/:locale`, whose served document is the merged + tree — still declares `settings`. The liveness ledger's `translation.settings` + row is deleted because the key left the ITEM's shape; the platform capability it + evidenced is untouched. + + Ruling batch #210 item 2 letter B (2026-09-22) — maintainer 「210 同意」. + + +- 5b9402d: fix(spec,objectql)!: `scale` is retired from the `currency` field type — refused at parse, and no longer enforced on currency writes (#19629) + + Clause-②: no (narrowing) + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. A `currency` field that declares `scale` — any value, `scale: 0` included — no longer parses. The hand-migration prescription is registered under protocol major 18 as `field-currency-scale-refused`. + + A currency's decimal places are the currency's, not a setting. On a currency field the key was three-faced. The metadata-admin field designer offered it as stored metadata; the amount's cell never read it, because a currency amount's fraction digits come from its currency's own ISO 4217 minor unit; and the record validator's `max_scale` branch still refused writes carrying more decimals. An author who set `scale: 3` bought a narrower write contract and no visible change. The maintainer's rulings retire the key from the type rather than aligning the money faces to it. + + **`@objectstack/spec`** — `FieldSchema` refuses `scale` on `type: 'currency'` with a located issue at `scale`. Its remedy: delete the key; the currency's ISO 4217 minor unit decides how the amount displays, and the field's write allowance stays unconstrained. The remedy names no other key to carry the value. No alias and no grace window. `scale` on `number`, `percent`, `rating`, `slider` and `formula` is untouched, and the key's describe now names that set. Studio's object editor no longer offers `scale` on a currency field: the fields grid of the `objectForm` this package registers in `METADATA_FORM_REGISTRY` now shows it only for `number` and `percent`. + + **`@objectstack/objectql`** — the record validator's `max_scale` branch no longer reads `scale` for `currency`, so the type leaves the enforced set. A field definition that reaches the validator without passing `FieldSchema` (stored before this release, or built by hand at runtime) therefore narrows nothing either. `min`, `max` and the finite-number check still apply to `currency`, and `number` / `percent` / `rating` / `slider` still refuse over-scale writes exactly as before. A currency write with more decimals than a former `scale` is now ACCEPTED: the write allowance stays unconstrained, the contract every currency field without `scale` already had. Enforcing a currency width on writes instead was offered to the maintainer and not taken. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `Field.currency({ label: 'Amount', scale: 2 })` | `Field.currency({ label: 'Amount' })` | + | `{ type: 'currency', scale: 2, currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }` | `{ type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }` | + + The one-line fix: delete `scale` from every `currency` field. Nothing replaces it, so ⛔ do not re-declare the value under any other key. The currency's ISO 4217 minor unit decides how the amount displays. + + What an upgrade changes beyond the refusal: + + - **Writes.** A currency value with more decimals than the deleted `scale` is accepted where it used to answer `VALIDATION_FAILED` with field code `max_scale`. + - **Two console faces.** At the console pin measured when this change was written, the grid summary footer and the dashboard metric widget read a currency column's `scale ?? 0`. This change lands only after the console derives both faces from the currency, the way the cell does, and after this repository's console pin has moved past that console change. So in the console bundled with this release, deleting `scale` changes neither face. + + ## Who is affected, measured + + AST sweep on `origin/main` `1f89ba0d70`: 15 `Field.currency` declarations in `examples/` (app-crm 4, app-showcase 11) and 13 documentation code examples carried `scale`, every one `scale: 2`. All were deleted in this change. No platform object, seed or JSON fixture in the tree declares it. Seven test fixtures pinned the old shape and were re-judged. One of them, a flow oracle that needed a live `scale` gate, moved its field from `currency` to `number`. + + +- cc6dfd9: fix(spec): a stored view filter rule with no value on a value-taking operator is now refused at save instead of failing every query (#19751) + + **BREAKING** — an accept-set narrowing on a published authoring surface, pulling `ViewFilterRuleSchema` back to what its own `value` description already declares: every operator outside `in` / `not_in` / `between` and the four unary operators takes a scalar, and only the unary operators ignore the key. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings: during the launch window a breaking change ships as `minor`, so the level alone does not signal the break — this banner and the ADR-0087 disposition below carry it. The hand-migration prescription is registered under protocol major 18 as `view-filter-rule-absent-value-refused`. + + ## What changes + + A filter rule that omits `value` (or carries `value: undefined`) on `equals`, `not_equals`, `contains`, `not_contains`, `icontains`, `starts_with`, `ends_with`, `greater_than`, `less_than`, `greater_than_or_equal`, `less_than_or_equal`, `before` or `after` used to parse green and then fail at query time: the rule lowers to `[field, operator]`, and the query path refuses that with `400 INVALID_FILTER` ("Filter comparand at … is undefined") — which failed the whole view, not just that rule. It is now refused when the view is saved, at the rule's `value` path, on every carrier of `ViewFilterRuleSchema` (`ListView.filter`, a tab filter, `Page.filterBy`, a related-list filter, a lookup picker filter): + + ```text + Filter comparand for operator "icontains" on field "name" is undefined. The rule carries no value, … + ``` + + This reverses a carve-out the #19514 entry records: its statements that an **omitted** value still parses (`value` is optional) and that an **absent** `icontains` comparand is left unjudged on a view rule no longer hold for any operator that takes a value — such a rule is now refused once, with the message above. + + ## What stays accepted + + - The unary operators `is_empty` / `is_not_empty` / `is_null` / `is_not_null`, with or without a value. + - `in` / `not_in` / `between` refused an absent value before this change and still do, with their own wording. + - `value: null` on a scalar operator is a value (the null predicate), not an absent one, and still parses. + + ## Migration + + For each refused rule, decide what it meant: + + ```ts + // FROM — no value on an operator that takes one + { field: 'status', operator: 'equals' } + + // TO — a comparison: write the value + { field: 'status', operator: 'equals', value: 'open' } + + // TO — a test for "no value": use an operator that takes none + { field: 'status', operator: 'is_empty' } + ``` + + A rule that was an unfinished row is deleted. The console's filter builder never saved this shape (it drops a half-filled row before saving), so the rules to look for are hand-authored or written by another tool. + + Clause-②: no (narrowing) — no key is added, removed or renamed and no exported symbol moves; the accept set of `ViewFilterRule.value` narrows back to what its published description declares. + + +- 7536721: fix(spec)!: the shared comparand-shape face refuses an ARRAY in the equality slot — `{ field: [...] }` and `{ field: { $eq: [...] } }` — for every driver at once (#19757) + + **BREAKING** — an accept-set narrowing at the runtime filter doors, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Ruled on #19757 (record 5793368540, letter 乙, 「217 同意」): an array in the implicit-equality slot is refused at the shared face, for every driver at once — no alias, no grace window. The hand-migration prescription is registered under protocol major 18 as `filter-equality-array-comparand-refused`. + + ## What changes + + `parseFilterAST` lowers `['tags', 'equals', ['a']]` — and the same triple on `=`, `==` and `eq` — to the implicit form `{ tags: ['a'] }`. That shape, and its explicit spelling `{ tags: { $eq: ['a'] } }`, now get `INVALID_FILTER` / 400 from the shared comparand-shape face (`assertListComparandShapes` in `@objectstack/spec/data`). That face runs inside `parseFilterAST` and at the engine's lowering seam on both engine doors, so the refusal lands before any driver runs, at any depth under `$and` / `$or` / `$not`. The empty array is refused too. The message names the field, the path, and the two operators a list in that slot was standing in for: `{"$in": […]}` for "one of these values" (authoring spelling `in`), and `{"$contains": "…"}` for "the stored list holds a value" on a multi-value field (authoring spelling `contains`), with an `$or` of those for any-of. + + How each backend answered the lowered `{ tags: ['a'] }` before this change. Each was run for this change beside a scalar and an `$in` control: + + | backend | before | how it was measured | + |:--|:--|:--| + | `driver-sql` (SQLite) | **refused**, 400, at the top level. Nested under `$and` / `$or` / `$not` it answered **500 `DATABASE_ERROR`**: SQLite could not bind the list. | `SqlDriver.find` on better-sqlite3 | + | `driver-memory` | **refused**, 400, at every depth | `InMemoryDriver.find` | + | `@objectstack/formula` | **no row**, including a row storing exactly `['a']` | `matchesFilterCondition` | + | `driver-mongodb` | **answered**. `translateFilter` emits the array unchanged. MongoDB equality on an array operand selects a stored array **equal to** `['a']` **or holding** `['a']` as an element. | `translateFilter`, then mingo 7.2.4 as the named proxy for the server. Over `['a']`, `'a'`, `['a','b']`, `['b','a']`, `[['a'],'x']`, `[['a']]`, `'b'` and `[]`, it selected `['a']`, `[['a'],'x']` and `[['a']]`. | + | `service-analytics` filter normalizer | **answered as membership**. The FilterArray form `[['stage', '=', ['won', 'lost']]]` charted as `stage IN ('won', 'lost')`. | `normalizeAnalyticsFilterTree` | + + ⚠️ NOT MEASURED: a live `mongod`, MySQL, PostgreSQL, and a live Turso server. `driver-turso` and `driver-sqlite-wasm` are built on `driver-sql` and were not run separately. + + After this change, every row above that goes through a platform door gets the 400. That covers `parseFilterAST`, the engine's lowering seam on both doors (every engine verb's `where` passes through it; measured on `find` and `count`), and the analytics normalizer's FilterArray form. The drivers themselves are untouched, so a caller that hands a raw `FilterCondition` straight to a driver, without `parseFilterAST`, still gets that driver's own answer. + + ## What does NOT change + + - **`$ne` carrying an array is not judged.** The ruling names implicit and explicit equality. `$ne` measured the same split (refused by `driver-sql` and `driver-memory`, answered by `driver-mongodb`) and is left to its own ruling. + - The other scalar operators carrying an array (`$gt`, `$contains`, `$like`, …) are not judged here either. + - The list operators keep their arrays: `$in`, `$nin` and `$between`, including `$in: []` / `$nin: []`. + - Every scalar equality comparand is untouched. That includes `null`: `{ field: null }` and `{ field: { $eq: null } }` are the has-no-value predicate. + - A `{ $field }` reference on an equality spelling still lowers to `$eq` and passes. + - A field spec with no `$` key (`{ author: { tags: ['a'] } }`) is still not descended into. + - This change does not touch the schema doors. A separate change in this release does: `FilterConditionSchema` and `FieldOperatorsSchema.$eq` now refuse the same shape when a document is saved, with this refusal's sentence (the location is carried by the issue's path instead). Its changeset and the ADR-0087 entry `filter-equality-array-comparand-refused-at-save` describe it. + - `ViewFilterRule` already refused an array on every scalar view operator at authoring time. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ tags: ['a', 'b'] }` / `[['tags', 'equals', ['a', 'b']]]`, meaning "one of these values" | `{ tags: { $in: ['a', 'b'] } }` / `[['tags', 'in', ['a', 'b']]]` | + | `{ tags: ['a'] }`, meaning "the stored list holds `a`" on a multi-value field | `{ tags: { $contains: 'a' } }` / `[['tags', 'contains', 'a']]` | + | `{ tags: ['a', 'b'] }`, meaning "the stored list holds `a` or `b`" | `{ $or: [{ tags: { $contains: 'a' } }, { tags: { $contains: 'b' } }] }` | + | `{ tags: ['a'] }`, meaning one value | `{ tags: 'a' }` | + | `{ tags: { $eq: [...] } }` | any of the rows above | + + On `driver-mongodb`, check what the query is supposed to return, and do not assume the old rows were right. The old answer was MongoDB array equality, and neither `$in` nor `$contains` gives the same rows. A dashboard or dataset filter written as the FilterArray sugar with an array on equality used to chart as membership. It is now refused, and `$in` is the spelling that charts the same rows. + + ## Who is affected, measured + + Nothing in this repository's examples, seeds, docs or published skills authors the shape. The repo was grepped for the FilterArray triple on `=` / `==` / `equals` / `eq` carrying an array, for `$eq` carrying an array, and for filter / where objects whose field value is an array. The hits are tests and the engine-double conformance tables. The full suites of `@objectstack/spec`, `objectql`, `driver-memory`, `driver-sql`, `driver-mongodb`, `driver-turso`, `driver-sqlite-wasm`, `formula`, `service-analytics`, `metadata-protocol`, `metadata-core`, `plugin-sharing` and `lint` were run, and four things went red. Each was re-judged, not rewritten by rote: + + - The comparand-shape suite pinned `{ tags: ['a','b'] }` and `$eq: ['a','b']` as shapes the face passes through. Both rows are inverted, and the shapes now live in the arm's refusal section. + - The field-reference lowering suite pinned `['stage', '=', ['a','b']]` lowering to the implicit form. What that row proved still holds, because an array is not promoted to `$eq`. The row now asserts the refusal, which names the implicit slot and not `$eq`. + - Two probe helpers passed a two-element array through every AST spelling to find its `$` operator. One is in this package's comparand-shape suite, the other in `driver-memory`'s vocabulary suite. Each assumed the array could never trip the face. The equality spellings now refuse it, so each helper reads that refusal as `undefined`, which is the answer the helper always gave those spellings. + - `@objectstack/metadata-core`'s engine-double dispatch tables carried three ARRAY `where.id` rows. The real engine now refuses that input at the face before its dispatch runs, so the rows are retired. Their own changeset explains why. + + `FILTER_COMPARAND_TYPE_CASES` gains three `door-refusal` rows (implicit, `$eq`, and nested under `$or`). Every driver suite that consumes the table runs them through `parseFilterAST`. + + Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. The runtime accept set narrows: one comparand shape in one slot, which the face now refuses the way `driver-sql` and `driver-memory` already did. + + +- 0b83e01: fix(spec): `composeStacks` refuses a stack whose value for a concatenated collection (`permissions`, `data`, `views`, …) is not an array, with the ADR-0112 envelope + + **BREAKING** — `composeStacks`, a public root export, now refuses a class of input it used to compose with that stack's content silently missing. + + Step 3 of `composeStacks` concatenates every collection key the composer declares `concat` (`permissions`, `data`, `apps`, `views`, `flows`, `agents`, `packages`, … — every `'concat'` row of `COMPOSE_KEY_DISPOSITIONS`). It kept only the array values and announced the rest with a one-time `console.warn`. The strict `defineStack` parse already rejects a non-array value for any of these keys, so the reachable population is an input that bypassed it — a hand-built stack object, or `defineStack(config, { strict: false })`. Measured before this change, per key, composing a well-formed stack with one whose value for the key is a map: + + | the second stack's value | before | after | + | :--- | :--- | :--- | + | a map, a number, a string, `null`, `false`, a `Set` | composed; the composed collection lacks every entry of that stack (e.g. its permission-set grants, its seed rows), one `console.warn` | refused, `STACK_SCHEMA_INVALID`, `status: 422` | + | the same, under `manifest: 'preserve'` | composed; the top-level collection lacks the entries, while that stack's package body still carries the malformed value — the artifact disagrees with itself | refused, `STACK_SCHEMA_INVALID`, `status: 422` | + + A composed artifact is complete or it is refused, so no non-array value is skipped. An absent key (`undefined`) is not malformed and composes as before. The refusal is the one `composeStacks` already raises for a non-array `objects`: the code the strict parse raises for the same authored mistake, the zod issue on `issues` (`path` rooted at the key, `expected: 'array'`), and a message naming the stack by manifest id and position and the key. For a key `defineStack` accepts in the map form, the message says so. A non-object entry inside an array is still concatenated as-is — the entry is carried, not lost. + + The one-line fix: author the key as an array, or pass the stack through strict `defineStack` (which normalizes the map form and rejects every other shape where it is written). + + No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`, and the error class stays module-local. + + + + Clause-②: no (narrowing) +- ebc6afe: fix(spec): `defineStack(config, { strict: false })` refuses a non-array `objects` with the ADR-0112 envelope + + **BREAKING** — `defineStack`, a public root export, now refuses under `strict: false` a class of input it used to crash on or hand on unusable: a non-array `objects`, or an `objects` array holding an entry that is not an object. + + The non-strict door skips the parse and hands the normalized input to the action merge that ends every `defineStack` call. That merge read `objects` with no shape guard. Measured before this change: + + | `objects` under `strict: false` | before | after | + | :--- | :--- | :--- | + | a number or a string (`5`, `'abc'`) | bare `TypeError: config.objects.map is not a function`, `code` and `status` both `undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422` | + | `null`, `''`, `0`, `false` | returned untouched, refused one call later by `composeStacks` | refused, `STACK_SCHEMA_INVALID`, `status: 422` | + | an array holding `null` (`[null, obj]`) | bare `TypeError` reading `actions` off `null` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['objects', index]` | + | an array holding another non-object (`[obj, 7]`, `[obj, 'x']`) | returned with the entry in place, a success whose objects are not all objects | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['objects', index]` | + + `strict: false` skips validation — cross-references and schema detail — and never promised to accept a shape the merge cannot read. The refusal carries the code the strict parse raises for the same authored mistake, with the zod issue on `issues` — `path: ['objects']`, `expected: 'array'` for the collection, `path: ['objects', index]`, `expected: 'object'` for each non-object entry — the same line `composeStacks` draws for a non-array `objects`. Every row narrows: nothing that used to be refused is accepted now. An absent `objects` (`undefined`) is not malformed and behaves as before; the map form (`{ name: { … } }`) is still normalized to an array first and accepted. + + Fix: author `objects` as an array of object definitions or in the map form, or drop `strict: false` to have every schema check run. + + No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`. + + + + Clause-②: no (narrowing) +- 0e06f3b: fix(spec): strict `defineStack` refuses a `Set`, `Map` or other non-plain object for a map-form collection key instead of accepting it as an empty collection + + **BREAKING** — `defineStack` (strict, the default) now refuses a class of input it used to accept with every authored entry silently missing. + + `normalizeMetadataCollection` turns the map form of a collection (`permissions: { rep: { … } }`) into an array before the schema parse. It read any `typeof 'object'` value as that map form, so a `Set`, a `Map` or a `Date` went through `Object.entries`, which yields `[]` for them. The parse then saw a valid empty array: `defineStack({ manifest, permissions: new Set([{ name: 'rep', … }]) })` was accepted with `permissions: []` — the author's grants gone, no error, no warning. This reached every map-form key (every entry of `MAP_SUPPORTED_FIELDS`: `objects`, `apps`, `permissions`, `flows`, `agents`, …). + + | the key's value | before | after | + | :--- | :--- | :--- | + | a `Set`, a `Map`, a `Date` | accepted, the collection is `[]` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, zod issue at the key (`expected: 'array'`) | + | a class instance | read as a map of its own fields | refused the same way | + | an object literal, `Object.create(null)`, a plain object from another realm | normalized (key → `name`) | unchanged | + | an array | passed through | unchanged | + + Only a plain object is the map form; every other value reaches the parse unchanged and is refused there, at the key where it was written. `normalizeMetadataCollection`, `normalizeStackInput` and `normalizePluginMetadata` (public `@objectstack/spec` exports) now return such a value unchanged instead of `[]`. + + The one-line fix: author the key as an array (`[...set]`, `[...map.values()]`) or as a plain-object map (`Object.fromEntries(map)`). + + No code is added to the ADR-0112 ledger and no export changes: the refusal is the strict parse's existing `STACK_SCHEMA_INVALID`. + + + + Clause-②: no (narrowing) +- c1dfa52: fix(spec): `defineStack(config, { strict: false })` refuses a malformed `actions` — top-level or an object's own — with the ADR-0112 envelope + + **BREAKING** — `defineStack`, a public root export, now refuses under `strict: false` a class of input it used to crash on or hand on unusable: a non-array `actions`, or an `actions` array holding an entry that is not an object, at the top level or on an object. + + The non-strict door skips the parse and hands the normalized input to the action merge that ends every `defineStack` call. That merge stable-sorts every `actions` array by `order` and read each one with no shape guard. Measured before this change: + + | `actions` under `strict: false` | before | after | + | :--- | :--- | :--- | + | top-level, a number or a string (`5`, `'abc'`) | bare `TypeError: actions.some is not a function`, `code` and `status` both `undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['actions']` | + | top-level, an array holding `null` (`[null]`) | bare `TypeError` reading `order` of `null` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['actions', index]` | + | top-level, an array holding another non-object (`[5]`) | returned with the entry in place | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['actions', index]` | + | an object's own, a non-array (`5`, `'abc'`, `{}`) | bare `TypeError: actions.some is not a function` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['objects', i, 'actions']` | + | an object's own, an array holding a non-object (`[null]`, `[7]`) | bare `TypeError` reading `order` of `null`, or returned with the entry in place | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['objects', i, 'actions', index]` | + + A falsy non-array (`null`, `false`) at either site, which the merge used to hand on untouched, is refused the same way. `strict: false` skips validation — cross-references and schema detail — and never promised to accept a shape the merge cannot read. Each refusal carries the code the strict parse raises for the same authored mistake, with the zod issues on `issues` at the strict parse's own paths, all findings in one refusal; the top-level line is the one `composeStacks` already draws for a non-array `actions`. The same merge ends `composeStacks`, so a hand-built input stack whose `actions` carries a non-object entry is now refused there with the same code instead of being carried into the artifact. Every row narrows: nothing that used to be refused is accepted now. An absent `actions` (`undefined`) is not malformed and behaves as before, and the top-level map form is still normalized to an array first. + + Fix: author every `actions` as an array of action definitions (the top-level one may also use the map form), or drop `strict: false` to have every schema check run. + + No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`. + + + + Clause-②: no (narrowing) +- 90ff10a: `AutomationContext` declares `callerParamKeys?: string[]` — the flow doors say which `params` keys the caller supplied, and a `screen` node reads that instead of inferring it (#19846). + + Clause-②: yes + + A screen whose fields the run's caller already supplied continues without pausing (#15787). Deciding "did the caller supply this field?" from the params bag was an inference: the bag a flow receives also holds the subject record's columns and the launched row's id, which the door seeds itself. Two constructions still skipped a screen that should have paused — both a non-default `recordIdField` with a `recordIdParam` naming a key the record lacks, on an object-less action whose record has a `recordId` column, or on an object-bound action whose record shadows `recordId` and the `Id` alias. Maintainer ruling on #15705, verbatim: 「15705同意」. + + **What the key says.** The keys of the caller's own `params`, recorded before the door seeds anything. An empty array means the caller supplied nothing; an absent key means the producer does not say. + + **Who fills it.** The action door (`dispatchFlowAction`: `POST /api/v1/actions/...` and MCP `run_action`) and the trigger door (`buildAutomationContext`: `POST /api/v1/automation/:name/trigger`, the legacy `POST /api/v1/automation/trigger/:name`, and a declarative `type: 'flow'` endpoint). Record-change, time-relative and webhook triggers, and code calling `execute` directly, leave it absent; the schedule trigger, whose run has no caller, states an empty list (#19900). `subflow` and `map` nodes drop the parent's list from the child run's context, because it describes the parent's bag. + + **What the screen does with it.** When the key is present, a field is caller-supplied when its name is in the list and `params` holds a value for it; the inference is not consulted. A present value that is not an array names nothing, so the screen pauses. When the key is absent, the inference from #15787 applies unchanged. + + **Accepted cost, precisely:** both doors leave out of that list the keys they use to carry the launched row's id — `recordId`, the camelCase `Id` alias, and on the action door the action's own `recordIdParam` — even when the caller's bag names them, because a client that mirrors the row id into `params.recordId` is addressing the row, not answering the screen. On a run started through either door, a field named like one of those keys is therefore not caller-supplied: a required such field is collected interactively, and an optional one does not count as answering the screen. + + **What moves for a headless caller, through either door:** + + - the two constructions above pause instead of skipping; + - a field whose value equals a column of the subject record, or equals the row id, now counts as supplied when the caller named it — the inference could not tell those from the seeds and paused; + - a field named like the action's `recordIdParam` no longer counts as supplied when the caller sent that key with a value other than the row id — the inference counted it; the door now treats that key as the row-id channel. + + Nothing moves for an implementation of `IAutomationService`: the key is optional, and a context without it keeps its prior meaning. +- 2bbebf5: fix(spec): a `joined` report refuses a top-level `dataset` / `rows` / `columns` / `values`, pointing each onto `blocks[]` (#19856) + + Clause-②: no (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, + never by the level). + + A `joined` report selects nothing itself: each block binds its own `dataset` and + selects its own `rows` / `columns` / `values`, and the renderer's joined branch + reads `blocks` and returns before it reads any top-level selection key. + `ReportSchema` already refused a container-level `order` for exactly that + reason, but the four selection keys beside it parsed green and were then dropped + without a word. Each is now refused at its own path: + + ``` + FROM ReportSchema.safeParse({ name: 'overview', label: 'Overview', type: 'joined', + blocks: [/* … */], dataset: 'tasks', values: ['task_count'] }) + -> { success: true } // both keys silently ignored at render + + TO -> { success: false, issues: [ + { code: 'custom', path: ['dataset'], + message: 'a `joined` report selects per block — move `dataset` onto `blocks[]`, or delete it; on the container it selects nothing.' }, + { code: 'custom', path: ['values'], + message: 'a `joined` report selects per block — move `values` onto `blocks[]`, or delete it; on the container it selects nothing.' } ] } + ``` + + **Fix.** Move the key onto the `blocks[]` entries that need it, or delete it. + Either way the report renders exactly as before, because the container value was + never read. + + **What does not change.** A present `dataset` and a NON-EMPTY list are refused; + an empty `rows` / `columns` / `values` list selects nothing and still parses (the + same threshold as the container `order` refusal, whose message is unchanged). A + joined report's container `runtimeFilter` and `drilldown` — the two container keys + the joined branch does read — parse as before, and every non-joined report is + untouched. An aliased spelling (`measures` → `values`, `dataSet` → `dataset`, …) + is still renamed first, and the renamed key on a joined container then meets this + refusal. + + +- 369bcbe: `DecisionConfigSchema` declares an optional `mode: 'exclusive' | 'inclusive'` — the contract half of the #15429 ruling. The author of a `decision` node that routes on its out-edges can now declare whether it takes only the first out-edge whose condition holds or every one of them — with taking every one as the value that must be written down, the way BPMN separates the exclusive gateway from the inclusive one and n8n's Switch keeps "send to all matching outputs" behind an off-by-default toggle (#19867). + + Clause-②: yes (widening) — one new OPTIONAL key on a published, strict node-config schema, so the set of accepted configs grows. Nothing previously accepted is refused, no key is renamed or retired, and the parsed output of an existing config is unchanged (the key has no `.default()`). + + - **`'exclusive'`** — only the first out-edge whose condition holds, in the order the edges are declared; this is what an omitted `mode` means. **`'inclusive'`** — every out-edge whose condition holds. Any other value is refused at `mode` with a prescription naming both members' meanings. + - **⚠️ Declared ahead of its enforcement, on purpose.** The ruling's split order lands this key first, then the engine semantics together with the `os migrate meta` conversion in one change, then the docs. Until that second step ships, nothing reads `mode`: an edge-branched decision still takes EVERY out-edge whose condition holds, whatever `mode` says. The key's own description says so, and the conversion that writes `mode: 'inclusive'` onto every decision relying on today's behaviour ships in the same change as the new traversal, so no flow changes behaviour silently. + - **A `conditions` list is unaffected**: it is ordered first-match on its own, and `mode` speaks about the out-edges. + - **Where it binds today**: `decision` config stays export-only (nothing parses it at run time), so the closed pair is enforced by `tsc`, by the published JSON Schema and by a direct parse — the same doors `conditions` has. +- 3bd28e2: feat(spec): a package-id segment may open with a digit, and the refusal states the rule the pattern enforces (#19870) + + Clause-②: yes (widening) — the accept set of `ManifestSchema.id` and `PackageSchema.manifestId` (one shared constant, `MANIFEST_ID_PATTERN`) grows by the ids that carry a digit-led segment. Nothing previously admitted is refused, no key is added, removed or renamed, and no export moves. + + **The rule, as the pattern now enforces it:** two or more lowercase dot-separated segments of letters, digits and inner hyphens. A segment may open with a letter or a digit — never with a hyphen — and underscores are not admitted. `com.163.crm`, `com.example.2app` and `local.2024-app` are package ids now; `com.example.-app`, `com.example.my_app` and `Com.Example.App` stay refused. + + **Why the leading-letter clause went.** The package id is a registry key (`manifest_id`), a runtime package-map key and a grant-source key — never a table name, a JS identifier, a filesystem path or a hostname — and a DNS label may itself open with a digit. The clause had no downstream reason, so the refusal sentence could not state one: an author who followed the sentence could write `com.example.2app`, satisfy every clause it listed, and still be refused. + + **The refusal sentence** is now `… Expected reverse-domain notation ('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments of letters, digits and inner hyphens; a segment may not open with a hyphen; underscores are not admitted.` It was `… — lowercase dot-separated segments; hyphens allowed inside a segment, underscores are not.` The doors that surface it verbatim (`POST /api/v1/packages`, the protocol install and duplicate primitives, `POST /api/v1/marketplace/install-local`, `os package publish`) follow it with no change of their own. A digit-led bare word now gets the prefixed repair (`2fa` → `Did you mean 'com.example.2fa'?`), where it used to get none. + + **What moves with it.** `os package publish` derives `local.` from `manifest.name` or the artifact filename when no id is declared; a slug is lowercase letters, digits and inner hyphens, so a derived id now always parses — a manifest named `2024 App` publishes as `local.2024-app` instead of being refused. A declared `manifest.id` is still used or refused, never replaced by a derived one. + + **What does not move.** `manifest.namespace` — the physical object-name prefix (`crm` → `crm_account`) — keeps its own leading-letter rule, for the SQL-identifier reason. A namespace derived from a package id still strips a leading digit run from its last segment (`com.163.crm` → `crm`; `com.example.123` derives none, and the author declares `namespace`). + + **If you relied on the refusal:** nothing you stored changes. A consumer that assumed a package id opens each segment with a letter should stop assuming it; parse it through `PackageSchema.shape.manifestId` or `ManifestSchema.shape.id` rather than a copy of the pattern. +- e7344f0: fix(spec)!: an ARRAY under `$ne` is refused at the shared comparand-shape face and at `FieldOperatorsSchema.$ne`, with one remedy text naming `$nin` (#19886) + + **BREAKING** — an accept-set narrowing at the runtime filter doors and at one published operator slot, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Ruled on #19886 (record 5805254639, ruling A, class 1): "The shared comparand-shape face refuses an array under `$ne` for every driver, and `FieldOperatorsSchema.$ne` refuses it at parse — one remedy text, naming the declared list-negation operator by its spec spelling". No alias, no grace window. The hand-migration prescription is registered under protocol major 18 as `filter-ne-array-comparand-refused`. + + ## What changes + + `$ne` already declared its comparand as "a literal, or a { $field } reference to another column of the same table". An array is neither. Two doors now say so. + + **The shared comparand-shape face** (`assertListComparandShapes` in `@objectstack/spec/data`) refuses `{ field: { $ne: [...] } }` with `INVALID_FILTER` / 400. It refuses it at any depth under `$and` / `$or` / `$not`, and it refuses the empty array too. `parseFilterAST` lowers `['tags', 'ne', ['a']]` to that shape, and so do `!=`, `<>`, `neq`, `not_equals` and `notequals`. The face runs inside `parseFilterAST` and at the engine's lowering seam, so the refusal lands before any driver runs. The message is: + + ```text + Operator "$ne" on field "tags" requires a single comparable value, but received an array (["a"]) at where.tags.$ne. For "none of these values" use {"$nin": […]} (authoring: nin, not_in, notin). The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set. + ``` + + Its first sentence is `driver-memory`'s own wording for this condition. It names one remedy: `$nin`, the list-negation operator `FieldOperatorsSchema` declares ("Not in list"), with its authoring spellings. + + **The operator slot** `FieldOperatorsSchema.$ne` refuses an array on parse. So do its documentation copy `EqualityOperatorSchema.$ne` and the `NormalizedFilter` AST that validates against it. They print the same sentence without ` on field "…"` and ` at `, because a slot cannot see either. The issue's own `path` carries the location instead (`$ne`, `$and.0.stage.$ne`). + + How each door answered `{ tags: { $ne: ['a'] } }` on `origin/main` `9e7824a4`, just before this change: + + | door | before | after | + |:--|:--|:--| + | the shared face, `parseFilterAST`, the engine's lowering seam | **passed** it to the driver, at every depth | `INVALID_FILTER` / 400, before any driver runs | + | `FieldOperatorsSchema`, `EqualityOperatorSchema`, the `NormalizedFilter` AST | **parsed it green** | refused on parse at `$ne` | + | the analytics `where` door (`normalizeAnalyticsFilterTree`) | **compiled it**: `{ stage: { $ne: ['won', 'lost'] } }` became `stage` not-set OR `stage` not-equals `['won', 'lost']`, and both analytics strategies render a not-equals member from its first value, so `'lost'` was dropped and its rows were counted | `INVALID_FILTER` / 400 with the face's sentence | + | `driver-sql`, `driver-memory` | refused, 400, in their own words | unchanged; the face now answers first | + | `driver-mongodb`, the formula evaluator | refused, since earlier stages of this card, in their own words | unchanged; the face now answers first | + + The analytics row's compiled tree was measured with the face's `$ne` arm removed, which is `main`'s face. Its rendering from the first value was read at source (`values[0]` in the native SQL strategy, `v0` in the ObjectQL strategy) and was not executed. A live `mongod`, MySQL, PostgreSQL and a live Turso server were NOT measured. + + So on the SQL family and `driver-memory` the verdict does not move (400 before and after). What moves is the text and the moment: the refusal now arrives at the face with the `$nin` remedy, before any driver runs. On the analytics `where` door the verdict does move: a query that used to answer with a silently widened row set is now refused. + + ## What does NOT change + + - **A stored filter carrier still saves the shape.** `FilterConditionSchema`, which every stored filter parses through (a dataset filter and measure filter, a dashboard widget filter, a report `runtimeFilter`, a rollup filter and the rest), does not parse a field's operator map through `FieldOperatorsSchema`. Its own walk judges `$eq` and does not judge `$ne`. The ruling names the face and the operator slot, not that walk. So `DatasetSchema` with `filter: { stage: { $ne: ['won', 'lost'] } }` still parses green, and every query that uses it is refused at the face. + - `$ne: null` is the has-a-value predicate and is untouched. Every scalar, a `Date` and a `{ $field }` reference are untouched too. `['amount', '!=', { $field: 'budget' }]` still lowers to `{ amount: { $ne: { $field: 'budget' } } }` and passes. + - The list operators keep their arrays: `$in`, `$nin` and `$between`, including `$in: []` and `$nin: []`. + - A field spec with no `$` key (`{ author: { tags: { $ne: ['a'] } } }`) is still not descended into. + - The ordering operators carrying an array, a nested array inside `$in`, and a `{ $field }` referent to a multi-valued field are not judged here. They are the same class and are scoped separately on the card. + - The drivers are untouched. A caller that hands a raw `FilterCondition` straight to a driver, without `parseFilterAST`, still gets that driver's own refusal. + - `ViewFilterRule` already refused an array on `not_equals` at authoring time. + - The published JSON Schema cannot state the check. `z.toJSONSchema()` has no projection for it, so `data/FieldOperators`, `data/EqualityOperator` and `data/NormalizedFilter` still read `{}` at `$ne`. The three sites are declared in `dropped-refinements.baseline.json`. + - No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ stage: { $ne: ['won', 'lost'] } }`, meaning "none of these values" | `{ stage: { $nin: ['won', 'lost'] } }` | + | `[['stage', 'not_equals', ['won', 'lost']]]` (or `ne`, `!=`, `<>`, `neq`, `notequals`) | `[['stage', 'not_in', ['won', 'lost']]]` (or `nin`, `notin`) | + | `{ stage: { $ne: ['won'] } }`, meaning one value | `{ stage: { $ne: 'won' } }` | + + On `driver-mongodb`, check what the query is supposed to return and do not assume the old rows were right. Before this card, MongoDB read `$ne` against an array as "not equal to that array and not holding it as an element", and `$nin` does not reproduce that. On the analytics `where` door, the old answer dropped every member after the first, so a chart built on it counted rows its filter named. + + ## Who is affected, measured + + Nothing shipped authors the shape. Each count below was read against the named tree, with a control: + + - **This repository**, `origin/main` `9e7824a4`. `$ne` followed by an array literal in `packages/**`, `examples/**`, `apps/**`, `scripts/**`, `content/**` and `skills/**` has 20 hits. All of them are tests, refusal code, comments or migration prose. The control, `$in` followed by an array literal in `packages/**` and `examples/**`, has 901 hits. The FilterArray triple on `ne`, `!=`, `<>`, `neq`, `not_equals` or `notequals` carrying an array has 0 hits. A CEL `!=` against a list literal in `packages/platform-objects/**`, `examples/**` and `packages/qa/**` (tests excluded) has 0 hits. `$ne` fed by a variable in non-test source has 11 sites, and none of them builds a list. Each passes a list's first member, a stored-form value, a named constant, a `{ $field }` reference, or a caller's comparand passed through: the better-auth adapter's `ne`, and the CEL lowering, which refuses a list under `!=` before it emits. + - **objectui** at the `.objectui-sha` pin `f8a9d0fb05`: 2 hits, both in its own refusal test. The dataset filter builder gives `notEquals` a scalar arity, and the rollup summary editor gives only `in` / `notIn` a list. The control, `$in` with an array, has 46 hits. + - **cloud** `main` `48d7066`: 0 hits. The control has 33 hits. + + Deployed stacks were NOT measured. Every query that carries the shape is refused with `INVALID_FILTER` / 400 naming the field, the path and `$nin`, so a test suite that exercises the query finds each one. A clean re-save of a stored carrier does not find them, because the carrier schema still accepts the shape. Exercise stored filters, or grep them. + + Clause-②: no (narrowing) — nothing is widened. One comparand shape in one slot, which `$ne`'s published description already excluded, is refused at the face and at the operator slot. + + +- 6aa3188: fix(spec)!: a filter carrying an array in the equality slot is refused when it is saved, in the query face's own words (#19889) + + **BREAKING** — an accept-set narrowing of published authoring schemas, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Ruled on #19889 (record 5805248669, letter A). The hand-migration prescription is registered under protocol major 18 as `filter-equality-array-comparand-refused-at-save`. + + ## What changes + + `FilterConditionSchema` now refuses an ARRAY in the equality slot at parse: the implicit form `{ field: [...] }` and the explicit form `{ field: { $eq: [...] } }`, the empty array included, at any depth under `$and` / `$or` / `$not`. `FieldOperatorsSchema.$eq` refuses an array comparand too, and so do its documentation copy `EqualityOperatorSchema.$eq` and the `NormalizedFilter` AST that validates against it. + + The comparand-shape face (`assertListComparandShapes`) refuses both shapes on every query, from the equality-slot change earlier in this release. Before this change the schema accepted them, so a dataset or dashboard filter carrying one published clean and then failed every query that used it. Measured on `origin/main` `a0920b42dc`: `DatasetSchema.safeParse` with `filter: { stage: ['won', 'lost'] }` answered `success: true`, and so did a measure `filter` of `{ stage: { $eq: ['won', 'lost'] } }`. + + The schema door prints the face's sentence: the field, the received list, and the two operators a list in that slot stood in for. Both doors import it from one builder. The face adds `at where.`. The schema door leaves that out, because the issue's `path` already says where it is (`filter.stage`, `measures.0.filter.stage.$eq`). + + Every schema that carries a `FilterCondition` refuses it on parse. That covers the dataset `filter` and measure `filter`, the dashboard widget `filter` and options-source `filter`, the report and joined-report-block `runtimeFilter`, the field `relatedListFilter` and rollup `summaryOperations.filter`, the solution-blueprint summary `filter`, the analytics query `where`, the dataset selection `runtimeFilter`, the query `where` and `having`, the data-engine aggregate call's `having`, the aggregation `filter`, and the query-filter `where`. So `defineStack`, `os validate` and a save through the metadata protocol (`422 INVALID_METADATA`) refuse such a document at the filter's path. + + Two request doors parse these carriers, and they now answer before the analytics compiler does. The REST dataset selection (its `runtimeFilter`) and the analytics query body (its `where`) answer `VALIDATION_FAILED` / 400 with the sentence at the field. Before, the compiler answered `INVALID_FILTER` / 400. + + ## What does NOT change + + - **Nothing stored is rewritten, and nothing is dropped.** The parse fails and strips nothing. The read path does not re-validate stored rows, so a stored document keeps loading, and its next save is refused. Such a filter has failed every query since the equality-slot change, so the refusal is a repair. + - **The reach is the face's, and no wider.** A field spec with no `$` key, such as the nested-relation condition `{ account: { region: ['a'] } }`, is not judged by `FilterConditionSchema`, because the face does not judge it either. The analytics `where` door does refuse that shape, because it flattens the relation to a dotted member. So the two carriers that door charts, the dataset `filter` and the measure `filter`, refuse it on save as well, in the same words. That is a separate change in this release, ADR-0087 entry `dataset-filter-nested-relation-equality-array-refused-at-save`. + - **The data-engine calls' `where` option still parses.** Its type is a union whose first arm is an open record. The face refuses the shape when the call runs. + - **`$ne` carrying an array is not judged.** + - The list operators keep their arrays, `$in: []` and `$nin: []` included. Every scalar, `null`, a `Date` and a `{ $field }` reference pass as before. + - **The published JSON Schema cannot state the check.** `z.toJSONSchema()` has no projection for it, so `data/FieldOperators`, `data/EqualityOperator` and `data/NormalizedFilter` still read `{}` at `$eq`. The three sites are declared in `dropped-refinements.baseline.json`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ stage: ['won', 'lost'] }`, meaning "one of these values" | `{ stage: { $in: ['won', 'lost'] } }` | + | `{ tags: ['a'] }`, meaning "the stored list holds `a`" on a multi-value field | `{ tags: { $contains: 'a' } }` | + | `{ tags: ['a', 'b'] }`, meaning "the stored list holds `a` or `b`" | `{ $or: [{ tags: { $contains: 'a' } }, { tags: { $contains: 'b' } }] }` | + | `{ stage: ['won'] }`, meaning one value | `{ stage: 'won' }` | + | `{ stage: { $eq: [...] } }` | any of the rows above | + + ## Who is affected, measured + + Nothing shipped in this repository carries the shape. A brace-matched scan of every filter-carrier literal (`filter`, `where`, `runtimeFilter`, `having`, `relatedListFilter`) in `packages/**`, `examples/**`, `apps/**`, `content/docs/**` and `skills/**` read 4709 literals across 7785 files and found 20 field entries whose value opens an array. Sixteen are test fixtures, and four are not filter carriers (a realtime subscription filter and a plugin-permission filter). A grep for `$eq` followed by an array found 24 lines: prose, MongoDB aggregation expressions, and one door-refusal conformance row. Deployed datasets, dashboards and reports were NOT measured. Validating each stack, or re-saving each document, finds every instance the surface above lists. + + Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. One comparand shape in one slot, which every query door already refused, is now refused on save as well. + + +- cf55914: fix(spec): `ApiError.code` and a flattened list overlay's legacy `options` bag carry the shapes their doors accept (#19920) + + Clause-②: no (narrowing) + + **BREAKING for TypeScript code that annotates with `ApiError`, with any response type built on `BaseResponseSchema` (`BaseResponse`, `BatchUpdateResponse`, `SessionResponse`, the metadata, package, storage, analytics and automation response types, and the rest), with `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`, or with the input type of a schema returned by `makeApiErrorSchema`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no value and no export changes, and no export is added. + + Two places in the published types were wider than the doors that judge the same bodies, so values those doors refuse type-checked: + + - `ApiError.code` (the INPUT type of `ApiErrorSchema`): FROM `unknown` TO `ErrorCode`, the vocabulary the schema parses against (`StandardErrorCode` and the registered ledger codes). `ErrorCode` was cast to `z.ZodType` with its output type only, and `z.ZodType`'s input type defaults to `unknown`, so `{ code: 42, message: 'x' }` compiled as an `ApiError` while the schema refuses it at `code`. The same `code` narrows in the `error` of every response envelope built on `BaseResponseSchema`, and in each `ApiError` row of a batch result. `makeApiErrorSchema(codes)` had the same cast for a caller-supplied vocabulary: its schema's input `code` is now the standard catalogue plus `codes`, where it was `unknown`. The parsed types (`ApiErrorParsed`, the `…Parsed` response types) do not move: their `code` was already typed. + - A flattened list overlay's legacy `options` bag: FROM a string-keyed record of `unknown` TO one optional entry per list kind that has a block (`calendar`, `chart`, `gallery`, `gantt`, `kanban`, `map`, `timeline`, `tree`), each entry that kind's own block with every key optional. This holds on the list overlay member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. `options: { foo: 1, kanban: 42 }` type-checked as all four while that member refuses both keys. + + **If your code stops compiling.** A value you annotated with one of these names is not the shape the door accepts: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. An error `code` is a member of `ErrorCode` (or, for a `makeApiErrorSchema` schema, of the standard catalogue plus the codes you supplied); a producer whose own code is outside the vocabulary reports it on `declaredCode`, not `code`. An `options` bag carries only the per-kind blocks listed above, each judged key by key like the top-level block of the same kind; `grid` has no block, and its settings are top-level keys of the view. + + The declared types of `ErrorCode` and of `makeApiErrorSchema`'s `code` narrow with them, so `z.input` of each is typed where it was `unknown`. The types are the schemas' declared shapes, not their verdicts: refinements are not types, so each schema remains the only judge. + + +- 17bd318: fix(spec): `InlineAction`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed` name the shapes their TSDoc promises instead of being `unknown` (#19920) + + Clause-②: no (narrowing) + + **BREAKING for TypeScript code that annotates with `InlineAction`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema, no parse and no export changes, and neither does the declared type of any schema. + + Four published type aliases were derived from a schema whose own static type erases to `unknown`, so any value type-checked against them. Each is now derived from the member schema the parse actually runs: + + - `InlineAction`: FROM `z.input` (`unknown`, because the schema is a `z.preprocess` whose input is the preprocess function's `unknown` parameter) TO `z.input<(typeof InlineActionSchema)['out']>`, the input type of the picked action object. + - `ViewMetadataParsed`: FROM `z.infer` (`unknown`, because the union's members are cast to `z.ZodTypeAny` where it is built) TO the union of the OUTPUT types of `VIEW_METADATA_MEMBERS`, the same record `ViewMetadata` reads its input types from. `diagnoseViewMetadata` keeps returning the schema's own parse output as `data`; only that value's static type changes. + - `AssembledViewArtifact` / `AssembledViewArtifactParsed`: FROM `z.input` / `z.infer` of `AssembledViewArtifactSchema` (`unknown`, the same cast) TO the input / output union of the three non-container `VIEW_METADATA_MEMBERS`, the members that schema's union is mapped from. A container body is now a compile error here, as it always was at the schema. + + **If your code stops compiling.** A value you annotated with one of these names is not the shape the name describes: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. For `InlineAction`, the legacy `type: 'navigation'` and `to` spellings are refused by the type while `InlineActionSchema` still folds them onto `url` / `target`: write `type: 'url'` and `target`. + + The types are the members' declared shapes, not the schemas' verdicts. Each schema still accepts some bodies its type refuses (the preprocess folds and strips) and still refuses some bodies its type admits (refinements are not types), so the schema remains the only judge. + + `JoinedReportBlock` is not changed by this change. It stops resolving to `unknown` in its own entry (#19920). + + +- 681868c: fix(spec): `JoinedReportBlock`, a ViewItem's `config`, a flattened overlay's `viewKind` and a flattened list overlay's `type` / `columns` carry the shapes their doors accept (#19920) + + Clause-②: yes (narrowing) + + **BREAKING for TypeScript code that annotates with `JoinedReportBlock`, `Report`, `ReportParsed`, `ViewItem`, `ViewItemWire`, `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`, or that passes an unchecked value to `defineReport` / `defineViewItem`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no value and no existing export changes. Three parsed-state type names are added (below); nothing is removed or renamed. + + Four places in the published types were wider than the doors that judge the same bodies, so values those doors refuse type-checked: + + - `JoinedReportBlock`: FROM `unknown` TO the input shape of `JoinedReportBlockSchema`. The schema was annotated `z.ZodTypeAny`, which erased its shape; it now carries its inferred type. The same erasure made every `blocks[]` element of `Report` / `ReportParsed` (and so of `defineReport`'s parameter) `unknown`; each is now a block. + - A ViewItem's `config`: FROM `unknown` TO the arm's own config type, a `ListView` config on the `list` arm and a `FormView` config on the `form` arm. This holds on `ViewItem`, `ViewItemWire`, `defineViewItem`'s parameter and return, and the `viewItem` member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. The arm builder took `config` as `z.ZodTypeAny`; it is now a generic parameter. + - A flattened overlay member's `viewKind`: FROM `'list' | 'form'` on both members TO `'list'` on the list overlay and `'form'` on the form overlay, the one value each member accepts. A list-shaped body naming `viewKind: 'form'` used to type-check, through the list overlay member, as `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. + - A flattened list overlay's `type` and `columns`: FROM `unknown` TO the list view's own types, both optional: `type` one of the list view types, `columns` a field list. This holds on the list overlay member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. The member read both keys off the list view shape through a cast that erased them, so `{ object, viewKind: 'list', columns: 42 }` type-checked as all four while that member refuses it. + + **If your code stops compiling.** A value you annotated with one of these names, or passed to `defineReport` / `defineViewItem`, is not the shape the door accepts: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. A ViewItem's `config` must match its `viewKind`: a `ListView` config under `viewKind: 'list'`, a `FormView` config under `viewKind: 'form'`. A flattened list overlay's `columns` is a field list and its `type` one of the list view types. + + The declared types of `JoinedReportBlockSchema`, `ViewItemSchema` and `ViewItemWireSchema` narrow with them, so `z.input` / `z.infer` of each is typed where it was `unknown` (or carried an `unknown` `config`). Typed, each schema's input and output now differ by its defaults, so three ADR-0122 parsed-state aliases are added beside the bare names: `JoinedReportBlockParsed`, `ViewItemParsed` and `ViewItemWireParsed`. Nothing is removed or renamed. + + One default is applied by the parse and is absent from `ViewMetadataParsed` / `AssembledViewArtifactParsed`, and their TSDoc now says so: the flattened list overlay member re-applies `type: 'grid'` in an `.overwrite()`, so every body it parses carries `type`, while its output type leaves `type` optional. + + The types are the members' declared shapes, not the schemas' verdicts: refinements are not types, so each schema remains the only judge. + + +- e462186: `create_record` / `update_record` field values accept the CEL value envelope, declared and evaluated together. + + A value in a `create_record` or `update_record` node's `fields` map may now be a CEL value envelope, `{ dialect: 'cel', source: '…' }`, with the same shape and dialect rules the `assignment` node's `assignments` map already has. The envelope is evaluated by the expression engine that flow conditions use, so the whole CEL stdlib is reachable from a field value, and the result is written with its type kept: + + ```ts + fields: { + subject: 'Quote for {account.name}', // `{token}` template — unchanged + total: { dialect: 'cel', source: 'round(amount * 100.0) / 100.0' }, // CEL, evaluated to the value written + } + ``` + + Clause-②: yes (widening) — a published authoring slot's accept set grows (a valid envelope in `fields.*` is newly evaluated), and the one newly refused shape is the edge the `assignments` map accepted when it gained the envelope: a malformed one. + + **What newly passes.** A valid CEL value envelope as a top-level `fields` value, on both nodes. Before this release the executor wrote such an object into the record verbatim: a text or JSON column stored `{"dialect":"cel","source":"…"}` and the run reported success, and a number column was refused by the data engine. + + **What newly refuses.** A top-level `fields` value that is a plain object with a string `dialect` key and is NOT a valid CEL value envelope. That covers a missing, empty or whitespace-only `source`, an `ast` with no `source`, a `template` or `cron` dialect, and a `source` that does not parse as CEL. Every door refuses it, located at `config.fields.`: `AutomationEngine.registerFlow` refuses the flow, `objectstack validate` reports an `expression-invalid` error, the runtime publish gate answers `422 INVALID_METADATA`, and the node's execute-time contract parse refuses it. Such an object used to be written as data. + + **The rule for nested and literal values.** Only the top-level value of each field is judged. An object nested inside a JSON value or an array is data, whatever keys it carries, and strings inside it still interpolate. A plain string is always a `{token}` template with its existing meaning, and every other literal is written as before. A JSON column whose intended literal value is itself an object with a string `dialect` key is now read as an envelope. To write such an object as data, bind it to a flow variable and write `'{thatVariable}'` (a sole token keeps its type). Measured: no flow in this repository or in HotCRM writes an envelope-shaped object into `fields`. + + **The refusal sentence is slot-neutral.** A refused field value used to be told it was "an assignment value". The sentence every value-slot refusal leads with is now `VALUE_ENVELOPE_REFUSAL`: "A value carrying a `dialect` key is read as an expression envelope, and this one is not a valid CEL value envelope." The published `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` is kept and is the same string, so code that matches on the constant keeps matching. Code that matched the old literal text ("An assignment value carrying…") does not. + + **New in `@objectstack/spec/automation`** (5 exports, 0 removed): + + - `VALUE_ENVELOPE_REFUSAL`, the slot-neutral refusal sentence. + - `FlowValueSlotSchema` / `FlowValueSlot` / `FlowValueSlotParsed`, the value contract every value slot shares (`AssignmentValueSchema` is the same rule under the assignment map's description). + - `resolveFlowNodeValueSlots(nodeType, config)`, which returns every authored value in the ledger's value slots, strings included. + - The expression ledger `FLOW_NODE_EXPRESSION_PATHS` has two new rows, `create_record.fields.*` and `update_record.fields.*` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries both CRUD contracts. + + **Author-time hint (`@objectstack/lint`).** `objectstack validate` warns when a value slot holds a `{…}` template expression, meaning arithmetic or a call to `round` / `floor` / `ceil` / `abs` / `min` / `max`, and points it at the envelope. The warning never fails a build, and the template form keeps working unchanged. Plain references, the `NOW()` / `TODAY()` macros and `$User` paths are not hinted. CEL's `now()` / `today()` are timestamps rather than the strings those macros write, and the flow's CEL scope binds no user. + + **Corrected guidance: `/ 100.0`, not `/ 100`.** The template dialect's `round()` arity refusal used to call `round(x * 100) / 100` the CEL authoring pattern. In CEL that expression truncates: `round()` returns an int, and int / int is integer division, so `x = 1234.5678` gives `1234` instead of `1234.57`. The refusal now prescribes `round(x * 100) / 100.0`, which is correct in both dialects. In the template dialect `/ 100` and `/ 100.0` give the same value. +- 16c5473: fix(spec)!: a `decision` branch with no `expression` — the key absent, or `null` — is refused at authoring (#19961) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on one authored flow-node slot, shipped as + `minor` under the launch-window convention (`check-changeset-no-major` refuses + `major` until GA; breaking-ness is carried by this banner and the ADR-0087 + disposition above, not by the level). + + **What changed.** `DecisionConditionSchema` declares a branch `{ label, expression }` + with `expression` a required `z.string()`. Nothing enforced that: a decision node's + `config` is an open record no schema is parsed against, and the expression ledger's + resolver skipped an absent value as "not authored". So `conditions: [{ label: 'y' }]` + passed `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate`, + and the run then failed at that branch — the executor evaluates every branch it + reaches, and a branch with no `expression` is a condition with no `source`, which + `evaluateCondition` refuses. The ledger now marks the slot `required` (reconciled + against the schema's own `required` list), and the branch is refused at all three + doors through the walk and the function that already refuse a blank one — by + `FlowSchema.parse` with a `custom` issue anchored at the slot (for example + `nodes.1.config.conditions.0.expression`), by `registerFlow` and `objectstack validate` + through that same parse, and by `validateStackExpressions` for a stack handed to it + directly — with one message, led by the published `PREDICATE_SLOT_STRING_REFUSAL` + sentence. `expression: null` is refused the same way, and so is a branch that wrote + its predicate under `condition` (the edge's spelling), which has no `expression` + either. The Studio flow designer writes the refused shape when a branch row's + expression cell is left empty. Where such a branch already sits, the whole flow is + refused: registered from the metadata + registry or `sys_metadata` at boot, it is skipped with a `failed to register flow` + warn naming it while the flows beside it register; a `defineStack({ flows })` source + throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused + whole at load. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `conditions: [{ label: 'high' }]` on a `decision` node | the predicate you meant — `{ label: 'high', expression: 'record.amount > 10000' }` | + | `conditions: [{ label: 'high', condition: 'record.amount > 10000' }]` | the same predicate under `expression` | + | `conditions: [{ label: 'high', expression: null }]` | the predicate you meant, or `expression: 'false'` to keep the branch and never take it | + + **One-line fix:** write the predicate under `expression`. `expression: 'false'` keeps + the branch and its label and never takes it — a change of behaviour, not a preserved + one: a run that reached the branch used to FAIL there, and now routes on to the next + branch or the declared fallback. ⚠️ Do not drop a decision's only branch: the node + then routes by its out-edges alone, and the out-edge that branch labelled is no + longer held back. + + **Unchanged.** A branch carrying a non-blank predicate parses, registers and + validates as before; a blank one keeps its refusal and its own prescription + (`flow-predicate-slot-blank-string-refused`); a `decision` with no `conditions`, or + an empty list, still routes by its out-edges; an absent screen field `visibleWhen` + is still legal (that slot is not required); and `PREDICATE_SLOT_STRING_REFUSAL` + keeps its name and its text. +- b276d44: **BREAKING for authored metadata** — a row-level security policy (`RowLevelSecurityPolicySchema`, authored as `rowLevelSecurity[]` on a permission set) whose `operation` is `select` or `delete` may no longer declare a `check`. It is refused at parse, at the `check` path, with a message that names the operation and says what to write instead (#19965). + + Clause-②: no + + ## What changed, and why + + `check` judges the post-image of a write: the new row of an insert, the changed row of an update. A `select` or `delete` writes no row, and the runtime's write gate only ever collects the policies whose `operation` is the write's own or `all`. So a `check` on a `select` or `delete` policy was accepted, stored and **never evaluated**. It did not guard any write, and beside a USING-only sibling it did not replace that sibling's `using` default the way a `check` on an `insert`, `update` or `all` policy does. An author who wrote a `check` on a `delete` policy believed deletes were guarded by it (ADR-0049: declared is not enforced). + + ``` + FROM RowLevelSecurityPolicySchema.safeParse({ + name: 'no_archived', object: 'account', operation: 'delete', + using: 'owner_id == current_user.id', check: "status != 'archived'" }) + -> { success: true } // the check never ran + + TO -> { success: false, + issues: [{ code: 'custom', path: ['check'], + message: '`check` is never evaluated on a `delete` policy: it validates the new + row an insert or an update writes, and a delete writes none. Remove + `check` from this policy. …' }] } + ``` + + Through a permission set the issue lands at `rowLevelSecurity[N].check`; through `defineStack` it is part of the `STACK_SCHEMA_INVALID` refusal (422). + + ## Migration — FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `operation: 'select'` with a `check` meant to limit which rows can be read | that predicate as the policy's `using` (AND it into an existing `using` with `&&`), and remove `check` | + | `operation: 'delete'` with a `check` meant to limit which rows can be deleted | that predicate as the policy's `using` (AND it into an existing `using` with `&&`), and remove `check` | + | `operation: 'select'` or `'delete'` with a `check` meant to validate written rows | the `check` on a policy whose `operation` is `insert`, `update` or `all` | + + **The one-line fix: remove `check` from every `select` / `delete` policy, and write its predicate as that policy's `using` or on an `insert` / `update` / `all` policy, depending on what it was meant to guard.** This cannot be converted automatically: dropping the key would discard the predicate, and moving it would change which rows the policy admits. So it ships as an ADR-0087 D3 structured TODO with **no D2 conversion**. + + + + ## Stored permission sets + + Stored rows are not rewritten. A permission set already stored with a `check` on a `select` or `delete` policy is refused the next time it is parsed through `@objectstack/spec`, for example on its next save. Removing the `check` changes nothing at runtime, because it never ran. Moving its predicate into `using` or onto a write policy does change behaviour, so re-check the policy set afterwards. + + ## What does NOT change + + - A `check` on an `insert`, `update` or `all` policy parses and is enforced exactly as before. + - A `select` or `delete` policy with `using` only parses exactly as before. + - A blank `check` (empty or whitespace only) declares nothing, and the runtime reads it as absent. It is not refused by this rule. + - No export is added, removed or renamed. +- 172b4cf: fix(spec)!: a turso datasource config the driver refuses, or ignores a key of, is refused where it is written + + Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed and no exported symbol moves. Combinations of `url`, `syncUrl`, `mode` and `timeoutMs` that the turso driver refuses when it is built, and one it builds and then ignores, are now refused at parse. + + `TursoConfigSchema` (the `turso` / `libsql` `datasource.config` contract in `@objectstack/spec`, and the published mirror in `@objectstack/driver-turso`) parsed each key on its own. So it accepted configurations that `new TursoDriver()` refuses with `VALIDATION_ERROR` / 400: a datasource published clean and then failed at boot, or at a test connection. Measured on `main` before the change, both schemas accepting every row: + + ``` + libsql:// (any scheme, any case) + syncUrl -> constructor refuses + libsql:// + mode: 'replica' or mode: 'local' -> constructor refuses + ./data/app.db (a bare path), sqlite:, :MEMORY: -> constructor refuses + :memory: or file::memory: + syncUrl -> constructor refuses + wss:// or ws:// + timeoutMs -> constructor refuses + libsql:// + mode: 'remote' + syncUrl (+ sync) -> constructs; syncUrl ignored + ``` + + On that last row the remote client is built without `syncUrl`, no sync interval starts, the driver's sync call rejects `SYNC_NOT_SUPPORTED`, and the driver still reports sync as enabled. + + **BREAKING** accept-set narrowing on a published schema, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). Refused now, each as one `custom` issue on the key it names: + + - **on `url`**, in a local or replica mode (a forced `mode: 'local'` / `'replica'`, or `syncUrl`, or a url that is not remote): a remote url (`libsql://`, `https://`, `http://`, `wss://`, `ws://`, in any letter case); a url that is none of a `file:` url, `:memory:` or a remote url, such as a bare path, another scheme, `:MEMORY:`, a remote scheme with no `//` or a blank url; and a replica on an in-memory url (`:memory:`, `file::memory:` in any case, with or without a query string); + - **on `timeoutMs`**: a window beside a `wss://` / `ws://` url in remote mode; + - **on `syncUrl`**: `syncUrl` under a forced `mode: 'remote'`. The constructor accepts this one, so it is refused at authoring only; + - **on `sync`**, in the `@objectstack/driver-turso` mirror only: `sync` with no `syncUrl`, in the words the spec contract has always used for it. + + The rules mirror the constructor's own: a scheme matches in any letter case, `:memory:` matches exactly, and the url is read trimmed, as both datasource loaders hand it to the driver. A forced `mode: 'remote'` keeps its url unjudged, as the constructor does. Nothing the constructor accepts is refused, the `syncUrl`-under-`mode: 'remote'` row aside. The mirror declares no `mode` key and strips an authored one, so it judges every config in the mode its url and `syncUrl` select. A test in `@objectstack/driver-turso` holds the constructor and both schemas to one case table of 54 rows, with messages compared byte for byte. + + The spec `url` describe named "a file path" among the accepted spellings, which is the one spelling the driver refuses. It now reads "a local file written as a file: URL (never a bare path)". + + ### Migration: FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `url: 'libsql://my-db.turso.io', syncUrl: 'libsql://my-db.turso.io'` | a remote database: `url: 'libsql://my-db.turso.io'` alone. An embedded replica: `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io'` | + | `url: 'libsql://my-db.turso.io', mode: 'replica'` (or `'local'`) | drop `mode`, or set `mode: 'remote'` | + | `url: './data/app.db'` | `url: 'file:./data/app.db'` | + | `url: ':memory:', syncUrl: …` | a replica on a file: `url: 'file:./data/replica.db'` beside `syncUrl`. An in-memory database: drop `syncUrl` and `sync` | + | `url: 'wss://my-db.turso.io', timeoutMs: 30000` | `url: 'libsql://my-db.turso.io', timeoutMs: 30000`, or drop `timeoutMs` | + | `url: 'libsql://my-db.turso.io', mode: 'remote', syncUrl: …` | drop `syncUrl` and `sync` | + + Each refusal prints these ways out and names only a remote url's scheme, never the url, which may carry a token. Stored datasource rows are not re-parsed when they load, so a stored row keeps loading as before; creating, testing or editing its `config` through the datasource admin service, `defineStack` or `os validate` is refused at the key until it is rewritten. The constructor already refuses the first five rows at boot. + + Blast radius, measured on this tree: no example, template, published skill or hand-written doc authors a refused combination. Four test fixtures spelled one and are rewritten in this change, each named in the PR: one in `@objectstack/spec`, two in `@objectstack/driver-turso` (one of them pinned a placeholder url as accepted), and the stored-row redaction fixture in `@objectstack/service-datasource`, now an embedded replica on a `file:` url. Loader fixtures in `@objectstack/runtime`, `@objectstack/cli` and `@objectstack/service-datasource` that spell a remote url beside `syncUrl` exercise only the config builder or a capturing constructor. They never parse this schema or build the real driver, and are unchanged. Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero. + + +- 67c98f6: **BREAKING** — retire `currencyConfig.precision`: a currency's decimal places are its currency's (#19992). + + `currencyConfig.precision` was declared, validated against ISO 4217, and baked to `2` + into parse output — and **no renderer or runtime ever read it**. objectui's + `CurrencyField` derives an amount's decimal places from the currency's ISO 4217 + minor unit (2 for USD, 0 for JPY, 3 for KWD) and never looked at the key, so an + author who wrote `precision: 4` saw the same two decimals as everyone else. Its + only reader was its own contradiction check. ADR-0049 enforce-or-remove; triage + direction REMOVE under ruling 乙 on #19910 — 「a currency's decimal places are the + currency's, not a setting」. + + Clause-②: no + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }` | `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }` | + | `currencyConfig.decimals` / `currencyConfig.scale` (always refused, with a suggestion to write `precision`) | nothing — delete the key; the refusal now says why instead of suggesting `precision` | + | a field whose amounts need a different number of decimals | a different currency: the width is the currency's minor unit and is declared nowhere | + + **The one-line fix:** delete `precision` from every `currencyConfig`. ⛔ Do not move + the number to the field-level `precision`: that key is the amount's TOTAL digit count + (a DECIMAL(18,2) amount declares `precision: 18`), not its decimal places, and it is + unchanged by this release. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply + them by hand. + + ## The retirement kit + + - **`CurrencyConfigSchema.precision`** — removed from the shape. The schema is a + `strictObject`, so the route is strict deletion plus a `guidance` entry: an + authored key is refused as `unrecognized_keys` at `currencyConfig`, and the message + carries the prescription (``currencyConfig.precision` was removed in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer or runtime ever + read it: …``). `tsc` refuses a literal in a typed position too — the key is off + `CurrencyConfig`'s input type. + - **The `decimals` / `scale` aliases** — gone with their target. Each is now answered + with the same reason (`` `currencyConfig.scale` is not a currency configuration key, + and nothing replaces it: … ``) and no rename suggestion. + - **The ISO 4217 contradiction check** (the `.superRefine`) and **the + default-materializing `.overwrite()`** — both existed only for this key and are + removed. `CurrencyConfigSchema.parse({})` now returns exactly + `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`; `CurrencyConfigParsed` no + longer declares `precision`. The internal helpers `currencyPrecisionContradiction` + and `currencyFractionDigits` (never exported from a public entry) are removed; the + CLDR table they read stays, because the `iso_4217_currency` value domain reads its + key set. + - **The field designer form** — the field-level `precision` row's help text read + "Decimal places (e.g., 2 for $10.50)", the one reading the contract refuses. It now + reads "Total digits", matching the key's describe and the object designer's row; + the zh-CN / ja-JP / es-ES translations follow (`@objectstack/platform-objects`). + - **Registry** — `RETIRED_KEYS_BY_MAJOR[18]` gains `data/CurrencyConfig:precision`; + the protocol-18 step gains the D2 conversion `currency-config-precision-removed` and + its D3 entry `currency-config-precision-retired`, which states the two judgments the + strip cannot make: a width declared where the old check never looked (a `dynamic` + field, or a code with no known ISO 4217 minor unit) never applied, and code of your + own that read the served key must derive the width from the field's currency. + + ## What an operator with STORED metadata sees + + Nearly every stored currency field carries this key without anyone having written it: + the old `.overwrite()` baked `precision: 2` into parse output, so `sys_metadata` + object rows and built artifacts hold it. Nothing breaks at read: the conversion + `currency-config-precision-removed` is retired from the load path but replayed by the + stored-row and artifact seams, which strip the key from every field's + `currencyConfig` on objects and object extensions and serve the row canonical. The + strip is lossless — the key never had an effect — and the field-level `precision` is + never touched. `os migrate meta --stored --apply` rewrites the stored rows so the + per-row notice stops. + + +- b98fbc2: feat(objectql,spec)!: a numeric field's declared `precision` ("Total digits") is enforced on writes — a value that needs more digits is refused with field code `max_precision` (#19992) + + Clause-②: yes + + **BREAKING** — a narrowing of the write accept set on `@objectstack/objectql`, shipped as `minor` under the repo's launch-window convention (`check-changeset-no-major` refuses `major` until GA); the breaking-ness is carried by this banner and the ADR-0087 disposition, never by the level. Nothing an author writes changes spelling: `precision` keeps its key, its type and its legality. + + `FieldSchema.precision` was declared ("Total digits") and read by nothing. Every numeric column is the fixed exact decimal of `NUMERIC_COLUMN_REPRESENTATION`, the record validator had no branch for it, and the renderer reads the liveness ledger cited are gone, so `precision: 5` on a `number` stored `123456789` verbatim. The metadata designer writes the key (labelled Precision, beside Scale), so it was a setting an author could make and see nothing come of. It is now enforced at the one place a write is judged. + + **`@objectstack/objectql`** — the record validator refuses, after `min` / `max` and `max_scale`, a `number`, `currency`, `percent`, `rating` or `slider` value whose digit count exceeds a declared `precision`. It refuses with `400 VALIDATION_FAILED` and the field code `max_precision`, and it never rounds. The count is the SQL `DECIMAL(p, s)` one, taken on the stored value: + + - **With a `scale`**, digits are counted at the field's decimal places, so the integer part may carry `precision − scale` digits. `precision: 5, scale: 2` holds up to `999.99` and refuses `1234.5`, which is `1234.50`, six digits. + - **With no `scale`**, the value's own digits count. Leading zeros never count, and trailing zeros of the integer part always do: under `precision: 4`, `0.001` fits and `10000` does not. + - **On `currency`**, where `scale` is refused, an amount counts at its own decimals. The decimals themselves stay unconstrained, and only the total is bounded: `precision: 18` refuses a 19-digit amount. + - **On a fraction-stored `percent`** the count is taken two places further right (`scale + 2`, or 2 with no `scale`). The count is then the percentage-point value's digits as displayed: `precision: 4, scale: 2` holds 99.99% and refuses 100%. + + What an author with an oversize value sees: the write is refused, nothing is stored, and the field error names the declaration and the count. For example, `constraint: { precision: 5, scale: 2, actual: 6 }` renders as "Hourly rate must have at most 5 digits in total, counting 2 decimal places (got 6)" in four locales. The REST create, batch, update and import routes all answer it, and `validate` (the dry run) predicts it. Only NEW writes are judged: a stored value longer than a `precision` declared later is never re-read. Nothing changes in storage or DDL. + + The fix is one of three. Write a value that fits. Raise `precision` to the digits the field really holds. Or delete the key if the number was meant as decimal places: those are `scale`, and a currency's decimal places are its ISO 4217 minor unit. + + **`@objectstack/spec`** — `FieldErrorCode` (the ADR-0114 field-level catalog) gains `max_precision` beside `max_scale`. `BUILTIN_VALIDATION_MESSAGES` gains its two sentences, `max_precision` and `max_precision_scaled`, in `en` / `zh-CN` / `ja-JP` / `es-ES`. `FieldSchema.precision`'s describe now states the counting rule and where it is enforced. The `precision` row of the field liveness ledger is re-evidenced at the write seam. + + **Who is affected, measured** on `origin/main` `df3ba164`: no example app, template, platform object, seed or JSON fixture in the tree declares a field-level `precision`. Two test fixtures do (`precision: 5, scale: 0` on a 1–12 hours field), and every value they write fits. + + +- e7f69db: A record-scoped filter token, `{record_id}`: the id of the record a `type: 'record'` page is showing. It resolves where a record is in context, and is refused by name everywhere else (#20003). + + On a record page, `record:related_list` was the only component that could scope itself to the record in view. Every other data-bearing component takes a `FilterCondition`, and the only dynamic values a filter could hold named the signed-in viewer. So "open tasks" on a person's record page counted the whole organisation's tasks, under that person's name. `{ assignee: '{record_id}' }` now says "this record's". + + **Where it is accepted, and where it is refused:** + + - **Accepted:** a filter on a component of a `type: 'record'` page (`regions[].components[]`, `slots`, and any filter key inside them). `os lint` / `os validate` pass it there. A page with no `type` is a record page by `PageSchema`'s default. + - **Refused by `os lint` / `os validate`** (rule `filter-token-unknown`, `error`), with the reason "no record in context on this surface" rather than the unknown-token message: list views (top-level `views` and an object's list views and field filters), dashboard widgets and dashboard filters, reports, datasets, app navigation filters, and every page whose `type` is not `'record'` (`home`, `app`, `utility`, `list`, including a list page's `interfaceConfig.filterBy`). + - **Refused on every server path.** `resolveFilterTokens()` in `@objectstack/core` throws `UnresolvedFilterTokenError` (`FILTER_TOKEN_UNRESOLVED` / 400, `token: 'record_id'`) on the ObjectQL read path (`find`, `findOne`, `count`, `aggregate`), the write path (`update` / `delete`, by id or `multi`), the analytics query door and the dataset executor. That is the same envelope a session token gets when the request has no value for it. It happens whatever the request carries, because no server path knows which record a page is showing. The token never becomes `null` (a count "about nobody"), is never dropped (a count "about everybody"), and never reaches the driver. + + **What is in `@objectstack/spec/data`:** + + - `RECORD_CONTEXT_TOKENS` (`['record_id']`), `RecordContextToken` and `isRecordContextToken()`: a sibling of `CONTEXT_TOKENS`, not a member. `CONTEXT_TOKENS` resolves against the caller's session, and `{record_id}` resolves against the surface. So `isContextToken('record_id')`, `ContextTokenSchema` and `ContextTokenPlaceholderSchema` are unchanged and still reject it, and a client resolver that fills `CONTEXT_TOKENS` from the session does not pick it up. + - `classifyFilterToken('{record_id}')` returns the new kind `{ kind: 'record-context', token: 'record_id' }` instead of `unknown`. A consumer that switches exhaustively on `kind` gets a compile error until it handles the new kind. + - `isKnownFilterToken('record_id')` stays `false`. That predicate answers "can the server resolve it?", and its one consumer, the flow engine's filter hand-off, is a server position. A flow addresses its own record as `{record.id}`. + - Near misses are still refused, now with `{record_id}` suggested: `{recordId}` (the URL / flow-template placeholder), `{record.id}`, `{record-id}`, `{current_record_id}`. `CONTEXT_TOKEN_SUGGESTIONS`' value type widens to `ContextToken | RecordContextToken`. + + **Presentation scope, not access.** Like `{current_user_id}`, `{record_id}` narrows what a component shows. It decides nothing about which rows the caller may read; that is still RLS. + + **What you do:** on a record page, filter a component on the record in view with `{ : '{record_id}' }`. If `os validate` refuses it with "no record in context on this surface", the filter is on a surface with no record: move it onto a component of a `type: 'record'` page, or filter on a concrete id. Until the renderer you run resolves `{record_id}`, a record-page query that carries it is refused by the server with `FILTER_TOKEN_UNRESOLVED` rather than answered with a wrong number. +- 84156c7: fix(spec): a currency field's `precision` is its total digit count again — `precision: 18` on a fixed-USD field parses instead of being refused as a contradiction of the currency's two decimal places (#20011) + + Clause-②: yes (widening) — one refusal is removed from `FieldSchema`, so the accepted set grows. Nothing that parsed before is refused now, no key is renamed or retired, and the output of every previously accepted field is byte-identical. + + ## What was wrong + + `FieldSchema` compared a currency field's field-level `precision` with the ISO 4217 fraction digits of its fixed currency, and refused any difference. The key is declared `Total digits (non-negative integer)`, so a DECIMAL(18,2) USD amount writes `precision: 18`. That field was refused with "currency USD has 2 fraction digits; `precision: 18` contradicts it", and the refusal told the author to write `precision: 2`, which is a total-digit count of 2. + + The check assumed the field-level key was the currency display width, because the Studio currency widget used to read it that way. That reading is gone. The ruled contract is that a currency amount's decimal places come from the currency and are not a field setting. No renderer reads the field-level `precision` as decimal places, and the SQL column does not read it at all. + + ## What it does now + + - The field-level `precision` on a `currency` field is not compared with the currency. Any non-negative integer parses in every currency mode and is carried through unchanged. + - `currencyConfig.precision` is a different key and is unchanged. Under `currencyMode: 'fixed'` it must still agree with the currency's fraction digits, and a contradiction is still refused at `currencyConfig.precision` with the same message. + - `scale` on a `currency` field is still refused. + + Nothing to migrate. Metadata that parsed before parses the same way. A currency field that had to drop `precision` or set it to the currency's fraction digits to get through validation can now declare its real total digit count. +- 1df29df: `AutomationApiContracts` now names the paths the platform actually serves — `/api/v1/automation…` instead of `/api/automation…` (#20034). + + The dispatcher mounts the automation door at its `prefix` plus `/automation`, the prefix defaults to `/api/v1`, and `objectstack serve` passes none. So all nine declared paths answered `404 ENDPOINT_NOT_FOUND` on the default composition while the same requests under `/api/v1/automation` answered `200`, and the generated API reference printed the nine unserved paths as the endpoints. Every other `*ApiContracts` map in `@objectstack/spec/api` already carried `/api/v1`; this one was the only outlier. The runtime is unchanged — only the declaration moves. + + Clause-②: no + + **What moved on the published surface** + + | entry | from | to | + | --- | --- | --- | + | `listFlows` (`GET`), `createFlow` (`POST`) | `/api/automation` | `/api/v1/automation` | + | `getFlow` (`GET`), `updateFlow` (`PUT`), `deleteFlow` (`DELETE`) | `/api/automation/:name` | `/api/v1/automation/:name` | + | `triggerFlow` (`POST`) | `/api/automation/:name/trigger` | `/api/v1/automation/:name/trigger` | + | `toggleFlow` (`POST`) | `/api/automation/:name/toggle` | `/api/v1/automation/:name/toggle` | + | `listRuns` (`GET`) | `/api/automation/:name/runs` | `/api/v1/automation/:name/runs` | + | `getRun` (`GET`) | `/api/automation/:name/runs/:runId` | `/api/v1/automation/:name/runs/:runId` | + + The module's `Base path` and endpoint list move with them, and so does the text `ListRunsRequestSchema` raises for a retired `cursor`: it now names `GET /api/v1/automation/:name/runs`. + + **Who notices.** A caller that built request URLs from these constants was calling paths nothing served on the default composition; it now reaches the serving door with no code change. A caller that hard-coded one of the old strings should send the `/api/v1/automation…` form. The `path` type is unchanged (`string`), no accepted input narrows, and no method changes. + + A host that mounts the dispatcher under a different prefix — `@objectstack/hono`'s `createHonoApp`, whose `prefix` defaults to `/api`, is the in-repo example — serves every contract family under that prefix, so it replaces the leading `/api/v1` of any `*ApiContracts` path, now including these nine. The environment-scoped mount (`/api/v1/environments/:environmentId/automation…`, the only one served under `projectResolution: 'required'`) is not declared here, as it is not in any other contract map. + + **Kept from drifting again.** A new test in `@objectstack/runtime` boots the dispatcher plugin with its default prefix and requires every contract route to be one it mounts, and to be a row of the runtime route ledger under the `/api/v1` wire prefix that the live-mount parity gate probes. +- 8a44ce7: fix(spec, drivers)!: a `$like` / `$ilike` pattern holding U+0000 is refused by every driver that answers `$like`, instead of being cut at the NUL on SQLite + + Clause-②: yes (narrowing) + + On the SQLite faces `$like` / `$ilike` compile to `GLOB`, and SQLite reads a pattern only up to its first U+0000. A pattern holding U+0000 was cut there, so the filter answered a different question, and nothing raised. Measured through `find` over 13 stored values (12 non-NULL), against `@objectstack/formula` on the same rows: all 20 U+0000 cases of the probe (10 patterns, bare and under `$not`) differed on `SqlDriver` over better-sqlite3, on `SqliteWasmDriver`, on `TursoDriver`'s local mode, and on its remote mode over a stub and over a real `@libsql/client` engine, with identical answers on all five. For example: + + - `$like: '%'` + U+0000 returned all 12 non-NULL rows, where `formula` returns the two ending in U+0000; + - `$like: 'a'` + U+0000 + `'b'` also returned `'a'`; + - `$ilike: 'AB'` + U+0000 also returned `'AB'` and `'ab'`. + + `driver-memory` answered all 20 as `formula` does. SQLite has no NUL-safe pattern primitive to compile to instead: `LIKE` cuts the same way, `replace()` cannot target U+0000, and `instr()` has no wildcards. So the one contract is a refusal, the way a pattern ending in a lone unpaired backslash is refused. + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A filter that answered before is now refused**: a `$like` or `$ilike` pattern holding U+0000 anywhere (at the start, in the middle, at the end, alone, or after a backslash) gets `INVALID_FILTER` / 400, on every door that already refused the lone trailing backslash: + + - `@objectstack/driver-sql`: on the filter walk, before a dialect is chosen, so SQLite, Postgres and MySQL all refuse it. `@objectstack/driver-sqlite-wasm` and `TursoDriver`'s local mode inherit it; `@objectstack/driver-sqlite-wasm`'s own code does not change. + - `@objectstack/driver-turso`: the remote transport's `$like` / `$ilike` arm, before anything is sent to the engine. + - `@objectstack/driver-memory`: the shape gate of the query path and of the reference matcher `match()`, and the QueryAST `comparison` spelling (`like` / `ilike`). + - `@objectstack/spec` exports the shared test, `hasNulInLikePattern`, beside `hasDanglingLikeEscape`, and the `$like` operator's description now names the refusal. + + On `driver-sql` and the Turso remote transport the refusal goes through the read-scope provenance seam, like every other filter-compile refusal there. On `driver-sql` (and so `driver-sqlite-wasm` and Turso's local mode), a caller whose predicate is marked `'author'` reads the operator, the field, the filter path and the pattern, with U+0000 written as `\u0000`. Any other caller gets only the class statement, and the rest goes to the server log. The remote transport withholds the same way, and through `TursoDriver` in remote mode no mark reaches it, so every caller gets the class statement there. On `driver-memory` every caller reads the full text, as for its dangling-escape refusal. + + A pattern that ends in a lone unpaired backslash AND holds U+0000 keeps the dangling-escape refusal it had before. + + **What stays accepted**, pinned per face: every `$like` / `$ilike` pattern without U+0000 answers exactly as before. + + **Not changed here:** + + - A pattern without U+0000 matched against a STORED value that holds U+0000 is not refused: it is well formed, and on the SQLite faces it reads the whole stored value, by its own entry in this release. + - `@objectstack/formula` still evaluates such a pattern. It refuses nothing, and answers `false` for a dangling escape rather than refusing it, so it is not one of these doors. + - `driver-mongodb`, objectql `having` and `service-analytics` refused every `$like` / `$ilike` before this change, and still do. + + **What an affected author does.** Remove the U+0000 from the pattern. No escape makes it portable: a backslash before it still leaves a U+0000 in the pattern. + + Blast radius, measured on this tree: no example or template writes a `$like` or `$ilike`, and the published `objectstack-query` skill and the hand-written docs that show one show no pattern holding U+0000. Whether any out-of-repo caller sends one is NOT measured and is not claimed to be zero. + + +- ca753c0: fix(spec)!: `scale` is refused on a `currency` inline grid column, and the column's `prefix` no longer promises a default symbol (#20045) + + Clause-②: no (narrowing) + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. An `inlineColumns` entry that declares `type: 'currency'` and `scale` — any value, `scale: 0` included, computed or not — no longer parses. The hand-migration prescription is registered under protocol major 18 as `inline-grid-column-currency-scale-refused`. + + A currency's decimal places are the currency's, not a setting. `scale` was already retired from the `currency` field type; the inline grid column, the strict mirror of the console grid's column, still offered per-column decimals on a currency column. It now follows the field. + + **`@objectstack/spec`** — `InlineGridColumnSchema` refuses `scale` on a column declaring `type: 'currency'`, with a located issue at the column's `scale`. The refusal opens with the currency field refusal's first sentence and carries its remedy: delete the key; the currency's ISO 4217 minor unit decides how the cell displays the amount and the width a computed amount is rounded to. The remedy names no other key to carry the value. No alias and no grace window. `scale` on a `number` column, and on a column that declares no `type`, is untouched, and the key's describe now names the currency refusal. The column's `prefix` describe no longer promises a `¥` default: it replaces the resolved currency's symbol, and when it is omitted the cell shows the symbol of the currency it resolves. `prefix` is still accepted on a currency column. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `inlineColumns: [{ name: 'amount', type: 'currency', computed: true, expr: 'quantity * unit_price', scale: 2 }]` | `inlineColumns: [{ name: 'amount', type: 'currency', computed: true, expr: 'quantity * unit_price' }]` | + | `inlineColumns: [{ name: 'unit_price', type: 'currency', prefix: '$', scale: 2 }]` | `inlineColumns: [{ name: 'unit_price', type: 'currency', prefix: '$' }]` | + + The one-line fix: delete `scale` from every inline grid column that declares `type: 'currency'`. Nothing replaces it, so ⛔ do not re-declare the value under any other key. A `number` column keeps its `scale`. + + ## Who is affected, measured + + On `origin/main` `1c8b320a89`: one authored `inlineColumns` block in the tree (the showcase invoice, seven identity-only columns, none declaring `type` or `scale`). No platform object, skill, documentation example or JSON fixture declares an inline grid column. One test fixture carried `scale: 2` on a currency column and was re-judged. Deployed metadata was not measured. + + +- 8ecbe0f: feat(spec): a view that declares no page size now shows 50 per page — `PaginationConfigSchema.pageSize` defaults to 50 (was 25) + + The platform display page size is 50 (maintainer ruling on objectui#9853, + 「9853 默认页大小改为50」). It is declared in one place, the view contract's + `PaginationConfigSchema.pageSize`, and the renderer reads that spec default + rather than keeping a number of its own — so the change lands in the protocol + first. + + **What changes for an author who omits the page size:** + + - A view whose `pagination` block does not set `pageSize` now parses to + `pageSize: 50` where it parsed to `25`: 50 rows per page on a paged view, and + a fetch ceiling of 50 on a view with no pager (kanban, gallery, timeline). + - A view with no `pagination` block at all parses with none, before and after; + its page size comes from the renderer, which takes this spec default (the + Console does so once objectui#9853 lands). + + **What does not change:** the accept set. `pageSize` is still a positive + integer; `0`, negatives and fractions are refused exactly as before, and every + page size an author wrote parses to the number they wrote. + + ### Migration: FROM → TO + + | FROM | TO | + | :--- | :--- | + | a view that declares no page size and relied on 25 rows per page | write it: `pagination: { pageSize: 25 }` | + | a view that declares no page size and should follow the platform default | change nothing — it now shows 50 | + | reading `PaginationConfigParsed.pageSize` after parsing a `pagination` block without `pageSize` | it yields `50` where it yielded `25`; the type is unchanged | + | reading the published JSON Schema's `default` for `ui/PaginationConfig` `pageSize` | it is `50` | + + The move is declared in `DEFAULT_CHANGES_BY_MAJOR` + (`packages/spec/scripts/lib/default-changes.ts`, `ui/PaginationConfig:pageSize` + 25 → 50) and carried on the upgrade path as the semantic entry + `view-pagination-page-size-default-50`. +- 6a4aec7: fix(spec)!: a flattened list view overlay's legacy `options` bag is judged at the view write door, so an out-of-contract `options.KIND` key is refused by name exactly as the direct spelling is (#20051) + + **BREAKING** accept-set narrowing on the `view` write door (`PUT /api/v1/meta/view/:name`, the Studio and MCP save). It ships as `minor` under the repo's launch-window convention for breaking changes. This is the door half of ruling A on objectui#10380 (maintainer 「其他同意」). + + Clause-②: no + + ## What was wrong + + The flattened list overlay member of `ViewMetadataSchema` re-opens its top level with `.strip()` so the console's round-trip keys survive. That strip also dropped a top-level `options` bag from the parse without looking inside it. `saveMetaItem` stores the request body, not the parse output, and objectui's interface page forwards a stored view's `options` into the list renderer, which merges `options.KIND` under the top-level `KIND` block. Measured on `origin/main` @ `8d1f7ab` through the real save: `timeline: { metaFields: [...] }` answered `422`, while the same key written as `options: { timeline: { metaFields: [...] } }` answered `200` and the row held it as sent. + + ## What it does now + + - **The list overlay declares `options`.** Each `options.KIND` (`kanban`, `calendar`, `gantt`, `gallery`, `timeline`, `chart`, `map`, `tree`) is judged by that kind's own block schema: the same closed key set, the same per-key schemas and the same unknown-key message. The refusal names the key, with only the `options.` prefix added to the path. The kinds are derived from the list-view shape, not listed by hand. + - **Key by key.** The renderer reads the bag as a per-key underlay of the top-level block, so the block's required keys are not asked of it. `kanban: { groupByField, columns }` beside `options.kanban: { titleField }` stays legal, which is the population objectui pins. + - **The bag is closed.** A key that is not a kind (`options.foo`, `options.grid`) is refused by name at `options`. It is no longer dropped. + - **The form overlay pins `options` absent.** Without this, a column-less, type-less list body that the list overlay refused over its bag would be accepted by the form overlay and stored unjudged. The refusal says the bag belongs to a list view. + + The legacy `options.map` bag that objectui pins (`locationField`, `titleField`) is still accepted, and it round-trips. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `options: { kanban: { groupField: 'stage' } }` | `kanban: { groupByField: 'stage', columns: [...] }`, or `options: { kanban: { groupByField: 'stage' } }` | + | `options: { calendar: { dateField: 'kickoff' } }` | `calendar: { startDateField: 'kickoff' }` | + | `options: { timeline: { metaFields: ['region'] } }` | delete `metaFields`: the timeline block has no such key | + | `options: { chart: { xAxisField, yAxisFields } }` | the dataset-bound block, `chart: { dataset, values, dimensions }` | + | `options: { foo: 1 }` | delete `foo`: the bag carries per-kind blocks only | + | `options: {...}` on a form overlay (`viewKind: 'form'`) | delete `options`: a form view has no per-kind blocks | + + **The one-line fix:** read the refusal. It names the key and the block that refuses it; move the key to the top-level block's declared spelling, or delete it. + + ## Stored-row census (ruling item 3) + + - **objectstack** @ `8d1f7ab` (examples, dogfood, fixtures, tests): zero view bodies carry a top-level `options` bag with a kind block. Authored views go through the strict authoring shape, which has always refused `options`. + - **objectui**, at the `.objectui-sha` pin `f8a9d0fb0` and at `main` `c3a26ccda`: 28 `options` bag literals, plus the finding's own probe body, judged against this change. 17 pass and 12 fail, and every failure is an out-of-contract key refused by name. None fails for a missing key. + - **Bodies that model a stored or authored view** (a console-merged `listViews` entry or a named view): 8, of which 5 pass, the pinned `options.map` path among them. The 3 that fail: `options.kanban.groupField` in `plugin-view` `ObjectView.tsx`'s docblock example (write `groupByField`), `options.calendar.dateField` in `ObjectView.calendarAliasRefused-8355.test.tsx` (write `startDateField`; that test already pins the alias as refused on objectui's side), and the finding's `options.timeline.metaFields` (delete it). + - **Renderer-level `ListView` props** (21), which never reach this door: 12 pass. The 9 that fail spell the legacy keys objectui's own refusal pins already retire (`groupField`, `groupBy`, `dateField`, `metaFields`, and the object-bound chart keys `xAxisField` / `yAxisFields` / `aggregation`). + - **Production `sys_metadata` rows: NOT MEASURED.** No deployment's store is reachable from the repository. A stored row that fails keeps being read and served exactly as stored. It is refused only on its next save, and the refusal names the key. + + ## Not in this change + + Persisting the parse output instead of the request body (ruling item 2) is not in this change. This change leaves the save path's storage behaviour as it was: a body the door now accepts is stored as sent, so every stored `options` bag is one the door judged. + + +- e4471e6: fix(lint)!: a field-level predicate that reads through a reference field is refused at `objectstack validate` (#20078) + + + + **BREAKING** in the accept-set sense — a stack that validates today can fail tomorrow. Landing in + the launch window as `minor` (`major` is refused by `check-changeset-no-major`); breaking-ness is + carried by this banner, the `!` above and the ADR-0087 registration. + + Clause-②: no + + A field `requiredWhen` / `readonlyWhen`, or a select option's `visibleWhen`, that reads THROUGH a + `lookup` / `master_detail` / `user` / `tree` field — `record.account.tier` — passed `objectstack + validate`, `build` and `lint`. It cannot work: the field level is never hydrated, so the reference + holds the related record's bare id and every read through it faults. At run time a traversing + `requiredWhen` refuses every write that reaches it, a traversing `readonlyWhen` refuses every + update that writes its field (ADR-0137 D2), and a traversing option predicate is never enforced + (option visibility is fail-open). The authoring pass now refuses all three as + `expression-invalid`, naming the slot, the reference path and the related column, before deploy. + + What to write instead — the refusal says the same: + + - **A `record..` read** — express the check as a `validations[]` rule of + `type: 'script'`. Its `condition` is the one predicate the server reads one hop through a + reference, and it states the FAILURE: for `requiredWhen: P` on `po_number`, + `P && (record.po_number == null || record.po_number == '')`; for `readonlyWhen: P` on + `discount`, `P && record.discount != previous.discount` with `events: ['update']`; for an + option gated by `P`, that option picked while `P` does not hold (the option is then offered to + everyone and refused on save). Or read a column the object itself declares. + - **A `previous..` or `parent..` read** — no seam hydrates + either root, a validation rule included, so read a column the bound record declares. + + Unchanged: the same traversal inside a `validations[]` `script` rule is accepted, as is reading + the reference itself (`record.account == 'acc_1'`, `record.account != null`), an object-valued + field that is not a reference (`record.ship_to.city`), and an option gated on `current_user` + (including `current_user.can(…)`). The runtime is untouched, and an object already stored in + `sys_metadata` is not re-validated by this. `@objectstack/spec` states the rule on the three + slots' `.describe()` text and registers the ADR-0087 semantic entry + `field-predicate-reference-traversal-refused`. +- e8fcf55: fix(spec)!: a dataset or measure filter with a list inside a nested relation is refused when it is saved, not when it is charted (#20080) + + **BREAKING**: an accept-set narrowing of a published authoring schema, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `dataset-filter-nested-relation-equality-array-refused-at-save`. + + ## What changes + + `DatasetSchema.filter` and `DatasetMeasureSchema.filter` now refuse an ARRAY in the equality slot of a field inside a nested-relation condition. That covers the implicit form `{ account: { region: ['a'] } }` and the explicit form `{ account: { region: { $eq: ['a'] } } }`, the empty array included, at any relation depth and under `$and` / `$or` / `$not`. So `defineStack`, `os validate` and a save through the metadata protocol (`422 INVALID_METADATA`) refuse such a dataset at the filter's path, for example `filter.account.region` or `measures.0.filter.account.region.$eq`. + + Before this change the dataset saved clean. The analytics `where` door, which charts both filters on every path, flattens the relation to the dotted member `account.region` and refuses the list with `INVALID_FILTER` / 400. So every chart built on the dataset failed. Measured on `origin/main` `9e7824a445`: `DatasetSchema.safeParse` answered `success: true` for both forms. + + The refusal is the analytics door's sentence: the field, the received list, and the two operators a list in that slot stood in for. The door adds `at where.account.region`. The schema leaves that out, because the issue's `path` already says where it is. + + ## What does NOT change + + - **The shared `FilterConditionSchema` keeps its reach.** Every other schema that carries a `FilterCondition` still accepts a list inside a nested relation, because the engine reads that spec as a deep-equality comparand. + - **Nothing stored is rewritten, and nothing is dropped.** The parse fails and strips nothing. The read path does not re-validate stored rows, so a stored dataset keeps loading, and its next save is refused. Such a filter has failed every chart, so the refusal is a repair. + - A list outside a nested relation is refused as before, once, by `FilterConditionSchema`. + - `$ne` carrying an array is not judged. The list operators keep their arrays, `$in: []` and `$nin: []` included. Every scalar, `null`, a `Date` and a `{ $field }` reference inside a nested relation pass as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ account: { region: ['a', 'b'] } }`, meaning "one of these values" | `{ account: { region: { $in: ['a', 'b'] } } }` | + | `{ account: { tags: ['a'] } }`, meaning "the stored list holds `a`" on a multi-value field | `{ account: { tags: { $contains: 'a' } } }` | + | `{ account: { region: ['a'] } }`, meaning one value | `{ account: { region: 'a' } }` | + | `{ account: { region: { $eq: [...] } } }` | any of the rows above | + + ## Who is affected, measured + + Nothing shipped in this repository carries the shape. At `9e7824a445`, the dataset and measure filters of `examples/app-crm`, `examples/app-showcase`, `examples/app-todo` and the platform objects, plus the dataset examples in `content/docs` and `skills`, carry no list inside a nested relation. Deployed datasets were NOT measured. Validating each stack, or re-saving each dataset, finds every instance. + + Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. One comparand shape in one position of two carriers, which the analytics door already refused on every chart, is now refused on save as well. + + +- 8d1f7ab: feat!: retire the saved-report stack — `sys_saved_report` / `sys_report_schedule`, `/api/v1/reports`, `client.reports`, `IReportService`, the `reports` capability and `@objectstack/plugin-reports` (#20102) + + **BREAKING** — the saved-report stack is removed whole, with no deprecation window + (maintainer ruling 2026-09-25, 「A. 退役」). It persisted a raw object query + (`object_name` + `{ filter, fields, orderBy, limit, groupBy }`) with a render format + and an owner, and could e-mail it on a schedule. Measured on the main branch of this + repository, objectui and cloud before removal: zero callers of the routes, the SDK + namespace or the service contract outside their own tests, and no app declaring the + capability. + + **NOT affected: the `report` metadata kind.** `ReportSchema`, `defineReport`, + `/meta/report`, datasets and the analytics service are unchanged. The two shared the + word "report" and nothing else. + + FROM → TO, per surface: + + - `requires: ['reports']` → **refused** by `defineStack` (`STACK_CAPABILITY_UNKNOWN`, + 422) with the prescription "requires: 'reports' was removed in @objectstack/spec + 17.5.0 … Delete the token." Fix: delete the token. `os serve` on an older artifact + that still carries it warns with the same prescription and ignores it; `os validate` + and `os build` over a plain-object config (no `defineStack` call, so no parse-time + vocabulary check) report it as a non-fatal capability advisory carrying the same + prescription, never "check for a typo". The token is + gone from `PLATFORM_CAPABILITY_TOKENS` and `PLATFORM_CAPABILITY_PROVIDERS`; the new + `RETIRED_PLATFORM_CAPABILITY_GUIDANCE` (`@objectstack/spec/kernel`) carries the + prescription. + - `IReportService`, `SavedReport`, `ReportSchedule`, `ReportQuery`, `ReportFormat`, + `ReportRunResult`, `SaveReportInput`, `ScheduleReportInput` + (`@objectstack/spec/contracts`) → removed, no replacement export. Fix: delete the + import. + - `SysSavedReport`, `SysReportSchedule` (`@objectstack/platform-objects/audit`) and + the names `sys_saved_report` / `sys_report_schedule` in + `PLATFORM_PROVIDED_OBJECT_NAMES` → removed. A stack referencing either name is now + flagged as a probable typo instead of resolving. + - `GET|POST /api/v1/reports`, `GET|DELETE /api/v1/reports/:id`, + `POST /api/v1/reports/:id/run`, `POST /api/v1/reports/:id/schedule`, + `GET /api/v1/reports/:id/schedules`, `DELETE /api/v1/reports/schedules/:scheduleId` + → unmounted: each answers the standard unmatched-route `404`, byte-identical to a + path that never existed. Their nine error codes (`REPORTS_LIST_FAILED`, + `REPORT_DELETE_FAILED`, `REPORT_GET_FAILED`, `REPORT_NOT_FOUND`, + `REPORT_RUN_FAILED`, `REPORT_SAVE_FAILED`, `REPORT_SCHEDULE_FAILED`, + `SCHEDULES_LIST_FAILED`, `SCHEDULE_DELETE_FAILED`) leave `ERROR_CODE_LEDGER` with + their only emitter. + - `client.reports.*` (`list`, `save`, `get`, `delete`, `run`, `schedule`, + `listSchedules`, `unschedule`) → removed. Fix: delete the call. A report is `report` + metadata, read through `meta.*` and queried through `analytics.*`; a saved ad-hoc + object query is a ListView on that object. + - `RestServer`'s constructor keeps the position of the retired saved-report provider, + typed `undefined`, so no later positional argument re-binds. Pass `undefined` there; + passing a provider is a compile error. + - `@objectstack/plugin-reports` → no longer built or published from this repository, + and `@objectstack/cli` no longer depends on it or mounts it. Fix: remove the + dependency. There is no successor package and no scheduled-delivery replacement. + + **Existing databases.** `sys_saved_report` / `sys_report_schedule` tables in a deployed + database are left in place, untouched — no backfill, no reaper, no drop — under the + repository's convention for a retired platform object: the platform never drops a + table that metadata stops declaring, and `os migrate plan` lists such a table in its + informational unmanaged-tables section so an operator can decide. + + `@objectstack/metadata-protocol` (patch): the `INVALID_SORT` hint for a sort node + spelled `{ field, direction }` no longer names the retired saved-report contract as + the source of that vocabulary; it names the better-auth adapter's `sortBy`, which + still uses it. Code and status are unchanged. + + Breaking ships as `minor` per the launch-window convention + (`scripts/check-changeset-no-major.mjs`). + + **Clause-②: yes (narrowing)** — a published capability token, a service contract and + its types, two platform objects, eight routes, nine registered error codes and an SDK + namespace are removed; nothing previously refused is now accepted. + + +- cfc3bcf: fix(spec)!: a filter carrying a comparand the query faces refuse is refused when it is saved (#20116) + + **BREAKING** — an accept-set narrowing of published authoring schemas, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The save door narrows to exactly what the query faces already refuse. The hand-migration prescription is registered under protocol major 18 as `filter-query-face-comparands-refused-at-save`. + + ## What changes + + `FilterConditionSchema` now refuses, at parse, every comparand slot the query faces refuse: + + - a `$null` or `$exists` flag that is not a boolean — `"false"`, `"x"`, `null`, `1`; + - a `null` comparand of `$gt` / `$gte` / `$lt` / `$lte`; + - an `$in` or `$nin` comparand that is not a list, or a list holding `null`; + - a `$between` comparand that is not a two-element list, or whose endpoint is `null`, blank (`""`) or a `{ $field }` reference; + - an array under `$ne`. + + It judges the field entries of a condition and of every `$and` / `$or` / `$not` member. The dataset `filter` and measure `filter` also judge the same slots INSIDE a nested-relation condition, through the nested-relation walk they already carry, because the analytics `where` door flattens a relation and judges its entries. The shared comparand-shape face (`assertListComparandShapes`) is called read-only as the judge for each slot, so the save door refuses exactly what that face refuses on every query. The two boolean flags, which that face does not judge, are refused on the predicate every flag face uses (`driver-sql`, `driver-memory`, `driver-mongodb`, the read-scope compiler and the analytics `where` door): the comparand is not a boolean. + + Measured on `origin/main` `af32cf9a` before the change: a dataset `filter`, a dataset measure `filter`, a dashboard widget `filter` and a report `runtimeFilter` each parsed with `success: true` for one instance of every shape above. The comparand-shape face refused each one but the flags with `INVALID_FILTER` / 400, and the analytics `where` door refused all of them, inside a nested relation too. So such a document published clean and then failed every chart built on it. + + Every schema that carries a `FilterCondition` refuses on parse. That covers the dataset `filter` and measure `filter`, the dashboard widget `filter` and options-source `filter`, the report and joined-report-block `runtimeFilter`, the field `relatedListFilter` and rollup `summaryOperations.filter`, the solution-blueprint summary `filter`, the analytics query `where`, the dataset selection `runtimeFilter`, the query `where` and `having`, the data-engine aggregate call's `having`, the aggregation `filter`, and the query-filter `where`. So `defineStack`, `os validate` and a save through the metadata protocol (`422 INVALID_METADATA`) refuse such a document at the slot's path, for example `filter.stage.$null` or `measures.0.filter.amount.$between.0`. + + The words are the query face's. For an array under `$ne`, a non-list `$in` / `$nin` and a malformed `$between`, the refusal is the face's sentence without its location clause (`at where..`), because the issue's path carries the location. For a `null` ordering comparand, a `null` list member or endpoint, and a blank or `{ $field }` endpoint, it is the sentence the enforced operator slot (`FieldOperatorsSchema`) already prints for the same comparand. A non-boolean flag gets the query faces' sentence: `Operator "$null" on field "stage" requires a boolean comparand (true or false).`, then the received value and the prescription. + + Two request doors parse these carriers, and they now answer before the analytics compiler does. The REST dataset selection (its `runtimeFilter`, parsed against `DatasetSelectionSchema`) and the analytics query body (its `where`, parsed against `AnalyticsQueryRequestSchema`) answer `VALIDATION_FAILED` / 400 with the sentence at the field, for a top-level or combinator slot. Before, the compiler answered `INVALID_FILTER` / 400 for the same filter. + + This also changes the `$ne` note of the equality-slot change earlier in this release: an array under `$ne` is now refused on save too, in the sentence `FieldOperatorsSchema.$ne` and the face print. + + ## What does NOT change + + - **Nothing stored is rewritten, and nothing is dropped.** The parse fails and strips nothing. The read path does not re-validate stored rows, so a stored document keeps loading, and its next save is refused. Such a filter has failed every query since the runtime refusal of its shape, so the refusal is a repair. + - **The shared reach is the face's, and no wider.** On every carrier but the two dataset ones, a field spec with no `$` key, such as the nested-relation condition `{ account: { region: { $in: ["a", null] } } }`, is not judged, because neither the face nor the drivers' flag checks descend one. A dashboard widget `filter` and a report `runtimeFilter` therefore still save that shape, and the analytics `where` door refuses it when they are charted. + - **What the face passes still passes:** `$eq: null` and `$ne: null` (the null predicate), a `{ $field }` reference as the whole comparand of a scalar comparison, `$in: []` and `$nin: []`, a whitespace-only `$between` endpoint, and falsy endpoints such as `[0, 0]`. + - **The data-engine calls' `where` option still parses.** Its type is a union whose first arm is an open record. The face refuses the shape when the call runs. + - **No key, export or JSON Schema changes.** The published JSON Schema cannot state a refinement, and `FilterCondition`'s already could not state the equality-slot one. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ stage: { $null: "true" } }`, `{ stage: { $null: 1 } }` | `{ stage: { $null: true } }` ("has no value") | + | `{ stage: { $exists: "false" } }`, `{ stage: { $null: null } }` | the boolean you meant: `$exists: false` is "has no value", `$null: false` is "has a value" | + | `{ amount: { $gt: null } }` | `{ amount: { $eq: null } }` ("has no value") or `{ amount: { $ne: null } }` ("has a value") | + | `{ stage: { $in: "won" } }` | `{ stage: { $in: ["won"] } }` or `{ stage: "won" }` | + | `{ stage: { $in: ["won", null] } }` | `{ $or: [{ stage: { $in: ["won"] } }, { stage: { $null: true } }] }` | + | `{ stage: { $nin: ["lost", null] } }`, meaning "has a value and is not `lost`" | `{ stage: { $nin: ["lost"], $null: false } }` | + | `{ stage: { $in: [null, ""] } }`, a filter builder's "is empty" | `{ $or: [{ stage: { $null: true } }, { stage: "" }] }` | + | `{ stage: { $nin: [null, ""] } }`, a filter builder's "is not empty" | `{ stage: { $null: false, $ne: "" } }` | + | `{ amount: { $between: [null, 5] } }`, `{ amount: { $between: ["", 5] } }` | `{ amount: { $lte: 5 } }`, or the bound you meant | + | `{ amount: { $between: [{ $field: "floor" }, 5] } }` | `{ amount: { $gte: { $field: "floor" }, $lte: 5 } }` | + | `{ amount: { $between: 5 } }`, `{ amount: { $between: [1] } }` | `{ amount: { $between: [1, 5] } }` | + | `{ stage: { $ne: ["won", "lost"] } }` | `{ stage: { $nin: ["won", "lost"] } }` | + + ### FROM → TO at the HTTP doors + + Same status, different code: the refusal now comes from the route's schema door, located on the member, instead of from the analytics filter normalizer. + + | request | before | after | + |:--|:--|:--| + | `POST /analytics/dataset/query` with `selection.runtimeFilter: { amount: { $between: [10] } }` | `400 INVALID_FILTER` from the analytics normalizer, in the comparand-shape face's sentence | `400 VALIDATION_FAILED`, `details.fields[]` entry `selection.runtimeFilter.amount.$between` with the sentence `Operator "$between" on field "amount" requires a [min, max] value array. Received array ([10]). …` | + | the same route, any other slot above in `selection.runtimeFilter` (top level or in `$and` / `$or` / `$not`) | `400 INVALID_FILTER` | `400 VALIDATION_FAILED`, located on the slot, with that slot's sentence | + | `POST /analytics/query` (`AnalyticsQueryRequestSchema`) with the same shape in `where` | `400 INVALID_FILTER` | refused by the request schema at `where.amount.$between`, answered `400 VALIDATION_FAILED` | + + A client that branches on `INVALID_FILTER` for these shapes reads `VALIDATION_FAILED` instead. Both are 400 and both name the field. + + ## Who is affected, measured + + A literal-comparand scan of every member shape, with a lit control per shape, over `examples/**` and the non-test `packages/**` of this repository at `af32cf9a`, the console repository at its pinned commit `f8a9d0fb05`, and the cloud repository's `main` at `48d70663ab`, found authored filters carrying one in one place. The console's filter-condition widget writes "is empty" as `{ field: { $in: [null, ""] } }` and "is not empty" as `{ field: { $nin: [null, ""] } }`. That widget edits a field's `relatedListFilter` and a rollup's `summaryOperations.filter` in the Studio field designer, and a sharing rule's criteria. Both shapes carry a `null` list member, which the face has refused on every query since the 2026-08-31 ruling, so a filter saved that way has been failing its related list or rollup since then. After this change, the Studio save is refused instead, with the `$or` / `$null` prescription. Every other hit is prose, a type table or a test fixture. Deployed datasets, dashboards and reports were NOT measured. Validating each stack, or re-saving each document, finds every instance the surface above lists. + + Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. Comparand shapes that every query face already refused are now refused on save as well. + + +- dd1b803: fix(spec)!: a filter carrying a comparand the comparand-type face refuses is refused when it is saved, and every charted presentation filter judges its nested relations (#20116) + + **BREAKING** — an accept-set narrowing of published authoring schemas, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The save door narrows to exactly what the query faces already refuse; this is stage 2 of the change whose first stage registered `filter-query-face-comparands-refused-at-save`. The hand-migration prescription is registered under protocol major 18 as `filter-comparand-types-and-widget-nested-slots-refused-at-save`. + + ## What changes + + **The comparand-type face, asked on save.** `FilterConditionSchema` now refuses, at parse, every comparand the comparand-type face (`normalizeFilterComparandTypes`, the accepted set `string | number | bigint | boolean | null | Date`) refuses on every query: + + - a plain object where a single value belongs — `{ stage: { $eq: { a: 1 } } }`, and a `{ $field: … }` whose name is not a string; + - a `Map`, a class instance, a function or a Symbol; + - `undefined`, as `{ owner: undefined }` or under an operator; + - a bigint beyond ±2^53; + + as the comparand itself, as an implicit-equality comparand, or as an `$in` / `$nin` / `$between` list member (`{ stage: { $in: [{ a: 1 }] } }`). The face is called read-only as the judge, after the comparand-shape face, so the save door refuses exactly what it refuses and passes what it passes. A field value that is not a PLAIN object (a `Map`, a class instance) is the comparand the face calls it, never an empty nested relation. + + **Every charted presentation filter is an analytics carrier.** `DashboardWidgetSchema.filter`, `ReportSchema.runtimeFilter` and `JoinedReportBlockSchema.runtimeFilter` now declare the same filter as `DatasetSchema.filter` and `DatasetMeasureSchema.filter`, because the dataset executor ANDs each into the same analytics query. So they judge the slots INSIDE a nested-relation condition the way the analytics `where` door does: `{ acct: { stage: { $in: ["won", null] } } }`, `{ acct: { region: ["a"] } }` and `{ acct: { region: { $eq: ["a"] } } }` are refused on save at `widgets.0.filter.acct.…`, `runtimeFilter.acct.…` and `blocks.0.runtimeFilter.acct.…`, as on the two dataset carriers — every shape the earlier stage refuses at the top level, and every type-face value above. That declaration moved, verbatim, into its own module shared by the five carriers; every carrier's published JSON Schema body is byte-identical. + + Measured on `origin/main` `17bd3187` before the change: `FilterConditionSchema`, a dataset `filter`, a dataset measure `filter`, a dashboard widget `filter`, a report `runtimeFilter` and a joined report block `runtimeFilter` each parsed with `success: true` for `{ stage: { $eq: { a: 1 } } }`, `{ stage: { $in: [{ a: 1 }] } }` and a `Map` comparand, at the top level and inside a nested relation. The comparand-type face and the analytics `where` door refused each with `INVALID_FILTER` / 400. A dashboard widget `filter`, a report `runtimeFilter` and a joined report block `runtimeFilter` also parsed with `success: true` for the three nested-relation shapes above, which the analytics door refuses when they are charted. + + **One issue per slot at the top level and in the combinators — a dedupe; no verdict moves.** The faces' verdict on a slot is one refusal: the first the query doors give, in their order — the comparand-shape face, then the comparand-type face, then the `$null` / `$exists` flag rule. So `{ stage: { $null: { a: 1 } } }` reads as the type face's refusal, as it does on chart. At the top level of a filter and in its `$and` / `$or` / `$not` members, where a face refuses a slot the schema door's own `$icontains` and date-preset arms stay silent on it. Before, two issues could land there for one defect: `{ created_at: { $between: ["last_7_days"] } }` reported the malformed range at `created_at.$between` AND the preset endpoint at `created_at.$between.0`; now it reports the range only, and the preset is reported once the range is fixed. + + Inside a nested relation on an analytics carrier (a dataset or measure `filter`, a widget `filter`, a report or joined-block `runtimeFilter`) a slot can still carry TWO issues. There the carrier's nested-relation walk asks the faces, while the schema door's own `$icontains` and date-preset arms keep judging nested slots as they did before this change. So `{ acct: { name: { $icontains: new Map() } } }` gets both the `$icontains` sentence and the type face's at `filter.acct.name.$icontains`, and `{ acct: { created_at: { $between: ["last_7_days"] } } }` gets the malformed range at `filter.acct.created_at.$between` and the preset endpoint at `….$between.0`. The document is refused either way; only the issue count differs. + + Every document refused before is still refused, and every document accepted before is still accepted — the dedupe removes only a second issue on an already-refused slot at the top level and in the combinators. + + **The words are the type face's**, less its location clause (`at where..`), because the issue's path carries the location: for example `Filter comparand is a plain object ({"a":1}), which no driver can compare. A comparison value must be a string, number, bigint, boolean, null or Date. Refusing rather than guessing: …` at `filter.stage.$eq`, and at `filter.stage.$in.1` for a list member. + + `defineStack`, `os validate` and a save through the metadata protocol (`422 INVALID_METADATA`) refuse such a document at the slot's path. + + ## What does NOT change + + - **Nothing stored is rewritten, and nothing is dropped.** The parse fails and strips nothing. The read path does not re-validate stored rows, so a stored document keeps loading, and its next save is refused. + - **What the face passes still passes:** a `Date`, a `{ $field: "column" }` reference, a `{placeholder}` string the engine resolves at request time (`{current_user_id}`, `{today}`), and a bigint within ±2^53. The face narrows such a bigint to its number on a query; the save door keeps it as written. + - **The shared reach is unchanged.** On every carrier but the three analytics ones, a field spec with no `$` key (a nested-relation condition) is not judged, because no face descends one. + - **The data-engine calls' `where` option still parses.** Its type is a union whose first arm is an open record. The face refuses the shape when the call runs. + - **No key, export or JSON Schema body changes.** The published JSON Schema cannot state a refinement; the new nested-relation rule is recorded as a dropped refinement at `ui/DashboardWidget` `filter`, `ui/Dashboard` `widgets.element.filter`, `ui/Report` `runtimeFilter` and the joined block's `runtimeFilter`, and at the same positions inside the installed-package manifests. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ stage: { $eq: { a: 1 } } }` | the one value you meant: `{ stage: { $eq: "won" } }` | + | `{ stage: { $in: [{ a: 1 }] } }` | a list of values: `{ stage: { $in: ["won", "lost"] } }` | + | `{ amount: { $gt: { $field: 5 } } }` | a column name: `{ amount: { $gt: { $field: "budget" } } }` | + | `{ owner: undefined }`, `{ owner: { $eq: undefined } }` | `{ owner: { $eq: null } }` ("has no value"), `{ owner: { $ne: null } }` ("has a value"), or omit the key | + | `{ tags: new Map(…) }`, a class instance | the value itself, as a string, number, boolean, `null` or `Date` | + | `{ qty: { $gt: 2n ** 60n } }` | a bound within ±2^53, or the value compared as a string | + | a widget `filter: { acct: { stage: { $in: ["won", null] } } }` | `{ $or: [{ acct: { stage: { $in: ["won"] } } }, { acct: { stage: { $null: true } } }] }` | + | a widget `filter: { acct: { region: ["a", "b"] } }` | `{ acct: { region: { $in: ["a", "b"] } } }` | + | a report or joined-block `runtimeFilter` with either nested shape above | the same rewrite, at `runtimeFilter` / `blocks..runtimeFilter` | + + ### FROM → TO at the HTTP doors + + Same status, different code: the refusal now comes from the route's schema door, located on the member, instead of from the analytics filter normalizer. + + | request | before | after | + |:--|:--|:--| + | `POST /analytics/dataset/query` with `selection.runtimeFilter: { stage: { $eq: { a: 1 } } }` | `400 INVALID_FILTER` from the analytics normalizer, in the comparand-type face's sentence | `400 VALIDATION_FAILED`, `details.fields[]` entry `selection.runtimeFilter.stage.$eq` with the sentence `Filter comparand is a plain object ({"a":1}), which no driver can compare. …` | + | the same route with `selection.runtimeFilter: { stage: { $in: ["won", { a: 1 }] } }` | `400 INVALID_FILTER` | `400 VALIDATION_FAILED` at `selection.runtimeFilter.stage.$in.1` | + | `POST /analytics/query` (`AnalyticsQueryRequestSchema`) with either shape in `where` | `400 INVALID_FILTER` | refused by the request schema at `where.stage.$eq` / `where.stage.$in.1`, answered `400 VALIDATION_FAILED` | + + A client that branches on `INVALID_FILTER` for these shapes reads `VALIDATION_FAILED` instead. Both are 400 and both name the field. The other refused values cannot arrive over HTTP: JSON has no `Map`, `undefined` or bigint. + + ## Who is affected, measured + + A literal scan of every shape, with a lit control per shape, over `examples/**` and the non-test `packages/**` of this repository, the console repository at its pinned commit `f8a9d0fb05` and the cloud repository's `main` at `96eb092fbf`, found no authored filter carrying a plain object where a value belongs, a `Map` or class instance, `undefined` or a bigint beyond 2^53 — every hit was prose, a driver's operator switch, or a conformance table — and no `filter` / `runtimeFilter` / `where` / `relatedListFilter` whose first entry is a nested relation holding a list or an operator map. A runtime walk of every filter in the example stacks (`app-crm`, `app-todo`, `app-multi-package`, and `app-showcase`'s metadata modules) compared the old and new doors on each and found none refused by the new one alone. Deployed datasets, dashboards and reports were NOT measured. Validating each stack, or re-saving each document, finds every instance the surface above lists. + + Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. Comparand values that every query face already refused are now refused on save as well, and a dashboard widget filter and both report runtimeFilters judge the nested-relation slots the analytics door already refused on chart. + + +- 08c8484: `@objectstack/spec/data` declares which field types are masked on read — `MASKED_ON_READ_FIELD_TYPES` and `isMaskedOnReadFieldType(fieldType, managedBy)` (#20141) + + Clause-②: yes + + The protocol used to state this only in prose (the `FieldType` comments), so + two consumers each carried their own hand-written copy: objectql's + `collectMaskedReadFields` (the generic read mask and the echoed-mask write + guard) and the renderer's masked-type set. The fact is now declared once: + + - `MASKED_ON_READ_FIELD_TYPES` — a deep-frozen per-type rule table: + `secret` is masked on every object; `password` is masked on every object + except `managedBy: 'better-auth'` ones. Each masked type carries its own + `exemptManagedBy` list, typed against `ObjectSchema.managedBy`'s enum. + - `isMaskedOnReadFieldType(fieldType, managedBy)` — the one reading of that + table. `managedBy` is a required argument (pass `undefined` when the object + has none), and exemptions fail closed: an absent or unlisted `managedBy` + never unmasks a masked type. + + `@objectstack/objectql`: `collectMaskedReadFields` and + `collectMaskedPasswordFields` now ask `isMaskedOnReadFieldType` instead of + carrying their own `type === 'secret'` / `'password'` arms. No behaviour + change: the masked-on-read answer is identical for every `FieldType` × + `managedBy` cell, pinned by a table test. + + `@objectstack/spec`: `ObjectSchema.create()`'s author-time warning for a `password` field on a non-auth object now reads its `managedBy` exemption from `isMaskedOnReadFieldType` instead of hard-coding `'better-auth'`, so it follows the declaration; which objects and fields warn is unchanged. + + A client that renders credential fields (show the mask, offer no copy) should + derive its set from `isMaskedOnReadFieldType` rather than keep its own list, + so the server's mask and the client's cannot drift apart. +- 93cfc3f: `$like` / `$ilike`: `_` matches exactly one Unicode code point on every face, so an emoji or any other character outside the Basic Multilingual Plane is one `_`, as SQL `LIKE` and SQLite `GLOB` count it (#20143). + + The SQLite faces (`driver-sql` on better-sqlite3, `driver-sqlite-wasm`, `driver-turso` local and remote) already answered by code points. The JavaScript faces did not: they compiled the spec's `likePatternToRegexSource` with no regular-expression flags, so `_` read one UTF-16 code unit, which is half of an emoji. The same REST filter returned a different row set depending on which driver backed the object. Measured at `e7f69dbb` over values holding `😀` (U+1F600) and `𝒜` (U+1D49C), 48 answer cells on the JS faces differed from the SQLite faces; after this change, none do. + + - **`@objectstack/spec`**: a new export, `likePatternToRegExp(pattern, foldAscii?)`, compiles the translation with the `u` flag, the one compilation in which `_` is one code point. `matchesLikePattern` evaluates it. `likePatternToRegexSource` is unchanged and still exported; its source means one code point per `_` only under `u`. The `$like` description now says that a character is one Unicode code point. + - **`@objectstack/formula`**: `matchesFilterCondition` answers `$like` / `$ilike` by code points, through the spec's `matchesLikePattern`. Its own CEL `size()` already counted code points. + - **`@objectstack/driver-memory`**: all three `$like` doors (the `$like` filter and the AST `like` / `ilike` node through mingo, and the reference matcher) answer by code points. + + The answer set moves in both directions on those three faces, only for values holding a character outside the BMP: + + | pattern | a stored `😀` | `a😀b` | `a😀😀b` | + |---|---|---|---| + | `_` | now matches | — | — | + | `__` | no longer matches | — | — | + | `a_b` | — | now matches | — | + | `a__b` | — | no longer matches | now matches | + + `$ilike` moves the same way. No pattern is newly refused and no refusal is lifted. Values made only of characters inside the BMP answer exactly as before. + + Clause-②: yes (narrowing) + + +- 443b2f4: feat(spec,rest,lint): an import mapping target may name a declared part of a compound field (`mailing_address.street`), and the importer assembles the parts into one value (#20149) + + Clause-②: yes + + **What was missing.** A mapping could write each source column to one flat + field only, so nothing could build an `address` value from the separate + street / city / state / postal code / country columns a spreadsheet carries. + A dotted target such as `mailing_address.street` named no field and was + refused at `objectstack validate`, on the dry run and on the commit. + + **What changes.** + + - `@objectstack/spec`: `ImportFieldMappingSchema.target` declares the part + path. A target may name `field.part` when `field` is a declared field whose + stored value schema is a closed object of optional strings (today: + `address`), and `part` is a key that schema declares: `street`, `city`, + `state`, `postalCode`, `country`, `countryCode`, `formatted`. The part names + are read from the value schema, never listed by hand. The one verdict, + `judgeImportMappingTarget`, answers the new `{ kind: 'part', field, part }`; + `indexImportMappingTargets` carries each compound field's parts on + `parts`; `unknownImportMappingTargets` gives each refused target a `reason` + (`unknown` or `collides`) and, for a dotted target, what its `head` names. + `location` is not compound for import: its parts are required numbers, so a + value assembled from text cells would be the wrong type. + - `@objectstack/rest`: `applyMappingToRows` assembles every part target of a + row into one value under the field's key, before the engine sees the row, + whatever transform produced the part (`none`, `map`, `constant`, `join`, + each element of a `split`). A blank part cell (empty, whitespace or a + `nullValues` token) is left out, string parts are trimmed under + `trimWhitespace`, and a row whose parts are all blank leaves the field + unset, as a blank flat cell does. On an update the assembled value replaces + the stored one. The dry run and the commit judge the same assembled row. + - `@objectstack/lint`: `mapping-target-field-unknown` accepts a declared part + and reports what stays refused, naming the legal parts each time. + + **Still refused, at `objectstack validate`, on the dry run and on the commit + (`400 INVALID_FIELD`, before any row):** + + - a part the value does not declare (`mailing_address.stret`); the refusal + lists the declared parts; + - a dotted path on a field with no parts (`full_name.first`). A dotted target + never traverses a reference (`account.name`): map the column to the + reference field with transform `lookup`; + - a mapping that writes a field both whole and by part (`mailing_address` and + `mailing_address.street`): one row carries one value for the field. Map it + whole or by its parts, not both. + + **What to do.** Nothing, unless you want the capability: point each address + column at `field.part`, for example `{ source: 'Zip', target: + 'mailing_address.postalCode' }`. +- 7e7fab7: fix(spec,rest,lint): an import mapping target that names no field is refused on the dry run, on the commit and at `objectstack validate` alike (#20150) + + Clause-②: yes (narrowing) + + + + **BREAKING** in the accept-set sense only, landing in the launch window as + `minor`: the import route and `objectstack validate` now refuse a mapping they + used to pass, and every such mapping already failed on the commit. + + **What was wrong.** `ImportFieldMappingSchema.target` is declared as "Target + object field(s)", and nothing held a mapping to it. A mapping whose target named + no field of its `targetObject` (measured with `mailing_address.street` on an + object whose address field is `mailing_address`): + + - passed `objectstack validate`, `os lint` and `os build` with no diagnostic; + - answered `ok` for every row on `POST /api/v1/data/:object/import` with + `dryRun: true`; + - then failed every row on the commit with `INVALID_FIELD` ("Unknown field + 'mailing_address.street' on object '…'"). + + The dry run promised what the commit refused. + + **What changes.** + + - `@objectstack/spec` exports ONE verdict on what a target may name, beside the + schema it judges: `unknownImportMappingTargets(fieldMapping, objectDef)`, with + `indexImportMappingTargets`, `judgeImportMappingTarget`, + `importMappingEntryTargets` and `IMPORT_TARGET_ALWAYS_ADDRESSABLE_COLUMNS` + (from `@objectstack/spec/data`). A target may name a declared field, a column + the platform provisions on that object (`resolveInjectedSystemColumns`), or one + of `id` / `created_at` / `updated_at`, which the engine's write door admits on + every object. An object with no readable, non-empty field map is not judged. + - `@objectstack/rest`: `prepareImportRequest` refuses a named mapping (`mappingName`) + with a target that names no field, before any row, with `400 INVALID_FIELD` — + the code the commit's per-row refusal already carried. The dry run and the + commit give the same answer, and so does the async import-job route. + - `@objectstack/lint`: the reference-integrity suite (`os validate`, `os lint`, + `os build`) gains `validateMappingTargetFields`, rule id + `mapping-target-field-unknown` (`MAPPING_TARGET_FIELD_UNKNOWN`), severity + `error`, located at `mappings[i].fieldMapping[j].target`. It asks the same + spec verdict, so it never refuses a target the import door accepts. + + **What to do.** Point each reported target at a field the object declares. An + array target (`split`) is judged element by element. +- 4df101c: `IObjectQLEngine` gains an optional judge-only member, `judgeFilter(objectName, where, { operation?, context? })`, and `ObjectQL` implements it (#20157, #19995 ruling C). It answers "can this filter run against this object?" without running anything: `{ ok: true }`, or `{ ok: false, code, status, message }` with the same diagnostic execution would raise. + + - **The engine's own admission, not a copy.** The judge calls the two stage functions every verb that takes a `where` already runs, in their order. First the lowering doors: the shape gate, the list-comparand shape, the virtual-field and dotted-path refusals, the text operator over a non-text field, the uninterpretable temporal comparand and the comparand-type door. Then the filter-placeholder resolver. A new door on that pipeline is judged the day it lands. + - **Nothing executes.** No driver is resolved or called, and no hook or middleware runs. The member is synchronous, so a door that needs I/O cannot join it without a contract change. Driver-level refusals and the predicates middleware composes later (RLS, sharing, tenant scope) are not judged. + - **Placeholders resolve against `context`**, exactly as execution resolves them. A context placeholder the context cannot answer (`{current_user_id}` with no user) is refused with `FILTER_TOKEN_UNRESOLVED`, never resolved to `null`. + - **`operation`** (default `'find'`) names the verb the caller will run, so the message carries that verb's prefix. The verdict is the same on every verb. + - **The message is not redacted.** It names fields, operators and comparands. A caller judging a filter it must not disclose, such as a read-scope policy, withholds the message itself. + + Optional by the ruling. A caller probes for it (`typeof ql.judgeFilter === 'function'`) and keeps its current behaviour on an engine without it. Existing engine doubles and foreign engines need no change. Execution is unchanged for every CRUD caller: same diagnostics, same order. + + Clause-②: yes +- 6a6a17b: fix(spec): a `joined` report draws no chart — `blocks[].chart` is removed and a container `chart` on a joined report is refused (#20161) + + Clause-②: no (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, + never by the level). + + A `joined` report draws each of its blocks as a table. The renderer's joined + branch returns before its one read of the report's `chart`, and nothing ever + read a block's `chart` at all. So a chart on a joined report, on the container + or on any block, parsed green, passed the `validate-chart-bindings` lint, and + plotted nothing. Both coordinates now answer at parse: + + ``` + FROM ReportSchema.safeParse({ name: 'overview', label: 'Overview', type: 'joined', + chart: { type: 'bar', xAxis: 'status', yAxis: 'task_count' }, + blocks: [{ name: 'open_block', dataset: 'tasks', rows: ['status'], values: ['task_count'], + chart: { type: 'pie', xAxis: 'status', yAxis: 'task_count' } }] }) + -> { success: true } // both charts silently never drawn + + TO -> { success: false, issues: [ + { code: 'unrecognized_keys', path: ['blocks', 0], + message: '… `report.blocks[].chart` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … Delete the key. …' }, + { code: 'custom', path: ['chart'], + message: 'a `joined` report draws no chart — it draws each block as a table and never reads `chart`, on the container or on a block. Delete `chart`; …' } ] } + ``` + + **Fix.** Delete the `chart`. The report renders exactly as before, because + neither value was ever drawn. To plot one of the slices a block shows, give it a + non-joined report of its own with that `chart`. + `os migrate meta --from 17` lists the mechanical edits for existing sources. + + **What does not change.** `chart` on a `tabular` / `summary` / `matrix` report is + untouched: it is that report's live embedded chart. A joined report with no + `chart` parses byte-identically to before, and a block keeps every other key. + + ### The retirement kit + + - **Schema.** `JoinedReportBlockSchema` is closed (`strictObject`), so `chart` is + removed from its shape and answered by its `guidance` table with the + prescription (build-schemas check (c) proof 4). `ReportSchema.chart` stays + declared; the joined arm of its refinement refuses it, beside the + `dataset` / `rows` / `columns` / `values` / `order` refusals already there. + - **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `ui/JoinedReportBlock:chart`, and + the D2 conversion `report-joined-chart-removed` (protocol 18, retired from the + load path) strips a block's `chart` and a joined container's `chart` from old + sources and stored `sys_metadata` rows as a lossless delete. Stored rows can + carry them: the Studio report form offered a block `chart` input until this + change. The family's D3 semantic entry, `ui-report-joined-chart-retired`, states + what the strip cannot decide: whether the chart was wanted. If it was, it moves + to a non-joined report of its own, because a joined report has no chart channel. + - **Form.** `reportForm` drops the block `chart` input and shows the container + `chart` only when `type` is not `joined`; the `platform-objects` metadata-form + translation bundles drop the `blocks.chart` label in all four locales. + - **Lint.** `validate-chart-bindings` no longer resolves the axes of a block chart + or of a joined container's chart against a dataset: it would be vouching for a + chart that is refused at parse and never drawn. A block's own `dataset` / + `rows` / `columns` / `values` are still checked. + - **Ledger and docs.** `liveness/report.json` names a reader for `chart` only on + non-joined reports and drops `chart` from the `blocks` row; + `content/docs/ui/reports.mdx` lists what a joined container refuses. + + +- a91d12a: fix(spec)!: a flattened `view` overlay is judged by the member its `viewKind` names, so a column-less list patch has its list keys judged instead of stripped by the form member (#20186) + + **BREAKING** accept-set change on the `view` write door (`PUT /api/v1/meta/view/:name`, the Studio and MCP save), and on every door that parses `ViewMetadataSchema` (the assembled-manifest `viewItems:` union included). It narrows, and it widens two small classes, both declared below. It ships as `minor` under the repo's launch-window convention for breaking changes. + + Clause-②: yes (narrowing) + + ## What was wrong + + The two flattened overlay members of `ViewMetadataSchema` shared one `viewKind: 'list' | 'form'` enum. The list member required `columns`, so it refused a column-less `viewKind: 'list'` body. The union then tried the form member, which requires no list key and `.strip()`s every one, and ACCEPTED the body. `diagnoseViewMetadata` named `formOverlay`, the parse output was `type: 'simple'` (a form), and the body's `sort`, `searchableFields` and `timeline` were never judged. Measured through the real `saveMetaItem` on `origin/main` @ `4df101c3`, and again at `ce70876e`: a retired bare-string `sort`, a `timeline.metaFields` and a non-array `searchableFields` each answered `success: true`, and the row held them as sent. The mirror held too: the list member accepted a `viewKind: 'form'` body carrying list `columns`. + + That column-less list body is not a malformed one. It is what the console stores on every toolbar save (sort, hidden fields, inline edit, column widths) for a code-defined list view: objectui's `persistViewPatch` stores the patch and nothing else, per the maintainer ruling 「`persistViewPatch` 只存 patch,不存 merged base」. + + ## What it does now + + - **One `viewKind` per member.** The list overlay member admits `viewKind: 'list'` only; the form overlay member admits `viewKind: 'form'` only. A flattened body is judged by the member its `viewKind` names, and `diagnoseViewMetadata` names that member. + - **The list member judges a column-less patch.** `columns` is optional on the flattened list overlay member only. The authoring `ListViewSchema` keeps it required: an authored list view is a full config, never a patch. The console's patch-only writes keep saving, stored verbatim, and a lean list patch now parses to a list (`type: 'grid'`), not to `type: 'simple'`. + - **A column-less body that names a `type` is still refused**, now located at `columns`: a body that sets `type` is a full inline config, and a full config lists its columns. The refusal names both ways out. + - **A field list under a form overlay's `columns` is refused** at `columns`: on a form view `columns` is the body-column count. + - The served JSON Schema (`/api/v1/meta/types/view`) is still an `anyOf` of four members. The only movements: each overlay member's `viewKind` enum names one value, the list overlay member no longer lists `columns` as required, and in the output direction it no longer lists `type` as required (the `grid` default is still declared and still applied). + + ## FROM → TO + + Each row is refused now and was accepted before, on a flattened overlay: + + | you wrote | write instead | + |:--|:--| + | `viewKind: 'list'`, no `columns`, `sort: 'name desc'` | `sort: [{ field: 'name', order: 'desc' }]` — the bare string clause was retired in 17.5.0 | + | `viewKind: 'list'`, no `columns`, `sort: [{ field, direction: 'desc' }]` | `sort: [{ field, order: 'desc' }]` | + | `viewKind: 'list'`, no `columns`, `timeline: { …, metaFields: [...] }` | delete `metaFields`: the timeline block has no such key | + | `viewKind: 'list'`, no `columns`, `searchableFields: 'name'` | `searchableFields: ['name']` | + | `viewKind: 'list'`, no `columns`, `sharing: { enabled: true, publicLink, … }` (the form public-link block) | the list `sharing` block, `sharing: { type: 'personal' \| 'collaborative', lockedBy? }`, or delete `sharing` | + | any other list key the list view schema refuses, on a column-less list overlay | the value the list view schema accepts — the refusal names the key | + | `viewKind: 'form'` with `columns: ['name', …]` | a field list means a list view: `viewKind: 'list'`; for a form, `sections: [{ fields: ['name', …] }]` and `columns` as a count (`columns: 2`) | + + **The one-line fix:** read the refusal. It is located at the key it refuses and says what that key takes. + + A column-less list overlay that names a `type` (`{ viewKind: 'list', type: 'kanban', … }` without `columns`) was refused before and is refused now; only its location moved, to `columns`. Add `columns`, or drop `type` to save the body as a patch. + + ## Declared widening (why `Clause-②: yes`) + + Two classes of column-less, type-less `viewKind: 'list'` bodies go from refused to accepted: + + - **W2** — a list-legal value under a key both members declare with different schemas: `aria` (the form member carries a retirement tombstone there), an i18n `description` (the form member takes a plain string only), the list `sharing` block (the form member's is the public-link block), and a valid legacy `options` bag (the form member pins `options` absent). Measured: `{ name, object, viewKind: 'list', aria: { ariaLabel: 'Leads' } }` was refused, and is accepted. This is the change working: a list body is judged by list rules. + - **W1** — an invalid value under one of the 19 form-only keys (`layout`, `sections`, `title`, …). Measured: `{ name, object, viewKind: 'list', isPinned: true, layout: 'diagonal' }` was refused (the form member judged `layout`), and is accepted with `layout` dropped unread. That is the list member's existing handling of a key it does not declare: a list overlay WITH `columns` and `layout: 'diagonal'` was already accepted the same way. It is a named residual, not a contract. + + ## Census + + - **objectstack** @ `4df101c3` (examples, packages): no source writes a flattened `viewKind: 'list'` body without `columns` as a literal; every literal hit is a test fixture, a changelog line or a comment. `examples/**` authors views as containers (15 files with `listViews`) and carries no `viewKind` at all. + - **objectui**, at the `.objectui-sha` pin `f8a9d0fb0` and at `main` `25c7d584e`, and **cloud** `main` `48d7066`: no source literal either. The one real producer is dynamic: objectui's `buildPersistedViewBody` returns `{ ...patch, viewKind }` for an overlay and `updateViewConfig` stamps `object`, `name` and the overlay marker. Those bodies keep saving, now judged by the list member. + - **Production `sys_metadata` rows: NOT MEASURED.** No deployment's store is reachable from the repository. A stored row that fails keeps being read and served exactly as stored. It is refused only on its next save, and the refusal names the key. + + +- 5f9d7d7: `ERROR_CODE_LEDGER['@objectstack/lint']` now lists `INVALID_ARTIFACT_PACKAGES`, the code `packages/lint`'s `packagesOf` reader stamps for a malformed `stack.packages` (#20206) — required by `check:error-code-provenance`, which refuses a registered code stamped by a package whose own owner key does not list it. + + Clause-②: yes + + Provenance, not identity: the code was already registered under `@objectstack/core` (`resolveArtifactPackageOrder`, the producer `packagesOf` deliberately mirrors rather than mints a new code for), so the `ErrorCode` union, the wire, and every other package's rows are unchanged. What widens is the per-package face a consumer reads from `ERROR_CODE_LEDGER['@objectstack/lint']`, newly present where it was absent before. Nothing to migrate. +- 569d4d2: feat(spec)!: retire the `inline` and `grid` arms of form `layout` — every renderer folded both to `vertical`, and multi-column is `columns` (ADR-0049) + + + + **BREAKING** — form `layout` accepts exactly `'vertical' | 'horizontal'` on both surfaces + that declared the four-arm enum: the `object-form` page component + (`ObjectFormPropsSchema.layout`) and the form view (`FormViewSchema.layout` — `view.form`, + `view.formViews.*`, a form view item's `config`, and the flattened form overlay, which + spreads the form view's shape). `'inline'` and `'grid'` are refused at parse, each with a + prescription naming what to write instead. + + | surface | before | after | + |:--|:--|:--| + | `object-form` `layout: 'grid'` | parsed clean, rendered as `vertical` | refused — write `'vertical'` (or omit `layout`); for multi-column set `columns` | + | `object-form` `layout: 'inline'` | parsed clean, rendered as `vertical` | refused — write `'vertical'` (or omit `layout`) | + | form view `layout: 'grid'` | parsed clean, rendered as `vertical` | refused — write `'vertical'` (or omit `layout`); for multi-column set `columns` | + | form view `layout: 'inline'` | parsed clean, rendered as `vertical` | refused — write `'vertical'` (or omit `layout`) | + | `layout: 'vertical'` / `'horizontal'` | accepted | **unchanged** | + | `columns` | honoured under every layout | **unchanged** — the key multi-column always lived under | + + **What was actually wrong.** No renderer ever gave either value a behaviour of its own. + Measured at the `.objectui-sha` pin this repo builds against (`f8a9d0fb0`): the simple + `object-form` arm folds both to `vertical` (`ObjectForm.tsx:1406-1410`, under the comment + "Map 'grid' and 'inline' to 'vertical' as fallback"); the drawer and modal arms + (`ObjectForm.tsx:463`, `:499`) and `DrawerForm.tsx:575` / `ModalForm.tsx:597` pass only + `vertical` / `horizontal` through; `TabbedForm.tsx:556`, `SplitForm.tsx:445` and + `WizardForm.tsx:1075` hard-code `vertical`. So both values were a green parse for a value the + renderer threw away. The spec had admitted them from two declarations — the designer palette + and the registry `inputs` offered all four — never from a read. + + Under the maintainer's ADR-0049 family criterion (the capability exists on mainstream + platforms ⇒ build the consumer; it does not ⇒ retire), multi-column — what `grid` would + mean — already exists here under another key, `columns`, which the renderer honours under + every arm; `inline` is a toolbar / filter-row pattern, not a record-form layout. The two arms + are redundant vocabulary, retired with no alias window. + + ## What to write instead + + ```ts + // before — parsed clean, rendered single-column 'vertical' + { type: 'object-form', properties: { objectName: 'crm_lead', layout: 'grid' } } + // after — what it rendered; add `columns` if a multi-column form was the intent + { type: 'object-form', properties: { objectName: 'crm_lead', layout: 'vertical', columns: 2 } } + ``` + + `'inline'` → `'vertical'` (or delete `layout`: `'vertical'` is the renderer default). A form + that wrote `'grid'` without `columns` always rendered single-column; only its author knows + whether more columns were meant — set `columns` to the count you meant. + + Existing sources: `os migrate meta --from 17` lists the mechanical edits; apply them by hand. + + The retirement kit: + + - both enums narrowed to `'vertical' | 'horizontal'`, each with a per-value error map keyed on + the input (the `record:chatter` `position` precedent), so only a value that used to be legal + is told it "was removed"; a never-vocabulary value keeps zod's own enum refusal + - the D2 conversion `form-layout-inline-grid-to-vertical` (protocol 18, retired from the load + path) rewrites both values to `'vertical'` and leaves `columns` untouched — on `object-form` + page components, on every form payload a view carries, and on the assembled-manifest + `viewItems` channel, so stored rows and assembled artifacts replay clean; wired into the + protocol-18 chain step + - one D3 entry for the family, `ui-form-layout-inline-grid-retired`, carrying the one judgement + the chain cannot make: whether a form that said `grid` wanted columns it never declared + - pin tests (`ui/form-layout-inline-grid-retired.test.ts`): both values refused with the + prescription on five doors (`ObjectFormPropsSchema`, the `object-form` props-map row, + `FormViewSchema`, a view container's `formViews`, the flattened form overlay), `vertical` / + `horizontal` green on each as controls, the conversion's rewrite parsing green on the door + that refused its input, and the chain registration + - the `columns` descriptions no longer say "grid layout"; the generated references + (`content/docs/references/**`, `skills/objectstack-ui/references/react-blocks.md`, the JSON + Schema) print the two-arm enum; the hand-written `layout-dsl` page and the `form.layout` + liveness row (still `live`) are updated + - `api-surface/` and `authorable-surface/` are unchanged, correctly: they ratchet export and + key existence, and no export or key leaves — `layout` is still declared, two values narrower + + Clause-②: no (narrowing) +- 28ad7e4: fix(spec): `PackageInstallRequestSchema`'s wrapped branch refuses an unknown top-level key by name (#20249) + + Clause-②: no (narrowing) + + **BREAKING for callers of the install door** — a WRAPPED install body + (`{ manifest, … }`) that carries any top-level key the declaration does not + name is now refused `400` / `VALIDATION_ERROR` at `POST /api/v1/packages`, and + `PackageInstallRequestSchema` / `PackageInstallBodySchema` refuse it at parse. + It used to parse green with the key silently DROPPED, and the door installed + the package and answered `201`. + + This one narrows the declaration itself. The manifest and the bare form (a + manifest as the whole body) already refused an unknown key by name; the wrapped + top level was the one position of the install contract still declared strip + mode. The sharp case is a misspelled option: `{ manifest, enabledOnInstall: + false }` had `enabledOnInstall` dropped, so the package was installed + **ENABLED** — the caller's explicit `false` inverted, with no word said. The + wrapped branch is now a `strictObject`, like the other two positions: one rule + for the whole install contract (decision batch #227 item 3, letter A; ruling + record `5856869656`). No alias and no grace window. + + The install door needs no edit and gets none: since the door started parsing + its whole body through `PackageInstallBodySchema`, it answers exactly what that + declaration says, so the refusal reaches `POST /api/v1/packages` the moment the + declaration moves. The same fact retires the sentence that had forbidden this + close — «the declaration must not refuse a body the door answers `201` to» held + only while the door did not parse its body. + + **What is not affected.** A wrapped body carrying only `manifest` and the + declared install options — `settings`, `enableOnInstall`, `overwrite`, + `platformVersion`, `artifactRef` — parses and installs exactly as before, and so + does a bare manifest. Boot-time and in-process installs reach + `SchemaRegistry.installPackage` / `ObjectQL.registerApp` directly and never pass + through this declaration. + + **Reach, measured first-party.** The SDK's `client.packages.install` sends only + `manifest`, `settings`, `enableOnInstall` and `overwrite`, and the objectui + package dialog sends only `{ manifest }`; neither breaks. + **Out-of-repo callers are NOT MEASURED** — there is no telemetry on them, so a + caller that sends its own private top-level key (a trace id, a source tag) now + gets a `400` naming that key. Check your own callers before upgrading rather + than inheriting this result. + + **Migration — FROM → TO.** The refusal names the key and, for a near-miss, + offers the declared one, so the prescription arrives with the `400`: + + - A misspelled option: FROM `{ "manifest": { … }, "enabledOnInstall": false }` + TO `{ "manifest": { … }, "enableOnInstall": false }` — respell it as the + declared option it meant. + - Any other undeclared top-level key: FROM `{ "manifest": { … }, "_source": + "studio" }` TO `{ "manifest": { … } }` — remove it. There is no place on this + request to carry it. + + It is registered as an ADR-0087 structured TODO rather than a conversion: an + unknown key has no mapping target, and deleting it automatically would repeat + the silent drop this change closes. + + +- 40b315b: feat(spec)!: retire the connector resilience family — `health` (health probe + circuit breaker), `status` and the nested `webhooks`, sixteen keys nothing read (#20273) + + **BREAKING** — `connector.health` (the `healthCheck` probe, eight keys, and the + `circuitBreaker`, six keys), `connector.status` and the connector-nested + `webhooks` are removed from `ConnectorSchema` and `DeclarativeConnectorEntrySchema` + — so from `defineConnector`, `stack.connectors[]`, the `PUT /api/v1/meta/connector/:name` + door and `AutomationEngine.registerConnector`. ADR-0049 enforce-or-remove, one + batch for the family, by the maintainer's criterion: does the mainstream platform + offer this capability? Author-configured health probes and circuit breakers are + not connector metadata in the mainstream (breakers live in API-gateway + infrastructure), and an authored status and a nested webhook list duplicate what + is already delivered here by other keys. + + Measured before removal, each against a lit control: zero reads of any of the + sixteen keys outside `packages/spec`. No loop ever polled a connector endpoint, + counted consecutive failures or tripped a breaker, and none of the four + `fallbackStrategy` behaviours existed. Nothing read an authored `status`: the + runtime's dispatchability answer is the COMPUTED `state` (`ready` / `degraded`) + on `GET /api/v1/automation/connectors`, which no authored value sets. A webhook + nested in a connector was never registered as a `webhook` item, so it was never + materialized into `sys_webhook` and never delivered. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `connector.health` (`healthCheck.*`, `circuitBreaker.*`, including `monitoringWindowMs` and the pre-rename `monitoringWindow`) | delete the block. Put health probes and circuit breaking in the connector provider or an upstream gateway. | + | `connector.status` | delete the key. `enabled: false` on a declarative entry is what withdraws a materialized instance or marks a catalog-only descriptor; whether a registered connector can be dispatched is the computed `state`. | + | `connector.webhooks` | delete the array. A webhook that is actually delivered is declared in the stack's top-level `webhooks:` collection — moving one there STARTS deliveries this connector never made, so decide per webhook. `events` and `signatureAlgorithm` have no counterpart there. | + | `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` (schemas, types, `…Parsed` types) | no replacement — nothing parsed or constructed them. | + + **The one-line fix: delete `health:`, `status:` and `webhooks:` from every connector.** + `os migrate meta --from 17` lists the mechanical edits for existing sources. + + ⚠️ Runtime behaviour is deliberately **unchanged**: none of the sixteen keys ever + changed what a connector did. What changes is the answer an author gets — each + key is refused at parse with a prescription, and in `tsc` (its input type is + `never`), instead of being saved with no effect. + + ### The retirement kit + + - **Tombstones.** `health`, `status` and `webhooks` are `retiredKey()` tombstones + on the private `ConnectorBaseSchema` both published carriers wrap (the schema + is not `.strict()`, so a bare deletion would be a silent strip, ADR-0104). + `RETIRED_KEYS_BY_MAJOR[18]`: `integration/Connector:{health,status,webhooks}` + and `integration/DeclarativeConnectorEntry:{health,status,webhooks}`. + - **Retired-default residue.** `status` was `.default('inactive')`, so every 17.x + parse emitted `status: 'inactive'` into every connector; that exact value joins + `connectionTimeoutMs: 30000` in the residue stage (accepted and stripped, so a + def a 17.x toolchain built still registers). Every other value is refused. + - **Seven defs leave whole** (`RETIRED_DEFS_BY_MAJOR[18]`): the four + `integration/` schemas and three enums listed above. + - **D2 conversion `connector-resilience-keys-removed`** (step 18, retired from + the load path): strips the three keys from `connectors[]` and from stored + `sys_metadata` connector rows (the rehydration seam replays it), one notice per + key, as a lossless delete. Nested webhooks are stripped, never moved. + - **The chain.** In the same step, `connector-health-and-trigger-durations-unit-in-key` + renamed `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs`. That + breaker half is absorbed by this removal: the renamed key is itself removed, so + an author holding either spelling ends with no `health` block. The + conversion's `triggers[].interval` → `intervalSeconds` rename is unaffected. + - **D3 entry `connector-resilience-keys-retired`** carries the family's + judgement: which probe, breaker or nested webhook the author actually relied + on, and where it goes now. + - **Writers deleted.** The four shipped connector packages wrote + `status: 'active'` and the automation service's degraded husk wrote + `status: 'error'`; nothing read either back, and both writes are gone. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no telemetry was consulted for. + + Clause-②: no (narrowing) + + +- f2c7eef: An analytics cube's `public` now takes effect, and it defaults to visible: `CubeSchema.public` defaults to `true` (it was `false`), and the analytics service hides a cube that declares `public: false` from discovery and refuses every query against it (#20282). + + Clause-②: yes (narrowing) + + + + **BREAKING**: this narrows what the analytics API answers. A query or SQL dry run against a cube declared `public: false` (`POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`) was answered before this change and is now refused with `404 CUBE_NOT_FOUND`, and `GET /api/v1/analytics/meta` no longer lists that cube. The same happens to every cube in an artifact built by `os compile` before this release, which carries a materialized `public: false` from the old default. The remedy: delete `public: false` from any cube that is meant to be queried (cubes are visible by default), and recompile pre-release artifacts. It ships as `minor` under the launch-window convention; the widening half is the default moving to visible. + + Until this change nothing read `public`. `GET /api/v1/analytics/meta` listed a `public: false` cube and every query door answered it, so the flag withheld nothing. Its declared default, `false`, could not simply be switched on: enforcing it as declared would have hidden every cube that omits the key. The default is now the Cube.dev default (visible), and an explicit `false` is enforced: + + - `GET /api/v1/analytics/meta` omits a cube declared `public: false`, and `?cube=` naming one answers `[]`, the same as a name no cube has. + - `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` refuse it with `404 CUBE_NOT_FOUND` — the same refusal, byte for byte, that an unknown cube name gets, so a caller cannot use it to learn that a hidden cube exists. The one shared message names both possibilities, so it still tells an author how to expose a hidden cube. The refusal comes before any SQL is built, and it is never an empty result. + + `public` is visibility on the analytics API, not row security. An object's records stay governed by its permissions and row-level security on every door, whether or not a cube over it is hidden. What `public: false` does is exactly the two points above: the cube is left out of `/analytics/meta`, and queries and SQL generation against it are refused. The cube's definition stays readable on the metadata door, like any other authored schema. + + What to expect after upgrading: + + - **A cube that omits `public`** stays visible and queryable. It was visible before too, because nothing read the key. A client that parses cube metadata through the published JSON Schema now materializes `public: true` where it materialized `false`. + - **A cube that writes `public: false`** is now hidden and refused. If you wrote it only because it was the old default, delete the line (cubes are visible by default). A dashboard or report that queries such a cube starts answering `404 CUBE_NOT_FOUND` until you do. + - **A compiled artifact built before this release** carries a materialized `public: false` on every cube, because `os compile` writes the parsed stack with its defaults applied. Recompile it with this release before serving cubes from it. + - **Cubes the platform mints itself** stay visible: the cube inferred for an ad-hoc query on an object (the KPI path), a compiled dataset's cube (`POST /api/v1/analytics/dataset/query`), and `CubeRegistry.inferFromObject`. Each wrote a literal `false`, the old default, and now writes `true`. + + The showcase example's `showcase_delivery` cube, which is the app's demonstration of `/api/v1/analytics/*`, drops its `public: false`. +- 0bbe400: feat(spec,core,cli)!: a scenario's `requires` is checked before it runs — unmet `params` or `services` SKIP it with a reason; `requires.plugins` is retired into `requires.services` (#20289) + + Clause-②: yes (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, + never by the level). + + A Quality Protocol scenario's `requires` block declared preconditions — + `params` (environment variables) and `plugins` (plugins that must be loaded) — + that nothing checked: measured on a stub target, a scenario naming a missing + plugin and an unset variable reported PASSED exactly like its no-requirements + control. ADR-0049 enforce-or-remove, verdict ENFORCE (the mainstream has + declared preconditions: JUnit `@EnabledIfEnvironmentVariable`, pytest `skipif`), + ruled B for the shape: each key is judged against something `os test` can + actually observe. + + - **`requires.params`** — each variable must be set to a non-empty value in the + environment of the process running `os test` (not the target server's, which a + suite cannot see). An empty value counts as unset: an unconfigured CI secret + arrives as an empty string. + - **`requires.services`** (new) — each entry is a discovery service key + (`CoreServiceName`: `auth`, `automation`, `analytics`, `ai`, `storage`, …; a + misspelling is refused when the suite loads) that the target must declare + `enabled` with status `available` in its discovery document (ADR-0076 D12). + It is read from the discovery request the HTTP adapter already makes once per + run; a suite that requires no service issues no extra request. + - **SKIPPED.** A scenario with an unmet entry runs no step — `setup` included — + and `os test` prints it with its reason, naming every unmet entry and, for a + service, the services the target does declare available: + `Skipped: requires.services 'ai' is not available on the target (enabled: false, status: unavailable). The target declares available: auth, data, metadata.` + It is counted on its own — `SUCCESS: 3 scenarios passed. 1 skipped (not run, not counted as passed).` — + and never as passed. Skips alone exit `0`; a run in which EVERY selected + scenario was skipped prints `No scenario ran: …` instead of `SUCCESS`, exits + `0`, and exits `1` under `--fail-on-empty`. With nothing skipped, the summary + lines keep their spelling. + - **`@objectstack/core`:** `QA.TestResult` gains `status` (`'passed' | 'failed' | 'skipped'`) + and, on a skipped result, `skipped` (`reason`, `unmet[]`, `availableServices`); + `passed` stays and is `false` on a skip. `TestRunner` takes an optional + `{ env }` (default: this process's environment), and `TestExecutionAdapter` + gains an optional `readTargetServices()` — `HttpTestAdapter` answers it from + its one discovery probe. An adapter without it skips a service requirement + rather than running it. + + ``` + FROM { "id": "ai-summary", "requires": { "plugins": ["@objectstack/service-ai"] }, "steps": [...] } + -> ran anyway; the missing plugin surfaced as whatever failure it caused, or passed + TO -> os test refuses the suite at load: + ✗ scenarios.0.requires.plugins: `scenarios[].requires.plugins` was removed in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever checked it: … + Delete the key and name the service the scenario needs in `requires.services`, … + Plugin → service: @objectstack/service-analytics → analytics, @objectstack/plugin-auth → auth, … + + FROM { "requires": { "services": ["ai"] }, … } (new key) + TO -> against a target whose discovery does not declare `ai` enabled and available: + ⏭️ Scenario: Summarise an account [ai-summary] (skipped) + Skipped: requires.services 'ai' is not available on the target (…). The target declares available: … + ``` + + **Fix.** `requires.plugins: [""]` → `requires.services: [""]`, + using the mapping the refusal prints (derived from `CORE_SERVICE_PROVIDER`, the + provider table discovery itself reports): `@objectstack/plugin-auth` → `auth`, + `@objectstack/service-analytics` → `analytics`, `@objectstack/service-automation` + → `automation`, `@objectstack/service-storage` → `storage`, and so on; the `ai` + service is provided by ObjectStack Cloud/Enterprise. A plugin that fills no + discovery service slot has no service to require — gate that scenario with a + `params` variable or select it with `--tags`. `tsc` refuses `plugins` at a typed + authoring site (its input type is `never`). A `TestResult` consumer that counted + `!passed` as a failure should read `status` — a skipped result is `passed: false` + and is not a failure. + + **What does not change.** A scenario without `requires` runs exactly as before, + and a suite that requires no service issues no discovery request it did not + already issue. + + ### The retirement kit + + - **Schema.** `TestScenarioSchema.requires` is a non-strict `z.object()`, so + `plugins` is a `retiredKey()` tombstone carrying its prescription (a bare + deletion would have stripped it in silence); `services` is new, closed over + `CoreServiceName`. + - **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `qa/TestScenario:requires.plugins`. + No D2 conversion: a QA suite is a loose JSON file `os test` loads, never a + stack collection member or a stored row. The family's D3 entry, + `qa-scenario-requires-plugins-retired`, carries the prescription to + `os migrate meta` and the upgrade guide. + - **Ledger and docs.** `liveness/qa.json` moves `qa.scenarios.requires` from + `dead` to `live`, citing the runner's judgement and the adapter as producer; + `state-counts.md` moves `qa` to 9 live / 0 dead. The `os test` section of the + CLI reference documents the check, the skip line and the exit posture, and the + generated `qa/testing` reference page is regenerated. + + +- 862b6ce: Expression refusals now carry a stable `code` and typed `params` beside their English `message`, so a localized author surface can render its own words: `validateExpression` (every entry in `errors[]` and `warnings[]`), `collectCelRootIdentifiers` (its `ok: false` arm), `predicateSlotRefusal` and `structuralConditionRefusal` (#20291) + + Clause-②: yes + + Until now the only thing a refusal said was an English sentence, and a designer running in another locale could only show it verbatim, beside its own translated headings. Each refusal now also names its code — one of a closed, kebab-case set — and the values its sentence interpolates, so a consumer keys a catalogue row to the code and fills it from the params. The `message` is the same sentence, byte for byte; nothing is removed or renamed, so no existing reader changes. + + - `@objectstack/formula` exports `EXPRESSION_REFUSAL_CODES` (the closed set as a frozen list) and the types `ExpressionRefusalCode`, `ExpressionRefusalParams` (code → params), `ExpressionRefusal`, `ExprValidationCode`, `CelRootsRefusalCode`, `CelFieldRole`, `ExpressionSourceKind` and `CelRootIdentifiersResult`. `ExprValidationError` gains `code` and `params`. + - `@objectstack/spec/automation` exports `FLOW_SLOT_REFUSAL_CODES` (the closed set of both flow-slot refusal producers, as a frozen list) and the types `FlowSlotRefusalCode`, `FlowSlotRefusalParams` (code → params), `PredicateSlotRefusalCode`, `PredicateSlotRefusal`, `PredicateSlotValueKind`, `StructuralConditionRefusalCode`, `StructuralConditionRefusal` and `StructuralConditionValueKind`. `predicateSlotRefusal` returns `PredicateSlotRefusal` and `structuralConditionRefusal` returns `StructuralConditionRefusal`; each is its previous `{ message, source }` plus `code` and `params`. + - Narrowing on `code` narrows `params`. A code never changes once published: a reworded message keeps its code, and a new refusal gets a new one. + - A `detail` param is the CEL or template engine's own diagnostic, in English, passed through as the message carries it. + - These are authoring diagnostics returned as values, not ADR-0112 request error codes, which is why they are kebab-case. + - The `validate_expression` MCP tool forwards `validateExpression`'s `errors` and `warnings` as they are, so each entry in its answer now also carries `code` and `params`. +- 80153f5: feat(spec,rest)!: the served OpenAPI `info` carries the publisher's `api.documentation` identity; `api.documentation.version` retired (#20294) + + Clause-②: yes (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, + never by the level). The breaking half is one key: `api.documentation.version`. + + `RestServerConfig.api.documentation` (`RestApiConfigSchema`) declared nine + members, and `RestServer` parsed them, copied them into its config — and never + read them back. Measured before this change, with every member authored: both + doors that serve the OpenAPI document (`{apiPath}/openapi.json` and its + environment-scoped twin) answered the bundled artifact's `info` unchanged, 0 of 9 + honoured. ADR-0049 enforce-or-remove, split by who owns each field: + + - **Enforced — the publisher's identity.** `title`, `description`, + `termsOfService`, `contact` (`name` / `url` / `email`) and `license` (`name` / + `url`) now overlay the served `info` on both doors. A member you leave unset + keeps the bundled value, and a config with nothing authored — no block, + `documentation: {}` — serves `info` byte-identical to + `@objectstack/spec/openapi.json`, exactly as before. `contact` and `license` + replace the bundled object **whole**: `license: { name: 'MIT' }` serves + `{ name: 'MIT' }` with no URL, never MIT at the bundled Apache-2.0 URL, and a + partial `contact` never keeps ObjectStack's name or URL. + - **Retired — `documentation.version`.** The served `info.version` is the + protocol version, the version of the `@objectstack/spec` package that generated + the document, with no configured override: an earlier ruling made it equal the + published artifact's so an integrator can read which protocol version they are + talking to. A publisher-set version would give the field a third meaning, so + the key is now refused. + + ``` + FROM new RestServer(server, protocol, { api: { documentation: { title: 'Acme Orders API', version: '2.3.0' } } }) + -> constructed; GET /api/v1/openapi.json served info.title 'ObjectStack REST API' + and info.version = the spec version — both authored values ignored + TO -> throws: REST API configuration is invalid: `api` does not satisfy + `RestApiConfigSchema` … + - api.documentation.version: `api.documentation.version` was removed in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … Delete the key. To publish + your app's own release number, write it into `api.documentation.description`, … + + FROM new RestServer(server, protocol, { api: { documentation: { title: 'Acme Orders API' } } }) + -> GET /api/v1/openapi.json: info.title 'ObjectStack REST API' + TO -> GET /api/v1/openapi.json: info.title 'Acme Orders API' (and on the environment-scoped door) + + FROM RestApiConfigSchema.parse({ documentation: { description: 'd' } }).documentation + -> { title: 'ObjectStack API', description: 'd' } // a default no document ever served + TO -> { description: 'd' } + ``` + + **Fix.** `api.documentation.version` → delete the key. The served + `info.version` is always the protocol version; to publish your app's own release + number, write it into `api.documentation.description`. `tsc` refuses the key at + the authoring site (its input type is `never`), and `RestServer` construction and + the REST plugin's `start` refuse it with that prescription. + + **What else changes.** `documentation.title` is `.optional()` instead of + `.default('ObjectStack API')`: that default was materialized into every present + block and never served, so the parsed block now carries exactly what was + authored (the parsed `title` is typed `string | undefined` now). `api.version` (the route identifier) and the runtime version still + never reach `info.version`. A host that authors none of these keys — every + CLI-started deployment, since `os serve` forwards only `enableProjectScoping` + and `projectResolution` — serves the same document as before. + + ### The kit + + - **Schema.** The eight identity members carry describes naming the served + `info` field; `version` is a `retiredKey()` tombstone inside the live + `documentation` block (a non-strict `z.object()`, so a bare deletion would have + stripped it in silence), next to the `enabled` tombstone. + - **REST server.** `registerOpenApiEndpoints` builds `info` through a pure + helper that returns a NEW object — the cached artifact's own `info` is never + written — and the same handler serves both doors. + - **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains + `api/RestApiConfig:documentation.version`; the D3 entry + `rest-api-documentation-version-retired` carries the prescription to + `os migrate meta` and the upgrade guide. No D2 conversion: a `RestServerConfig` + is plugin TS configuration, never a stack collection member or a stored row. + - **Ledger and docs.** `liveness/rest_api.json`: the eight identity leaves and + the `contact` / `license` containers flip to `live` with the overlay as + evidence; the `version` row stays `dead` with a REMOVED note. The generated + `state-counts.md` moves `rest_api` from 12 live / 12 dead to 20 / 4; the + `rest-server` reference page is regenerated. + + +- 26daf0b: feat(spec,rest)!: retire `api.responseFormat` and `api.documentation.enabled` — parsed, defaulted, and read by nothing (#20295) + + Clause-②: no (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, + never by the level). + + Two keys of `RestServerConfig.api` (`RestApiConfigSchema`) were accepted, given + defaults and copied into the REST server's config by `normalizeConfig` — and no + site ever read them back. `responseFormat` (`envelope`, `includeMetadata`, + `includePagination`) toggled nothing: `envelope: false` unwrapped no response. + `documentation.enabled` was a second on/off switch for the OpenAPI document that + nothing consulted: `api.enableOpenApi` decides that mount. Measured before + removal, each against a lit control on the same instrument: no reader in this + repo's packages, and no author in objectui at its pinned commit or in cloud. + ADR-0049 enforce-or-remove; the verdict is RETIRE, by the maintainer's criterion — + mainstream data APIs keep a fixed response envelope that no administrator toggles + server-wide, and the OpenAPI switch already exists and is enforced. + + ``` + FROM new RestServer(server, protocol, { api: { responseFormat: { envelope: false } } }) + -> constructed; `envelope: false` changed nothing + TO -> throws: REST API configuration is invalid: `api` does not satisfy + `RestApiConfigSchema` … + - api.responseFormat: `api.responseFormat` was removed in @objectstack/spec 17.5.0 + (ADR-0049 enforce-or-remove) — … Delete the key. Response shapes are fixed, … + + FROM RestApiConfigSchema.parse({ documentation: { enabled: false, title: 'My API' } }) + -> { documentation: { enabled: false, title: 'My API' }, … } // served the document anyway + TO -> ZodError { code: 'invalid_type', path: ['documentation', 'enabled'], + message: '`api.documentation.enabled` was removed in @objectstack/spec 17.5.0 (ADR-0049 + enforce-or-remove) — … Delete the key; `api.enableOpenApi: false` is the switch …' } + ``` + + **Fix.** `api.responseFormat` → delete the key; response shapes are fixed, so + there is nothing to configure. `api.documentation.enabled` → delete the key; to + serve no OpenAPI document, set `api.enableOpenApi: false` (it leaves + `GET /openapi.json` and `GET /docs` unmounted). `tsc` refuses both keys at the + authoring site (their input type is `never`). + + **What does not change.** Every live key of the `api` block parses exactly as + before, including the rest of `documentation` (`title`, `description`, + `version`, `termsOfService`, `contact`, `license` — a separate decision). A + config without the two keys mounts the same REST surface: neither key ever + reached it. A `documentation` block no longer grows an `enabled: true` default. + + ### The retirement kit + + - **Schema.** `RestApiConfigSchema` and its inline `documentation` object are + non-strict `z.object()`s, so each key is a `retiredKey()` tombstone carrying its + prescription (a bare deletion would have stripped it in silence). + `responseFormat` retires whole — its three members were its only members. + - **REST server.** `normalizeConfig` runs the tombstones (the `crud.patterns` + posture, not `requireAuth`'s warn-and-ignore), so a config carrying either key + now fails `RestServer` construction and the REST plugin's `start` with the + prescription, and the normalized config no longer carries or re-defaults them. + - **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `api/RestApiConfig:responseFormat` + and `api/RestApiConfig:documentation.enabled`. No D2 conversion: a + `RestServerConfig` is plugin TS configuration, never a stack collection member or + a stored row. The family's D3 entry, `rest-api-config-dead-keys-retired`, carries + the prescription to `os migrate meta` and the upgrade guide. + - **Ledger and docs.** `liveness/rest_api.json` keeps both rows `dead` with a + REMOVED note (`responseFormat`'s three child rows collapse into its one row); + the generated `state-counts.md` moves `rest_api` from 14 to 12 dead; the + reference page for `rest-server` is regenerated. + + +- b810ddb: feat(spec): declare the `$empty` filter operator — what 「is empty」 means, once, per field type — staged ahead of its executors (#20311) + + Clause-②: yes (widening) — a declared operator slot and five exports are added. `FieldOperatorsSchema.parse({ $empty: true })` used to strip the undeclared key and now keeps it. The one refusal that comes with the declared type sits on a key nothing writes (see below). + + **⚠️ Authoring `$empty` today is refused at query time.** The operator is declared but STAGED: it is deliberately absent from `FILTER_OPERATORS`, so no query executor answers it yet. A hand-written `{ "f": { "$empty": true } }` gets `INVALID_FILTER` / 400 from `driver-sql` (and the drivers that inherit its compiler), `driver-turso`'s remote transport, `driver-memory`, `driver-mongodb`, objectql `having` and the analytics `where` compiler; `READ_SCOPE_COMPILE_FAILED` / 500 (fail-closed) from the analytics read-scope SQL compiler; and `@objectstack/formula`'s write-side `matchesFilterCondition` answers `false` for every record, its fail-closed posture for an operator it has no arm for. Until each of those faces has its arm, write 「is empty」 with the view operator `is_empty`, which is unchanged. + + **What the operator means.** Its description is the ruled per-type table (ruling B on #20311, spelled as an operator by ruling A on #20399): + + | field type | `$empty: true` matches | + |---|---| + | text-like (`STRING_VALUE_TYPES`: text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) | null or `''` | + | multi-value (`isMultiValueField`: multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with `multiple: true`) | null or `[]` | + | every other type | null only | + + `$empty: false` is the exact complement. A face that holds no field declaration (the formula matcher, objectql `having`) judges by the value: null, `''` and `[]` are empty. + + **The one expansion every face calls**, exported from `@objectstack/spec/data`: + + - `expandEmptyOperator(field)` — keyed on the field DEFINITION (type plus `multiple`), because a `lookup` is `null_only` and a `lookup` with `multiple: true` is `multi_value`. Returns one of the frozen `EMPTY_OPERATOR_ARMS` rows: `{ arm, emptyString, emptyList }` (`EmptyOperatorArm`, `EmptyOperatorExpansion`). + - `isEmptyFilterValue(value, expansion?)` — the value-level half: with an expansion, the declared row; without one, the by-value reading for the declaration-free faces. + + **What does not change.** + + - The `is_empty` / `is_not_empty` view operators still lower to `{ "$null": true | false }`. A later change flips that lowering to `$empty` once every face answers it; no stored filter changes result in this release. + - An empty list is still refused as an equality comparand: `{ "tags": [] }` and `{ "tags": { "$eq": [] } }` keep their refusal. The multi-value row lives in the operator precisely because it cannot be spelled as a lowered equality. + - `FILTER_OPERATORS` is unchanged, so every executor that derives its accepted set from it (`driver-memory`'s gate among them) keeps refusing `$empty` rather than dropping it. + + **One new refusal, on a key nothing writes.** A NON-boolean `$empty` (`"true"`, `1`, `null`) is refused where the declared boolean flags `$null` / `$exists` already are: at the operator slot, and at the save door (`FilterConditionSchema` and the analytics filter carriers that share its slot check), in the flags' own first sentence. `$empty` appears nowhere in this repository or in objectui's `main` before this change (0 occurrences in either). + + **Stored sharing rules** (ruling B's landing measurement): the criteria sharing rules in this repository's examples and objectui's fixtures that use 「is empty」 are 0, and this release changes no lowering, so none changes result. Production sharing rules are NOT MEASURED: they are unreadable from here. +- 7dc45eb: fix(spec)!: a flow node config its executor cannot run — a key its contract requires, left out, or a decision branch list it cannot read — is refused at authoring (#20316) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on authored flow-node `config`, shipped as + `minor` under the launch-window convention (`check-changeset-no-major` refuses + `major` until GA; breaking-ness is carried by this banner and the ADR-0087 + disposition above, not by the level). + + **What changed.** A flow node's `config` is an open record, so what its executor + requires was checked by no build door. `FlowSchema.parse`, `AutomationEngine.registerFlow` + and `objectstack validate` all admitted a node that left out a key its executor + contract requires — and the executor's own contract parse then refused the node on + every run that reached it. A `decision` branch with no `label` was worse: it never + failed, the matched branch reported no label, and traversal took EVERY out-edge, so + the flow ran green down the wrong paths. All three doors now refuse these shapes + through one judge, `flowNodeConfigRefusals` (new in `@objectstack/spec/automation`): + + - **A key a builtin's executor contract requires, left out.** Each builtin node's + config is parsed against the very contract its executor parses against + (`getBuiltinNodeConfigContracts()`, new, reconciled against the executors' own parse + calls), and only the keys left out are kept — a present value of the wrong type, and + an undeclared key, are judged where they were before. The keys: `objectName` on + `get_record` / `create_record` / `update_record` / `delete_record`; `recipients` on + `notify` (and `title` when there is no `template`); `url` on `http`; `function` on + `script`; `flowName` on `subflow`; `collection` and `flowName` on `map`; + `collection` on a `loop` that has a `body`; `branches` on `parallel`; `try` on + `try_catch`; and on `screen`, each field's `name`, each option's `value` and + `label`, and a `lookup` field's `reference`. A key a rule of the contract requires + (the `notify` title, the `lookup` reference) is refused in the contract's own words. + - **A `decision` branch list its executor cannot read.** `conditions` present and not + `null` must be an array; every branch must be an object; every branch's `label` must + be a non-blank string (absent, `null`, blank or non-text all name no out-edge). + + Each refusal is a `custom` issue anchored at the key (`nodes.1.config.objectName`, + `nodes.1.config.fields.0.name`, `nodes.1.config.conditions.0.label`, or the region + path `nodes.1.config.body.nodes.0.config…`), met at `registerFlow` and + `objectstack validate` through that same parse, and reported by + `validateStackExpressions` for a stack handed to it directly. The refusal codes join + `FLOW_SLOT_REFUSAL_CODES`: `node-config-key-missing`, `node-config-key-required-by-rule`, + `decision-conditions-not-array`, `decision-branch-not-object`, + `decision-branch-label-missing`. + + The Studio flow designer writes refused shapes when a node is added and saved before + it is configured, when a decision branch row's label cell is left empty, and when a + screen field row's name cell is left empty. Where such a node already sits, the whole + flow is refused: registered from the metadata registry or `sys_metadata` at boot, it + is skipped with a `failed to register flow` warn naming it while the flows beside it + register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the + whole stack; an artifact file is refused whole at load. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ type: 'get_record', config: { outputVariable: 'rows' } }` | the object it reads — `config: { objectName: 'account', outputVariable: 'rows' }` (the same for `create_record` / `update_record` / `delete_record`) | + | `{ type: 'loop', config: { body: { … } } }` | the array it iterates — `config: { collection: '{rows}', body: { … } }` | + | `{ type: 'map', config: { flowName: 'per_row' } }` | `config: { collection: '{rows}', flowName: 'per_row' }` | + | `{ type: 'http', config: { method: 'GET' } }` | `config: { url: 'https://api.example.com/v1/items', method: 'GET' }` | + | `{ type: 'script' }` | the registered function it calls — `config: { function: 'recalc_totals' }` | + | `{ type: 'notify', config: { recipients: ['{record.owner}'] } }` | a content source — `title: 'Deal won'`, or a `template` | + | `conditions: [{ expression: 'record.amount > 1000' }]` on a `decision` | the out-edge it routes to — `[{ label: 'large', expression: 'record.amount > 1000' }]`, beside an out-edge labelled `large` | + | `conditions: ['record.amount > 1000']` | `[{ label: 'large', expression: 'record.amount > 1000' }]` | + + **One-line fix:** write the key the node was meant to carry. To branch on the + out-edges instead of on `conditions`, delete `conditions` and put each predicate on its + edge's `condition`. + + **Unchanged.** A node carrying every key its contract requires parses, registers and + validates as before; a legacy flat-graph `loop` (no `body`) still needs no + `collection`; a `decision` with no `conditions`, `conditions: null` or an empty list + still routes by its out-edges; `assignment`, `wait`, `connector_action` and plugin node + types are not judged by this rule; and a key spelled by a D2 alias (`object`, `flow`, + `functionName`, …) is still canonicalized before `registerFlow` and `objectstack + validate` judge it. +- 17e4f52: feat(spec)!: retire `rowLevelSecurity[].tags` — no mainstream platform tags a row-level policy, and nothing here ever read one (#20321) + + Clause-②: no (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner, the `(narrowing)` arm above and the ADR-0087 disposition below). + + `tags` is removed from the row-level security policy (`RowLevelSecurityPolicySchema`, + the entries of a permission set's `rowLevelSecurity`). ADR-0049 + enforce-or-remove, graded RETIRE by the maintainer's criterion for + declared-but-unenforced families — does a mainstream platform have the + capability? None does: Salesforce sharing rules, Dataverse security roles and + PostgreSQL RLS policies carry no tag attribute, and compliance reporting there + keys on the rule itself. + + The key promised "categorization and reporting" for governance and compliance. + Nothing ever read it. Measured before removal, each against a lit control: the + RLS compiler reads a policy's `name`, `object`, `operation`, `positions`, + `enabled` and predicates, never `tags`; objectui's permission preview renders + the policy COUNT and its policy editor neither seeds nor reads the key; cloud + has no reader. No example, default permission set or cloud source wrote it. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | `rowLevelSecurity[].tags` | delete the key. To limit whom a policy applies to, list the positions in `positions` — a tag never did that. To say why a policy exists, use `description`. | + + **The one-line fix: delete `tags:` from every row-level security policy.** + `os migrate meta --from 17` lists the mechanical edits for existing sources; + apply them by hand. + + ⚠️ Runtime behaviour is deliberately **unchanged**. No access decision ever + depended on a tag, so removing the key removes no behaviour. What changes is the + answer an author gets: a policy carrying `tags` is now refused at parse, with the + prescription, instead of being stored with no effect. An author who wrote a tag + such as `managers_only` believing it scoped the policy now learns that only + `positions` does. + + ### The retirement kit + + - **A `retiredKey()` tombstone** on `RowLevelSecurityPolicySchema` (the + `priority` posture one key over): `tsc` types the key `never`, and every parse + raises the prescription rather than a bare unknown-key verdict. The shape's + did-you-mean never offers it: a near-miss `tag` is refused as unknown. + - **D2 conversion `permission-rls-tags-removed`** (step 18, retired from the load + path): a lossless delete over `permissions[].rowLevelSecurity[]`, so a stored + permission row that still carries the key replays clean through the + rehydration seam, while a live author is refused rather than rewritten. + - **`RETIRED_KEYS_BY_MAJOR[18]`**: `security/RowLevelSecurityPolicy:tags`, and + the family's D3 entry `permission-rls-tags-retired`, which states what the + strip cannot decide — any report, audit filter or review process built on the + belief that policy tags were read needs another path. + - **The liveness row stays**, `dead`, under its tombstone (the key is still in + the walked shape); `authorable-surface/security.json` carries it as + `security/RowLevelSecurityPolicy:tags [RETIRED]`, and the generated reference + pages print the prescription in place of the old describe. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no telemetry was consulted for. + + +- dcd3bce: **BREAKING** — `aria` on an action (`ActionSchema`, top-level `actions[]` and `objects[].actions[]`) is now refused at parse: no action surface ever applied it. Write the accessible name in the action's rendered `label`, and name the region that places the actions with `ariaLabel` / `ariaDescribedBy` / `role` in the placing node's `aria` block (`page.components[].aria` or the list view `aria`). + + Clause-②: yes + + `ActionSchema` declared a per-action ARIA block, and the liveness ledger graded it `live` on an uncited note — 「PARTIAL — honored by a few objectui renderers, not the core action buttons/menus」 — with no reader behind it. Re-measured at this checkout's own `.objectui-sha` pin `f8a9d0fb05`: none of the surfaces that render an action reads an action's `aria` — not `action:button`, `action:icon`, `action:menu`, `action:group` or `action:bar`, not the grid's row and bulk action menus, not `record:quick_actions`, not the declared-actions bar. The only `schema.aria` readers there are the placing nodes' own blocks (the `record:*` page components, the list view, `element:button`'s props), none of which looks inside an action. So an author — or an AI — who filled in `aria` got no accessible name on the rendered button, and nothing said so. + + It is the fourth member of the `aria` family retired for exactly this, after `dashboard.aria`, `dashboard.widgets[].aria` and the chart config's `aria`. + + **Removed rather than enforced** (ADR-0049 enforce-or-remove; the triage direction on the card, following the chart config retirement `2bf6ef18d`). The capability is already delivered under another key. Every one of those surfaces derives the accessible name from the action's **required** `label` — the visible button or menu-item text, and the `aria-label` of the icon-only `action:icon` and of the overflow-menu trigger — and the node that places the actions carries the node-level `ariaLabel` / `ariaDescribedBy` / `role`. The reversal condition the triage named (an icon-only action rendered with no accessible name at all) was measured and does not hold on any of them. A per-action block would be a second spelling of both, behind a precedence rule nobody has written. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `aria: { ariaLabel: 'Escalate this case' }` on an action, top-level or under `objects[].actions[]` | the name in the action's `label` — it is what every action renderer announces | + | `aria: { ariaDescribedBy: … }` / `aria: { role: … }` on an action | delete it; to describe or role the toolbar or list the actions sit in, put it in the `aria` block of the node that places them — `page.components[].aria` or the list view `aria` | + | `ariaLabel` / `ariaDescribedBy` / `role` on a page, page component or list view | unchanged — the shared `AriaProps` block stays live there | + + **The one-line fix:** delete `aria` from the action; put the accessible name in its `label`. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + + ## The retirement kit + + - **A `retiredKey()` tombstone, not a bare deletion** — even though `ActionSchema` is a `strictObject`. A bare delete would still be loud, but only as a generic unrecognized-key report that cannot carry the prescription; the tombstone types the key `never` for `tsc` and raises the upgrade text at parse. The key therefore stays in the walked shape: its liveness row stays (regraded `live` → `dead` with a `REMOVED` note that records the uncited 「PARTIAL」 claim it replaces) and the authorable-surface baseline marks `ui/Action:aria` `[RETIRED]`. + - **The D2 conversion `action-aria-removed`** (protocol 18, retired from the load path) strips the key from stack `actions[]` and from `objects[].actions[]` as a pure lossless delete — it never had an effect to lose. Its D3 record is the semantic entry `action-aria-retired`: its own family, not a member of the chart config's. + - **`AriaPropsSchema` is untouched** — a key retirement, not a def retirement; it stays live on pages, page components, the list view and the element props. + - **No form input and no locale bundle move.** The key never reached `action.form.ts`. The Studio action inspector's "More fields" section is derived from the served schema, where a tombstone node is dropped from the payload, so the served `aria` column goes with this release. + + ## Reach, measured + + - This repository: **0** authors of `aria` on an action in `examples/**`, `packages/**` fixtures or the published skills (control: 15 `variant:` lines in `examples/**`). Two hand-written docs pages taught the key and are corrected here. + - HotCRM at `origin/main` `2f7b2326`: **0** on an action; its 6 `aria:` blocks are all page-level `page.aria`, which stays live (control: HotCRM authors actions — 7 files under `src/**/actions/` declare `locations:`, 17 times). + - Other out-of-repo authors: NOT MEASURED. + + ## What an operator with a STORED action sees + + A `sys_metadata` `action` or `object` row written before this release can carry the key. Nothing breaks at read: the conversion replays on rehydration and strips it, so the row is served canonical and parses. `os migrate meta --stored --apply` rewrites the rows. + + +- 2d91c9a: fix(spec): `defineStack` refuses an auto-launched flow whose stack declares `triggers` without `automation` — the pair installs the trigger, `triggers` alone installs nothing (#20332) + + Clause-②: no (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner and the ADR-0087 disposition below, never by the level). + + **What is refused now.** A stack whose `requires` includes `'triggers'` but not + `'automation'`, and which declares a `record_change`, `schedule`, + `time_relative` or `api` flow, used to pass `defineStack` and `os validate`, + boot, and never fire the flow. Every trigger plugin installs its trigger into + the automation service when the kernel is ready, and without that service it + logs `automation service not available — … trigger NOT installed` and installs + nothing. No runtime resolves `triggers` into `automation`. `defineStack` now + refuses that stack with the same `STACK_TRIGGER_CAPABILITY_REQUIRED` code + (`status: 422`), one finding per flow: + + ```text + flow 'task_fanout' declares a 'record_change' trigger but `requires` does not include 'automation' — 'triggers' installs the 'record_change' trigger into the automation service, and without it no 'record_change' trigger would be registered, so the flow would never auto-launch. Add 'automation' to requires: ['automation', 'triggers'] (@objectstack/service-automation runs the flow; @objectstack/trigger-* only fires it). + ``` + + **The fix is the one the message names:** add `'automation'` to `requires`, so + it reads `requires: ['automation', 'triggers']`. Nothing is renamed or removed. + + **Also changed: the message for a stack that declares neither token.** An empty + or absent `requires` with such a flow was told to add `requires: ['triggers']`, + which would now be refused a second time. It is told to add both: + + ```text + flow 'task_fanout' declares a 'record_change' trigger but `requires` does not include 'automation' or 'triggers' — no 'record_change' trigger would be registered, so the flow would never auto-launch. Add requires: ['automation', 'triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-* and install into @objectstack/service-automation — 'triggers' alone installs nothing). + ``` + + Unchanged: `requires: ['automation']` with such a flow keeps the message it has + always had (add `'triggers'`), word for word. `requires: ['automation', + 'triggers']` is accepted, in any order. A stack with no auto-launched flow + (none at all, a `screen` flow, or an `autolaunched` flow started by hand) owes + neither token, and `obsolete` / `invalid` flows are still skipped. The refusal + code, the message header and the `issues` shape are the same, and no export is + added. + + In-tree producers measured: `examples/app-showcase` and `examples/app-todo` + already declare both tokens; `examples/app-crm` and the `create-objectstack` + `blank` template declare `automation` without `triggers` and no auto-launched + flow, so they are untouched. + + +- b285508: `@objectstack/spec/data` declares the platform's numeric grammar for a string, and the contract of the number-comparand declared-type door: which string comparands a field whose declared type is numeric may be compared against (#20336). + + Clause-②: yes + + **The grammar.** A string is numeric when its whole content is a JSON number literal (`-?(0|[1-9][0-9]*)(.[0-9]+)?([eE][+-]?[0-9]+)?`) that names a finite number; it then means what the same characters mean as a JSON number. `NUMERIC_STRING_PATTERN`, `parseNumericString(s)` (the number, or `undefined`) and `readNumericString(s)` (the number, or which of eight named forms the string is: `empty`, `padded`, `placeholder`, `radix-prefix`, `non-finite`, `digit-separator`, `non-json-spelling`, `not-a-number`). `NUMERIC_STRING_GRAMMAR_CASES` records every form with the reason it is admitted or refused: `"12"`, `"-3"`, `"12.5"`, `"1e3"` and every string `String(n)` produces for a finite number are admitted; `""`, `" 12 "`, `"0x10"`, `"Infinity"`, `"NaN"`, `"1,000"`, `"+5"`, `".5"`, `"007"` and any `{placeholder}` are not. + + **The door's contract.** `numberComparandDoorVerdict(field, comparand)` answers, for a comparand at a value position (implicit equality, `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`, and each member of `$in` / `$nin` / `$between`) of a filter on a field whose declared type is in `NUMERIC_VALUE_TYPES` (or a `formula` whose `returnType` is `number`): `door-refusal` (`INVALID_FILTER` / 400) for a non-numeric string, `narrows` with the number for a numeric one, `passes` for any other comparand or field, `deferred` for a `formula` without a readable `returnType`. `numberComparandRefusalMessage(site, context?)` is the refusal the door prints: the field, its declared type, the comparand, its position and what is wrong with it. A fixture object and a derived case table (`NUMBER_COMPARAND_DOOR_FIXTURE`, `NUMBER_COMPARAND_DOOR_CASES`) are published for the engine suite that pins the door. + + **What moves for consumers.** Nothing yet. This is the contract only, and no door reads it in this release: a non-numeric string compared with a number field still reaches the driver as written (a 500 on PostgreSQL, an empty or different result elsewhere). The engine door that refuses it with `INVALID_FILTER` / 400 and narrows a numeric string lands with #20351, and the record validator's number arm adopts the same grammar for writes with #20309. +- 2c31070: A row-level-security predicate or a sharing-rule condition that compares two fields of different comparison classes — a text field with a number field, a field with a single image or file field, a field with a formula field — is refused when it is authored, at `os validate` / `os build` / `os lint` and, for a permission set, at the metadata save door (#20347). The classification it is judged by is exported once, from `@objectstack/spec/data`. + + **BREAKING** — an accept-set narrowing in `@objectstack/lint`, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. `@objectstack/spec` gains exports only. + + Clause-②: yes (narrowing) + + `record.status != record.amount` (text vs number) and `record.status != record.photo` (text vs a single image) lower to a legal `{ status: { $ne: { $field: … } } }` filter and hold no list, so no authoring rule refused them. Measured before this change, through the real `os validate` and the real plugin-security and ObjectQL on driver-sql: `os validate` reported both valid; the read a `using` scopes answered `INVALID_FILTER` / 400 and a by-id update or delete it scopes `PERMISSION_DENIED` / 403, because driver-sql compiles a column-to-column comparison only between two columns of one comparison class; and a single-record insert judged by the `check` — or by a `using` standing in as the check — was admitted and stored, because the in-process write check compares the two raw values. A formula field (`record.status != record.is_open`) answered the same three ways. The same-class control (`record.status != record.note`) read, updated, deleted and inserted normally. For a sharing rule, the condition lowers and is seeded, and every criteria query it runs meets the same driver-sql refusal. + + What changes: + + - `@objectstack/spec/data` (`filter-cross-field-comparison-class.ts`): the cross-field comparison classification. `CROSS_FIELD_COMPARISON_CLASSES` names the six classes (`numeric`, `text`, `boolean`, `date`, `datetime`, `time`); `CROSS_FIELD_NO_CLASS_REASONS` the three families with none (`list-or-object`, `file`, `formula`); `CROSS_FIELD_COMPARISON_TYPE_CLASSES` classifies every `FieldType` member exactly once, by reference to the existing value-class sets; `crossFieldColumnVerdict` answers one declared column (a multi-capable type flagged `multiple: true` holds a list); and `crossFieldComparisonVerdict` answers two (`comparable`, `cross-class`, `no-class`, or `unjudged` for a type outside `FieldType`). It is lifted case for case from driver-sql's cross-field boundary, and a pairwise parity test in driver-sql holds the two equal over every declared field type. + - `@objectstack/lint`: `validateRlsPredicateEnforceability` reports `rls-predicate-unenforceable`, and `validateSharingRuleEnforceability` reports `sharing-rule-unlowerable-condition`, for every lowered field-to-field comparison (`==`, `!=`, `>`, `>=`, `<`, `<=`, either side, under `!` too) whose two declared columns are not `comparable`. It judges `using` and `check` on every operation, and sharing-rule conditions. The finding names each comparison, each column's declared type and class, and the clause's run-time consequence; the hint lists every class with the declared types it holds, read from the spec. A comparison either side of which holds a list or an object stays the existing list-holding finding, and a clause either arm refuses is not also handed to the engine's filter judge, so one defect earns one finding. + + Not changed: driver-sql and the in-process write check keep their own behaviour here; moving both onto the exported classification is the engine-lane half. A comparison between two columns of one class (`record.amount > record.budget`, `record.stage == record.account`), a file or formula field compared with a literal or tested against `null`, and any column the stack does not declare or declares with a type outside `FieldType`, are not reported. + + No shipped predicate moves: of the 163 `using` / `check` / `condition` string literals in this repository's packages and examples, the 105 that lower hold two field-to-field comparisons, both same-class (`spent > budget`, a hook condition; `a > b`, a gate fixture), and neither is an RLS predicate or a sharing-rule condition. + + To keep such a rule, compare a field only with a field of the same class, or with a literal or a `current_user` value; test a file field with `!= null`; or store the value the rule keys on in a field of the right type. If the two columns really hold comparable values, one of them is declared with the wrong type, and the declaration is what to fix. + + +- 7db1332: Clause-②: no + + Five live structured metadata keys are authorable in the metadata form: `object.access`, `object.highlightFields`, `object.requiredPermissions`, `object.searchableFields` and `permission.adminScope`. Each was **declared** by its schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each now has exactly one row, whose control mirrors a row a registered form already carries for the same node shape: + + - `highlightFields` and `searchableFields` (Basics, beside `nameField`) — `widget: 'string-tags'`, the view form's `searchableFields` row: a free-text chip list over `string[]`. A field picker is not offered because the object draft carries no source object for one to read its catalog from. A misspelt entry is not dropped quietly: publishing refuses it (`object-field-ref-unknown`, `searchable-field-unknown`, both at `error`), and so does `os validate`. The object schema's own parse does not judge these names. + - `access` (Advanced, beside `sharingModel`) — a `composite` over one declared `default` select (`public` / `private`), the `lifecycle` row's shape. Absent still resolves to `public`. + - `requiredPermissions` (Advanced) — `widget: 'json'`, **never** `string-tags`: the value is a union of `string[]` and a `{read, create, update, delete}` map, and the tag widget reads a non-array as an empty list and writes the list back, which would silently replace a stored per-operation map. With the `json` hint the renderer resolves the face from the stored value's branch, so a stored map is edited as a map. + - `permission.adminScope` (System Permissions) — `widget: 'json'`, the hint every structured row on the permission form carries; the renderer derives a nested form over its six keys, and edits merge into the stored scope. + + The help text states what the runtime does with each value, including what absence resolves to. The renderer behaviour described above is objectui's metadata-admin form engine at this repository's `.objectui-sha` pin. + + ⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles, whose 12 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. + + ⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers only. +- 1c1b8c8: A grouped or aggregated query now honours `search`: the groups and every aggregated number are computed over the searched rows, exactly the rows the same query without `groupBy` / `aggregations` returns. + + Clause-②: yes (widening) — `EngineAggregateOptionsSchema` gains two OPTIONAL keys, `search` and `searchFields`, so the accept set of the aggregate options grows. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write them. + + `QuerySchema.search` (ADR-0061) is declared on the query beside `groupBy` and `aggregations`, with no carve-out. Until now, `POST /data/:object/query` accepted a body such as `{ groupBy: ["business_unit"], aggregations: [{ function: "count", alias: "count" }], search: "harbour" }` and answered it with the UNSEARCHED groups — no error and no warning — while the same body without `groupBy` / `aggregations` returned only the searched rows. A grouped list view under a toolbar search would therefore show group headers that ignore what the user typed. + + - **`@objectstack/spec`** — `EngineAggregateOptionsSchema` declares `search` (the bare string, or the structured `FullTextSearchSchema` form) and `searchFields`, identically to `EngineQueryOptionsSchema`. A parse used to strip them. + - **`@objectstack/objectql`** — `engine.aggregate()` (and `ctx.api.object(name).aggregate()`) accepts the two keys it used to refuse as unknown options, and expands them through the same ADR-0061 expansion `find()` uses: the same server-resolved searchable fields, the same `searchFields` narrowing, AND-ed with `where` before the security middlewares run. There is one expander, not two. It applies on both aggregate paths, native `driver.aggregate()` and the in-memory lowering. A key the verb still does not execute, such as `$search`, is refused as before. + - **`@objectstack/metadata-protocol`** — `findData`'s grouped branch passes `search` / `searchFields` to `engine.aggregate()`. `searchFields` is validated on that branch exactly as on the flat one: a column search cannot scan is `400 INVALID_FIELD`. + + Nothing to migrate. A caller that worked around the gap, for example by grouping a page of searched rows on the client, can send the grouped query with its `search` instead. +- ba5927f: **BREAKING — one authoring shape for a stack config.** `objectstack validate` and `objectstack build` now refuse a config whose default export was not built by `defineStack(...)` (either mode) or `composeStacks(...)`, with `STACK_PROVENANCE_MISSING` and exit 1, right after the config loads and before any other check. `composeStacks` refuses an input no producer built the same way. + + Why: the stack family's cross-field refusals (`STACK_CAPABILITY_UNKNOWN`, `STACK_CROSS_REFERENCE_INVALID`, `STACK_NAMESPACE_PREFIX_INVALID`, `STACK_SINGLE_APP_VIOLATION`, `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`, `STACK_TRIGGER_CAPABILITY_REQUIRED`) run inside `defineStack` only. The same defective stack exported as a plain object passed both commands at exit 0, and `objectstack build` shipped it. Re-running those refusals on whatever the config exports cannot fix that: a built stack carries each bound action twice, so the re-run refuses every correct project that has one. So the commands check who BUILT the export instead. + + - `@objectstack/spec`: `defineStack` and `composeStacks` stamp a non-enumerable `Symbol.for` provenance mark on what they return. The mark is invisible to the schema, to `Object.keys` and to `JSON.stringify`, so no compiled artifact changes. New export: `hasStackProvenance(value)` — `true` only for a value one of the two producers returned. New registered error code: `STACK_PROVENANCE_MISSING` (422), raised by `composeStacks` for an unbuilt input. + - `@objectstack/cli`: `loadConfig` reads the mark off the default export before merging named exports into it (the merge is a spread, which drops the mark), and exposes it as `LoadedConfig.stackProvenance`. `objectstack validate` / `objectstack build` refuse on `false` through their existing error path: under `--json`, `error` + `code: 'STACK_PROVENANCE_MISSING'`. The envelope has no new fields. `objectstack dev` compiles through `objectstack build`, so it refuses the same way when it compiles. `objectstack serve`, `objectstack migrate`, `objectstack lint` and `objectstack generate` load configs exactly as before. + + **Migration** — FROM a plain-object (or copied) default export TO the value `defineStack` returns: + + ```ts + // FROM + export default { + manifest: { id: 'com.example.app', namespace: 'app', version: '1.0.0', type: 'app', name: 'App' }, + objects: [/* … */], + }; + // or: export default { ...defineStack({ … }), api: { … } }; + + // TO + import { defineStack } from '@objectstack/spec'; + + export default defineStack({ + manifest: { id: 'com.example.app', namespace: 'app', version: '1.0.0', type: 'app', name: 'App' }, + objects: [/* … */], + // every stack key inside the call — `api`, `plugins`, `requires`, … + }); + ``` + + One-line fix: wrap the export in `defineStack(...)`, and move any key spread onto a copy into the call. For compositions, wrap each input: `composeStacks([defineStack({ … }), …])`. Once wrapped, a config that used to pass can now fail with one of the family's own codes. Those findings were always there; the plain export hid them. Fix each one as its message says. Host-style configs whose `plugins` hold plugin instances are covered by the same rule, and the same wrap fixes them (`defineStack` accepts plugin instances). A project already exporting `defineStack(...)` or `composeStacks([...])` of `defineStack` inputs is unaffected. + + Clause-②: yes (narrowing) + + +- 75b2169: `ComponentPropsMap` declares `action:button`, `action:group`, `action:menu`, `action:icon`, `element:definition-list` and `element:repeater` — six blocks in objectui's curated public vocabulary that had no row (#20371). Each row is strict from birth, with its key set measured from the objectui renderer's own read points at the `.objectui-sha` pin, not transcribed from objectui's `UIActionSchema`, the registrations' `inputs`, or this package's object-metadata `ActionSchema`. + + Clause-②: yes + + Six new declared rows on a published surface, and two types the `element:` vocabulary now answers for, so the accept set a consumer writes against grows. Nothing previously accepted by a declared row is refused and nothing is retired. + + What changes at the authoring doors (`os validate` / `os build` / `os lint`): + + - **`element:definition-list` and `element:repeater` are no longer refused as `component-type-unknown`.** Both sit inside the reserved `element:` namespace; with no enum member and no row, the vocabulary refused them although objectui registers, publishes and offers both in the Studio page designer. They join the `element:` vocabulary through their rows (no enum member), and a typo inside the namespace (`element:repeatr`) is still refused. + - **The props gate now judges all six.** The four `action:*` types sat outside every reserved namespace, so an authored `properties` bag on them was skipped — a misspelled key parsed, stored and did nothing. Findings stay at the gate's existing warning tier. + + Measured decisions worth knowing when you author these blocks: + + - **`action:button` / `action:icon`** — `name` is optional (the renderer reads `name ?? label`). The executor is `actionType`; `type` inside `properties` is refused with a rename to `actionType` (on a page component `type` is the component itself). `visible` / `disabled` take a boolean, a CEL string or a `{ dialect, source }` envelope. The legacy `enabled` fallback and the host-only `autoTrigger` flag are refused with a prescription. `action:icon` reads no `size`. `objectName` names the object the action acts on (forwarded to the runner; omitted, the action acts on the page's object). + - **`action:group` / `action:menu`** — `actions` is a LIST of action objects (a member's executor is its own `type`); a bare list of action names is refused. A member's `objectName` rides the member object; the containers themselves read no `objectName`. `action:group` reads no group-level `name`, so it is refused with a prescription. `variant` / `size` take the Button primitive's vocabulary; `primary` and `md` are accepted only on `action:button` (and `primary` on `action:icon`), where the renderer maps them. + - **`element:definition-list`** — `items` of strict `{ term, description? }`; `columns` is the NUMBER `1` or `2` (the string `'2'` renders one column and is refused). + - **`element:repeater`** — `object` is required; `filter` / `sort` take the family's one orthography (`ViewFilterRule[]`, `SortItem[]`); `fields` takes a field name or `{ field }` (an unrendered `label` is refused). +- e956924: feat(spec,metadata-core)!: every retired ADR-0087 conversion carries `retiredAfter`, and the artifact door opens its window per entry (#20390) + + Clause-②: yes + + + + **BREAKING** for code that implements `MetadataConversion` itself — shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above). `MetadataConversion` is now a type alias of a live-or-retired union: an entry with `retiredFromLoadPath: true` must also carry `retiredAfter`, a stable `x.y.z` string, and a live entry carries neither. tsc names the missing member (`Property 'retiredAfter' is missing`). No in-repo conversion is left unstamped, and no metadata an author writes changes. + + **What the field means.** `retiredAfter` is the last published `@objectstack/spec` version whose authoring surface still accepted the entry's old shape. It is a fact when the entry lands: the package's own version label at that moment, because `main` carries the last release's label until the next release is cut. Every published retired entry is stamped from the published tarballs — the stable release just before the first tarball that carries it retired — and each entry not yet in any published tarball carries the current label, `17.4.0`. + + **Why the artifact door needed it.** Between two releases, `main` refuses keys that the next release retires while its label still reads the last release. The artifact-ingestion door (`applyArtifactForwardConversions`) compared an artifact's `engines.protocol` floor with that label alone, so an artifact built by the last published CLI — floor `^17.4.0`, dashboard `chartConfig.type`/`xAxis`/`yAxis` and page `assignedProfiles` — read as "authored current": nothing was converted and the strict parse refused the boot. The door now replays a registry entry when the floor is below the runtime label, **or** at or below that entry's `retiredAfter`. After a release the rule reduces to the old one, and an artifact whose floor is above an entry's `retiredAfter` still meets that entry's tombstone — a floor of `^17.5.0` on a 17.5.0 runtime is refused, not converted. `DEFAULT_FLIPS_NOT_REPLAYED_HERE` is still read first. + + **`@objectstack/metadata-core`.** `ArtifactForwardConversionVerdict` gains `'converted-retired-after'`: the floor is at or above the runtime label, but at or below the `retiredAfter` of at least one retired entry, and only those entries are replayed. `ArtifactForwardConversionResult` gains `replayedRetirements` (exported element type `ArtifactReplayedRetirement`): under that verdict, each retirement this runtime enforces past the artifact's floor, with its `retiredAfter`; empty for every other verdict. A consumer that switches exhaustively over the verdict adds that arm. + + **`@objectstack/metadata`, the artifact door — the arm added.** `MetadataPlugin` now reads which verdicts open the window from one total table over `ArtifactForwardConversionVerdict`, with `'converted-retired-after'` on the open side. The #12915 unbound form-predicate notice rides that same reading, so a 17.4.0-built artifact carrying a bare-root form predicate on `main` is announced now, rather than only once the package label moves past 17.4.0. A verdict added later fails to compile until it is placed on one side of the window. Under the new verdict the conversion summary no longer says the artifact "predates this runtime's spec" beside a runtime version equal to its floor: it names the retirement this runtime enforces past the artifact's floor, with the release that last accepted the shape, and says the artifact converts again on every boot until it is rebuilt with tooling from a release that ships the retirement. Summaries are still one per conversion per artifact, naming the site count. + + **Census.** 94 retired entries when this landed: 73 published (first retired in 15.1.0: 5, 17.0.0: 45, 17.1.0: 5, 17.2.0: 2, 17.3.0: 8, 17.4.0: 8) and 21 unpublished. `packages/spec/src/conversions/retired-after.census.json` holds the raw per-release facts, and `retired-after.census.test.ts` pins every value against it, offline. `packages/spec/scripts/build-retired-after-census.ts` re-derives the census from the npm registry (tarball integrity checked). Run it after each stable publish; `docs/releases-maintenance.md` lists that step in the GA release flow. +- 2304b16: fix(spec)!: a `connector_action` flow node its executor cannot dispatch — no `connectorConfig` block, or an empty `connectorId` / `actionId` — is refused at authoring (#20418) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on authored `connector_action` flow nodes, shipped + as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` + until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by + the level). + + **What changed.** A `connector_action` node's contract is its sibling `connectorConfig` + block — the executor reads nothing else, and refuses the node when `connectorId` or + `actionId` is empty. The block was optional on the node and both ids were any string inside + it, so `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` all + admitted a node with no block, or with an empty id, and every run that reached the node then + failed at the executor's guard. The flow parse now refuses what that read refuses, at any + depth including an ADR-0031 region body, and `registerFlow` and `objectstack validate` meet + the refusal through that parse: + + - **No `connectorConfig` block** — a `custom` issue at `nodes.N.connectorConfig`, whose + message prescribes the block and says that keys left under `config` are not read. + - **`connectorId` or `actionId` empty, or only whitespace** — a `custom` issue at + `nodes.N.connectorConfig.connectorId` / `.actionId`. Whitespace is refused with the empty + string (the spec's one notion of blank): a connector `name` is a snake_case identifier, so + it names nothing a dispatch can reach. + + The rule is judged in the flow walk, not by `FlowNodeSchema` alone, so a node nested in a + `loop` / `parallel` / `try_catch` body is refused at the path the author wrote + (`nodes.N.config.body.nodes.M.connectorConfig`). `FlowNodeSchema.parse` of a lone node is + unchanged. + + The Studio flow designer seeds a new connector node with `connectorId: ''` and + `actionId: ''`, so a connector node added and saved before it is configured is now refused + at save. Where such a node already sits, the whole flow is refused: registered from the + metadata registry or `sys_metadata` at boot, it is skipped with a `failed to register flow` + warn naming it while the flows beside it register; a `defineStack({ flows })` source throws + `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. + + The `node-config-key-missing` refusal (`FLOW_SLOT_REFUSAL_CODES`) now describes the old + behaviour in the past tense — "the flow used to register, and then every run that reached + this node failed there" — because the doors that message is shown at refuse the flow. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ type: 'connector_action', label: 'Post' }` | the connector and the action it dispatches — `connectorConfig: { connectorId: 'slack', actionId: 'chat.postMessage', input: { channel: 'C0WINS000', text: 'Done' } }` | + | `connectorConfig: { connectorId: '', actionId: '' }` | the registered connector's `name` and one of its action keys — `{ connectorId: 'rest', actionId: 'request' }` | + | `config: { connectorId: 'slack' }` (no `actionId`, no block) | the complete pair in the block — `connectorConfig: { connectorId: 'slack', actionId: 'chat.postMessage' }` | + + **One-line fix:** write the `connectorConfig` block the node dispatches by, or delete a + connector node you cannot configure yet — there is no placeholder connector. + + **Unchanged.** A connector node carrying a complete block parses, registers and dispatches + as before, and `input` stays optional. A complete `connectorId` / `actionId` / `input` trio + written under `config` is still lifted into the block before `registerFlow` and + `objectstack validate` judge it (the `flow-node-connector-config-lift` conversion). Other + node types are not asked for a `connectorConfig`. +- fb38607: feat(drivers,formula,objectql): the engine's filter faces answer the staged `$empty` operator (#20444) + + Clause-②: yes (widening) + + `$empty: true | false` is declared by `@objectstack/spec` (`FieldOperatorsSchema`) with a per-type meaning: a text-like field is empty when it is null or `''`, a multi-value field (multiselect, checkboxes, tags, or a select / radio / lookup / user / file / image with `multiple: true`) when it is null or `[]`, and every other type only when it is null. `$empty: false` is the exact complement. Until now every face in this list refused it (`INVALID_FILTER` / 400), except `matchesFilterCondition`, which answered `false` for every record. **A driver or evaluator called directly now answers it:** + + - **By the field's declared type**, through the spec's one expansion (`expandEmptyOperator`): `driver-sql`'s filter compiler (and so `driver-sqlite-wasm` and `driver-turso`'s local transport, which inherit it), `driver-turso`'s remote transport, `driver-memory`'s query path (`find` / `count` / `update` / `delete`) and `driver-mongodb`'s `translateFilter` (its `find`, its aggregate `$match`). The declaration is the one each driver already receives — `initObjects` / `registerObjectMetadata` / `registerExternalObject` on the SQL family, `syncSchema` on the others. On SQL a multi-value field's empty list is tested as stored JSON per dialect (SQLite `json_array_length` behind a `json_valid` guard, PostgreSQL a `jsonb` comparison, MySQL `JSON_LENGTH`), never as an equality comparand. + - **By value** — null, a missing value, `''` and `[]` are empty (`isEmptyFilterValue`) — on the faces that read no field declaration: `@objectstack/formula`'s `matchesFilterCondition` (the RLS write-side `check`), `driver-memory`'s reference matcher, and `@objectstack/objectql`'s `having` and per-aggregation `filter`. In `having`, a `count` or `sum` holding `0` is not empty. + + **Refused, never guessed** (`INVALID_FILTER` / 400): `$empty` on a field whose declaration the driver does not hold (a table built outside its registration, a builtin column such as `id`, a field with no `type`, or `translateFilter` / `RemoteTransport` used standalone without a declaration), a multi-value field on a SQL dialect the driver does not model, and a flag that is not a boolean. `driver-memory`'s analytics (cube) face refuses `$empty` as an operator it cannot compile, as it does `$null`. + + New optional API: `translateFilter(where, temporalKind?, valueShape?)` in `@objectstack/driver-mongodb` takes a declared-value-shape resolver (type `ValueShapeResolver`), and `buildAggregationPipeline` a `valueShape` option; `RemoteTransport.setDeclaredValueShapeResolver` in `@objectstack/driver-turso`, which `TursoDriver` wires. `@objectstack/spec`'s shared `FILTER_LOGIC_CASES` table gains seven `$empty` cases: a backend that runs it answers `$empty` or goes red, and its harness must declare the fixture's columns. + + `$empty` stays staged: it is not in `FILTER_OPERATORS`, so the engine's front door still refuses it until the flip card adds it, and the view operators `is_empty` / `is_not_empty` still lower to `$null`. +- dc07593: feat(spec)!: a view filter rule's `operator` is typed as the canonical `ViewFilterOperator`, not `unknown` + + **BREAKING for TypeScript code that writes a view filter rule through a published type**: `ViewFilterRule`, and every carrier of it — `ListView.filter`, a view tab's `filter`, `InterfacePageConfig.filterBy`, and the related-list, record-picker and `object-*` block filter doors. A narrowing of a published TYPE, landing as `minor` (the bump level is not the carrier; this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no value and no export changes. + + `operator` is a `z.preprocess` over the alias fold, and zod types a preprocess's input from its function's parameter. That parameter was `unknown`, so `ViewFilterRule['operator']` was `unknown`: `{ field: 'status', operator: 42 }` compiled as a rule on every carrier, and was refused only when the schema parsed it. The input type is now `ViewFilterOperator`, the vocabulary the alias table's own contract says new producers emit, so an alias spelling or a non-string is refused by the compiler. + + What does not change: + + - **The runtime.** `ViewFilterRuleSchema` still folds every legacy spelling it folded before (`eq`, `gt`, `notIn`, `isNull`, …) to its canonical id, and still refuses a non-string at `operator` with the enum's own issue. Stored `sys_metadata` rows, YAML and JSON bodies and plain-JS producers that carry an alias parse exactly as before, and `os validate` answers as before. + - **`normalizeFilterOperator`.** Its parameter stays `unknown`: it exists to fold untyped stored metadata, and its callers pass raw strings by design. + - **The parsed type.** `ViewFilterRuleParsed['operator']` was already the canonical enum. + + ## FROM → TO + + | Wrote (TypeScript) | Write instead | + | --- | --- | + | `{ field: 'status', operator: 'eq', value: 'open' }` | `{ field: 'status', operator: 'equals', value: 'open' }` | + | `{ field: 'amount', operator: 'gte', value: 100 }` | `{ field: 'amount', operator: 'greater_than_or_equal', value: 100 }` | + | `{ field: 'stage', operator: 'notIn', value: ['lost'] }` | `{ field: 'stage', operator: 'not_in', value: ['lost'] }` | + | `operator: someString` (a value typed `string`) | type the unvalidated rule `unknown` and `ViewFilterRuleSchema.safeParse` it, or fold it with `normalizeFilterOperator` and check it against `VIEW_FILTER_OPERATORS` first | + + The one-line fix: write the canonical id. Every alias maps to exactly one, and `VIEW_FILTER_OPERATOR_ALIASES` is that map; the rewritten rule selects the same rows, because the schema already folded the alias to that id. + + Clause-②: no (narrowing) + + +- e967cbd: feat(spec): the console's round-trip keys on a stored `view` row are declared on the wire, so a parse keeps them (#20456) + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing on the `view` write door (`PUT /api/v1/meta/view/:name`, the Studio and MCP save) and on every door that parses `ViewMetadataSchema`, shipped as `minor` under the repo's launch-window convention. The newly declared keys are typed, so a non-boolean `isPinned`, a non-integer `sortOrder`, a `visibility` outside `private` / `team` / `organization` / `public`, or an `_isOverride` other than `true` is now refused at the parse (`422 INVALID_METADATA` at the save door), where the strip used to swallow the key and the save stored the body as sent. To fix a refused body, correct the value or delete the key. The console writes none of these values: its pin toggle writes a boolean, its reorder an integer index, and it stamps the marker as `true`. The diff also widens: the keys are now declared, and `VIEW_CONSOLE_ROUND_TRIP_KEYS` is a new export. + + `saveMetaItem` stores a `view` body exactly as it was sent (ADR-0005 appendix (c)), and the members of `ViewMetadataSchema` that judge a stored row `.strip()` every key they do not declare. So the keys the console writes onto a stored view and reads back were in the store and nowhere in the contract. A census of objectui's console (at the `.objectui-sha` pin) measured which ones the parse dropped: + + - `isPinned` and `sortOrder` on a flattened list overlay (they were already declared on the ViewItem record); + - `visibility`, on both the flattened list overlay and the ViewItem record; + - `_isOverride`, the marker that tells the console a row is the settings overlay of a code-defined view and not a saved view of its own. + + ## What it does now + + - The ViewItem wire member (`ViewItemWireSchema`) and the flattened list overlay (`VIEW_METADATA_MEMBERS.listOverlay`) declare `isPinned`, `sortOrder` and `visibility` from one shared declaration, each with its meaning. The flattened list overlay also declares `_isOverride: true`, and its existing `isDefault` now carries its meaning. A parse of a console-written row keeps every one of them. + - **New export `VIEW_CONSOLE_ROUND_TRIP_KEYS`** (`@objectstack/spec/ui`): each round-trip key, mapped to the members whose rows the console writes it on (`isDefault`, `isPinned`, `sortOrder`, `visibility`, `columnState`, `_isOverride`). + - `visibility` is display grouping only (`private` / `team` / `organization` / `public` in the view switcher). It restricts nobody, and its declared meaning says so. + - None of these keys is authorable. `defineViewItem` still refuses each of them by name, and `visibility` now gets a prescription that says what it is. + + ## What does not change + + - **What is persisted.** The save still stores the request body verbatim. Storing the parsed body is a later, separate change. + - The alias spellings the census found keep their declared spellings: `objectName` is `object`, and a top-level `id` is `name`. The console's filter / sort builder row ids stay `VIEW_CONSOLE_ROW_DECORATIONS`, removed before the parse. +- b057434: fix(spec)!: a boolean, a `Date` or an array compared against a number field is refused with `INVALID_FILTER` / 400 at `where`, a per-aggregation `filter` and `having`, the same as a non-numeric string + + Clause-②: no (narrowing) + + + + **BREAKING**: this narrows what a filter may compare a number field with. `numberComparandDoorVerdict`, the published verdict the engine's number-comparand door consumes, judged strings only; it now also answers `door-refusal` for a boolean, a `Date` and an array, so the engine refuses them before any read, on every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. The string rule is unchanged, and so are both packages' root exports except three additions to `@objectstack/spec/data`: `NON_NUMERIC_VALUE_FORMS` and the types `NonNumericValueForm` and `NonNumericComparandForm` (the refusal's `form` and the refusal site's `value` widen to carry a non-string). + + FROM `true` / `false`, a `Date`, or an array where one value belongs (a scalar operator's comparand, or a member of `$in` / `$nin` / `$between`), compared against a `number`, `currency`, `percent`, `rating`, `slider`, `progress` or `summary` field (or a `count` / `sum` / `avg`, or a numeric `min` / `max` / groupBy column in `having`) → TO `INVALID_FILTER` / 400, naming the field, its declared type, the comparand, its position and what is wrong with it. The fix is one line: send the number the filter means (`12`, `-3.5`, `1e3`), or compare a `Date` with a date or datetime field. + + Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query`, three rows (5, 12, 30) of a `number` field: + + | position | comparand | before: memory · SQLite · PostgreSQL 16 | now, on all three | + |:--|:--|:--|:--| + | `where` | `$gt true` | no rows · every row · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | + | `where` | `$ne true` | every row · every row · 500 | `INVALID_FILTER` / 400 | + | `where` | `$between [true, 20]` | no rows · two rows · 500 | `INVALID_FILTER` / 400 | + | `where` | `$gt` a `Date` (in-process callers) | no rows · no rows · 500 | `INVALID_FILTER` / 400 | + | `where` | `$gt [1]` | a driver's own 400, in each driver's words | `INVALID_FILTER` / 400, in one set of words | + | `where` | a `$in` member `[1]` | no rows · a driver 400 · a driver 400 | `INVALID_FILTER` / 400 | + | per-aggregation `filter` | `$gt true` / `$gt [1]` (`$gt` a `Date`) | count 3 (count 0), on all three | `INVALID_FILTER` / 400 | + | `having` on `sum(amount)` | `$gt true` / `$gt [1]` (`$gt` a `Date`) | every group (no group), on all three | `INVALID_FILTER` / 400 | + | all three positions | `$gt 10` (the numeric control) | 2 rows / count 2 / both groups | the same | + + **Who is affected.** A caller that compares a number field with a boolean or an array through any door that reaches the engine (a `where`, `$filter` or `filter` body of `POST /data/:object/query`, or an in-process engine call), or with a `Date` in-process (a flow, a hook, server code). No example app, platform object or other in-repo producer compares a number field that way. + + **Unchanged.** A number, a `bigint` (narrowed or refused by the comparand-type door, as before) and `null` (the null test) are answered as before. A value outside the accepted comparand types (`undefined`, a plain object, a `Map`) keeps the comparand-type door's own refusal and words. An array at an equality slot (implicit, `$eq`, `$ne`) keeps the comparand-shape door's refusal. A boolean or a `Date` compared against a boolean, date or datetime field is not this door's subject. Driver-direct callers that never pass through the engine keep each driver's native binding. +- 5f392f0: feat(spec): the ADR-0112 error envelope gains a producer-side `refusal` declaration, so a deliberate 5xx refusal can keep its caller-authored `message` (#16335) + + `ApiErrorSchema` and `EnhancedApiErrorSchema` declare one new optional key, **`refusal: true`** — the producer's declaration that the 5xx it named is a deliberate REFUSAL whose `message` is authored for the caller, so the boundary keeps that message verbatim instead of withholding it. Director ruling, decision batch #58 (2026-09-06, option C): the refusal/fault distinction is a producer-side declaration on the published envelope — not a status heuristic and not a second allow-list. + + The three cases are now documented side by side on the envelope's TSDoc: + + - **undeclared 5xx** (no `status` on the throw) — unchanged: the leak heuristic decides per message. + - **declared fault** (`status >= 500` + `code`, nothing declared here) — unchanged, and still the DEFAULT: `message` is withheld from the body and logged for the operator. + - **declared refusal** (`status >= 500` + `code` + `refusal: true`) — new: `message` is kept verbatim, bounded exactly as a 4xx message is. + + Purely additive: a producer that says nothing here gets exactly the previous behaviour. `true` is the only value — `refusal: false` fails parse instead of becoming a third state consumers would have to interpret. `userMessage` is orthogonal (end-user text; it never replaces `message`) and may ride the same envelope; the TSDoc reconciles this flag with the recorded reason `userMessage` is a text-carrying field rather than "a boolean beside `message`". + + This is the spec half. The relay half — the three withhold arms reading the declaration (two in `@objectstack/rest`: `declaredServerFaultAnswer`, and `resolveErrorResponse`'s own 5xx passthrough arm, which the `/references` door reaches; one at `@objectstack/runtime`'s dispatcher exit, `errorResponseBase`, which `objectstack serve` mounts and which never consults the first), plus retiring the route-local patch from PR #16143 on `/meta/:type/:name/references` — is #16146 for the REST pair and its sub-issue #17153 for the runtime exit; until they land, a declared refusal is still withheld at the wire. +- 041d9fd: fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on a published + route — landing in the launch window as `minor` on all four packages (the + lockstep convention: during the window the bump level is not the carrier, this + banner and the disposition above are). Nothing that was already admitted + becomes refused **except** the requests `GET /data/` refuses today for + the same principal, which is the defect. Nothing that was refused becomes + admitted. + + `POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. + + The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. + + **This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. + + - **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. + - **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. + - **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. + - **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. + + The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. +- b8ec127: `defineStack`: a package of a multi-package release artifact can now grant permissions on, and seed data into, an object one of its SIBLING packages owns. + + **FROM** — every `permissions[].objects` key and every `data[].object` had to name an object the same stack declares. In an ADR-0130 artifact this made two accepted records contradict each other: the 2026-09-02 addendum keeps every permission set whole in the `type: app` package, so as soon as that package also owns objects of its own, its sets were refused for granting on its modules' objects (`Permission 'sales_rep' grants on object 'crm_case' which is not defined in objects.`). The only escapes were `strict: false` for the whole package or splitting the sets per package, which contradicts the addendum. + + **TO** — pass the artifact's other object names to `defineStack` and those two reference classes resolve against the artifact instead of the one stack: + + ```ts + const service = defineStack(serviceConfig); // owns crm_case + const app = defineStack(appConfig, { // owns crm_account, grants on crm_case + artifactObjects: service.objects?.map((o) => o.name), + }); + export default composeStacks([service, app], { manifest: 'preserve' }); + ``` + + Nothing else widens. `hooks[].object` and an app's own `navigation` `objectName` stay refused against the stack's own objects even when the name is listed, because ADR-0130 §1.5 records both refusals as the shape of the package seam. + + The refusal moved rather than disappearing: in a composition of **two or more** packages, `composeStacks` now re-checks those two classes over the composed artifact, so a name `artifactObjects` claims and no package in the artifact defines is refused there, with the same `STACK_CROSS_REFERENCE_INVALID` code, the same `422`, and the same per-finding message. Only the header differs, naming the pass that refused it. `composeStacks` returns a single input untouched, so a one-package composition does not re-check the claim. + + **What that changes about which inputs `composeStacks` accepts.** `defineStack` itself is unchanged for a stack that does not pass `artifactObjects` — every single-package app validates exactly as before. `composeStacks` is not: it applies the two artifact-scoped rules to **every** input carrying objects, not only the ones that opted in. For an input that passed the strict `defineStack` parse **and did not opt in**, that is a no-op, so such an input cannot newly fail — its references were already resolved against its own objects, which are a subset of the composed set. (An input that *did* opt in also passed the strict parse, but it resolved against its own objects plus the names it listed; checking a listed name against the real artifact is what this pass is for, so it can fail here by design.) For an input that **bypassed** the strict parse the no-op argument does not apply at all: `defineStack(config, { strict: false })` returns before cross-reference validation runs, and a hand-built stack object never enters it, so these two rules have never been applied to it. Such an input carrying a dangling `permissions[].objects` key or `data[].object` is now refused at composition where it previously composed with no diagnostic at all — the existing non-array warning covers a malformed collection key, not a dangling reference. If you compose unparsed stacks, that is the one behavioural change to expect, and there is no earlier warning to have noticed it by; a malformed `permissions` / `data` on such an input is still skipped with that non-array warning rather than raising. +- e81c4e5: **Declare the build-progress PHASE vocabulary on `@objectstack/spec/ai`.** + + The `data-build-progress` stream frame has shipped as prose only: `AIToolContext.onProgress` + documents the channel and its example carries a `phase`, but nothing ever declared which + phases exist. Consumers filled that gap by guessing, and a guess here is not merely + unlabelled — the objectui chat panel coerces any value it does not recognise to `structure`, + which renders a "still building" spinner, so a build turn that has finished and moved on to + verifying itself keeps claiming to be building. + + New exports (additive; nothing removed or renamed): + + - `BUILD_PROGRESS_PHASES` / `BuildProgressPhaseSchema` / `BuildProgressPhase` — the CLOSED + phase vocabulary: `structure`, `data`, `verify`, `done`, in lifecycle order. An + out-of-vocabulary value is refused, and the refusal names the accepted set. + - `BuildProgressFrameSchema` / `BuildProgressFrame` — the frame's FLOOR: a required `phase` + plus an optional `hop` (which post-apply verification hop) and `tool` (the tool that hop is + running). Deliberately loose, not strict: the presentation fields the chat panel already + reads ride the same frame and belong to it, so a strict schema here would refuse every + frame shipping today. + - `BUILD_PROGRESS_FRAME_TYPE` — `'data-build-progress'`, the one literal both ends select on. + + Producers emit these frames from the agent loop rather than from the applying tool: a tool's + `ctx.onProgress` handle dies when the tool returns, and the verification window opens after + it does. Consumers should compare phases by value and treat every phase as optional — a turn + that seeds no sample data never reports `data`. + + Clause-②: yes (widening) +- 28f9277: feat(spec): every `composeStacks` conflict refusal carries an ADR-0112 envelope — six new `STACK_COMPOSE_*` codes beside the `defineStack` family + + `composeStacks` refuses six authored-entity conflicts, and until now every one of them threw + `new Error(message)` with `code` and `status` both `undefined`. The `defineStack` family in the + same file has carried the envelope since #15963, so `packages/spec/src/stack.zod.ts` held two + refusal families that are the same thing to an author — a stack refused at authoring time, + through the same callers — and two different things to a consumer branching on `error.code`. + + Every message in the family carries the literal `composeStacks conflict:` prefix, which is how it + is located: FIVE of the six raise inside helper functions 300-800 lines above `composeStacks`' + own body, so reading the function the defect is named after finds one of them. + + | refusal | raised by | code | + | :--- | :--- | :--- | + | a single-valued top-level key declared with different values by two stacks | `composeSingleValue` | `STACK_COMPOSE_KEY_CONFLICT` | + | `functions` authored in the map form by one stack, the array form by another | `composeFunctions` | `STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT` | + | two stacks defining one handler name | `composeFunctions` | `STACK_COMPOSE_FUNCTION_CONFLICT` | + | under `objectConflict: 'merge'`, an object-level collection other than `fields` declared differently | `refuseUnmergeableCollections` | `STACK_COMPOSE_COLLECTION_CONFLICT` | + | the same object name in two stacks under the default `objectConflict: 'error'` | `mergeObjects` | `STACK_COMPOSE_OBJECT_CONFLICT` | + | a cross-stack action key collision | `collectComposedActionKeyCollisions` | `STACK_COMPOSE_ACTION_KEY_COLLISION` | + + Each carries `status: 422` — an unprocessable authored entity, not a server fault — and the + findings the site collected in `issues`, one entry per finding. **Message text is byte-for-byte + unchanged at every site**: this adds the machine-readable half, it rewords no sentence, and the + message pins across the repo read the prose they always did. + + One code per refusal site rather than a shared `STACK_COMPOSE_CONFLICT` catch-all — the + granularity the `defineStack` family landed with, and the granularity the ADR-0112 ledger's + boot-refusal class already had before it. The `STACK_COMPOSE_*` spelling says what the + per-stack family's spellings cannot: the defect is a disagreement BETWEEN stacks, each of which + is legal on its own, so the fix is in the composition rather than in one malformed stack. + `STACK_CROSS_REFERENCE_INVALID` stays the deliberate exception in the other direction — its + per-stack and artifact passes share one code because they are one rule family over two scopes. + + All six are registered in `ERROR_CODE_LEDGER` under `@objectstack/spec`, under the ruling that + every code shipped in `dist` is the published face, door or no door. No wire door raises them: + `composeStacks` runs at authoring and boot time, and the reading was re-measured here — zero + `composeStacks` call sites under `packages/runtime/src` + `packages/rest/src` (7 non-test + occurrences, all doc comments or message prose in one file), with `defineStack` lighting the + same probe 31 times across 8 files as the positive control. + + Not narrowed: `composeStacks` accepts and refuses exactly the inputs it did before, and no export + changes — the error classes stay module-local, as every member of the `defineStack` family is, + because `packages/spec/src/index.ts` re-exports the module with `export *` and the ADR-0112 + contract is the `code` / `status` pair read structurally. + + ⛔ The seventh bare `Error` in that file is deliberately untouched: + `composeStacks internal error: no source stack recorded for composed object …` is the code + discovering its own bookkeeping is inconsistent, not an authored entity being refused. Filing it + at 422 would tell an author their stack is invalid when the defect is ours. Whether it takes a + 500-class envelope of its own is a separate decision. + + Clause-②: yes +- 929d9e3: feat(spec)!: delete the seven cron-typed positions nothing evaluated — export schedules, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule`, backup / DR-test schedules (ADR-0049) + + + + **BREAKING** — seven authorable positions across five schemas are DELETED. Executes the + maintainer ruling of 2026-09-06 (director decision batch #56, 「其他同意」 on the per-family + recommendation: option A — retire — per family) under ADR-0049 enforce-or-remove, by the + route the maintainer ruled on 2026-09-10: **直接删** — a bare deletion, with no + `retiredKey()` tombstone, no ADR-0087 D2 conversion and no D3 semantic entry. + + Seven positions declared a `CronExpressionInputSchema` slot that the parse normalized into + the `{ dialect: 'cron', source }` envelope and that NOTHING evaluated — the ADR-0058 D7 + ledger row `cron-declared-unwired` had every one of them `unevaluated`. + + | family | schema | deleted position | reachable from a stack manifest | + |:--|:--|:--|:--| + | export schedules | `ScheduledExport`, `ScheduleExportRequest` (`api/export.zod.ts`) | `schedule.cronExpression` (both) | no — API contract nothing serves | + | flow schedule state | `ScheduleState` (`automation/execution.zod.ts`) | `cronExpression` (was REQUIRED) | no — runtime state | + | connector sync | `DataSyncConfig` (`integration/connector.zod.ts`) | `schedule` | **yes** — `Connector.syncConfig`, `defineStack({ connectors })` | + | cache warmup | `CacheWarmup` (`system/cache.zod.ts`) | `schedule` | no | + | backup / DR testing | `BackupConfig`, `DisasterRecoveryPlan.testing` (`system/disaster-recovery.zod.ts`) | `schedule` (both) | no | + + **What an upgrading author actually observes.** None of the five schemas is `.strict()`, so + a bare deletion means Zod DROPS the key at the PARSE: an existing document still parses and + still loads, and the value is discarded there without a word. There is nothing for + `objectstack migrate meta` to list and nothing for the ADR-0087 chain to replay — the value + was already inert before this change, and it is inert after. + + The parse is not the only channel, and the two that speak are worth stating exactly, + because a reader who stops at "non-strict schema" will conclude the opposite: + + - **`os validate` / `os build` NAME the dropped key**, for the one deleted position a stack + manifest reaches (`connectors[].syncConfig.schedule`). `os validate` exits 0 and reports + `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its + value is dropped at load.` — in the text face and in `--json`'s `warnings`; `os build` + prints the same line under `Undeclared authoring keys — dropped at load (#3786)`. The + channel is `lintUnknownAuthoringKeys`, which walks every stack collection whose entry + schema is strip-mode, and `connectors` is one. **`os validate --strict` treats that warning + as an error and EXITS 1**, so a pipeline running `--strict` over an otherwise-clean stack + refuses the upgraded manifest until the key is deleted. `os migrate meta` still lists + nothing, in either direction. + - **`tsc`**: a TypeScript author annotating with `Connector`, `ScheduledExport`, + `ScheduleState`, `CacheWarmup`, `BackupConfig` or `DisasterRecoveryPlan` gets an + excess-property error at the key and deletes it. + + The other six positions are not reachable from a stack manifest, so no CLI walk visits them: + for those the parse-level strip really is the whole of it. + + **What stays, byte-identical:** every other key of the five schemas and every export — no def + leaves the public surface. `ScheduledExport.schedule` / `ScheduleExportRequest.schedule` keep + their `timezone` (still defaulting to `UTC`); `ScheduleState` keeps `timezone`, `status` and + `nextRunAt`, and a state without `cronExpression` now parses (the requiredness left with the + key); `CacheWarmup.strategy` keeps its `scheduled` member — a value, not a position the + ruling names, and exactly as inert as before. + + **One published TS MEMBER does leave, and "no def leaves" does not cover it.** The required + `cronExpression: string` member is deleted from `ScheduleExportInput` in + `contracts/export-service.ts` — the input type of `IExportService.scheduleExport`, a + published runtime TS interface (both names are in `api-surface/contracts.json`). It follows + the two spec positions it mirrored: with `ScheduledExport.schedule.cronExpression` gone, an + input demanding the key would ask a provider for a cadence it cannot store. The interface, + the method and every other member stay. Measured blast radius: no source outside + `packages/spec` names `ScheduleExportInput` or `IExportService` — 0 hits in this repo + (positive control: a symbol of the same class resolves outside `packages/spec` in the same + sweep) and 0 in `objectui` (control: 1326 files there import `@objectstack/spec`). An + implementor that *does* exist off-tree drops the member from its object literal; a caller + constructing a `ScheduleExportInput` drops it from the literal it passes. + + **Not in scope, deliberately:** `CronSchedule.expression` (`system/job.zod.ts`, read by + `croner` — the ONE cron slot the platform evaluates), `KnowledgeRefreshPolicy.cron` + (experimental by design), `Object.titleFormat`, and the `PromptTemplate` pair (marked, not + retired, on its sibling card). + + ## This change states no before/after rewrite, because there is none + + A breaking changeset in this repo normally states the old spelling beside the new one. + This one has no such pair to state: the same document PARSES before and after, the value + was inert in both, and no conversion can be written for it — so a metadata upgrader has no + edit to make and `os migrate meta` has nothing to list. That is a statement about the + migration chain, not about silence: `os validate` / `os build` do name the dropped + connector key and `os validate --strict` refuses on it (above), and `tsc` names the key and + the line for a TypeScript author. What follows is guidance for authoring a cadence going + forward, not a rewrite of an existing document. + + ## What to write instead + + There is no replacement on any of the five schemas: no export scheduler, flow-state + scheduler, connector-sync scheduler, cache-warmup engine, backup engine or DR-test runner + exists to declare a cadence to. The one cron slot the platform evaluates is + `Job.schedule.expression` (`system/job.zod.ts`) — work on a cadence is a `job` whose handler + you write: + + ```ts + // A connector that used to carry `syncConfig.schedule: '*/15 * * * *'` declares + // the cadence as a job instead; the handler drives the connector. + defineStack({ + connectors: [{ name: 'sap_erp', label: 'SAP ERP', type: 'saas', syncConfig: { strategy: 'incremental' } }], + jobs: [{ name: 'sap_erp_sync', schedule: { expression: '*/15 * * * *' }, handler: 'syncSapErp' }], + }); + ``` + + The retirement kit, in the shape the 2026-09-10 ruling prescribes: + + - the key is DELETED at all seven sites (`api/export.zod.ts` ×2, + `automation/execution.zod.ts`, `integration/connector.zod.ts`, `system/cache.zod.ts`, + `system/disaster-recovery.zod.ts` ×2). Each site keeps a source comment recording what + left, why nothing ever read it, and what does work instead + - **no ADR-0087 registration at all** — no `RETIRED_KEYS_BY_MAJOR[18]` entry, no D2 + conversion, no D3 semantic entry, and nothing added to the protocol-18 chain step. That is + the ruling: 「直接删」, taken over the seat's written recommendation to keep the connector + family's D2, on the reading 「我们的客户也不会按照你的设想的版本按顺序升级」 + - the four baseline rows that existed (`automation/ScheduleState:cronExpression`, + `integration/DataSyncConfig:schedule`, `system/BackupConfig:schedule`, + `system/CacheWarmup:schedule`) are deleted from `authorable-surface/` in this same commit, + each carrying the #4650 proof the build computes for itself: the def is not reachable from + the 26 metadata-type roots. The three nested positions never had a row of their own + - no liveness-ledger row: none of the five schemas is an enrolled ledger type + - the ADR-0058 D7 expression-conformance ledger loses its `cron-declared-unwired` row (every + position it covered is gone, so discovery by roster name no longer sees them); the cron + dialect is now exactly the one evaluated slot plus the one experimental-by-design slot + - pin tests (`cron-typed-positions-retirement.test.ts`): per site, the authored value is + accepted and stripped and the enclosing block still parses, on the base schema and through + every nesting carrier (`Connector.syncConfig`, `stack.connectors[]`, the `/meta/connector` + door, `DisasterRecoveryPlan.backup`, `DistributedCacheConfig.warmup`); the `tsc` channel; + and — with lit and dark controls — that no `RETIRED_KEYS_BY_MAJOR` entry, no D2 conversion + and no D3 semantic entry names any of the seven + - generated baselines and docs follow the schema: the five reference pages are regenerated, + the published `objectstack-formula` skill's `cron` row drops the retired carriers and keeps + `Job.schedule.expression`, and `packages/spec/docs/SYNC_ARCHITECTURE.md` stops teaching + `syncConfig.schedule` + - `json-schema.manifest/` and `api-surface/` are unchanged, and correctly so: the first + ratchets def *names* and the second export *existence*; deleting keys removes neither +- c1d54db: feat(spec): a metadata-form repeater's row properties have a name — `DashboardHeaderAction` fields carry a JSON Schema `title`, and `resolveMetadataFormSchemaTitles` overlays a bundle's `metadataForms..fields..label` onto a derived JSON Schema (#16458) + + ## What was wrong + + The Studio property panel renders `dashboard.header.actions[]` as a table whose + column headers read `items.properties[k].title ?? k` from the JSON Schema + derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields + (`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback + arm ran for every locale, English included, and the maker saw machine keys. + Nothing could localise them either: the only channel, `resolveMetadataFormLabels`, + decorates the `FormFieldSpec` tree, which the table never reads. And the platform + catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared + no children under the composite, so `os i18n extract` emitted no + `header.showTitle` / `header.showDescription` / `header.actions` key and the + console shipped a private overlay for exactly those three. + + ## What changed + + - **`@objectstack/spec`** — `DashboardHeaderActionSchema`'s four fields author + `.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the + derived JSON Schema names each column. New export + `resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in + `@objectstack/spec/system`: every `metadataForms..fields..label` + at any locale of the chain becomes the `title` of the node the path addresses, + stepping through an array's `items` so a repeater ROW property is addressed + as `.` (`header.actions.label`) — the same path the + extractor emits. Pure; returns the input object itself when nothing applies. + `dashboardForm` enumerates the `header` composite's children + (`showTitle`, `showDescription`, `actions` with its four row properties) with + labels equal to the schema titles, pinned equal in `dashboard.test.ts`. + The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx` + → "Metadata authoring forms". + - **`@objectstack/rest`** — `GET /api/v1/meta` localises each entry's derived + `schema` beside its `form`, through that overlay. + - **`@objectstack/platform-objects`** — the four generated `metadata-forms` + catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`, + `ja-JP` and `es-ES`. + + Additive: no key removed, no accept set changed, no parsed output moved. + + `DashboardSchema.columns` deliberately still declares no `.default(12)`, and + the reason is stronger than the one #16458 assumed. The card reasoned that the + renderer already falls back to 12, which would make `.default(12)` + behaviour-preserving. Measured at objectui `origin/main` + (`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a + `columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4` + yields 12 and everything else yields **4** — and the next line switches the + whole layout on that value (`hasExplicitColumns = schema.columns != null || + inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the + default would therefore both retire the inference and flip every auto-flow + dashboard into the positioned grid. A default that silently materialises a key + is expensive to take back, so the round stopped at the declared condition and + left the key alone; see #16458. +- 1f0b565: fix(spec)!: `dashboard.widgets[].options.stageOrder` is refused on every widget type that does not read it (#17344, finding 1) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface. `options.stageOrder` was an ungated member of the widget `options` bag and parsed on every widget `type`; it is now refused at parse on every type except `funnel`. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying `stageOrder` on a non-`funnel` widget now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `dashboard-widget-stage-order-non-funnel-refused`. + + ## What was wrong + + The key never failed. It failed to *order*. + + `options` is the open renderer-extras bag, so nothing closed over `stageOrder`: a `horizontal-bar` widget carrying an authored seven-stage contract lifecycle parsed, booted, and forwarded the array to the renderer — which never looked at it, and rendered alphabetically by display label instead. + + Measured at this repo's `.objectui-sha` pin `53ded82b`: the forwarded `categoryOrder` prop has exactly **one** read in the charts plugin — `buildCategoryRank(categoryOrder)` at `AdvancedChartImpl.tsx:1514` — and it sits inside the `chartType === 'funnel'` guard opened at line 1473. The prop's only other occurrences in that file are its declaration (247) and its destructure (850). The producer has no gate either: `DatasetWidget.tsx:1468` builds the explicit order for **any** widget and forwards it whenever non-empty. + + So the authored order was accepted by the metadata layer, carried all the way to the chart, and dropped there with nothing anywhere to say so. A chart rendered in an order the author did not ask for, and did not ask for it *visibly* — it just looked deliberate. That is ADR-0049's enforce-or-remove shape, and a doc sentence saying "only `funnel` reads this" is not enforcement: it is prose the author has to read first. + + ## What it does now + + `DashboardWidgetSchema` carries an object-level check that refuses `stageOrder` unless the widget's `type` is `funnel`. + + It has to be object-level: `stageOrder` lives inside `DashboardWidgetOptionsSchema` while the `type` that decides whether it means anything is that object's **sibling one level up**, so a per-field refinement on `stageOrder` cannot see it. The check is a named function chained on with `.superRefine(…)` — the idiom this file already uses for `GlobalFilterSchema`'s date-default rule, rather than a second shape invented for one key. + + The refusal lands at `options.stageOrder` and names three things, because the defect was silence and a bare "unrecognized key" answers silence with a shrug: the key, the `type` this widget carries, and the one `type` that honours it — plus where ordering lives for everything else. + + ## FROM → TO + + | you wrote | write instead | + | --- | --- | + | `{ type: 'horizontal-bar', options: { stageOrder: [...] } }` | `{ type: 'horizontal-bar', options: { sortBy: 'contract_count', sortOrder: 'desc' } }` | + | `{ type: 'funnel', options: { stageOrder: [...] } }` | unchanged — this is the one type that reads it | + | `{ options: { stageOrder: [...] } }` (no `type`) | `{ type: 'funnel', options: { stageOrder: [...] } }` if a funnel was meant | + + ⚠️ Deleting the key changes nothing about what renders — the widget was already ignoring it. `sortBy` / `sortOrder` are what change it, and unlike a category order they lower into the dataset query as `order: { : 'asc' | 'desc' }` rather than re-sorting what it returned. + + ## What the gate does NOT cover + + Stated so the change is not read as complete: + + - ⚠️ **objectui's client-side authoring door.** This refusal is the **publish** door's, not the editor's. `@object-ui/types` builds its own `DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every object-level check — measured here: `z.strictObject(DashboardWidgetSchema.shape)` accepts a `horizontal-bar` carrying `stageOrder` and reports zero checks, while `.extend({})` keeps the refusal. At the pinned `.objectui-sha` that package re-attaches none of this spec's exported checks, so until it imports and chains `checkDashboardWidgetStageOrder` the dashboard editor keeps accepting the key on a `bar`. That mirror also redeclares `type` as optional with no default, so a typeless widget would reach a re-attached check as `undefined` rather than as `metric`; the exported check defaults it itself for exactly that caller, so re-attaching is sufficient. + - **A widget whose `type` is outside `ChartTypeSchema`.** zod treats that `invalid_value` as aborting and skips object-level checks for the input, so `type: 'ziggurat'` plus a `stageOrder` reports the type refusal alone. The author fixes the type, re-parses, and meets this refusal then; the two are never seen together. Pinned. + - **A widget that declares no `type`.** `type` carries `.default('metric')` and zod applies defaults before object-level checks, so an omitted `type` is indistinguishable here from an authored `metric`. The verdict is right either way — `metric` reads the key no more than `horizontal-bar` does — and that one case carries an extra sentence pointing at the missing `type` rather than a wrong one. + - **The array's contents.** Still unconstrained `string | number | boolean` members, unmatched against the dimension's picklist. A `funnel` carrying a misspelled stage parses and renders that stage in the sentinel position; whether a stored value exists is a fact about the dataset, not about the widget. + - **Consumers that derive this schema with `.omit()` / `.pick()` / `.partial()`.** zod 4 throws on all three once an object carries a refinement, so this change converts those three from working to throwing. Latent rather than live — no consumer in either repo derives the widget schema that way today — and `.extend()` is unaffected. + + ## The siblings, measured and deliberately not touched + + `stageOrder` was the only member of that bag with this shape. `dateGranularity`, `sortBy`, `sortOrder` and `limit` are read unconditionally at the top of `DatasetWidget` (lines 443–455, outside every type branch) and lower into the `DatasetSelection` the server compiles, so they act on every widget type. + + ## The other arm, deliberately not taken + + The card offered either/or: gate the key, **or** teach the ordered marks (`bar` / `column` / `horizontal-bar` / `line` / `area`) to honour it. The second is a renderer change in `objectstack-ai/objectui` and not this repo's to make. The asymmetry also favours gating: a narrowing that is later relaxed costs an author nothing, while an accepted-and-inert key costs them a chart that silently says something they did not author. +- 23aa83c: `DataMigrationFlagSchema` gains `columns_moved_at`, and the `sys_migration` platform object gains the matching column: the deployment-level attestation that a migration's COLUMN MOVE ran here — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. + + **What it attests** is a fact the ledger could not previously express. `applied_at` says the backfill ran in apply mode; `verified_at` says the self-check passed. Neither says anything about the physical columns, because the backfill and the column move are separate acts and only the first of them had somewhere to be recorded. A deployment can therefore have applied AND verified a migration and still store the legacy encoding. `columns_moved_at` is that second fact, carried as its own member rather than as a widening of either existing one: folding it into `verified_at` would change what an already-verified row authorises on every deployment that has never heard of a column move. + + **Absence is the contract, not a default.** The member is optional and nullable, and nothing in this change writes it. Null or absent means the columns still hold the legacy encoding — a real, expected steady state on any deployment that has run the backfill but not the move, and never an error state — so every row that exists in the world today, and any consumer that cannot read the member at all, lands on the legacy encoding with no extra logic. A required member, or a default value, would destroy the exact property the mechanism was chosen for. + + **Nothing reads it yet, and the arbiter is untouched.** `isDataMigrationFlagVerified` — documented as the ONE arbiter for the existing consumers (reap gating, the strict value-shape flip) — is unchanged in this diff, and is now pinned to return the same verdict for a row that omits the new member as it returned before the member existed; `authorisesIrreversibleAction`, which composes it, is pinned the same way. The predicate that will require `columns_moved_at` non-null belongs to the driver work this change unblocks, and reads it in addition to the arbiter, never inside it. + + This is an additive widening: `DataMigrationFlag` (`z.input` of the schema) gains one optional member, no existing member changes or moves, and no export is added or removed. +- 357f499: feat(service-analytics)!: a dataset measure whose `aggregate` its `field`'s declared type cannot carry is refused at compile time with `400 DATASET_INVALID` (#16737, compile leg of #16099) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface. A dataset + measure pairing `aggregate: 'avg'` with a `Field.datetime` used to compile to + `AVG(col)` and reach the backend; it is now refused by `compileDataset` before any + query is built. Shipped as `minor` under the repo's launch-window convention for + accept-set narrowings; the hand-migration prescription is registered under protocol + major 18 as `dataset-measure-aggregate-field-type-refused`. + + The pair is judged against `AGGREGATE_FIELD_TYPE_COMPATIBILITY` — the one table + `@objectstack/spec` declared in #16353 under the director ruling of decision batch + #59 (2026-09-06, "both legs, table in spec"). ⛔ This changeset adds no rows and + restates none: the refusal reads the shipped predicate, so the contract has exactly + one statement. + + ## What was wrong + + The answer to `AVG` over a temporal column was decided by the SQL dialect rather + than by the data. Both halves measured on this card: + + ``` + -- SQLite (better-sqlite3), the canonical UTC-text storage form (#3912) + select typeof(submitted_at), submitted_at from clm_contract limit 1; + text|2026-05-19T00:00:00.000Z + select avg(submitted_at) from clm_contract; + 2025.5 <- text->numeric coercion: the average YEAR + + -- PostgreSQL 16.13 + select avg(submitted_at) from t; + ERROR: function avg(timestamp with time zone) does not exist -- SQLSTATE 42883 + ``` + + The silent half is the dangerous one, and SQLite is the default dev datasource: + `derived: { op: 'difference', of: [avg_a, avg_b] }` over two such averages returned + `-0.85` and rendered on a tile labelled "average cycle time delta" — a number + indistinguishable from a correct one. Nothing refused it at any layer: not the + schema, not `os validate` / `os lint`, not the analytics service, not the renderer. + + ## What it does now + + - `compileDataset` refuses an incompatible `aggregate` × `field` pair with + `DATASET_INVALID` / **400**, naming the measure, the field, its declared type and + the accepted set (read off the table, never restated). Nothing reaches the driver. + - It reads the declared type from the `sourceFieldMeta` a host already wires, via a + new optional `DatasetCompileOptions.declaredFieldType` probe. + - **`derived` is covered by construction.** A derived measure's `of` operands are + base measures of the same dataset, so a dataset carrying a refused base measure + never finishes compiling and no `derived` op can be handed its output — including + when the selection names only the derived measure. + - Tiered "cannot answer, do not block" like every sibling probe: no + `sourceFieldMeta`, an unresolvable field, or a `relationship.field` path (whose + column lives on a joined object) leaves the pair unjudged. + + ## ⚠️ Scope: the compile leg executes the TEMPORAL rows only + + > ⚠️ **Superseded within the same release window.** This section was accurate when it was + > written and is kept as the record of where the compile leg stopped. Two later cards + > widened it before any of the three entries shipped, so at the version that compiles this + > entry the scope below is no longer the platform's: **#16099** judged `sum` / `avg` over + > every remaining field class (including `sum` over a `percent`), and **#17560** (director + > ruling, decision batch #127, 2026-09-13) judged `min` / `max` over every class the table + > refuses. ⇒ Three sentences in this section are false at that version and are corrected + > where they stand: the string rows are **not** awaiting a table amendment, `sum` over a + > `percent` does **not** compile as it did before, and `avg` / `sum` over a temporal field + > are **not** the only pairs whose behaviour changes. Read all three entries together. + + The gate judges only a measure whose field is declared `date` / `datetime` / + `time`; a field of any other class is never handed to the predicate. The + verdict for the pairs it does judge is the table's — no row is restated — but + which FIELDS are judged is narrower than the table, on purpose: + + - **String rows** (`min` / `max` over `text`, `select`, `lookup`, + `autonumber`, …) are **not enforced here**. ⚠️ This card recorded them as + 「under #16785, **ruled C** — the table itself is to be amended to accept + them」, because `measureResultType` (#15768) already typed those results as + `'string'` and pinned them end to end, so enforcing them from here would + pre-empt that ruling. **Both halves of that sentence turned out to be + wrong.** `16785` resolves to no issue, and decision batch #127 (#17560, + 2026-09-13) found no ruling C anywhere behind the citation — the one recorded + ruling on this table, decision batch #59, refuses the string rows. ⛔ The + table is **not** amended; #17560 enforces those rows and retires the + `measureResultType` opinion that disagreed with them. + - **Boolean rows** are not a refusal at all any more: #16685 was ruled A and + #16750 added `boolean` / `toggle` to `sum` / `avg` / `min` / `max`, so the + table ACCEPTS them and this gate never judged them. + - The table's `sum` × `percent` row is likewise **not** executed by this leg; + `sum` over a `percent` compiles exactly as it did before. ⚠️ True of this + card only — #16099 executes that row in the same release. + + ⇒ The only pairs whose behaviour changes **because of this card** are `avg` / + `sum` over a `date` / `datetime` / `time` field. ⚠️ ⛔ Not a statement about the + release: the full-table leg is #16099's and landed, and the `min` / `max` leg is + #17560's and landed, so at the shipping version every pair the table refuses is + refused at the compile door. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ aggregate: 'avg', field: }` | `{ aggregate: 'min' \| 'max', field: }` — a real instant of the field's own type | + | `{ aggregate: 'sum', field: }` | store the duration as a number (a computed "days open" field) and `sum`/`avg` that | + | `derived: { op: 'difference', of: ['avg_a', 'avg_b'] }` over temporal averages | fix the two operand measures; the `derived` spec itself is unchanged | + + ⭐ A duration is not recoverable from an aggregate over instants on any backend. + Where an "average cycle time" is wanted, the cycle length has to exist as a number + before it can be averaged. + + ## What is deliberately untouched + + `date` / `datetime` used as a **dimension** — grouping, bucketing, date-range + filtering — is unchanged; this is about aggregation only. `avg` over a genuine + numeric measure, `min` / `max` over a temporal one, and `count` / `count_distinct` + over anything all behave exactly as before. + + ⚠️ **Two faces stay uncovered, deliberately.** The refusal lives in + `compileDataset` and reads a `declaredFieldType` probe, so it applies only where + a host wires one: `/analytics/query` — the non-dataset face, whose measures a + Cube infers rather than an author declaring them — is NOT covered, and neither + is any other `compileDataset` caller that passes no probe (those stand down + unjudged rather than guessing). Closing those is #16099's, not this card's. + + Alongside the refusal, `service-analytics`' contradictory annotations about what a + SQLite `Field.datetime` column physically holds are reconciled to one statement — + **seven** source sites plus two test narratives, not the four the card quoted. Some + said the column holds an INTEGER epoch and ISO TEXT at once; one said flatly that it + IS an INTEGER epoch. Neither is current: since #3912 the column has ONE + storage form, canonical UTC text, with the epoch surviving only in a database not + yet converged by `backfillCanonicalDatetimes`. The fact is now stated once, on + `AnalyticsServiceConfig.coerceTemporalFilterValue`, and the other sites link to it. + No behaviour changes from that half. +- a61ae59: Email templates: say where the `en-US` fallback floor is, and report a bundle that has none. + + `IEmailService.sendTemplate` matches `(name, locale)` exactly and, for a call that NAMES a + locale, retries exactly one rung — the literal `en-US` — and stops. There is no language-subtag + folding, so a bundle whose English row is tagged `en` is unreachable from `en-US` and from every + other tag it does not itself carry; each such delivery raises `TEMPLATE_NOT_FOUND`, which + classifies permanent, so it dead-letters with no retry. An app declaring + `i18n.defaultLocale: 'en'` and authoring `locale: 'en'` has done the consistent thing throughout + and still shipped a bundle with no floor for those calls — and it validated, built and installed + clean. + + - `EmailTemplateDefinitionSchema.locale`'s `describe` and TSDoc now state the exact match, the + one literal `en-US` rung a call that NAMES a locale gets, the absence of folding, and that the + stack's own declared default locale is the wrong tag whenever it is not spelled `en-US`. + - New exported `EMAIL_TEMPLATE_FLOOR_LOCALE` names that tag once: it is both the schema default + and the rung `sendTemplate` retries for a call that NAMES a locale. The full ladder — including + the lowest-tag rung reachable only by a call that names NO locale — is on + `SendTemplateInput.locale` in `packages/spec/src/contracts/email-service.ts`. + - `defineStack` now reports (advisory `console.warn`, warn-once per bundle) an `emailTemplates` + bundle that carries rows for the stack's own `i18n.supportedLocales` but none tagged `en-US`. + + Advisory only — no accept set moves. The stack still parses and is returned unchanged; the + resolver's ladder is unchanged. +- fb59fb5: fix(spec): `enableOnInstall` becomes `optional()` so absence survives the parse + + The install door was ruled onto three states — 「缺省 = 保持,有旗 = 设置」 — and + implements them: `enableOnInstall: true` enables the row, `false` disables it, + and an **absent** key makes no lifecycle call at all, so a package an operator + disabled stays disabled across an upgrade or a re-install. A fresh id has no + state to keep and lands enabled. + + The published declarations said something else. `z.boolean().default(true)` + resolves absence **at parse time**, so a request that omitted the key came out + of the parse byte-identical to one that set `true` — the third state did not + exist on the published surface, while the door went on acting on it. That is a + declared default the runtime deliberately stops applying, on a contract this + repo does not own both ends of. + + All three declarations now spell `z.boolean().optional()`, with the semantics + written on the field in the `describe` and the docblock: + + - `api/PackageInstallRequest` (`src/api/package-api.zod.ts`) — the authority. + - `kernel/InstallPackageRequest` (`src/kernel/package-registry.zod.ts`) — the + copy restated on the in-process protocol primitive. It is re-exported through + `src/api/protocol.zod.ts`, so it publishes under `api/InstallPackageRequest` + too: one declaration, two published defs. + - `marketplace/MarketplaceInstallRequest` — a different party's key on a + different door, moved with the others so the consistency matrix stays one row + per state. Not a fold. + + **Runtime behaviour is deliberately UNCHANGED**, and nothing in this repo starts + or stops being refused. Nothing parses an install body through these schemas on + the serving path — the door reads the raw body, and `PackageApiContracts` is a + declarative catalog entry rather than a parse. The accept set does not move + either: absent, `true` and `false` are accepted before and after, and a string + or `null` is refused before and after. + + ### Migration: FROM → TO + + | FROM | TO | + | :--- | :--- | + | omitting the key and expecting an unconditional enable, because the schema said `default: true` | send `enableOnInstall: true` — the only spelling the door has ever read as "enable" | + | omitting it and expecting the package's current state to be left alone | change nothing; that is what the door already does, and now what is declared | + | sending `enableOnInstall: false` | unchanged in every respect | + | reading `PackageInstallRequestParsed.enableOnInstall` (or the `InstallPackageRequestParsed` / `MarketplaceInstallRequestParsed` copies) after parsing a body without the key | it now yields `undefined` instead of `true` — the third state, and the one the door acts on | + | reading the published JSON Schema's `default` keyword for this key | it is gone; the key is still `type: "boolean"` and still not `required` | + + **Who is actually affected:** a client or SDK outside this repo that validates + its request through the published schema and sends the **parsed** object. It + materialised `enableOnInstall: true` from the declared default and sent it + explicitly — and an explicit `true` is a force-enable, so that caller silently + re-enables a package an operator deliberately disabled, on every upgrade, while + a caller sending the identical body without validating preserves the disable. + Identical request bodies, opposite behaviour, decided by whether the caller + validated before sending. A caller that never parsed its own request body is + unaffected in every direction. + + The four moved published defaults are declared in + `DEFAULT_CHANGES_BY_MAJOR` (`packages/spec/scripts/lib/default-changes.ts`), + each with the consumer prescription above; `check:authorable-surface` prints + them in full on every build that accepts them. +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- 4792049: feat(spec)!: the binding-level `dataSource.filter` and the four `object-*` `filter` doors converge onto the `ViewFilterRule` array form — one filter orthography platform-wide reaches the family (#15442, #15449; objectui#6206-B, decision batch #55 option A) + + + + **BREAKING** accept-set change at five doors — `ElementDataSourceSchema.filter` + (the `dataSource` binding every data-bound page component carries) and + `ComponentPropsMap['object-grid' | 'object-metric' | 'object-kanban' | + 'object-calendar'].filter` — shipped as `minor` under the repo's launch-window + convention for breaking changes; the migration prescription is registered under + protocol major 18 as ONE entry for the family. + + One filter orthography platform-wide (maintainer batch adjudication 2026-08-25, + verbatim 「同意」; reached these two locations on 2026-09-06, decision batch #55, + verbatim 「同意」, option A: converge family-wide). Until this release the + binding alone declared the MongoDB-style record (`FilterConditionSchema`) — so it + refused the array the consumer's own pins author at that key, and + `element:record_picker` carried two orthographies at two keys resolved through + one `??` in the renderer — while the four `object-*` doors declared `z.unknown()` + and took the record, the ObjectQL AST tuple array and the rule array alike, + silently. All five now declare `z.array(ViewFilterRuleSchema)`, the form every + other `filter` door in the map already carried; the `FilterConditionSchema` + import that existed in `page.zod.ts` for this one site leaves with it. + + Sequenced measurement-first, as the family had to be: at the objectui pin + `a472b07` the `object-metric` aggregate path posted an array `where` that + `POST /analytics/query` refused (400 on every array form, #15828), so the + converge was parked behind the pin bump #16626. At the pin this repo builds + against (`53ded82b`, objectui#7754) the adapter lowers an authored array through + `translateFilterArray` and the spec's own `parseFilterAST` sink before the + wire; `ObjectGrid` lowers a rule array through `toFilterNode`; `ObjectKanban` / + `ObjectCalendar` hand it verbatim to `$filter`, where `convertQueryParams` + lowers it; the binding's composition seam AND-combines it with the named view's + rules through `mergeFilterNodes`. Nothing on those paths parses the value + against the installed spec. + + **Migration** (`element-data-source-and-object-block-filter-rule-array` — + listed by `os migrate meta --from 17` once the protocol major is 18): a + record-form `filter: { status: 'active' }` becomes + `filter: [{ field: 'status', operator: 'equals', value: 'active' }]`; an + operator object `{ status: { $ne: 'done' } }` becomes + `[{ field: 'status', operator: 'not_equals', value: 'done' }]`; several keys + become several rules (they AND); an AST tuple array + `[['owner_id', '=', '{current_user_id}']]` becomes + `[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` — + placeholders and date macros are unchanged. The record form is refused at + `filter` (`invalid_type`, expected array); the tuple array is refused at + `filter.0` (expected object). The dashboard widget `filter` + (`dashboard.zod.ts`) is a different family and is unchanged by this release + (#15829); `object-grid.defaultFilters` is a different key, not named by the + ruling, and is unchanged. + + In-repo authors migrated in the same change: four spec test fixtures at the + binding, five showcase authors (`my-work.page.ts`, `index.ts`) and three lint + fixtures. Type aliases: `ElementDataSourceParsed`, `ObjectMetricPropsParsed`, + `ObjectKanbanPropsParsed` and `ObjectCalendarPropsParsed` are declared (ADR-0122: + `operator` normalizes on parse, so input ≠ infer at these five schemas now). +- 53ec0b1: feat(spec)!: `FlowEdgeSchema.condition` is an evaluated slot — it composes the new `EvaluatedExpressionInputSchema`, and `structuralConditionRefusal` no longer admits an `ast`-only envelope (#15807) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition): the + edge condition of a flow — `FlowEdgeSchema.condition`, the branch predicate + `AutomationEngine.evaluateCondition` runs at every traversal — now refuses at + authoring an envelope the engine cannot evaluate, where it used to parse, + register, pass `objectstack validate`, and then answer a **silent `false`**: a + branch that quietly never fired. + + Two spellings of one seam, refused by ONE rule with one sentence + (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`, the rule #15430 introduced for the + `assignment` value envelope): + + ```yaml + edges: + - { id: e1, source: check, target: approve, condition: { dialect: cel, ast: { kind: const, value: true } } } # `ast` only — the engine never reads it + - { id: e2, source: check, target: reject, condition: { dialect: cel, source: ' ' } } # blank after trimming + - { id: e3, source: check, target: escalate, condition: ' ' } # the shorthand for the same blank source + ``` + + > An expression in an evaluated slot needs a non-blank `source`: the expression + > engine evaluates `source` (the canonical persisted form) and + > cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` + > that is blank after trimming, would validate and register and then fault at + > run time. Write `{ dialect: 'cel', source: '…' }`. + + - **New export `EvaluatedExpressionInputSchema`** (type `EvaluatedExpressionInput`), + the sibling of `ExpressionInputSchema` for an evaluated slot: the bare-string + shorthand still normalizes to `{ dialect: 'cel', source }`, but the string + must be non-blank after trimming, and the envelope arm composes + `EvaluatedExpressionSchema` (`source` required and non-blank) instead of + `ExpressionSchema`. `FlowEdgeSchema.condition` is the first slot to compose + it. An `ast`-only envelope and a blank bare string surface as one + `invalid_union` issue at the slot carrying the sentence above; a blank + `source` inside an envelope surfaces as one `custom` issue at `source`. + - **`ExpressionSchema` / `ExpressionInputSchema` are NOT narrowed.** They remain + the persistence contract (`source` OR `ast`), where `ast` is accepted as an + optional opaque structured value and carries no promise of becoming required. + If AST-only evaluation is ever chartered, `EvaluatedExpressionSchema` is the + one place to relax, and every evaluated slot follows. + - **`structuralConditionRefusal` no longer admits an `ast`-only envelope** on + either structural condition slot (`config.condition` on a node, + `edge.condition`). #15662's refusal admitted it on purpose through a + `rec.ast !== undefined` clause, because the spec still admitted the shape at + `edge.condition` and refusing it from the consumer side would have decided + #15430's question there; with the edge schema closed, that admission kept the + refusal deliberately holed for a shape the engine cannot run on either slot. + `STRUCTURAL_CONDITION_SHAPE_REFUSAL` now reads "an expression envelope + carrying a string `source`" and says why. Consequence on `config.condition` + (a start node's trigger gate, a decision node's predicate — an open record + with no schema in front of it): an `ast`-only envelope there is refused at + `registerFlow`, reported as a located `error` by `objectstack validate`, and + refused by `evaluateCondition` with the same sentence, instead of answering a + silent `false`. An `ast` BESIDE a string `source` is still admitted + everywhere. The whitespace-only STRING ruling on `config.condition` (#15662: + consistent `false` on both sides) is untouched by this change, but it does not + survive the release that carries it: two sibling notes in that release refuse + the value, at `registerFlow` (#17322, `@objectstack/service-automation`) and + at `objectstack validate` (#17495, `@objectstack/lint`). + - **Three doors agree, through the spec.** `registerFlow` refuses the flow at + `FlowSchema.parse` (edge) or at its structural pass (`config.condition`); + `objectstack validate` refuses it at its `ObjectStackDefinitionSchema` parse + (edge) or reports the structural refusal (`config.condition`); + `evaluateCondition` refuses the shape a stored flow or a direct caller hands + it. None of them grew a rule of its own. + + **What an author does with a refused edge condition.** An edge condition that + carried only `ast` has no evaluable form: author its `source`. A + whitespace-only condition — envelope or bare string — was never a predicate + (the engine answered `false`, so that edge never fired): remove the + `condition` key if the edge was meant to be unconditional, or write the + expression if it was meant to branch. Every edge condition with a + non-blank `source` is unchanged, and nothing is renamed, retired or rewritten — + the refusal itself carries the prescription. + + **A flow ALREADY STORED in `sys_metadata` stops running entirely — the whole + flow, not just the edge.** The paragraph above is the author's remedy, at + `objectstack validate` / `POST /api/v1/automation`; a stored row has no author in front of + it. Stored flows are deliberately NOT canonicalized by + `applyConversionsToStoredItem` (`spec/src/conversions/stored.ts`, and the same + skip in `metadata/src/loaders/database-loader.ts`'s `rowToData`) — flow-node + conversions need the automation engine's live executor registry, so flows + canonicalize at `registerFlow` instead, which parses through + `canonicalizeStoredFlow` → `FlowSchema.parse`. Each of the three boot paths in + `service-automation/src/plugin.ts` wraps that call in `try`/`catch`, logs one + `warn` naming the flow, and continues. So an edge that used to answer a silent + `false` while the rest of the flow ran now takes the flow down with it: it is + never registered, its trigger is never armed, and the only announcement is that + one warn line — `[Automation] failed to register flow` at boot, + `[Automation] cold-boot flow bind: failed to register flow` at the kernel:ready + bind, `[Automation] flow re-sync: failed to register flow` on a re-sync. That + warn line is also the locator: its `issues[].path` names the offending edge — + `edges[N].condition` — beside the sentence above, so nothing has to be exported + to find it. Author the `source` — or remove the key, if the edge was meant to + be unconditional — and republish. A stack authored in config files has a second + door, `objectstack validate`, which locates the same edge at + `flows.N.edges.N.condition`. Registered as the ADR-0087 D3 semantic entry + `flow-edge-condition-evaluated-slot-source-required`, which carries the same + judgment for a consumer replaying the chain. + + Not touched here: `start.config.condition` has no Zod schema to narrow (the + start node's `config` is an open record). Its producer-side gate is the + structural pass at `registerFlow` and `objectstack validate`: the shape refusal + above, which this change tightens but does not type, and after it a blank-source + check that runs this change's `EvaluatedExpressionInputSchema` on the + condition's `source` (added by #17322 at `registerFlow` and by #17495 at + `objectstack validate`). +- 0a56d3b: feat(spec,types,triggers)!: `group` runs package-authored scheduled work without a declaration, owning each run's writes per record (#18378) + + + + `Clause-②: yes (widening)` + + **ADR-0087 disposition — `not-required (already-registered)`, not `registered`.** + The ledger entry this change belongs to already exists + (`schedule-flow-acting-organization-required`, entry 18) and predates this diff + at the merge base, so `registered` would assert a registration this PR did not + make. The entry's `surface`, `replacement`, `reason` and `acceptanceCriteria` + each gained their `group` row here, the rejected bootstrap-organization arm + included — recorded because it is the one a later reader will re-propose. + + **Marked breaking (`!`) for the behaviour change, not for a narrowing.** Nothing + that worked stops working and nothing that was admitted becomes refused — the + accept set WIDENS in one cell. What earns the banner is the other direction: on a + `group` deployment with the switch already on, flows that were refused at bind + now arm and run, so clock-driven work appears where an operator had none. That is + worth reading before upgrading even though no consumer has to change anything. + + ## What changes + + With `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` on and tenancy posture `group`, a + time-triggered flow that declares no `config.organization` now **binds and + runs**, where it was previously refused at bind. The organization its writes + carry follows the record: + + | posture | declaration | a bound run's writes act as | + |---|---|---| + | `single` | not read | nothing — the install's one organization resolves beneath each write | + | `group` | **optional** | declared ⇒ the declaration; undeclared ⇒ **the swept record's own organization** | + | `isolated` | **required** | the declaration; undeclared ⇒ not armed, unchanged | + + A `timeRelative` sweep under `group` reads group-wide — inherent to the posture + (ADR-0105 D1) — and stamps each run it launches with that record's organization: + sweep contracts across four plants and each plant's contract yields a run acting + as that plant, whose notifications reach that plant's inboxes. + + ## Why this is not a fallback that guesses + + It is the order `sys_automation_run` was **already** ruled to use. + `ObjectStoreSuspendedRunStore` resolves a run's organization as + `organizationOf() ?? ctx.tenantId` — subject first, acting + context as the fallback and never the primary. Before this change those two + halves disagreed under `group`: the history row was stamped from the record while + the inbox and delivery rows followed an acting context that could not exist + there, so they were refused while the tick summarised itself as healthy. + + ⚠️ With one stated exception, because the two halves ask different questions: + the history row is STAMPED (`tenancy.organizationField` wins there) while the + run's acting organization is a WALL reading that never consults that key. They + agree on every object where the two coincide — which is every ordinary object, + since a declared stamp column is what makes them differ and one shipped object + declares one (`sys_api_key`, deliberately unwalled). Sweeping that object under + `group` stamps its history row while the run itself acts as nothing: the correct + pair of answers, not a residue of the old disagreement, and recorded rather than + smoothed over. + + ⛔ A record-less run under `group` that declared nothing still resolves + **nothing** and is refused at its first tenant-scoped write (`walled-posture`, + ADR-0112), loudly and by name. The rejected alternative was a fallback to the + bootstrap organization (`slug='default'`): under a wall that organization is + minted admin-keyed by the enterprise organizations runtime and may not exist at + all, and where it does it is whichever organization the platform owner + registered under — plausibly one plant of many, not the group's head office. + + ## Upgrading + + **Most deployments: nothing to do.** The switch this depends on is OFF by default + and ships unreleased alongside this change, so the `group`-is-walled behaviour + being amended has never appeared in a published version — no released consumer + can be relying on it. + + If you run posture `group` **and** turn the switch on, read your boot log: each + time-triggered flow's bind line now names which of the three shapes it bound as + ("as organization '…'", "with per-record acting organization", or "with NO + acting organization"). Two things to check: + + - A flow you expected to act as ONE organization but which binds per-record is + missing its `config.organization`. Add it — declaring still narrows, bounding + the sweep's query as well as its identity. + - A plain `schedule` cron flow that binds "with NO acting organization" has no + record to derive one from. If it writes notifications, inbox messages or any + other per-organization row, declare `organization` on its start node; the bind + line says so, and so does the refusal at the first tick. + + ## Which organization a record belongs to — the WALL question, not the stamp one + + `@objectstack/metadata-core` gains a second face on the record→organization + resolver, and the split is the point: `resolveRecordOrganizationField` / + `createRecordOrganizationResolver` answer **"who is this row ABOUT"** (the STAMP + question, whose `tenancy.organizationField` limb stays pinned to the three + sanctioned platform-row writers), while the new + `resolveRecordWallOrganizationField` / `createRecordWallOrganizationResolver` + answer **"what is this row WALLED by"** — `tenancy.enabled: false` ⇒ nothing, + then a declared `tenancy.tenantField`, then the kernel's `organization_id`. + + The sweep uses the WALL face, because "which organization does this run act as" + is a question about the wall. ⛔ It never reads `tenancy.organizationField`: that + key is declared on exactly one shipped object (`sys_api_key`, deliberately + unwalled, #8287), and reading it here would turn "the audit trail should follow + this row's own organization even though nothing walls it" into an acting + identity. A sweep over such an object resolves **nothing** and takes the + `walled-posture` refusal at its first tenant-scoped write, which is the honest + answer. Limbs 1 to 4 are one implementation shared by both faces, pinned as + such, so the half they agree on cannot drift apart. + + **API:** `ScheduledWorkPolicy` gains `runOwnership: 'unscoped' | 'per-record' | + 'declared'`, and `requiresActingOrganization` narrows from "any walled posture" + to `isolated` only. The two are deliberately separate axes: the boolean decides + whether BIND refuses, `runOwnership` decides what a run that DID bind carries. + Inside `@objectstack/trigger-schedule`, both triggers share one bind-line + vocabulary (`describeScheduleRunOwnership`) so they cannot describe one + deployment differently. ⚠️ That helper is module-level, NOT a package export: it + is not re-exported from the package barrel, whose own note says an export whose + only consumers live inside its own package belongs in a non-barrel module. The + new PUBLIC surface in this change is `ScheduledRunOwnership` and the + `runOwnership` key on `@objectstack/types`, plus + `resolveRecordWallOrganizationField` and + `createRecordWallOrganizationResolver` on `@objectstack/metadata-core` — and + those four are what put `Clause-②` at `yes`. Nothing existing is renamed or + re-typed: both stamp-face exports keep their names, their signatures and their + answers, limb 0 included. +- f8e5790: fix(spec)!: `grouping.fields[].field` refuses a padded field name instead of handing three renderers a lookup that always misses (#17360, ruling C on objectui#7347) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface. `GroupingFieldSchema.field` was a bare `z.string()`, so `' business_unit '` was valid authored metadata; it is now refused at parse. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying a padded grouping name now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `ui-list-view-grouping-field-padded-refused`. + + ## What was wrong + + The padded name never failed anywhere. It failed to *group*. + + Measured on objectui (M1–M11, with live controls): the projection harvester `collectGroupingFieldRefs` **trims** the name when it builds `$select`, while **three** renderers bucket rows by the **raw** name — plugin-grid `usableGroupingFields`, plugin-list `ObjectGallery.groupedItems`, plugin-kanban `effectiveSwimlaneField`. So the server answers under `business_unit`, every per-row lookup asks for `' business_unit '`, reads `undefined`, and the view collapses into one `(empty)` group (grid, gallery) or one `Uncategorized` lane (kanban) holding every record. + + That is a silent wrong answer that reads as a true statement about the data: a user looking at one giant `(empty)` group has no way to tell it apart from a dataset where the field genuinely is empty. Nothing weaker than a parse refusal is honest about it. + + ## What it does now + + `grouping.fields[].field` carries a **non-padded** pattern — no leading and no trailing whitespace. The refusal lands at `grouping.fields[N].field` (the offending element's own key, not the view or the array) and names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message, together with the trimmed name to write instead. + + ⛔ **Not a `.trim()`.** A trimming schema makes `' a '` and `'a'` silently equivalent, which is the consumer-tolerance direction AGENTS.md #0.1 refuses: the padded spelling is a mistake the author should be told about, not a dialect the producer quietly normalises away. objectui's harvester trim stays as defence-in-depth; nothing is removed there. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `grouping: { fields: [{ field: ' business_unit ' }] }` | `grouping: { fields: [{ field: 'business_unit' }] }` | + | `grouping: { fields: [{ field: 'status\n' }] }` | `grouping: { fields: [{ field: 'status' }] }` | + + The remedy is always the same: write the field name exactly as the object declares it and the server answers under. If a view has been silently showing one `(empty)` group, re-authoring the name is also the fix for that. + + ## Scope — what is deliberately NOT narrowed + + - **The blank name is unchanged.** It is already refused loudly one layer down by `compileListViewGroupQuery`'s `grouping_field_blank` (`400`, path `['grouping','fields',N,'field']`). This narrowing exists for the **silent** case; the empty string still parses here exactly as before. + - **This is not the snake_case machine-name grammar.** `packages/spec` spells `/^[a-z_][a-z0-9_]*$/` inline for object, field and tool **names**, and this key deliberately does not take it: a grouping level is authored as a field **reference**, and a dotted relationship path (`owner.name`) is an in-tree spelling of one. The ruling asked for a non-padded pattern and this is exactly that — nothing wider, nothing narrower. + - **The sibling `groupByField` axis** (kanban / gantt / timeline) is symmetric and is **not** touched by this change. + + ## Who is affected, measured + + Every `grouping.fields[].field` spelling in this repo parses unchanged: 50 literal occurrences under a `grouping:` key across 19 files, harvested with the TypeScript parser and cross-checked against a deliberately over-approximating second pass over 906 shape-exact `{ field, order?, collapsed? }` literals in `packages/**`. The single harvested spelling this refuses is `' '` in `view-grouping-query.test.ts` — a **negative** fixture handed straight to `compileListViewGroupQuery` with no parse on its path, pinning that same `grouping_field_blank` refusal. Nothing in the tree reddens. + + Outside the repo, only metadata that was already grouping wrongly is affected: a padded name has never produced a correct grouped view on any renderer. + + ## Consumer + + **objectui#7347 unblocks on the INSTALLABLE RELEASE of this package, not on merge.** Its side of the work — a pin bump plus a regression test that a padded name is refused before it reaches any renderer — needs a published `@objectstack/spec` to depend on, so it stays `pm:blocked` until this ships in a release a consumer can install. The gallery and kanban sites are covered by this one producer fix and get no cards of their own. +- d2c1d19: fix(objectql)!: `beforeUpdate` receives the record the engine intends to persist, and the caller's submission travels on `ctx.submitted` (#16344) + + + + **BREAKING** — what a `beforeUpdate` handler reads on `ctx.input.data` changes. A `readonly` field the caller supplied a value for is no longer there. The hidden set is the update strip's own subject set: author-declared `readonly: true` **and** the types whose value the runtime owns end to end (`autonumber`, implicitly read-only since #5503). `readonlyWhen` locks are deliberately not hidden. + + ## The defect + + On update, a value sent for a field declared `readonly: true` was correctly **not persisted** — and was still handed to the object's `beforeUpdate` hook. A hook deriving columns from the incoming record therefore derived them from a value the row would never contain, and **those derived writes persisted**, because they are the hook's own. + + Measured on a real app (17.2.0, sqlite, dev runtime) and reproduced in `packages/objectql/src/engine-readonly-hook-input.test.ts`. One `PATCH { actual_value: 380, target_value: 1, weight: 1 }` against a `readonly` `target_value`: + + ``` + read back: target_value 400 weight 10 ← the strip worked + score 1.2 calc_trace "实际 380 / 目标 1 … 权重 1%" + ``` + + The row's own audit trail cites values the row does not hold. No error, no warning, 200, and `droppedFields` correctly reporting the strip the whole time — every channel said the write was fine, because by every channel's own lights it was. The only way for an application to be safe was for every hook to re-read its read-only columns and ignore the incoming record, which defeats declaring them read-only at all. + + ## What changed + + **`ctx.input.data` on `beforeUpdate` is now the record the engine intends to persist.** Caller-supplied values for `readonly` fields are taken out of the hooks' view before the before phase is dispatched, and handed back at the engine's post-hook confluence — so the payload every engine-owned consumer below reads is byte-for-byte what it read before. `onFieldsDropped` reports the same fields with the same `readonly` reason, the read-only WARN says the same sentence, and `strictReadonlyWrites` refuses exactly the same writes. + + **The caller's submission travels on a new `HookContext` member, `ctx.submitted`** (`@objectstack/spec`, `HookContextSchema`) — the payload as sent, snapshotted at engine entry before any middleware or hook stamp, frozen, and documented as *diagnostics only, never the persist image*. It is bound on the update verb, both phases, and every per-row dispatch of one caller write. + + Two things deliberately did **not** move: + + - **The enforcement pass is still after the hooks.** It is the only point that can tell a hook's stamp from a caller's forgery (`hookWrittenKeys`), so a `beforeUpdate` that stamps a read-only column still lands — including when the caller echoed the same key back, which is the whole subject of #5591 / #14088. + - **`beforeInsert` is untouched.** The create side's strip position is settled post-hook by ruling C (#14147, "one semantics, one enforcement point"), and `readonlyWhen`-locked fields stay hook-writable per #9107. + + `@objectstack/plugin-auth`'s ADR-0092 identity write guard is migrated onto the new member in the same change, which is why nothing degrades: its 403 and its security warn still name the non-whitelisted field the caller sent. Without that migration the identical request answers `None of the submitted fields (—) are editable` — as strong a refusal, saying nothing about what was refused. Both readings are pinned side by side in `identity-write-guard.test.ts`. + + Ruled 2026-09-08 (maintainer, verbatim 「批 #87 同意」, director seat, decision batch #87). The refused primary was the same strip move **without** the new member: the ADR-0092 diagnostic degrades and every third-party `beforeUpdate` guard reading `ctx.input.data` degrades with it, silently. The refused alternative on the other side was documenting that hooks must read read-only columns from `ctx.previous` — which outsources the invariant to every application, the exact shape triage had already rejected. + + ## Who is affected + + A `beforeUpdate` handler that **reads a `readonly` field (declared, or runtime-owned) out of `ctx.input.data`**, on a non-`isSystem` write. Three shapes, and the fix is one line each: + + - **deriving a value from it** — this is the defect; the handler now derives from `ctx.previous`, or from `ctx.input.data` with the payload's absence meaning "unchanged", which is what it always meant for a field the caller never sent. + - **reporting on what the caller sent** (a guard naming the offending key) — read `ctx.submitted`. + - **a self-assignment** (`data.x = data.x`) on such a field — this used to promote the caller's forged value to hook-owned and commit it. It is now a **no-op**: the key the hook reads is gone, so the line re-creates it holding `undefined`, and the engine treats set-to-undefined of a hidden read-only key as the no-op it is — deleting the key, dropping it from the hook-write record, and letting the ordinary hand-back put the caller's value back for the strip to judge. **The stored value stands**, and the write reports exactly as it would with no hook at all (stripped, `onFieldsDropped`, the WARN, `strictReadonlyWrites` refusing). Persisting the `undefined` instead would erase the stored value on the memory driver and hand knex an undefined binding on a SQL one — neither is the record the engine intends to persist. That laundering route closing is intended, and it is re-pinned in both directions rather than removed. + + ⚠️ **The sharpest edge is a sandboxed `body` hook, and it is a refusal rather than a quiet change.** A body that reaches *through* such a key — `ctx.input.locked_meta.who = 'hook'` — now dereferences `undefined` and throws, and a `body`'s default `onError` is `abort`, so the caller's **whole write is rejected** where it used to succeed. What that body used to do was persist a value derived from the caller's forgery, so refusing is the correct direction; but the message the author sees is a raw `TypeError` from their own dereference and names nothing actionable. Measured end to end through a real QuickJS sandbox and pinned in `packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts`. + + A body hook cannot read `ctx.submitted`: it is deliberately not marshalled onto the sandbox face, for the reason `dispatch.scope` is not — that face is assembled key by key, and a key added there is a second published contract with its own compatibility story. A body deriving a column from a read-only field reads **`ctx.previous`**, the stored row, which is the correct source either way. + + ⚠️ **One ADR-0092 boundary changes a status code, and no in-repo object hits it today.** On an object whose UPDATE whitelist admits a field that is ALSO declared `readonly`, a whitelist-only payload now answers **403** where it used to answer **200 having written nothing**. The identity write guard composes its refused list from what the engine left it, and a whitelisted key is excluded from that list by design, so the refusal reads `None of the submitted fields (—) are editable` — naming nothing. The write was already being dropped by the read-only strip before this change; what moves is that the caller is now told, and told imprecisely. `sys_user`'s three writable fields are not read-only, so nothing in this repository is on that boundary; an application that puts a `readonly` field in an UPDATE whitelist should take it out, which is what the whitelist meant either way. + + An `isSystem` caller sees no change at all: the strip has never applied to one, and neither does the hide. +- 681871e: feat(spec): `HookContext` admits a row-invariant-in-effect rewrite by per-row `previous` on a predicate write, kept safe by the engine's key-divergence refusal (#16074) + + The `hook.zod.ts` contract said that on a predicate (`multi: true`) write the per-row `previous` is supplied *so a guard can REFUSE (throw), not so a rewrite can be aimed*. Three shipped `beforeUpdate` provenance stamps (`sys_email_template`, `sys_sharing_rule`, `sys_webhook`) read `ctx.previous` per row and write `customized: true` conditioned on it — inside the letter of what the engine allows, outside the stated purpose of the input they use. Maintainer ruling (recorded by the director seat, decision batch #59, 2026-09-06), option 1: **the contract admits the shape.** + + The amended D3 clause (`HookContextSchema.input` TSDoc, mirrored in `bulk-write-hook-conformance.ts`) now states: + + - Per-row `previous` is supplied so a guard can REFUSE, **and** so a `before*` hook can make a **row-invariant-in-effect rewrite** — one whose written KEY SET is the same on every matched row **and is assigned in place** (`ctx.input.data.customized = true`, not a wholesale replacement of `ctx.input.data`). + - What makes that shape safe is the engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099): the dispatch records, per row, the payload keys that row's hook chain assigned **in place**, and if any two rows disagree the whole batch is refused **before any write**. In place is the condition the refusal rests on: a hook that REPLACES `ctx.input.data` leaves the dispatch unable to attribute keys, so the comparison is skipped and the batch is not judged at all. + - What an operator sees when it fires: an ADR-0112 envelope with `status: 400`, `code: 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE'`, `keys` (the sorted keys some rows' hooks wrote and others did not, e.g. `['customized']`), `rows` (how many rows the predicate matched), `object`, and a message that says "Nothing was written" before naming the remedy. A bulk edit over rows that already disagree on the stamp's condition is refused whole rather than half-stamped; that is the engine working, not the hooks misbehaving, and the remedy is the caller's — write those rows by id, or from inside the handler through `ctx.api`. + - Three shapes the rule does **not** admit: a rewrite whose written key set differs across rows (that is the refusal itself); the same key written with a per-row VALUE — the engine judges key sets, never values, so that shape clears the check and applies the last dispatch's value to every row; and a row-conditioned REPLACEMENT of `ctx.input.data`, which silences the recording above so that shape is judged by nothing at all. All three stay out of contract. + + Purely additive at the contract: no schema key, type or accept set of `HookContextSchema` itself changes, and the engine's behaviour is unchanged — the three stamps become conforming by amendment, and the rule for the next hook author is written down where the contract lives. Option 2 (change the hooks to stop aiming by `previous`) was not adopted: #15302 measured that declining on a predicate write leaves unstamped exactly the rows the next boot overwrites, turning a visible 400 into silent loss of an admin edit. +- 54e8234: **BREAKING** `engine.registerHook` refuses an engine lifecycle event the engine never dispatches (#17713) + + `registerHook(event, handler)` took `event: string`. For a name outside the dispatched set it logged a warning and then **registered the handler anyway**, so the declaration succeeded and the handler never ran — ADR-0078's prohibited fourth state (parsed, unmarked, silently inert) on an authorable seam. + + The measured cost is a data-visibility one. A consumer registered **read filters** on `beforeFindOne` and `beforeCount`, expecting them to scope single-record reads and list totals. They sat inert through every boot behind ~40 warning lines: `findOne` was still filtered (`beforeFind` covers it, so the mistake gave no signal), `count` was not — a `limit`ed list answered a `total` counting rows the caller could not see — and `aggregate` was not either, so a `groupBy` was not narrowed at all. + + Six event names now throw at registration instead of registering inert. They are the engine's own lifecycle namespace — `before`/`after` × `OperationContext['operation']` — minus the eight the engine dispatches, derived in code rather than typed out. + + FROM → TO: + + | was | now | fix | + | --- | --- | --- | + | `registerHook('beforeFindOne', h)` | throws | register on `'beforeFind'` — it already fires for `findOne` | + | `registerHook('afterFindOne', h)` | throws | register on `'afterFind'` — same reason | + | `registerHook('beforeCount', h)` | throws | `count()` dispatches no hook; use `engine.registerMiddleware(fn)` and read `ctx.operation === 'count'` | + | `registerHook('afterCount', h)` | throws | same as `beforeCount` | + | `registerHook('beforeAggregate', h)` | throws | `aggregate()` dispatches no hook; use `engine.registerMiddleware(fn)` and read `ctx.operation === 'aggregate'` | + | `registerHook('afterAggregate', h)` | throws | same as `beforeAggregate` | + + One-line fix for a read filter that was on `beforeCount` or `beforeAggregate`: move it into `engine.registerMiddleware(async (ctx, next) => { if (ctx.operation === 'count' || ctx.operation === 'aggregate') ctx.ast.where = ctx.ast.where ? { $and: [ctx.ast.where, scope] } : scope; await next(); })` — the same seam RLS and sharing already use, so the predicate reaches the driver call. + + What is **not** affected: an event name outside the engine's lifecycle namespace (`'myPlugin:flush'`) still warns and still registers, so a plugin that dispatches its own events through `triggerHooks` keeps working. Metadata-authored hooks were never exposed — `HookSchema.events` is `z.array(HookEvent)` and `HookEvent` enumerates exactly the eight dispatched names, so the gap only ever existed on the code door. + + +- 4bbf766: Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). + + **BREAKING** (return shape) — `walkAddressedPageComponents` is a published export of `@objectstack/spec` and its return value is now the rebuilt roots pair `{ regions?, slots? }` where it used to be the regions array alone. A caller that only enumerates components through the visitor and ignores the return value is unaffected. A caller that reads the return value binds `const { regions } = walkAddressedPageComponents(doc, visit)` and reads `regions` exactly as it did before; `slots` is the other half of the same rebuild and is present exactly when the input page authors slots. The bump stays `minor` because the launch-window convention `scripts/check-changeset-no-major.mjs` enforces refuses a `major` while the fixed group is in lockstep — during that window the version number carries nothing about breaking-ness, so this banner and the disposition below are the carriers. + + **`walkAddressedPageComponents` widens in both dimensions.** The shared page walk behind `translatePage` and the CLI extractor (`os i18n extract` / `os i18n coverage`) rooted at `regions[].components[]` only and descended `properties.children` only. A slotted record page authors `regions: []` and puts everything under `slots.`, so the walk visited nothing on it and `pages.` carried exactly two addressable keys however many components the page authored; a `page:tabs` / `page:accordion` keeps its panels' components under `properties.items[].children`, one level deeper than the descended slot, so a related list inside a tab was unreachable on any page kind. The walk now roots at `regions[].components[]` **and** `slots.` (one component or an array per slot, regions first, then slots in authored order — both root level for the collision arbitration and for the page-name `page:header` route, so a slotted page's `slots.header` is translated as the page's header), and descends `properties.children` **and** `properties.items[].children` (matched by shape, so a custom container speaking the same vocabulary is walked too; `body` / `footer` remain undescended — a renderer back-compat fallback, not an authorable spelling). The depth cap, the cycle guard and the ruled id arbitration are unchanged. + + - Signature: the parameter is `AddressedPageRoots` (= `Pick`) instead of `Pick`, and the walk returns the rebuilt roots pair `{ regions?, slots? }` (each key present exactly when present on the input) instead of the regions array alone. `PageLike` gains `slots`. An enumeration-only consumer that ignores the return value needs no change; a consumer reading the returned regions destructures `{ regions }`. + - `translatePage` carries the rebuilt `slots` back onto the document. + + **`dashboards..globalFilters.` is a new bundle group.** A dashboard's filter bar draws directly above the widget titles the bundle has always translated, and neither a filter's label nor its static option labels had a key. The group is keyed by the filter's `name` (`GlobalFilterSchema.name`, declared as defaulting to `field` — a filter that authors no `name` is keyed by its `field`) and carries `label` and an `options.` map keyed by the option `value` spelled as a string. `translateDashboard` overlays it on the served document, which is what objectui's filter bar already reads; the exported `globalFilterKey()` is the one key derivation both the resolver and the extractor use. `optionsFrom` options are fetched rows and are deliberately not addressable. + + **`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. + + **`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards..globalFilters.created_at.label` entry for the system-overview dashboard's date-range filter, which authors no `name` and is therefore keyed by its `field`. + + **Why no ADR-0087 ledger entry.** Nothing an author writes moves. The authorable side is purely additive — `dashboards..globalFilters.` is a new optional group and every bundle that was valid before is valid unchanged — no spec key is retired, no stored `sys_metadata` shape changes, and no conversion or migration id is touched, so `objectstack migrate meta` has nothing to act on. The one incompatible surface is a published function's TypeScript return type, which reaches every affected consumer through the compiler. + + +- 6e3e546: feat(spec)!: retire the list view's own `tabs` key — parsed, stored, and drawn by nothing; named presets are `listViews` entries + + **BREAKING** — `tabs` is removed from the list view (`ListViewSchema`, + `ObjectListViewSchema` — a `defineView` container's `list` / `listViews`, an + object's `listViews` — a view item record's list `config`, and the flattened + list overlay the `PUT /api/v1/meta/view` door accepts). ADR-0049 + enforce-or-remove; triage verdict RETIRE, on the rule that a capability the + mainstream has and this platform already delivers keeps ONE spelling. + + The key parsed at every list-view door and was stored, and no renderer ever + drew it. Measured before removal, each reading beside a lit control: a list + view's own `tabs` has no reader, and objectui's `TabBar` — the one component + that would draw it — has zero production mounts at the objectui commit this + repo pins (every occurrence is in its own two test files), while the saved-view + switcher (`ViewTabBar`) mounts in the object view and is fed from the object's + `listViews`. That switcher IS the tab strip above an object's records: one tab + per named list view. `userFilters.tabs` is a different key with the same + element type: it is read and rendered as a page list's preset bar, and it + stays. Zero list views in this repo's examples or platform sources authored the + key; the one published skill example that taught it is corrected here. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | a list view's `tabs: [{ name, label, filter, … }]` | one named list view per tab, under the object's `listViews`: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too). A tab whose `view` already named a list view needs nothing more. | + | the tab keys `icon`, `order`, `pinned`, `isDefault`, `visible` | nothing — none of them ever had an effect. | + + **The one-line fix: delete `tabs:` from every list view, and add a `listViews` + entry for each tab you want users to switch to.** `os migrate meta --from 17` + lists the mechanical edits for existing sources; apply them by hand. + + ```ts + // before — parsed clean, drew no tab bar + defineView({ + object: 'crm_ticket', + list: { + type: 'grid', columns: ['subject', 'status'], + tabs: [{ name: 'open', label: 'Open', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }], + }, + }); + // after — the switcher above the records shows "Open" beside the default view + defineView({ + object: 'crm_ticket', + list: { type: 'grid', columns: ['subject', 'status'] }, + listViews: { + open: { + type: 'grid', label: 'Open', columns: ['subject', 'status'], + filter: [{ field: 'status', operator: 'equals', value: 'open' }], + }, + }, + }); + ``` + + ⛔ **Untouched: the page-only preset bar.** `userFilters: { element: 'tabs', + tabs: [...] }` on a page list is a different key, it renders, and + `ViewTabSchema` stays for it. + + ### The retirement kit + + - **A `retiredKey()` tombstone on the list-view shape**, beside the `pageName` + tombstone on the same strict shape. Every door built from it refuses: `tsc` + types the key `never`, and the parse raises the prescription (which names the + move to `listViews`) instead of a bare unknown-key report. + - **D2 conversion `view-list-tabs-removed`** (protocol 18, retired from the load + path): strips `tabs` from every list payload in `stack.views[]`, in all three + persisted spellings, as a lossless delete — nothing ever drew the tabs — so a + stored `view` row replays clean through the rehydration seam. An object's own + `listViews` is reached by no conversion, so such an object is refused at its + door until edited by hand. + - **D3 entry `list-view-tabs-retired`** beside it, carrying the part no + conversion can decide: which tabs deserve a `listViews` entry. + - **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ListView:tabs`, `ui/ObjectListView:tabs`; + both `authorable-surface/ui.json` rows become `[RETIRED]`. + - **The metadata form's `tabs` repeater** leaves with the key, and the + extracted form-label bundles are regenerated. + - **The liveness row stays `dead`**, re-verified, with a REMOVED note — the + tombstone keeps the key in the walked shape. + - **The published `objectstack-ui` skill** no longer teaches the key: its + list-view rules example and the "tabs win over dropdowns" rule (which + described a tab bar that never rendered) are replaced by the `listViews` + pointer. + - **Pins** (`ui/view-list-tabs-retirement.test.ts`): the refusal, its issue + code, path and prescription at seven doors, each with a lit control; the tsc + channel; the `userFilters.tabs` boundary; the conversion's reach, boundary and + idempotence; the D2/D3 registration; and a tree-scoped absence walk over the + declared radius. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no telemetry was consulted for. + + Clause-②: no (narrowing) + + +- 9cdffbe: One physical representation for the NUMERIC column family, read by every producer of DDL + + `packages/spec` now states, per field type, what column a numeric field gets, and all three + producers read it: `SqlDriver.createColumn`, `os generate migration --format sql` and + `os generate migration --format typescript`. Measured on live PostgreSQL 16.13, one object + through all three producers, before and after: + + ``` + BEFORE AFTER + driver sql gen ts gen all three + number real numeric(18,2) numeric(8,2) numeric(65,30) + currency real numeric(18,2) numeric(8,2) numeric(65,30) + percent real numeric(5,2) numeric(8,2) numeric(65,30) + slider real numeric(18,2) numeric(8,2) numeric(65,30) + summary real numeric(18,2) numeric(8,2) numeric(65,30) + progress real numeric(5,2) numeric(8,2) numeric(65,30) + rating real integer integer integer + ``` + + 7 of 7 columns diverged before, 0 of 7 after. Every arm of the old split lost data in its own + direction: `real` is IEEE-754 binary32, so a `currency` of `1234567.89` read back `1234567.9`; + `numeric(5,2)` and `numeric(18,2)` silently ROUND a legitimate `33.333` to `33.33` (round + half-up — executed, not inferred); `numeric(8,2)` refused `1234567.89` outright. `65,30` is + MySQL's documented `DECIMAL` maximum and therefore the portable one, and it is the only + candidate measured to lose nothing on a nine-value corpus. + + Both migration formats also take the physical `NOT NULL` from `storage.notNull` and never from + `required`, which is where `SqlDriver.createColumn` has taken it since ADR-0113: `required` is + the write-time contract the record validator enforces, and binding the DDL to it made every + post-deploy tightening a destructive migration. + + **BREAKING** — new columns only; no existing column is retyped, no migration is planned, and no + backfill runs. Four consequences to know before creating new tables: + + - `rating` is an INTEGER column, and the two server dialects dispose of a fractional star count + DIFFERENTLY — do not read one answer for both. PostgreSQL REFUSES `4.5` outright, where a + `real` column accepted it. MySQL does NOT refuse: it ROUNDS, and `4.5` becomes `5` with no + error, which is a silent alteration and the reason to declare a `slider` (in the exact-decimal + set) for anything that wants fractional values. SQLite refuses nothing either: it stores `4.5` + as a REAL in an INTEGER-affinity column, unchanged from today. + - An exact-decimal column is bounded where a float is not, in BOTH directions. It keeps 30 + fractional digits: a magnitude whose significant digits run past the 30th decimal place loses + the tail silently — `1.2345678901234567e-15` stores as `0.000000000000001234567890123457`, so + the loss begins around |x| < 1e-13 and is total below 1e-30 — and magnitudes at or above 1e35 + are REFUSED, where `real` kept about seven significant digits out to ~1e38. A refusal is loud; + the rounding it replaces was not. + - Reads are bounded by the wire contract, not by the column. `find()` hands back a JS number + (`z.number().finite()`), so a value that was never a JS double does not survive the round trip + exactly — `1234567890123456.123` reads back `1234567890123456`, and 2^53+1 reads back 2^53. + The fidelity this buys is an exact COLUMN read through a double: values written by this + platform round-trip exactly, and SQL-side writers, `summary` roll-ups computed in SQL and any + magnitude at or above 2^53 are bounded by the read seam. Widening that is a wire-contract + change and is not in this release. + - A generated migration no longer emits `NOT NULL` for a field marked only `required: true`. + Declare `storage: { notNull: true }` for a physical constraint — which is what the platform's + own table has always done since ADR-0113, and what `os migrate meta` deliberately does NOT + supply on your behalf (the conversion that stamped it was withdrawn by maintainer ruling on + 2026-09-08). A source author who wants the column they had must write that block themselves; + `required: true` keeps its own meaning, the write-time contract the record validator enforces. + + SQLite emits byte-identical DDL for the six exact-decimal members: knex compiles both + `table.decimal(name, p, s)` and `table.float(name)` to the same `float` column there. + + +- 331a1a2: fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) + + Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** + + **The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: + + | identity path | `crm_account` | `crm_opportunity` | `crm_task` | + |:--|--:|--:|--:| + | API key, `principalKind: human` | 9 | 23 | 45 | + | OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | + + The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. + + **The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. + + **(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. + + ⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. + + **(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. + + **(3)** The Setup page's promise is untouched — it is now true rather than rewritten. + + Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. + + `DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: + + | direction, after release | consumer cost | + |:--|:--| + | ship optional fields, later tighten them to required | a compile break | + | ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | + + The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. +- 9788f1e: feat(spec)!: `object-grid` and `object-calendar` constrain the `sort` VALUE to the `SortItem` array — one sort orthography platform-wide reaches the last two unconstrained doors (#16553; objectui#8221, decision batch #77 option B) + + + + **BREAKING** accept-set change at two doors — `ComponentPropsMap['object-grid'].sort` + and `ComponentPropsMap['object-calendar'].sort` — shipped as `minor` under the + repo's launch-window convention for breaking changes; the migration prescription + is registered under protocol major 18 as `object-block-sort-item-array`. + + One `sort` spelling platform-wide, the array (objectui#8221, decision batch #77, + 2026-09-07, maintainer verbatim 「其他同意」, option B; the consumer half is + objectui PR #8758, which drops the legacy string arm from + `convertSortToQueryParams`). Item 4 of that ruling is this release's subject: + 「`ComponentPropsMap` for `object-calendar` and `object-grid` constrains the + `sort` value to the array shape (today it accepts anything), so the spec, the + registrations and the helper agree; that is a pull-back to the declared contract, + ordinary tier」. + + Until this release both doors declared `z.unknown()` — no orthography at all. + Measured on `@objectstack/spec` 17.2.0 and re-measured on this tree before the + change: an array, the legacy string clause and a bare NUMBER all returned + `success: true`, while `bogusProp` was refused by name on the same call. So key + checking was live and only the VALUE was unheld, and an author following + objectui's own registrations (`plugin-grid/src/index.tsx:222` has published + `type: 'array'` all along) and an author following the legacy string each got a + silent success receipt for a different shape — while objectui's html tier + answered `type-mismatch` on the second one. Both doors now declare + `z.array(SortItemSchema)`, the array `ElementDataSourceSchema.sort`, + `ListPageSchema.sort` and `element:record_picker`'s flat `sort` shorthand already + carry: one shared schema, not a third copy. + + Sequenced measurement-first, as this family has to be. At the objectui pin this + repo builds against (`53ded82b`) the string is still lowered — + `ObjectGrid.tsx:1844-1851` carries an explicit `typeof === 'string'` arm onto + `$orderby` beside the array arm, and `ObjectCalendar.tsx:431` hands `schema.sort` + to `convertSortToQueryParams`, whose string arm is still present at + `sort-query.ts:66-70`. This declaration therefore lands ahead of the pinned + consumer, which the ruling permits explicitly — either order, since the + registrations already declare the array — and the next pin bump carries the + retirement in. + + **Migration** (`object-block-sort-item-array`): `sort: 'created_at desc'` becomes + `sort: [{ field: 'created_at', order: 'desc' }]`; a bare field name + `sort: 'created_at'` meant ascending and becomes + `sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required in + `SortItemSchema`, so it is written out rather than omitted; a comma-separated + clause becomes one array entry per key, in the same order. The string is refused + at `sort` (`invalid_type`, expected array), as is a bare number; a misspelled or + absent direction is refused at `sort.0.order`. Metadata AT REST is not rewritten + and this disposition adds no D2 conversion — a stored page carrying a string + `sort` keeps loading and still renders at the pinned `.objectui-sha`; what + changes is that RE-SAVING it is refused at the `sort` door. + + **Not moved by this release.** `record:related_list.sort` keeps its declared + string arm: that string is the `'field'` / `'-field'` dialect read by + `RelatedList.normalizeSortSpec`, it never reaches `convertSortToQueryParams`, and + retiring it was not ruled — objectui#8221's own implementing round narrowed it, + established the dialect and reverted the narrowing byte-identically. + `object-grid.defaultSort` is a different key, already retired by #11805. Zero + authored `sort` values on either block exist in this repo (the two showcase pages + that author `object-grid` declare none), so nothing in-tree was converted. + + Type aliases are unchanged: `SortItemSchema`'s input equals its infer, so neither + block's parsed state moves for this key, and both already take the + `…PropsParsed` route for `filter` (ADR-0122). +- 5d527f7: fix(spec): `PageSchema`'s rejection guidance stops prescribing `assignedProfiles` as a page gate (#16929) + + The two wrong-layer prescriptions `PageSchema` hands an author at parse time both ended by pointing at `assignedProfiles`: the `visibleWhen` pointer said "or gate the page with `assignedProfiles`", and the `permissions` pointer said "reach it through `assignedProfiles`". Neither is true. `assignedProfiles` gates nothing. + + Measured 2026-09-10 on `origin/main` `e1eee43beb` and objectui `3fbdd4a2d`: `assignedProfiles` has **zero readers** in this repo — every one of its 25 matching files is a declaration, a generated artifact, prose, a `CHANGELOG`, the liveness ledger, or this schema's own round-trip test — and **zero readers** in objectui, whose three hits are a docs table row and two type/zod declarations. Lit controls in the same sweeps (`visibleWhen` 308 files, `PageSchema` 94 files in objectui; `visibleWhen` 168 files here) prove the instrument fired; a fabricated dark control read 0 in both. The key is also named for the concept **ADR-0090 D2** removed, which `security/permission.zod.ts` states to authors three times over. + + Prescribing it was Prime Directive #10's exact prohibition — advertising a capability the runtime does not deliver — delivered to the author in the error that is supposed to be teaching them the correct spelling. Both prescriptions now say only what the platform actually does: put `visibleWhen` on the component inside a region, and gate the DATA a page shows with the object's permission sets. + + **Nothing about what `PageSchema` accepts changes.** `assignedProfiles` remains an authorable key with its declaration untouched, and the `profiles:` / `assignedTo:` alias entries are untouched. Both channels edited here fire only from the `unrecognized_keys` path, so every key involved is rejected before this change and rejected after it, with identical `issue.code` and identical `path` — only the human-readable text moves. The key's own disposition (keep, rename, or remove) needs a ruling and stays open on #16929. +- 9165d5c: Declare the ASSEMBLED manifest stage on the installed-package read API. + + **BREAKING** — a TYPE-level break on two PUBLISHED response types. It ships + `minor` under the pre-GA launch-window convention (ADR-0087, *Ratified: the + pre-launch launch-window exemption*), where the npm level is deliberately not the + carrier of breaking-ness; this banner and the ADR-0087 disposition at the bottom + are. Runtime is untouched and stays additive — every payload that parsed before + still parses — so the affected party is a TypeScript consumer and the channel is + the compiler at their own call site. Reading a manifest field off + `ListInstalledPackagesResponseSchema` or `GetInstalledPackageResponseSchema` can + stop compiling, and assigning a malformed manifest to either can start compiling + where the old annotation refused it. Both directions are measured against the + built `.d.ts` under *The STATIC gain is one-sided* below, which is also where the + point-of-use reading lives. + + `GET /api/v1/packages` and `GET /api/v1/packages/:packageId` serve whatever a + package was installed with, and two stages reach that table through declared + doors: `POST /api/v1/packages` installs an authoring manifest (`manifest.objects` + = glob patterns), while a `defineStack()` host installs the assembled body + (`manifest.objects` = object definitions). Both response schemas typed every row + at the authoring stage alone, so the shipped `defineStack()` path served a + payload its own declared contract refused. + + Following the #14242 ruling — declare the assembled stage rather than widen the + authoring one — `@objectstack/spec/api` gains two exports: + `AssembledInstalledPackageSchema` (the assembled-stage counterpart of + `InstalledPackageSchema`) and `InstalledPackageAtEitherStageSchema`, a union + over the two whole closed stage declarations. `ListInstalledPackagesResponseSchema` + and `GetInstalledPackageResponseSchema` are bound to the union. + + This is additive at runtime, and the runtime parse is where the gain is: every + payload that parsed before still parses, payloads that were refused for their + manifest stage now parse, and a row belonging to neither stage — an `objects` + array mixing globs with definitions — is still refused. `ManifestSchema` is + unchanged. + + The STATIC gain is one-sided, and smaller than a union normally implies. + `AssembledPackageBodySchema` is annotated `z.ZodType, …>` + in `stack.zod.ts` — deliberately, for the declaration-size reasons recorded + there, and untouched by this change — so the assembled branch carries no field + typing. Measured against the built `.d.ts`: a plain `.manifest.version` read off + one of these two response types now yields `unknown` where it used to yield + `string`; narrowing toward the AUTHORING branch restores the whole of + `ManifestSchema` (`version: string`, `objects: string[]`), while narrowing away + from it yields `Record` — every manifest field `unknown`. In the + assignment direction the assembled branch admits any object at `manifest`, so a + garbage manifest and the mixed-stage row named above both typecheck clean even + though the runtime union refuses both. So: narrow at the point of use for the + authoring stage, and treat an assembled manifest as a record the runtime — not + the compiler — has checked. + + `@objectstack/spec/api` also gains a `browser` export condition. Declaring the + assembled stage makes this entry's module graph reach the datasource + declaration and with it the driver-config validators, whose postgres URL + refinement links `pg-connection-string` — a package whose `parse` statically + resolves `require('fs')`, so a browser bundler that reaches it fails on + `Can't resolve 'fs'`. The entry now resolves, for browser consumers only, to a + build with the pg-grammar arm swapped for its dependency-free twin: exactly the + boundary the four entries that already carry the condition use. Node resolution + and the Node bundles are unchanged, byte for byte. For browser consumers the + postgres `url` refinement degrades to the shape-only checks it already performs + before `parse` — the unix-socket short-circuit and the refusal of the + filesystem-reading `?sslcert=` / `?sslkey=` / `?sslrootcert=` query parameters + are kept; the "is this a URL `pg` can open" arm answers "no findings". Datasource + publish is a server-side act, so that arm never legitimately ran in a browser. + + +- 07150b3: `PluginSchema.version` now accepts the whole of the SemVer 2.0.0 grammar, and `version` becomes the ninth declared key `kernel.use()` enforces. + + Two declarations in this repository disagreed about what a plugin `version` is, and the disagreement became load-bearing the moment the boot path started running the schema: + + | Declaration | Grammar | Accepted `1.0.0-alpha.1` / `1.0.0+20230101` | + |---|---|---| + | `PluginSchema.version` (`@objectstack/spec`, `kernel/plugin.zod.ts`), described `"Semantic Version"` | `/^\d+\.\d+\.\d+$/` | **no** | + | `PluginLoader.isValidSemanticVersion` (`@objectstack/core`), the check the boot path has always run | `/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/` | **yes** | + + SemVer 2.0.0 defines prerelease and build metadata as **parts of** a semantic version, so the key's own `describe()` — `"Semantic Version"`, no qualifier — claimed the wide grammar while its regex implemented a subset of it. The spec key was the one that was wrong, and it is the one that moved. + + **The spec adopts the loader's grammar character for character**, deliberately, rather than a third spelling: that is the check the boot path has always run, so the two declarations now converge exactly and nothing that loaded before is refused now. + + **`@objectstack/spec` — a WIDENING of a published contract.** `Plugin.json`'s `pattern` in the shipped `json-schema/` tree changes from `^\d+\.\d+\.\d+$` to `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`. This is a strict superset — same three-segment core, two **optional** suffix groups — so every string that validated before still validates. A tool that mirrors this schema to validate plugin manifests should widen with it; one that does not will merely keep refusing prerelease versions the platform accepts. + + **`@objectstack/core` — `version` joins the enforced set, which NARROWS `LiteKernel`.** **BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A plugin object `LiteKernel` accepted before can be refused now.** `assertPluginContract` filtered `version` issues out while the two spellings disagreed; that stopgap is gone. The full enforced set is now **NINE** keys, each refused with the offending key named in the message: + + - **`id`** — a non-string, or the empty string. + - **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`. + - **`staticPath`** — a non-string. + - **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`. + - **`default`** — a non-boolean. + - **`version`** — a non-string, or a string outside the SemVer grammar above. **New in this release.** + - **`description`** — a non-string. + - **`author`** — a non-string. + - **`homepage`** — a non-string, or a string that is not a URL. + + **`null` is refused on every one of the nine**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is still refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope. + + ⚠️ **This supersedes the eight-key enumeration published in `@objectstack/core@17.4.0`.** Both of that release's entries — the `kernel.use()` and the `LiteKernel.use()` enforcement notes — say the enforced set is eight keys and that `version` is excluded, and both point at reconciling the two `version` spellings as separate spec work. This is that work. Those entries stay as written, because they describe what 17.4.0 did; **nine is the current set**, and `version` is no longer excluded from anything. + + **What actually changes behaviour, stated narrowly.** On **`ObjectKernel`** nothing moves: `PluginLoader.validatePluginStructure` already judged `version` with this exact grammar and still runs first, so a malformed `version` is still refused as `Invalid semantic version`, never as `PLUGIN_CONTRACT_VIOLATION`. On **`LiteKernel`** a plugin object with a malformed `version` — `version: 'v1.0.0'`, say — was **registered** before and is **refused** now, with `PLUGIN_CONTRACT_VIOLATION` at `'version'`. `LiteKernel` has never run the loader's structural checks, so `version` was the one declared key it did not judge at all: such a plugin was green in vitest and refused by `ObjectKernel` at production boot. That is exactly the split the `LiteKernel` convergence closed for the other eight keys, closed now for the ninth. + + **What is unchanged.** `1.0.0-alpha.1`, `1.0.0+20230101` and `0.0.0-fixture` load on **both** kernels, as they did before — measured, not assumed, and pinned per kernel. A version-less plugin still loads; `version` is `.optional()`. Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the object that was passed in). A class-based plugin keeps its identity, prototype and prototype methods. + + ⚠️ **The accepted grammar is wider than SemVer 2.0.0 itself**, and this release neither introduced nor widened that fringe: leading zeroes in the numeric core (`01.1.1`) were accepted by **both** spellings before this change and are accepted by both after it, and the loader's prerelease/build classes admit degenerate identifiers SemVer forbids (`1.0.0-alpha..1`, `1.0.0-0123`, `1.0.0+.`). Tightening to the official SemVer regex would have **narrowed** this key rather than widening it, so it is deliberately not done here. + + **Migration.** Nothing to rename, and nothing to do if your plugin's `version` is a real semantic version. If you register plugins on `LiteKernel` with a `version` string that is not one — a leading `v`, a two-segment `1.0` — spell it `MAJOR.MINOR.PATCH` with optional `-prerelease` and `+build`, or drop the key. The refusal names the plugin and the key. + + +- fb2bccf: feat(spec): refuse unknown keys inside a rate-limit budget — `RateLimitConfigSchema` goes strict, so one declaration stops answering for two doors + + **BREAKING** accept-set narrowing on a published spec schema, landing after the + v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). + + Clause-②: no (narrowing) + + + + `ServerRateLimitConfigSchema` was declared + `strictObject({ … guidance: { keyBy, store } }, RateLimitConfigSchema.shape)` — + built from the OPEN schema's own shape object. One declaration therefore answered + for TWO emitted defs with opposite doors: `system/ServerRateLimitConfig` refused + an undeclared `keyBy` and handed back the prescription, while + `shared/RateLimitConfig` — the same shape, mounted bare on `apis[].rateLimit` — + accepted the key and dropped it in silence. Both guidance entries prescribed to + nobody there. A misspelled budget was the same story one key over: + `windowSeconds: 60` parsed green and metered the 60000 ms default, a + thousandfold miss on the one key whose job is to bound spend, reported as + success. + + **What is refused:** any key the budget does not declare, wherever it is mounted, + with a message naming the surface and the offending key. A near miss carries the + declared spelling (`window` / `windowSeconds` are answered with `windowMs`; + `max` / `maxRequest` / `limit` with `maxRequests`). `keyBy` and `store` keep + their wrong-layer prescriptions — the limiter's key is the resolved principal + falling back to the caller IP, and its counters live in the kernel `cache` + service (ADR-0069 D2) — and those two now reach the author on both mounts + instead of one. + + **What stays accepted:** every declared key, byte-identically, with the same + defaults. `server.security.rateLimit` keeps its two bounds checks + (`maxRequests > 0`, `windowMs > 0`) and answers exactly as before. The published + JSON Schema, the authorable surface and the API surface are all unchanged — + `check:authorable-surface`, `check:api-surface` and `check:docs` pass with no + regeneration, because in `io: 'output'` zod already emitted + `additionalProperties: false` for the stripping shape too. + + **Breaking for metadata that was already silently broken.** An `apis[].rateLimit` + carrying an undeclared key now fails `objectstack validate`, `objectstack build` + and the metadata write path instead of publishing with the key discarded. + Measured blast radius before landing: every shipped `rateLimit` block writes + only declared keys — three in `content/docs/`, one in `skills/objectstack-api`, + and none at all in `examples/`, the `os init` templates or the + `create-objectstack` blank template, which declare no budget. +- d2badf7: feat(spec): a repeater's property-panel table has column NAMES, and an untitled item schema is now loud (#17232) + + ## What was wrong + + Studio renders a `type: 'repeater'` form field as a table whose column headers + read `items.properties[k].title ?? k` off the JSON Schema served by + `GET /meta/types` — derived by `packages/metadata-protocol`'s `toJsonSchemaSafe`, + i.e. `z.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any' })`. + The bundle overlay `resolveMetadataFormSchemaTitles` (#16458 / PR #17227) only + replaces a title that is already there, so an item schema carrying no + `.meta({ title })` falls through to the raw machine key — in **every** locale, + English included. The maker read `actionUrl`, `defaultCollapsed`, `dateGranularity` + inside an otherwise fully translated panel. This is a missing authoring label in + the contract, not a translation gap. + + PR #17227 titled exactly one repeater, `dashboard.header.actions`, and was scoped + by dispatch to that one. **The class stayed silent**: the next repeater to land + would reproduce the defect with every gate green. + + ## Measured on `origin/main` at `e758131b39` + + 22 repeater fields are declared across 11 `*.form.ts` files. Derived through the + platform's own predicate rather than a source regex: + + - **1** was fully titled — `dashboard.header.actions`, PR #17227's instance. + - **1** has no object row shape at all — `action.locations` is an array of enum + STRINGS, so it renders no column headers and leaks no key. It is **not** a + carrier, which is why the class is **20** untitled tables today and not the 21 + the card premised. + - **20** were untitled. + + ## What changed + + **Thirteen carriers are now titled** — every row property of `action.params`, + `app.areas`, `dataset.dimensions`, `dataset.measures`, `flow.nodes`, + `flow.edges`, `flow.variables`, `page.variables`, `page.regions`, + `page.interfaceConfig.sort`, `report.order`, `report.blocks` and + `skill.triggerConditions` carries a `.meta({ title })`. `page.interfaceConfig.sort` + is titled through the shared `SortItemSchema` it composes. + + **The silence is closed.** `packages/spec/src/kernel/repeater-item-titles.test.ts` + enumerates every repeater declared across every `*.form.ts` in the package, + derives each row schema through `z.toJSONSchema`, and requires a title on every + authorable row property. Carriers still owed one sit in an EXACT, shrink-only + ledger: a repeater absent from the ledger must be fully titled, and a ledger + entry whose debt has been paid must be deleted. A new repeater is therefore red + on the day it lands, and the ledger can only shrink. + + Two exclusions the pin makes deliberately, each with its own control: + + - a `retiredKey()` tombstone is a parse-time refusal, not an authorable column + (`flow.nodes[].outputSchema`); + - a scalar-item repeater has no row properties to name (`action.locations`), + and is pinned by name so an object-shaped one cannot land there silently. + + ## What is still owed, and why + + Seven carriers remain on the ledger because their item schemas live in files held + by other in-flight PRs at the time of writing — `dashboard.widgets` and + `dashboard.globalFilters` (`ui/dashboard.zod.ts`), `view.columns` / `view.sort` / + `view.tabs` (`ui/view.zod.ts`), and `field.options` + `object.fields.options` + (the one `SelectOptionSchema` in `data/field.zod.ts`). The pin OBSERVES them + without editing them, so the ledger states the whole class rather than the slice + one PR could reach. + + Localisation is additive and unchanged by this round. `.meta({ title })` is the + English authoring layer by contract — `translation.zod.ts` states it in those + words — and a bundle's `metadataForms..fields...label` + overlays it per locale. No form file here enumerates repeater children, so + `os i18n extract` emits no new catalog keys and no catalog moves. Until those + leaves are authored, a non-English panel shows the English title rather than the + machine key — strictly better than today, and the localisation layer is still owed. +- d64bcb6: **BREAKING** — retire the `adr-0030-notification-event` data migration. + + `migrateSysNotificationToEvent` had no way to be run: zero production callers + anywhere in the repo, and no `os migrate` sub-command, while the two sibling + members of `CREATION_ATTESTED_MIGRATION_IDS` had both. The runner, its barrel + export, its tests, the ruled `sys_migration` receipt-claim matrix, that matrix's + pin, and the id's membership in `CREATION_ATTESTED_MIGRATION_IDS` are removed + together. Pre-ADR-0030 `sys_notification` rows are not carried by the platform + on this line. + + ## What is gone, and what an upgrader does about it + + ⭐ **Nothing is renamed and nothing replaces it**, so there is no new spelling to + adopt — every item below is a deletion, and the fix is to stop using it. + + - `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) — deleted. + No replacement exists, and none is coming: an `os migrate notification-event` + sub-command was considered and refused. Delete the call. The compiler delivers + this one: the import fails to resolve. + - `SysNotificationMigrationResult`, `SysNotificationMigrationOptions` and + `SysNotificationMigrationReceipt` (same entry point) — deleted with it. They + described that runner's own result, options and receipt and nothing else. + - `CREATION_ATTESTED_MIGRATION_IDS` (`@objectstack/spec/system`) — was a + three-member tuple and is now a two-member one holding + `'adr-0104-file-references'` and `'adr-0104-value-shapes'`. Both ADR-0104 ids + keep their sub-commands, their receipt rows and their birth attestation; only + the notification id left. Code typed against + `(typeof CREATION_ATTESTED_MIGRATION_IDS)[number]` that names the notification + id no longer compiles — delete that arm. + + `NOTIFICATION_EVENT_MIGRATION_ID` (`@objectstack/spec/system`) is **kept**. A + deployment attested at birth, or one that made the operator call while the runner + shipped, still holds a `sys_migration` row keyed `'adr-0030-notification-event'`, + and the constant is that row's name. Nothing writes or reads a row under it any + more — `attestFreshDatastore` no longer includes it — and it is not a + registration: it gates nothing and never did. + + ## Reversal path + + Two answers were considered and both refused: an `os migrate notification-event` + sub-command is a permanent operator surface for a migration with no measured + demand, and a boot-time invoker is an unattended data rewrite nobody asked for. + ⚠️ Nobody has measured whether any live deployment carries pre-ADR-0030 + `sys_notification` rows. If a **named** deployment turns out to hold rows it + needs, the migration returns as an operator-runnable sub-command shaped exactly + like `files-to-references` / `value-shapes` — dry-run default, `--apply` gate, + documented consequence — under its own card. + + +- d4f5232: **BREAKING** — retire the `type: 'page'` list-view mount and its `pageName` binding. + + A list view could declare `type: 'page'` and name a published page in `pageName`, + and the view was to render nothing of its own and delegate to the page renderer. + Only the spec half of that was ever built. **No renderer ever routed the member**: + objectui's list-view switch shares its `default:` arm with `case 'grid'`, so a page + view has always drawn an empty table where the page was supposed to be, and the + three parse refusals that policed the binding policed a mount that never mounted + anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `{ type: 'page', pageName: 'sales_home', columns: [] }` on a list view | nothing on the view. Delete it, and reach the page from the app's `navigation`: `{ id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }` | + | `pageName` beside any other list-view `type` | delete the key — it was refused already, and is now a tombstone | + | a list view that wanted rows | pick a row-drawing `type` — `grid` and its siblings, all unchanged | + + **The one-line fix:** delete `type: 'page'` and `pageName` from the list view; put + the page behind an app navigation item, which is a different key on a different + surface (`PageNavItem.pageName`) and is the page mount that has always rendered. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply + them by hand. + + ## The retirement kit + + - **`pageName`** — a `retiredKey()` tombstone on `ListViewSchema` and + `ObjectListViewSchema`. `tsc` types the key `never`, and a value reaching a parse + raises the prescription rather than a bare unrecognized-key report. + - **`'page'`** — an enum VALUE, so there is no tombstone to hang a prescription on + (the def survives, one value lighter, and the four generated-surface ratchets are + blind to that by construction). The `type` enum's own `error` map carries it, + keyed on `issue.input` so only the value that used to be legal gets the + "was removed" message; every other invalid `type` keeps zod's default text. + - **`checkListViewPageMount`** — the exported object-level refinement existed only + to police this mount, so it is removed with it, along with its three refusal + messages. A downstream mirror that re-attached it (the reason it was exported) + should drop the `.superRefine` line; the compiler delivers this one. It held no + `ERROR_CODE_LEDGER` row — the three refusals were message constants, not codes. + - **`validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`** (`@objectstack/lint`) — the + `os validate` and publish-gate rule that resolved a mount against `stack.pages`. + Removed: there is no reference left to resolve. Its nav twin + (`validateNavTargetRefs`, on the app navigation item) is **untouched**. + - **`RuntimeStackContext.pages`** (`@objectstack/lint`) and the `page` row of + `CLOSURE_CONTEXT_KEY_BY_TYPE` (`@objectstack/metadata-protocol`) — the live page + universe joined the per-write snapshot for that one rule, and leaves with it. A + `PUT /api/v1/meta/view` publish no longer pays a `sys_metadata` round trip for a + collection nothing consults. Hosts calling `runRuntimeAuthoringRules` / + `evaluateRuntimeAuthoringGate` with an explicit `context.pages` drop that key. + - **`defineStack`** — the `validateCrossReferences` branch that resolved a mount's + `pageName` against `stack.pages` is gone. The surviving three page references in + that function (an app nav item's `pageName`, a modal action's `target` at two + rungs) keep their own policy. + - **The metadata form** — `view.form.ts`'s `page` section, whose one input was + `pageName`, is removed. A form input for an unwritable key is the false-compliant + UI half of a retirement. + + ## What an operator with a STORED page view sees + + A `sys_metadata` `view` row written before this release can carry `type: 'page'` and + a `pageName`. Nothing breaks at read: the ADR-0087 conversion + `view-page-mount-removed` (protocol 18) replays on rehydration and strips both keys, + so the row is served canonical. `type` is **stripped, not rewritten** — it defaults + to `grid` in the schema, so the row lands on exactly what it already rendered + without the platform guessing a view type. + + The strip is announced once per row per process, on whichever seam served it. + Grep for `carries a pre-protocol shape` — there are **three** emitters, one per + rehydration seam, and they differ: + + - `[DatabaseLoader] stored view/ carries a pre-protocol shape; ` + - `[ObjectQLPlugin] stored view/ carries a pre-protocol shape; ` + - `[Protocol] stored view/ carries a pre-protocol shape; The row + itself is unchanged — re-save it (Studio edit -> save, or run + "os migrate meta --stored --apply") to persist the canonical shape.` + + `os migrate meta --from 17` lists the same edits for authored sources; + `os migrate meta --stored --apply` rewrites the stored rows so the warn stops, and + the next save through `PUT /api/v1/meta/view` heals one row the way it heals any + pre-protocol shape. + + ⚠️ The conversion walks `stack.views[]` in all three persisted spellings; it does + **not** reach `objects[].listViews.*`, which no conversion in the registry reaches. + An object body still carrying a page mount is refused at its own door with the + prescription rather than converted. Measured population for both at the ruling: + **zero** authored `type: 'page'` list views in this repository or any consuming app + the seats can read — the in-tree `type: 'page'` hits are all app nav items. + + +- ecdfc94: fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization behind a tenancy wall, and both its query and its run are confined to it (#16659, narrowed by #17396) + + + + > ⚠️ **Read this banner with #17396's ruling applied — it NARROWS everything below, and the narrowing shipped in the same launch window, so no released version ever saw the wider rule.** Two deployment facts now sit in front of every statement here, and neither is metadata: (1) package-authored scheduled work is gated by `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` and is **OFF by default in every tenancy posture and every kernel** — while it is off NOTHING below happens, because nothing arms; (2) with it on, the declaration requirement below applies under a **walled** posture (`group` / `isolated`) only. Under `single` an armed time-triggered flow declares nothing, carries no organization, and resolves the deployment's one organization beneath it exactly as it did before #16659. ⇒ Wherever this banner says "a time-triggered flow MUST declare", read "under a wall, with scheduled work switched on". The lint finding it announces, `flow-schedule-organization-missing`, is **deleted**: lint can see neither fact. + + **Registered as an ADR-0087 semantic migration** + (`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable + is renamed, retired or re-typed — no `packages/spec` key changes its name, its + type or its optionality, no stored shape moves, and every flow, node and + start-node `config` that parses today parses byte-identically afterwards, + because the start node's `config` is an OPEN record (ADR-0018) and the new + `organization` key is an addition to a slot that already accepted anything. So + `objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a + value only the deployment holds, a `sys_organization.id` minted at runtime, with + no authored artifact and no stored representation a rewrite could act on — and + inventing one is precisely what the ruling forbids. ⚠️ That is the argument + against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a + migration that cannot be expressed declaratively gets a structured TODO + (surface, reason, acceptance criteria) rather than nothing, and what follows IS + a prescription in that sense — declare `config.organization` once per + organization, no fan-out, then act on the three consequences of the split named + below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — + behaviour-only, no shape moved, a deployment judgement no transform can make, + registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before + this narrowing landed, so the enforcement rides the 17.x line by the + launch-window convention while the prescription belongs at the major boundary + where `migrate meta` users look. + + **BREAKING** in the accept-set sense, and in TWO places rather than one — + landing in the launch window as `minor` on all four packages (the lockstep + convention: during the window the bump level is not the carrier, this banner and + the disposition above are). Nothing that was refused becomes admitted. ⚠️ #17396 + changes that last sentence in one direction: under `single` with the switch on, + a flow that this changeset would have left unarmed **binds and runs**. That is a + widening, it lands in the same window, and it is why #17396's own changeset is + also a `minor`. + + 1. **Bind time.** A `schedule` or `time_relative` flow that declares no + `organization` is no longer armed. + 2. **Run time — the DATA PLANE.** A time-triggered run now carries a + `tenantId`, and a `time_relative` sweep now carries one on its own query. + Where a run previously read, updated and deleted across every organization, + it is now confined to the one it declares. + + ⚠️ **Read (2) as a narrowing that can stop something that was working**, because + it is one. Two shapes to plan for, and neither is hypothetical: + + - **A deployment running ONE time-triggered flow to cover ALL organizations must + now declare one flow per organization.** That is the ruling + (「不允许跨组织的定时任务」) and it is the whole point, but it is migration + work: there is no fan-out, and a sweep wanted in N organizations is N + declarations. Nothing detects the shape for you — the flow simply starts + seeing one organization's rows. + + ⚠️ **And the split has three effects the sentence above does not carry.** Each + is deployment work, and none of them is detected for you either: + + 1. **A NULL-organization row fans out N-fold.** The driver's scope is + `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no + tenant column value stays visible to a *scoped* read — this PR's own + negative control fixture selects exactly that row under scope, on purpose. + After the split every `organization_id IS NULL` row in a swept object is + therefore matched **once per flow**: N runs, N notifications, each acting + as a different organization. Before the split it was matched once. ⇒ Either + backfill the tenant column on swept objects or declare the object + platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the + scope rather than multiplying under it. + 2. **The current window's dispatch claims are abandoned.** The dedup key + embeds the FLOW NAME — `schedule::` and + `time-relative:::` — so N differently-named + flows claim under N different keys. A window already delivered under the + old name can deliver again, once, under each new one. ⇒ Cut over at a + window boundary, or accept one duplicate window. + 3. **A run suspended before the upgrade is not retroactively confined.** + Resume rebuilds the run's context from `context_json` + (`suspended-run-store.ts`), and a row written before this change carries no + `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills + it. Not a regression (that is how it already ran), but the banner would + otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight + suspended time-triggered runs, or accept that the tail of them is + unconfined. + - **On a SINGLE-organization install a time-triggered flow WAS delivering** — + the #8844 guard derives the only organization there — and after this change it + is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` + that install loses nothing at run time once the line is added: the scope is + `org = :tenant OR org IS NULL` and its one organization is the only scope there + was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss + has no legal configuration.** That driver refuses *any* call handed a tenant + scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) + — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / + `bulk*` / `aggregate`, one call at a time, regardless of how many + organizations the install holds. So a time-triggered flow that touches + per-organization data on that driver is refused per call if it declares an + organization and unarmed at boot if it does not. The declaration is not what + breaks it — the driver has no row-level tenant isolation to offer either way — + but this change is what moves such a flow from the "no organization context at + all → served" case into the refused one. Multi-organization deployments use + `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are + genuinely platform-global can declare them so (`tenancy: { enabled: false }`, + ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the + refusal on data that really is per-organization. + + A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. + + Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 + + A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. + + - **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. + - **`@objectstack/lint`** ⚠️ **nothing, after #17396.** This changeset originally added `flow-schedule-organization-missing` at `warning`; that id is deleted in the same window and was never published. The reason is the rule family's own criterion — *is this stack enough to know the flow is dead?* — answered honestly: it is not, because the deployment switch and the tenancy posture decide it and neither is in any stack. The near-miss diagnostic it shared with the triggers stays at BIND, where both facts are readable. + - **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. + - **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. + + **What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. + + ⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. + + **Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. + + No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. + + **What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: + + - **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. + - **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. + + **The four flows this repo itself ships** — ⚠️ this paragraph is superseded by #17396 and kept for the record of what was measured. Their answer is now the deployment switch, not an authoring repair: off, they are listed as *disabled by deployment policy*; on under `single`, they run as written; on under a wall, they still need a declaration no package can carry. The original measurement follows. + + **They stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. +- f04be62: feat(types,triggers,service-automation,runtime,cli,spec,lint)!: package-authored scheduled work is a deployment decision — `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, off by default everywhere (#17396) + + + + Maintainer ruling, 2026-09-12, verbatim, untranslated: + + > schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,有没有可能定义为一个环境变量,根据环境变量控制? + + > 如果多租户暂时只接禁用定时任务,完整的考虑一下影响面。 + + > group 默认也关,云端每库一租户全局默认关 + + **A new deployment variable, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, decides whether this deployment runs PACKAGE-AUTHORED scheduled work at all** — time-triggered flows (`type: 'schedule'` with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. It is read at boot beside `resolveTenancyPosture` and is ⛔ **not** a metadata concept and ⛔ **not** a new spec key: whether a clock-driven workload is affordable is a fact about the deployment — its database, its tenants, its budget — that no package author can know, and a metadata key would ask them to. + + **OFF by default, in every posture and in every kernel.** Unset means off; `true` / `1` / `on` / `yes` (case-insensitive) means on. ⛔ Deliberately not the opt-out `!== 'false'` shape `OS_MULTI_ORG_ENABLED` uses, which reads a typo as "on" — here that would arm exactly the workload an operator meant to refuse. + + ⛔ **Platform-internal scheduled work is NOT gated** and runs either way: approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill. The boundary is **authored by a package**, not "runs on the job service" — the platform's own maintenance is part of the runtime a deployment asked for. + + **BREAKING**, in two directions, and both land inside the same launch window as #16659 / PR #17334, so no published version ever saw the rule this narrows. + + 1. **A NARROWING, and it is the one to plan for.** A deployment that upgrades and does nothing runs **no** packaged time-triggered flow and **no** packaged `defineJob`. Anything that was firing from a package stops. ⇒ Set `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` if you depend on it. Nothing detects the shape for you at authoring time, by design — but nothing is silent either: every such flow is listed in `getTriggerBindingAudit()` and the `os dev` / `os start` startup summary with a DISTINCT reason, **disabled by deployment policy**, ⛔ never as "binding failed"; the packaged-job loop says so once per app at `info` with the count; and `os doctor` prints the effective value in both states. + 2. **A WIDENING of what binds.** With the switch on and tenancy posture `single`, a time-triggered flow that declares **no** `config.organization` now binds and runs — under #16659 alone it was refused. That posture holds exactly one organization (a second is refused), so the run carries **no** organization and every tenant-scoped insert beneath it resolves that one the way a single-organization install always did; a `timeRelative` sweep there runs **unscoped**. ⛔ Nothing is invented: the key is OMITTED, never filled from the install, the platform organization, or the swept record's own `organization_id`. + + **Under a walled posture (`group` / `isolated`) the 2026-09-08 ruling on #16659 stands unchanged**: a time-triggered flow declares `config.organization` or it is not armed, there is no fan-out, and no organization is ever chosen for it. `group` is walled here for a measured reason rather than by analogy — `resolveSystemWriteOrganization` refuses an organization-less system insert under any wall and `TenancyService.defaultOrgId()` answers `null` (ADR-0093 D3), so an organization-less group-wide sweep could read the whole group while every row it inserts is refused. Which organization such a sweep's inserts belong to is not yet decided; until it is, `group` behaves as walled. + + **`flow-schedule-organization-missing` is DELETED** from `@objectstack/lint` (the id and its exported constant, `FLOW_SCHEDULE_ORGANIZATION_MISSING`; both are unreleased — they were introduced by the still-unconsumed #16659 changeset in this same window, so no consumer can be holding either). The rule family's criterion is *is this stack enough to know the flow is dead?*, and the honest answer here is no: the deployment switch and the tenancy posture decide it, and neither is in any stack. A finding that is false for the default deployment is noise. ⛔ The near-miss diagnostic did **not** go with it — `describeMissingScheduleOrganization` and its `organizationId` / `tenantId` / … scan still fire at BIND, the one door that can read both facts, and only where the key is actually required. + + **ADR-0087 semantic entry 18 (`schedule-flow-acting-organization-required`) is REWRITTEN, not added.** Its acceptance criteria required every time-triggered flow to declare; that is no longer the rule. It now prescribes the two decisions in order — decide the switch, then declare per organization under a wall — and records that `os lint` reporting nothing is the criterion being met rather than a check that was skipped. The unconsumed `.changeset/schedule-trigger-acting-organization.md` carries a banner saying the same, so a reader of either one cannot get the narrower half alone. + + **Where the switch is read, and where it is not.** Both triggers gate at `start()`, ahead of the descriptor and the declaration, so an operator on a deployment that was never going to run a flow is not sent to fix a descriptor nothing would have read. `AutomationEngine.activateFlowTrigger` reads the same resolver and does not call `start()` at all when it is off — that is what keeps the audit's reason precise, since a refusal arriving as a THROW can only be reported through the catch that says "Failed to bind". Neither read is cached: the resolver reads `process.env` live, so a host that rebinds after the environment changes sees the value current at the bind. The scope is `schedule` and `time_relative` only — `record_change` and `api` are fired by a caller that already exists and already carries an identity, and a kind added to `FlowTriggerKind` later is OUTSIDE the switch until someone decides otherwise, because a new capability that disappears on arrival is the worse default. +- 3b1dab9: Declare `continueRestoredRun` on the `IApprovalService` contract, so the approvals half of the operator repair pair is reachable through the published interface rather than only off the implementation class. + + `IAutomationService.restoreConsumedSuspension` re-arms the pause a failed resume consumed and, by its own contract, does not replay the resume signal — the continuation must be re-issued. For an approval suspension nothing could re-issue it: every front door guards on a live request — `pending` for decide and send-back, `returned` for resubmit, and `pending` or the revise window for recall — and the stranding call leaves the row where none of them can issue the continuation it owes. The issuer landed as a class member on `plugin-approvals`; this declares it, so a caller programs against the contract instead of importing the implementation. + + Additive and OPTIONAL, the way `cancelRun` / `restoreConsumedSuspension` are declared on `IAutomationService`: an existing implementation still conforms, and a service that does not declare the member has no operator door for it — a caller must probe for presence and refuse fail-closed rather than answer success for a verb it could not dispatch, because promising a repair verb that will refuse is worse than promising nothing. No REST or CLI route is declared or implied. +- 7607076: `CLOUD_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system`) gains a member: + `sys_environment_credential`. `isPlatformProvidedObjectName('sys_environment_credential')` + now returns `true`, so a reference to that name resolves instead of being + diagnosed as a platform-prefixed name nothing registers (#18309). + + This widens an accept set. The list is a closed set and the name was not in it, + so the object-reference ladder now accepts a value it used to warn on, and the + widening reaches every surface that consults the predicate: a dataset `object`, + an action parameter `reference`, a field `reference`, a dashboard + `optionsFrom.object`, a navigation `requiresObject` and a translation + `objects.` subtree naming `sys_environment_credential` all stop being + diagnosed. + + Why this name: as read in the cloud repository at `cb8ee7ff60`, + `@objectstack/service-tenant` registers it on exactly the path the list's + existing `sys_package`, `sys_package_version` and `sys_package_installation` + members take — `objects/sys-environment-credential.object.ts` exported through + `objects/index.ts`, listed in `tenantObjects`, spread into + `manifestService.register({ objects })` by `tenant-plugin.ts`. That reading is + the cloud repository's and is carried here on its filer's name; per this list's + header it cannot be conformance-tested from this repo, and this change does not + claim to have re-taken it. + + Unlike the earlier additions, this one fixes no diagnostic that fires today: no + `*.object.ts` in this repository references the name, so nothing shipped was + being mis-diagnosed. What was wrong is the registry's own claim about the name. + This repository's governed records already treat the object as real — ADR-0007's + inventory table lists it as existing, and ADR-0131 cites a measured cross-tenant + read of its rows — while the list that decides whether a reference resolves said + no package registers it. The first author to write the reference would have been + told it looked like a typo. + + One entry is added; no other member moves and nothing is removed or narrowed. + The cloud-side half of the contract — that `@objectstack/service-tenant` + registers the table — is owned by the cloud repository per the list's header and + is not asserted from here. +- 1555ed4: `CLOUD_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system`) gains a member: + `sys_package_version`. `isPlatformProvidedObjectName('sys_package_version')` now + returns `true`, so a reference to that name resolves instead of being flagged as + a platform-prefixed name nothing registers (#16745). + + This widens an accept set. The name was previously refused, the list is a closed + set, and nothing in the published header enumerated this member — so the ladder + now accepts a value it used to warn on, and the widening reaches every surface + that consults the predicate: a dataset `object`, an action parameter + `reference`, a dashboard `optionsFrom.object` and a navigation `requiresObject` + naming `sys_package_version` all stop being diagnosed. + + Why this name and not another: the list already carried `sys_package` and + `sys_package_installation` — the head and tail of the three-table package family + that `cloud/package.zod.ts` declares — but not the release-snapshot table + between them, whose row schema this repository ships as + `cloud/package-version.zod.ts`. Platform metadata that ships with the product + references it: `sys_metadata.package_version_id` in `@objectstack/metadata-core` + is a `Field.lookup('sys_package_version', …)`. + + One entry is added; no other member moves and nothing is removed or narrowed. + The cloud-side half of the contract — that `@objectstack/service-tenant` + registers the table — is owned by the cloud repository per the list's header and + is not asserted from here. +- 776d64c: feat(spec)!: the `@objectstack/spec/cloud` subpath is removed — the cloud control plane's contracts leave the open-source spec, and the package & marketplace format moves to `@objectstack/spec/marketplace` (#16325) + + + + **BREAKING** — a published subpath export of `@objectstack/spec` is deleted, with no + alias and no deprecation window (maintainer, 2026-08-27, verbatim: 「项目在创业阶段, + 用户也很少,短期不考虑渐进。」). Shipped as `minor` under the repo's launch-window + convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness + is carried by this banner plus the ADR-0087 disposition; the hand-migration prescription + is registered under protocol major 18 as `cloud-subpath-retired`. + + ## What moved, and why + + Maintainer direction (2026-09-06, verbatim): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」, + ruled option B "cut by owner" on #16325 (director batch #62, 2026-09-07, 「同意」). + `packages/spec/src/cloud/` held two families with different owners: + + - **The cloud control plane's own contracts** — `environment.zod`, `environment-package.zod`, + `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` (62 JSON-Schema + defs, 2087 lines). Their producer and every consumer live in the closed cloud repo; the + open-source tree read exactly one type from them. They are gone from `@objectstack/spec`: + `environment` and `tenant` are re-declared in the cloud repo (objectstack-ai/cloud#2037), and + the other four are deleted outright — zero consumers in any repo (#16526, ruled A). All of it + is recoverable from git history at `d5d8d50db`. + - **The package & marketplace format** — `package.zod`, `package-version.zod`, `marketplace.zod`, + `package-l10n`, `template-manifest.zod` (30 defs, 1400 lines). A package author needs it and the + open-source CLI's `os package publish` speaks it, so it STAYS, relocated to `src/marketplace/` + and published as `@objectstack/spec/marketplace`. Every def, key and JSON Schema is + byte-identical under the new `$id` category (`RENAMED_DEFS`, 32 entries; nothing left the + author-facing contract). + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `import { PackageSchema, CreatePackageRequestSchema, … } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/marketplace'` — same symbols, same shapes | + | `import { EnvironmentArtifactSchema } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/system'` (it was only ever a re-export of that declaration) | + | `import type { EnvironmentType } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/api'` (re-declared beside the discovery fold table that reads it) | + | `import { EnvironmentSchema, TenantPlanSchema, ProvisionEnvironmentRequestSchema, … } from '@objectstack/spec/cloud'` | no open-source replacement — these are the cloud repo's own declarations now | + | `/docs/references/cloud/` | `/docs/references/marketplace/` for the format pages (redirected); the control-plane pages have no successor | + + Why the mis-binding hazard closes with this: `client.environments.*` keeps its erased `any` + deliberately (#11925/#12036), and the camelCase `Environment` row used to be the obvious-looking + binding for it — it compiled and read `undefined` at runtime against the snake_case wire. That + type no longer exists in the open-source package, so the wrong binding is structurally + impossible rather than warned about in a docblock. + + `@objectstack/cli` and `@objectstack/metadata` change only an import path (`marketplace` and + `system` respectively); no behaviour moves. +- 9bd4344: feat(auth)!: adopt better-auth's account-issuer rollback — drop `sys_account.issuer`, retire the backfill, lift the `@better-auth/*` family to an exact `1.7.3` (#17440) + + + + **BREAKING** — a platform object drops a declared field and `@objectstack/plugin-auth` + drops six published symbols. Shipped as `minor` under the launch-window convention + (`major` is refused by `check-changeset-no-major`; breaking-ness is carried by this + banner plus the ADR-0087 disposition above). The hand-migration prescription is + registered under protocol major 18 as `sys-account-issuer-retired`. + + better-auth `1.7.3` removed the issuer-scoped account identity outright + (`better-auth/better-auth#10909`): `createLocalAccountIssuer` is deleted, + `accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the + `account.issuer` column and its unique index are gone from `get-tables`. There is no + drop-in replacement. `#16186` pinned the family at an exact `1.7.2` as a stopgap; this is + the durable half, per the maintainer ruling of 2026-09-10 on `#16629`. + + ## 迁移:FROM → TO + + | FROM | TO | the one-line fix | + |:--|:--|:--| + | `sys_account.issuer` (column + `{ fields: ['issuer','account_id'], unique: true }`) | — | nothing replaces it; identity is `(provider_id, account_id)`, declared UNIQUE on `sys_account` since the object was created | + | reading `account.issuer` off a row or off `client.accounts.list()` | `sys_sso_provider.issuer`, resolved through the account's `provider_id` | `provider_id` is unique per environment, so it names the authority on its own | + | `backfillAccountIssuer(ql, …)` | — | delete the call; there is no successor pass | + | `CREDENTIAL_ISSUER` / `oauthIssuerFor(id)` | — | drop the argument; `internalAdapter.createAccount({ userId, providerId, accountId, password })` takes no `issuer` | + | `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult` | — | delete the import; the compiler names every site | + | `@better-auth/*` at an exact `1.7.2` (eleven members) | an exact `1.7.3` (eleven members) | the family moves as ONE line — `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions | + + ## ⭐ Existing deployments: run the pre-flight BEFORE the column is dropped + + Uniqueness moves from `(issuer, account_id)` to `(provider_id, account_id)` — a + **narrower** key. Two rows sharing `provider_id` + `account_id` and differing only in + `issuer` are legal under the old key and are ONE account under the new one. + + ``` + os migrate account-issuer # read-only; exits non-zero when the drop must not proceed + # … take a backup (the operator's act, and the apply step's precondition) … + os migrate apply --allow-destructive + os migrate account-issuer # post-check: reads zero + ``` + + The pre-flight reads **rows**, never the index declaration. `syncDeclaredIndexes` logs a + plain UNIQUE whose CREATE failed on existing duplicates onto the durability channel and + lets the boot continue (`#14902` / `#15479`), so a database can carry the declaration + without the constraint — and on such a database the drop does not fail loudly, it + degrades silently: the rows become indistinguishable and a sign-in can resolve onto the + wrong user's account. `os migrate apply --allow-destructive` re-runs the same pre-flight + and refuses the drop before writing any DDL. A read that throws, or a scan that + truncates, refuses too — an unread table is not a clean one. + + ⛔ Colliding rows are never merged or dropped for you: which row survives is application + knowledge, and two different people can be behind one colliding key. Keep the row whose + provider account is live, delete the rest so a fresh sign-in re-links, and re-run. + + The boot refusal is unchanged and needs no new machinery: a runtime already refuses to + start against unapplied destructive drift, naming the command to run, and never + auto-migrates. + + ## ⚠️ A `provider_id` re-pointed at a different IdP must have its bindings REBUILT + + This is the one case `issuer` still discriminated. After the drop no column records which + IdP vouched for a row, so if a re-pointed provider's new IdP mints a subject the old one + had already issued to somebody else, the key resolves that sign-in onto the other + person's account. Under the old key that failed loudly (`unable_to_link_account`); under + the new one it is silent. + + ⇒ `sys_sso_provider` now **refuses an `issuer` change while `sys_account` rows are still + bound to that `provider_id`** (`RESOURCE_CONFLICT` / 409). Delete the provider's account + bindings first; each user re-links on their next sign-in. + + ## Why the column was a liability, not an asset + + A credential row whose `issuer` was not the local credential issuer was invisible to + `findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not + found" warn pointing at the `sys_user` row rather than at the account. **Four checklist + items had that recorded as a knownGap, each rediscovering it.** Its discriminating power + here was near zero anyway: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: + true }`, so `provider_id → issuer` is a function within an environment. + + ## Also in this change + + `pnpm check:vendor-export-contract` (from `#16186`) keeps its exactness requirement and + still resolves every named symbol — its self-test re-anchors from the now-retired + `@better-auth/core/db` specimen onto a live edge, and gains a case asserting the two + deleted names are imported nowhere. `#11627`'s hash-shadow-key machinery is untouched: it + is a generic driver capability serving five UNIQUE members of the >768-char class. +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. +- 9c44eed: fix(spec)!: `TimeUpdateInterval` retires its three sub-day intervals and derives its members from `DateGranularity` (#17296) + + + + ## ADR-0087 disposition + + `second`, `minute` and `hour` leave a published closed enum that reaches TWO authored sites: an analytics request body's `timeDimensions[].granularity`, and an analytics cube dimension's `granularities[]`, which is stored metadata (`defineCube()` / `defineStack({ analyticsCubes })`). The stored half is rewritten by the D2 conversion `cube-sub-day-granularities-removed`, which strips the retired members from `analyticsCubes[].dimensions..granularities` and drops the key entirely when nothing coarser remains (an empty list would read as "offers none", the absent key as "offers all"). The semantic entry `time-update-interval-sub-day-retired` carries the half no transform can decide: a dimension that offered ONLY sub-day intervals needs an author to say what it actually serves. `day`, `week`, `month`, `quarter` and `year` are untouched and parse byte-identically. + + **BREAKING** for anyone authoring or sending `granularity: 'second'`, + `'minute'` or `'hour'`, and for anyone importing the `TimeUpdateInterval` + TYPE. Landing in the + launch window as `minor` under the lockstep convention this cluster's siblings + already use. + + ## What was wrong + + `TimeUpdateInterval` declared **eight** intervals. The rest of the contract + never carried three of them, and this is the measurement rather than the + argument: + + | layer | declares | + |:---|:---| + | `TimeUpdateInterval` (`data/analytics.zod.ts`) | **8** — the five below plus `second`, `minute`, `hour` | + | `DateGranularity` (`data/query.zod.ts`) — what a `groupBy` entry and every driver bucket expression are typed by | 5 | + | `@objectstack/core`'s `BUCKET_GRANULARITIES` — the canonical bucket-KEY output contract a drill-down crosses | 5 | + | `driver-mongodb`'s `MONGODB_DATE_GRANULARITIES` | 5 | + + `DriverCapabilitiesSchema.supports.queryDateGranularity` — the one mechanism a + backend has for saying which granularities it buckets natively — is a + `z.record(DateGranularity, boolean)`. Measured: `{ day, week, month, quarter, + year }` parses; the same record plus `hour` raises `unrecognized_keys: ["hour"]`. + **No driver could advertise sub-day bucketing even if it had one.** That is what + makes this a retirement rather than a capability gap: a declared value one + backend cannot serve is a gap and the contract has a place to say so, but a + declared value *no* backend can even claim has no counterpart anywhere in the + contract that carries it. + + Driven against the built packages, two rows fourteen hours apart on one UTC + calendar day, before this change: + + | face | `granularity: 'hour'` | `granularity: 'day'` (control) | + |:---|:---|:---| + | `driver-memory` analytics | `NOT_IMPLEMENTED` / 501 | 1 group, `2026-09-06` | + | `driver-mongodb` bucket builder | `NOT_IMPLEMENTED` / 501 | `$dateToString` `%Y-%m-%d` | + | engine in-memory aggregation — the fallback every SQL/ObjectQL analytics query carrying a granularity lands on, since `NativeSQLStrategy` declines on a granularity | **200, 2 groups keyed on the RAW instant** | 1 group, `2026-09-06` | + + Two honest refusals and one silently wrong answer. No third behaviour, and no + backend that bucketed it. + + ## What changed + + - `TimeUpdateInterval` is now `z.enum(DateGranularity.options, …)` — the members + come from the single source instead of a second literal list that disagreed + with it by three members for as long as both existed. + - A refusal message splits two populations that are not the same mistake: a + **retired** sub-day name gets the retirement and the `os migrate meta --from + 17` line; anything else gets the vocabulary. `driver-memory`'s own analytics + door carries the same split. + - `driver-memory`'s `NOT_IMPLEMENTED` / 501 answer for these three is **not + silenced** — the declaration it announced is gone, so the class moves to the + 400 the retirement makes correct. The 501 arm stays, and a pin measures that + its population is now empty (`TimeUpdateInterval.options` equals + `BUCKET_GRANULARITIES`), so the day one of the two is widened alone it lights + up again instead of a freshly declared value being called undeclared. + + ## What this does NOT decide + + Sub-day analytics bucketing as a **capability**. Offering it means widening + `DateGranularity`, the `queryDateGranularity` record, the canonical bucket-key + vocabulary and every driver's bucket expression together — new capability, + decided as such, rather than a name that parses in one enum and resolves + nowhere. +- 3cb84d0: feat(spec)!: retire the view item's `owner` and `hidden` keys — declared, stored, and read by nothing (#20085) + + **BREAKING** — `owner` and `hidden` are removed from the view item + (`defineViewItem`, `ViewItemSchema`, and the `view` metadata door's ViewItem + record `{ name, object, viewKind, config }`). ADR-0049 enforce-or-remove; triage + direction, verbatim: 「retire both keys」. + + Both keys were accepted by the strict authoring door and by the wire member the + `PUT /api/v1/meta/view` door validates, and `saveMetaItem` stored them verbatim — + but nothing ever read or wrote either. Measured before removal, each against a + lit control: no reader or writer of the view-item keys in the framework, in + objectui at its pinned commit and at `main`, or in cloud. Both view-switcher read + paths (`GET /meta/view?object=` and `getViewsByObject`) filter on `viewKind` + + `object` and sort on `order`. So `hidden: true` hid nothing, and a view with + `owner` set was listed for every user who can read the object. The `owner` half + was a visibility claim nothing enforced, which is the security shape ADR-0049 is + about. Per-user view scoping is a parked direction (ADR-0017, amended + 2026-09-04), not a shipped mechanism. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | view item `owner` | delete the key. Nothing restricts a view item to one user today; a view item is visible to everyone who can read its object. | + | view item `hidden` | delete the key. To take a view out of the switcher, delete the view item (or stop shipping it from source). | + + **The one-line fix: delete `owner:` and `hidden:` from every view item.** + `os migrate meta --from 17` lists the mechanical edits for existing sources. + + ⚠️ Runtime behaviour is deliberately **unchanged**. Neither key ever changed what + a view showed or to whom, so removing one removes no behaviour. What changes is + the answer an author gets: a view item carrying either key is now refused at + parse, with a prescription, instead of being saved with no effect. + + ### The retirement kit + + - **Tombstones, on the shared shape.** Both keys are `retiredKey()` tombstones on + the view-item base shape. That shape feeds two doors: the strict authoring door + (`ViewItemSchema`) and the `.strip()` wire member (`ViewItemWireSchema`, which + the `view` write door and the assembled-manifest `viewItems` channel both run). + A bare deletion there would have been a silent strip (ADR-0104), so one + tombstone serves both: `tsc` types the key `never` on `defineViewItem`'s input, + and every parse raises the prescription. + - **D2 conversion `view-item-owner-hidden-removed`** (step 18, retired from the + load path). It strips both keys from the view item **record** spelling as a + lossless delete, in both collections a record travels in: `views` (stack + sources, and stored `sys_metadata` rows, which the rehydration seam replays as + `{ views: [row] }`) and the assembled-manifest `viewItems` channel (package + export and environment artifacts). Without the second, an artifact assembled + before this release would fail its registration parse. + - **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ViewItem:owner`, `ui/ViewItem:hidden`, + `ui/ViewItemWire:owner`, `ui/ViewItemWire:hidden`. + - **No liveness row, and no authorable-surface line.** Both instruments read a + def's top-level `properties`, and `ViewItem` / `ViewItemWire` are + discriminated unions that have none. So the four surface ratchets stay + byte-identical on this retirement, and that reading is expected on this route. + - **No deprecation window**, per the project's startup-stage posture. + + ⛔ **Untouched: the flattened-overlay members' own `owner` / `hidden`.** Those + two members of the `view` door (a lean personalization PUT carrying no `config`) + declare the same names on a different door, which this change does not touch. A + `{ object, viewKind, hidden: true }` overlay still parses. The conversion leaves + overlays alone for the same reason. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` + is published, so this is breaking for consumers no telemetry was consulted for. + + Clause-②: no + + +- 119a02b: fix(spec): the published type `ViewMetadata` names a `view` body instead of being `unknown` (#19871) + + Clause-②: no (narrowing) + + **BREAKING for TypeScript code that annotates with `ViewMetadata`**: a narrowing of a published + TYPE, landing in the launch window as `minor` (the lockstep convention: the bump level is not the + carrier, this banner and the disposition below are). The runtime accept set does not move at all: + `ViewMetadataSchema` is unchanged, and a 44-body parse probe answers byte-identically before and + after. + + `ViewMetadata` was declared as `z.input`. That schema is a + `z.preprocess`, whose input type is `unknown`, so the name documented as "any persisted view + metadata body: container | ViewItem record | flattened overlay" type-checked any value at all, + including bodies the schema refuses. It is now the union of the input types of + `VIEW_METADATA_MEMBERS`, the four members the schema's union runs, so `unknown`, a non-object and a + key no member declares are compile errors, and a body of each member still type-checks. + + **What still differs from the runtime verdict.** The type is the members' declared shape, not the + door's answer. The door still accepts bodies the type refuses (it removes the console's row `id`s + and three members strip undeclared keys), and still refuses bodies the type admits (the identity + precondition, the members' refinements, and a body mixing keys of different members, which + TypeScript checks against the union as a whole). `ViewMetadataSchema.safeParse` remains the only + judge. + + **If your code stops compiling.** A value you annotated as `ViewMetadata` is not one of the four + member shapes. Correct the body, or type a value that is still unvalidated as `unknown` and let + `ViewMetadataSchema.safeParse` decide. + + `ViewMetadataParsed` is not changed by this change. It is re-derived from the same members, as their output types, by its own entry (#19920). + + The `@objectstack/metadata` changelog entry for #19852 gives `ViewMetadata` being `unknown` as the + reason a saved `view` file is written with no annotation; that reason is superseded here, and the + outcome stands for another one: `ViewMetadata` is no longer the `z.input` type of + `ViewMetadataSchema`, the schema `getMetadataTypeSchema('view')` binds. + + +- eea8787: feat(spec)!: retire the flattened view overlay's `owner` and `hidden` keys — accepted at the save door, stored, and read by nothing (#20230) + + **BREAKING** — `owner` and `hidden` are removed from the flattened view overlay: + the lean `view` body with no `config` that `PUT /api/v1/meta/view/:name` (the + Studio and MCP save) accepts, members 3 and 4 of the `view` metadata door, and + the same members in the assembled-manifest `viewItems:` channel. ADR-0049 + enforce-or-remove; triage direction, verbatim: 「follow #20085's disposition for + the same key pair」. This completes the family: the view item record's `owner` / + `hidden` are retired in this same release by its own entry, with the same texts. + + ⚠️ **This supersedes one sentence of the view item retirement's note in this same + release.** That note says the flattened overlay's own `owner` / `hidden` are + untouched and that a `{ object, viewKind, hidden: true }` overlay still parses. + True of that change alone; after this one, such an overlay is refused too. Read the + two notes together: after this release, neither door accepts either key. + + Clause-②: no (narrowing) + + The overlay door declared both keys separately from the view item's pair. A bound + overlay such as `{ object, viewKind, hidden: true }` saved clean and one row was + stored with the key, and nothing ever read it. Both view-switcher read paths + (`GET /meta/view?object=` and `getViewsByObject`) filter on `viewKind` + `object` + and sort on `order`, so `hidden: true` hid nothing, and a view with `owner` set + was listed for every user who can read the object. + + Writer census, taken before removal: no writer of either overlay key in this + framework or its examples, in objectui at its pinned commit and at `main` (the + toolbar writes only `rowHeight`, `sort`, `hiddenFields`, `columnState` and + `inlineEdit`; the switcher only `label`, `isPinned`, `isDefault` and `sortOrder`), + or in the HotCRM app. The cloud repository was not reachable from the census. + + ### FROM → TO + + | removed | what to write instead | + | --- | --- | + | flattened overlay `owner` | delete the key. Nothing restricts a view to one user today; a view is visible to everyone who can read its object. | + | flattened overlay `hidden` | delete the key. To take a view out of the switcher, delete the view item (or stop shipping it from source). | + + **The one-line fix: delete `owner:` and `hidden:` from every view body you save.** + `os migrate meta --from 17` lists the mechanical edits for existing sources. + + ⚠️ Runtime behaviour is deliberately **unchanged**. Neither key ever changed what + a view showed or to whom. What changes is the answer an author gets: a save that + carries either key is refused `422 INVALID_METADATA`, with the prescription + located at the key, instead of being stored with no effect. The prescriptions are + the view item's own texts, so the family answers with one voice on both doors. + + ### Stored rows + + Every read of a stored `view` row replays the conversion chain before the row is + served or badged, and the D2 conversion strips both keys there. What that leaves + depends on what else the row holds: + + - **A row with any other view key** (a column state, a sort, a default flag, an + order): served and badged valid without the keys. A GET then a PUT of the whole + row saves (if it was otherwise valid), so the console's next read-merge-write of + it saves, and `os migrate meta --stored --apply` rewrites it. + - **A hide-only row**, holding nothing but its identity (`name`, `object`, + `viewKind`, `label`) and `owner` / `hidden`, such as + `{ object, viewKind, hidden: true }`: the strip leaves identity only, which the + `view` door refuses ("only identity fields"). The row is served badged invalid + (it was badged valid before this release). A whole-row re-save, or one that adds + only identity (a rename sets `label`), answers `422 INVALID_METADATA`. + `--apply` reports it `failed` and leaves it as stored; every read strips it + again. A write that adds a real view key, such as a toolbar toggle, saves. + **Fix: delete the row** (it never changed what anyone saw), or add the + personalization setting its author meant and save that. + + ### The retirement kit + + - **Tombstones on both overlay members.** `retiredKey()` in + `flattenedViewOverlayFields()`, with the view item's prescription texts. Both + members `.strip()`, so a bare deletion would have dropped the key in silence + (ADR-0104). + - **D2 conversion `view-overlay-owner-hidden-removed`** (step 18, retired from the + load path). A lossless delete from the flattened spelling (no `config`, no + container slot) in `views` (stack sources and stored rows) and `viewItems` + (assembled artifacts). It is disjoint from `view-item-owner-hidden-removed` by + `config`, so no row is judged by both. + - **D3 semantic entry `view-overlay-owner-hidden-retired`**: the family's one D3 + record, naming its D2 conversion. The view item record's pair is a separate + family with its own conversion and its own D3 entry; the two share the + prescription texts. + - **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ViewMetadata:owner`, `ui/ViewMetadata:hidden`. + `ui/ViewMetadata` is unemitted (its `z.undefined()` guards have no JSON Schema + form), so no build gate judges these rows and the four surface ratchets are + byte-identical on this retirement. The rows are pinned by the retirement test. + - **No liveness row**: the `view` ledger walks the container keys only. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo population is NOT MEASURED.** `@objectstack/spec` is published, + and production `sys_metadata` rows are not reachable from the repository. Stored + rows are stripped on read by the conversion above, and a hide-only row among them + needs the fix above. A client that still sends either key is refused at its next + save. + + + +### Patch Changes + +- 863c7c4: `liveness/agent.json`, `liveness/skill.json` and `liveness/action.json` — the 21 cloud citations these ledgers rest on now carry the date they were read and the symbol they were read at, and the two claims that reading falsified are corrected in the prose (#13272). + + The ledgers ship inside this package, so the pointers an upgrading reader follows are these. Until now they named a package root and nothing else: `cloud: packages/service-ai/src/agent-runtime.ts`, with no date and — after #13309 repointed them off a path that existed in neither repository — still no evidence that anybody had opened the file. Every row was re-read in a cloud checkout at cloud `@cb8ee7ff60c097cc21a584fe9caf8ef4391cc0e8` and now carries `verifiedAt: 2026-09-15`, `evidenceScope: "cross-repo"`, and a `#symbol` anchor on the consuming function. + + - **A symbol instead of a line, because a line rots in range.** Three of the cited line numbers had already drifted onto unrelated prose (`agent-runtime.ts:264`, `agent-access.ts:50`, `action-tools.ts:535`) while every mechanical check kept passing. A symbol moves with the consumer and goes red when the consumer is renamed or deleted. + - **The framework half is now gate-checked.** `packages/mcp/src/skill-prompts.ts#projectSkillPrompt` is a repo-local anchor in five skill rows — the `;` before it ends the `cloud` realm's scope — so `check:liveness` resolves it against the file on every run, where the old parenthesised `(projectSkillPrompt)` was prose no check read. Cloud anchors are counted, never resolved, which is why the date on them is load-bearing. + - **Two ledger assertions were false and are repaired.** `agent.role` was noted as *"persona → system prompt."*: it reaches `AgentSummary` through `listAgents` and nothing else — `buildSystemMessages` never reads it. `agent.planning` was cited at `agent-runtime.ts`, which does not read the key at all; its three readers are `routes/agent-routes.ts`, `routes/assistant-routes.ts` and `eval/eval-runner.ts`. + - **One row is deliberately left unstamped.** `agent.tools` was falsified by the same read — zero consumers in cloud, and this package's own `AgentSchema` already declares the key `retiredKey(...)`. Its verdict is a liveness re-grade rather than a stamping decision, filed separately as #18304; a `verifiedAt` there would certify the wrong thing. + + No verdict moved and no schema changed: this is the evidence layer of the ledger, and `check:liveness` reports the same 505 repo-local paths resolving as before with five more anchors now checked. +- 0f95f43: docs(identity): re-point the cloud-identity `ADR-0024` citations at the records that decide them (#14361) + + From this repository's point of view `ADR-0024` names two unrelated decisions. + `docs/adr/0024-mcp-connectors.md` is *MCP Servers as Connectors* — an open, + vendor-neutral tool protocol, with a Decision section numbered §1–§5 and no + D-lettered clauses at all. The identity surface's citations mean something else + entirely: the identity-and-access decision taken in `objectstack-ai/cloud` as + its own ADR-0024, whose open mechanism half has been mirrored into this repo + since 2026-09-07 as + [ADR-0135](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0135-identity-and-access-architecture.md). + A reader following one of those citations landed on a real page about the wrong + subject, which is worse than a dangling id: a plausible-looking record invites + belief rather than a second question. + + 79 citation lines were read one at a time and re-pointed. 73 mean a clause + ADR-0135 restates and now name it with its letter — D4 (source-of-truth marking, + managed vs env-native), D5.2 (the break-glass last-administrator invariant), D6 + (SSO per production environment, including the opt-in DNS domain-verification + clause this tree spelled `ADR-0024 ②`) and D9 (environment users and + organization membership). 6 mean a clause ADR-0135 deliberately leaves in the + cloud record and now carry the anchors gate's cross-repo qualifier + `cloud ADR-0024`: `V1` (the SSO default-role provisioning, the roadmap and + commercial framing) and `§7` (the `ai_seat` synthesis, which ADR-0135 does not + restate). + + What actually reaches a consumer of these packages: + + - `@objectstack/plugin-auth` — the **operator-facing break-glass refusal + detail** now reads `break-glass invariant, ADR-0135 D5.2 — an environment must + always keep at least one administrator who can sign in`. The condition that + raises it, its status, its error code and the rest of its wording are + unchanged; only the ADR number moves. ⚠️ A deployment that greps that message + for the literal `ADR-0024` should grep for `ADR-0135`. The guard's + registration log line moves the same way. + - `@objectstack/platform-objects` — `sys_sso_provider`'s `domain_verified` field + help text, its `protection.reason`, and the matching leaf in all four shipped + locale bundles (`en`, `es-ES`, `ja-JP`, `zh-CN`). + - `@objectstack/spec` — the doc comment above `AuthConfigSchema`'s + `ssoDomainVerification`, published both in `dist/` and as + `src/system/auth-config.zod.ts`. + - `@objectstack/core`, `@objectstack/cli` — doc comments only, published in + `dist/`; no runtime string and no behaviour. + + No behaviour moves. No schema accepts or refuses anything it did not accept or + refuse before, no security or permission semantics are touched, and no ADR + record is written or edited. Bare `ADR-0024` still resolves exactly as it did: + the 15 citations that mean the local MCP-connectors record are byte-identical to + `main`, and `check:adr-anchors` reports the same resolving-citation totals before + and after. Historical archives are deliberately untouched — 36 CHANGELOG lines + across seven packages, and the 22 lines under `docs/adr/`, which is a governed + surface this change does not enter. +- 825d70f: docs(identity): re-point the SCIM/identity `ADR-0071` citations at the records that mean them (#14361) + + From this repository's point of view `ADR-0071` named two unrelated decisions, + and only one of them had a record here. `docs/adr/0071-dataset-semantic-layer-depth.md` + is *Dataset semantic-layer depth — multi-hop joins*. The identity and SCIM + citations mean something else entirely: the enterprise-identity decision taken in + `objectstack-ai/cloud`, whose open mechanism half has been mirrored into this + repo since 2026-09-07 as + [ADR-0134](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0134-env-side-scim-provisioning.md). + So a reader following one of those citations landed on a real page about the + wrong subject — worse than a dangling id, because a plausible-looking record + invites belief rather than a second question. + + 44 identity-meaning citations now name the record that holds the decision they + describe. 43 of them read `ADR-0134` (the open mechanism half: effective SCIM + forces the better-auth `admin` plugin on, `active:false` lands as a ban plus + session revocation, the SCIM 2.0 Service Provider mounts in the environment, and + the seven stable `sys_scim_*` models). One reads `cloud ADR-0071` — the + "paid Identity lifecycle" note in `auth-manager.ts`, which names the commercial + half that deliberately stays in the cloud record. + + What actually reaches a consumer of these packages: + + - `@objectstack/plugin-auth` — the **operator-facing construction-time refusal** + raised when SCIM is effective beside an explicit `plugins.admin: false` now + cites ADR-0134 instead of ADR-0071. The condition that triggers the refusal, + its wording otherwise, and the two documented ways out are unchanged; only the + ADR number in the sentence moves. ⚠️ A deployment that greps that message for + the literal `ADR-0071` should grep for `ADR-0134`. + - `@objectstack/spec` — the `admin` flag's `.describe()` text (shipped both as + `src/system/auth-config.zod.ts` and in the generated `json-schema/` bundle), + and therefore the generated `content/docs/references/system/auth-config.mdx` + reference page app authors read. + - `@objectstack/platform-objects` — the `protection.reason` strings on the eight + `sys_scim_*` identity objects and on `sys_user`. + + No behaviour moves. No schema accepts or refuses anything it did not accept or + refuse before, no security or permission semantics are touched, and no ADR + record is written or edited. Bare `ADR-0071` still resolves exactly as it did: + the 22 dataset-meaning citations are byte-identical to `main` and + `check:adr-anchors` reports the same 35477 resolving citations before and after. + Historical archives — the six package CHANGELOGs — are deliberately untouched. +- a60e04d: `liveness/rest_api.json` — `RestApiConfigSchema` (the `api` sub-object of `RestServerConfig`) is now a governed liveness type. Every key it declares carries a status, the evidence that settles it and the producer that populates it: 12 `live`, 14 `dead` across 26 classified properties (#14640). + + The ledgers ship inside this package (`files[]` includes `liveness`), so this is one new file in the tarball plus two changed ones — `liveness/README.md`'s index row and heading, and the generated `liveness/state-counts.md`. Nothing else moves: no schema accepts or refuses anything it did not before, no export changes, no runtime behaviour changes, and no CLI author warning is added (no entry is marked `authorWarn` — a `RestServerConfig` is not part of a stack, so the lint that emits those warnings could never reach one). + + - **Why it was ungoverned, and why that reason has expired.** #14369 enrolled four of the five `RestServerConfig` sub-objects and deliberately left this one out: the `api` block's consumption seam was then still validate-only, so a census would have recorded a half that was about to move. That half has moved — `RestServer.normalizeConfig` now builds the `api` block from `parseDeclaredApiConfig`'s output rather than discarding it, and the change is released, not in flight. The fence was re-tested before anything here was written; it no longer holds, so the sub-object is measured on the settled seam. The stale sentence is corrected in the gate source and in the four README rows that repeated it. + - **The ledger is `rest_api.json`, never `api.json`.** That name was already taken, by a different `api`: `ApiEndpointSchema`, the registered `api` metadata type, with real consumers in the matcher, executor, policy chain and mapping layer. One spelling, two unrelated meanings inside this package — filing here would have published one file's measurement under the other's name. The gate's own override paragraph now carries that fence too. + - **Twelve keys are `live`.** `version`, `basePath` and `apiPath` become the prefix of every route the server mounts, through `getApiBasePath` — read via a whole-block destructure rather than a property access, which is why the census for the dead keys swept destructuring shapes with their own control instead of relying on a property-access pattern. The eight `enable*` switches each gate a mount, and most of them also the discovery document's capability block, so mount and advertisement move together. `projectResolution` decides whether the unscoped legacy routes mount alongside the scoped ones. + - **Fourteen are `dead`**: the `requireAuth` tombstone, and the two declared containers `documentation` and `responseFormat`, which `normalizeConfig` copies into `this.config.api` and nothing reads back. So `api.responseFormat.envelope: false` unwraps no response, and `api.documentation.title` retitles no served OpenAPI document — that document's `info` block is written by this package's own `build-openapi.ts` and passed through untouched by the REST layer. + - **`documentation` is drilled**, including its nested `contact` and `license` objects, so the change adds no row to the undrilled-container baseline. Those five leaf verdicts rest on a structural argument rather than a spelling sweep, which is stated in each row: `name` / `url` / `email` are too generic to grep, but the container that holds them is unreachable from outside `RestServer` — the field is `private` and `NormalizedRestServerConfig` is module-local with no `export` — so nothing can read a member of an object nothing reads. + - **The two dead containers deliberately do not share one verdict.** This file records status; it decides nothing. The enforce-or-remove call per key (ADR-0049) is a follow-up on the human floor, and the two differ: `documentation`'s members are OpenAPI `info` fields whose enforce route collides with a recorded ownership decision, while `responseFormat`'s enforce route would mean making the response envelope configurable — a larger claim. + + For an embedder, the practical read: every key that changes what this server mounts or advertises is marked `live` and evidenced; `documentation` and `responseFormat` are accepted, validated and normalized, and change nothing. +- abc4b83: `search-fields.ts`'s module docblock says `$search` expands to an `$or` of `$icontains`, the operator the engine actually emits + + The docblock's ENGINE bullet claimed `@objectstack/objectql`'s + `expandSearchToFilter` expands a `$search` term into an `$or` of **`$contains`** + clauses. It has compiled to `$icontains` since objectstack#7641: + `packages/objectql/src/search-filter.ts:23` carries the ruling verbatim — *"The + case-insensitive operator is `$icontains`, NOT `$contains`. `$contains` is + contractually case-SENSITIVE (#4706 Q2 = A)"* — and both return paths of + `fieldClausesForTerm` (`:109`, `:111`) emit `$icontains`. + + **Why the distinction is worth a clause rather than a word swap.** `$contains` + is contractually case-SENSITIVE, so a reader who trusted the old sentence built + an ingress gate, a test or a driver **stricter** than the platform is — a false + refusal, not a leak. The corrected bullet now says that in one clause, so the + next reader of this module does not have to reconstruct it from two other + packages. + + ⛔ No behaviour changes. This is a module docblock; the engine has been right + since #7641 and no accept set, authorable key or published behaviour moves. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/spec`'s published `files[]` ships `dist`, and + this TSDoc is emitted into `dist/data/index.d.ts` and `dist/data/index.d.mts` — + measured on the built artifact, with the old spelling absent from all 216 built + files afterwards and the docblock's own neighbouring sentence present at 2 as + the lit control. `src/data/search-fields.ts` is not a `.zod.ts`, so it is not + shipped as source; the emitted declarations are the whole of its published + reach, and they change. + + The sibling INGRESS sentence two lines below — `@objectstack/metadata-protocol` + `findData` refusing a `$searchFields` override the resolved set does not admit + (#4254) — was measured on the same tip and is unchanged: `findData` still calls + `assertSearchFieldsAreSearchable`, which resolves through this module's own + `resolveSearchFieldResolution` rather than re-implementing the rule. +- 48f5200: `src/migrations/entries/README.md` — the ADR-0087 entry authoring rules now record that an entry's prose is scanned as source **twice**, and that the rule is never to spell a shape a live textual ratchet matches (#15130). + + This file ships inside the package (`files[]` carries `README.md`, which matches at every depth — measured with `npm pack --dry-run`: 277 files, this one among them), so the rules an entry author reads are these. + + - **The mechanism is the counter-intuitive part.** Every string an entry declares — `surface`, `replacement`, `reason`, `acceptanceCriteria` — is concatenated verbatim into the generated `src/migrations/registry.ts`, which is ordinary `.ts`. A repo-wide textual scan therefore reads the same sentence once in the entry file and once in the registry. The tree's one code/prose separator masks **comments** and leaves **string literals** intact by design, so a quoted example is code to every scan built on it, and prose in this package can turn **another package's** test red. + - **The rule is the broad one, and the parenthesis is its instance.** A rule worded as "quote a retired call site without its parentheses" would make counter-examples of entries that spell a parenthesised call and are green — they go unmatched only because no live ratchet enumerates *those* methods, which is a fact about today's ratchets rather than a licence. What an author controls is not spelling a shape some ratchet matches; the guidance is to name the surface rather than spell a call of it. + + No schema, no export and no authorable key moves; the three existing rules in that section are unchanged and no entry was edited. +- 245f360: `EventMetadata.cluster` and `ServiceMetadata.cluster` cite the live docs page by SITE URL, not a dead filename + + Both `.describe()` strings pointed at `cluster-semantics.mdx`, a page that is no + longer in the tree — `apps/docs/redirects.mjs` has redirected + `/docs/concepts/cluster-semantics` to `/docs/kernel/cluster` since the page was + folded in. The section numbers still resolved, so nothing was broken for a + reader following a link; what was broken is retrieval by filename, which finds + nothing. + + These two strings are the published half. `gen:docs` copies them into + `content/docs/references/kernel/events-core.mdx` and `service-registry.mdx`, and + they also ship as JSON Schema `description` values under `packages/spec/json-schema/` + and as string literals in `packages/spec/dist/`. So the citation had to become + something a SITE reader can follow: + + ``` + - See cluster-semantics.mdx §4. (a file that does not exist) + + See /docs/kernel/cluster §4. (the address the redirect already resolves to) + ``` + + ⛔ Deliberately NOT the in-repo house style. Source comments elsewhere in the + tree cite `` `content/docs/kernel/cluster.mdx` §N `` — a repo path, correct for a + reader who has the repo checked out. Copying that convention into a `.describe()` + would tell a docs-site reader to open a `content/docs/...` file they do not + have, which is the same class of unfollowable reference pointed the other way. + There is no in-repo precedent to copy either way: these are the only two + `.describe()` strings in `packages/spec/src` that cite a docs page at all. + + The site URL is also redirect-independent — it is the redirect's own target, so + the reference survives the redirect being retired. + + No accept set moves and no authorable key is added or removed: the schemas, + their parse behaviour and their exported types are byte-identical apart from + these two description strings. The two regenerated reference pages carry the + same one-line change on three rows. +- 9dcdb77: `ListViewSchema` declares the `columns` x `hiddenFields` x `fieldOrder` composition instead of leaving it to be inferred from a renderer (#15184) + + The three keys that together build a list view's field list now say how they compose, in their own `.describe()` text — the string that ships into `json-schema/` and into the generated `content/docs/references/ui/view.mdx`: + + - `columns` is the **projection**: the candidate set and the baseline order, and neither other key can add a field it omits. An **empty** `columns` declares no projection, so neither other key applies: which columns show is left to the renderer (objectui's `ListView` grid derives the object's default columns). + - `hiddenFields` **subtracts** from that projection, before any ordering runs. A name `columns` never projected subtracts nothing. + - `fieldOrder` **orders what survives** and never adds a field. A surviving column absent from `fieldOrder` sorts **last**, after every listed one, keeping its `columns`-relative order; a name listed there that did not survive orders nothing. + + **Why this is a declaration and not a precedence rule.** `fieldOrder` was proposed for retirement as a second spelling of `columns` with no contract deciding who wins. They never compete: one selects, the other sorts. Maintainer decision batch #115 (2026-09-11) kept the key and ruled the composition into the contract, which is what this change lands. + + ⛔ **No accept set moves.** No key is added, removed, narrowed or widened; no parse verdict changes; the four `@objectstack/lint` list-view validators are untouched. What changes is the published description of three keys that were already there, plus one ledger row's evidence. + + **`packages/spec/liveness/view.json` — the `/props/list/children/fieldOrder` row is re-cited**, `verifiedAt: 2026-09-21`. The ledger ships in this package's `files[]`, so the pointers an upgrading reader follows are these, and both halves of the 2026-08-10 citation had rotted: its first path (`objectui packages/react/src/spec-bridge/bridges/list-view.ts`) no longer exists, and its second had drifted in range through three sets of line numbers. The row now anchors both pointers on symbols, splits the relay rung out into `producer`, and names the measurement it was taken at. + + The declaration and the accept set are held together by `packages/spec/src/ui/view-field-order-composition.pin.test.ts`: it reads the three descriptions off the live schema and parses a document carrying all three keys through the page-list, object-views, `defineView` and registered-metadata doors, asserting the arrays come back verbatim — the spec declares the composition, it does not perform it. +- 324968e: The `translation-validation-messages-removed` migration text names the object-scoped bundle key, not just the authored literal + + `validationMessages` was retired in 17.0.0 (#4667). The ADR-0087 conversion that + migrates it told an author to author the message on the rule + (`object.validations[].message`) and stopped there. Since 17.3.0 (#14381, + #14253) that message has a translation route — + `objects.._validations..message`, resolved on the write + path — and the sibling prescription ten metres away in the same package + (`TRANSLATION_KEY_GUIDANCE.validationMessages`, the text the strict door + returns) already names it. + + ⛔ Nothing the old text said was false, and none of it is deleted. The defect is + **silence**: this is the *migration* text, read by exactly the population that + authored the retired key — the authors who wanted their rule messages + translated — and it steered them to a plain authored literal without mentioning + that the bundle key now exists. The literal advice stays; the route is added + after it. + + **Two texts in the file carried the narrow prescription, not one.** The + conversion's `summary` is the one the card named; the docblock above it asserted + that rule messages are *"not translated through a group"*, which would have sat + directly above the corrected summary. Both are completed. The docblock keeps its + 17.0.0 sentence — still true of the retired key — and says what 17.3.0 changed, + including why the object-scoped group is not `validationMessages` returning (the + retired one was keyed by rule name at the top level, could not tell two objects' + rules apart, and had no reader). + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `packages/spec/src/conversions/registry.ts` is not a + `.zod.ts`, so it is not shipped as source — but two published paths move, + measured on the built tree rather than reasoned about: + + - `dist` is in `files[]`, and the new sentence is emitted into six built files + (`dist/index.js` / `.mjs`, `dist/shared/index.js` / `.mjs`, + `dist/browser/index.js` / `.mjs`); a negative control string scored 0 on the + same tree. An author running `os migrate meta --from 16` reads the changed + notice out of that runtime string. + - `spec-changes.json` is itself listed in `files[]`, and it carries the summary + twice. It is generated (`gen:spec-changes`), and `check:generated` caught it + stale — the conversion registry feeds two generated artifacts, not one. + + `docs/protocol-upgrade-guide.md` is the third, regenerated with + `gen:upgrade-guide` and verified by `check:upgrade-guide`; all three are + regenerated, never hand-edited. + + ⛔ No behaviour changes. The conversion id, its `apply`, its accept set and its + fixture are untouched; no authorable key is added or removed. +- 4844840: `check:duration-unit-keys` refuses a duration key whose JSDoc names a unit its describe does not + + The gate read a key's unit from `.describe()` and `.meta({ description })` only. + A duration-shaped `z.number()` whose unit was written solely in the JSDoc block + above it appeared in `--list` as a census row with `[prose: -]` and was never + judged — and its own self-test pins *"a describe declared through + `.meta({ description })` is READ — no exemption by blindness"*, which made the + JSDoc blindness read as deliberate, measured coverage. + + **Ruled 2026-09-07 (decision batch #65).** JSDoc is developer commentary, not + governed prose: `.describe()` is what `content/docs/references/**` renders and + what rides into the published dist, and the JSDoc stops at the source file. So + the gate does **not** start reading JSDoc as a unit channel — a unit written + only there still has not satisfied the rule. What it now refuses is the + DIVERGENCE: the JSDoc names a unit and the describe names none (or there is no + describe at all), so the two channels disagree about whether this number's unit + is written anywhere a reader can reach, and the channel that is silent is the + published one. New rule `unit-in-jsdoc-not-in-describe`; the remedy is to move + the unit into the describe, where the existing rule then puts it in the key + name. + + ⛔ **The JSDoc is read in exactly one direction: to refuse, never to satisfy.** + A duration-shaped key with no unit in *either* channel is still listed and + still not judged (the #14519 shape, unmoved). The new branch tests for a unit + PRESENT in the JSDoc; it never tests for one absent from the describe, which is + what would have made it the option the ruling declined. + + **The population this rule adds was remediated before the rule landed.** When + the gate was written it found **21** offenders. Ruling A on #15939 sequenced + those out of this change and into seven per-file cards (#17780–#17786), all + merged: eighteen were renames of published keys, each carrying its own ADR-0087 + conversion and `retiredKey()` tombstone, and the other three needed only their + describe corrected. On this tree the gate reads **zero offenders** among **211** + duration-shaped numeric keys across **2482** source files (6 declared `EpochMs` + instants, 11 declared `externalVocabulary` mirrors). ⛔ **No offender was + exempted to reach that zero** — there is no baseline in this gate by ruling, and + none was added. + + **One wrongly-recorded reason repaired, comment-only.** The blindness did not + merely miss keys, it produced confident wrong prose about why they were missed: + the retired-key entry for `SandboxConfig:process.timeout` said the neighbouring + `RuntimeConfig.resourceLimits.timeout` was "outside the gate's population", when + that key was inside the census and merely never judged — its unit lived in a + source JSDoc only. That note now records the true reason, and points at the + neighbour's own entry rather than describing a landed rename as pending. + `registry.ts` regenerated to mirror it. The same wrong reason in the + `metrics.test.ts` burn-rate pin was corrected by #17783 when it renamed that + key, so nothing is owed there. + + ⛔ No published key, accept set, default or runtime behaviour moves. +- 97f4f8c: `plugin-sharing` recognises the engine's organization refusal through objectql's own published recognizer instead of a locally re-spelled literal, and the `PROVENANCE_WAIVERS` row that excused that local spelling is retired with it (#16160). + + Clause-②: no + + The waiver carried its own expiry in its `reason`: *removed together with the stamp site when objectql publishes a recognizer*. It does, so both halves land here — `check:error-code-provenance` reconciles a waiver in three directions at once (the `registeredUnder` key still lists the code, the waived package still does not, and the scan still finds a site for the pair), so removing either half alone reddens the gate on the other. + + - **`ENGINE_ORGANIZATION_REFUSAL_CODE` is gone.** It was a `constdef` stamp site in `plugin-sharing/src/sharing-rule-service.ts` for a code this package only ever RECOGNISES — `@objectstack/objectql` is the emitter and already carries the row. The per-grant catch now asks `isSystemWriteOrganizationRequiredError(err)`, and the `warn` that reports an absorbed refusal names `SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE`. Both are imported from `@objectstack/objectql`, which exports them for exactly this: a consumer performs the `code` compare without authoring the string, so it acquires no stamp site of its own and cannot drift from what the engine throws. + - **Nothing about the absorbed set moves.** The catch stays as narrow as it was — one engine refusal absorbed, everything else rethrown unchanged — and `plugin-sharing` still emits this code nowhere: the surviving mention is a structured log field on the refusal it just absorbed, not a refusal envelope of its own. + - **No error-code membership moves.** `ERROR_CODE_LEDGER` and `StandardErrorCode` are untouched; `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` stays registered under `@objectstack/objectql` exactly as before. The only ledger change is one `PROVENANCE_WAIVERS` element, 10 waivers → 9, and the gate's site census 339 → 338 with `listed` unchanged at 322. +- 482d34d: fix(devx): the json-schema tree's freshness rule can be answered — a generation stamp acquits a tree whose sources were re-checked-out unchanged (#16175) + + `scripts/check-regen-pending.mjs` exports three freshness predicates over the + same `newestMtime(artifact) < newestMtime(src)` comparison, and all three share + one blind spot: `git merge`, `git checkout` and `git worktree add` re-check-out a + source file with **identical bytes** and bump its mtime, the build that follows + correctly does not run (turbo's cache hashes content), and the rule then refuses + an artifact that is exactly current. + + Two of them were answered already — `distIsStale` by `dist/.build-input-hash-dts` + (#14985/#16176) and `bundlesAreStale` by `dist/.build-input-hash` (#16240). + `schemaTreeIsStale` was the third, and the one with **no evidence of any kind to + read**: nothing recorded which sources `packages/spec/json-schema/` came from. + Measured on a checkout whose `git status` was empty, after a bare + `touch packages/spec/src/data/query.zod.ts`: + + ``` + pnpm --filter @objectstack/spec check:docs exit 1 + packages/spec/json-schema is older than packages/spec/src. + ``` + + The only remedy on offer was a full `gen:schema` — minutes under a shared verify + lock — for a tree that needed nothing. The same command now exits 0 with no + rebuild, and a genuine source edit still refuses. + + **The evidence is new, because neither `dist/` stamp could stand in.** Both are + written at the END of the build, whereas `gen:schema` is its FIRST step and is + also run standalone and again by `check:authorable-surface` — so a `dist/` stamp + is evidence about `dist/`, and in the standalone case there would be none at all. + `build-schemas.ts` now writes `json-schema/.build-input-hash-schema` as the last + thing it does: one write point, after the unconditional whole-tree regeneration + that precedes its `--check` / `--update-base` fork, so all three entry points are + covered, and after every ratchet that can exit 1, so a refused run vouches for + nothing. + + **⛔ The digest may only ACQUIT, never accuse.** A missing, unreadable or + non-64-hex stamp is `unstamped` — no evidence — and leaves the mtime refusal + exactly where it stood (#4690). Nothing that passes today can start failing, and + the rule keeps its only conviction instrument: mtimes still see the hand-edited + tree and the toolchain change a content digest is blind to. + + **Why this ships, and why it is a changeset rather than `skip-changeset`.** + `json-schema` is in `@objectstack/spec`'s published `files[]`, so the new stamp + travels in the tarball — measured with `npm pack --dry-run`: + `json-schema/.build-input-hash-schema` is present alongside the two existing + `dist/` stamps. One 65-byte file is added to the published package. No export, no + schema key, no runtime behaviour and no authorable surface moves. + + **One other published-adjacent change**, for the same soundness reason: the build + digest (`scripts/build-input-hash.mjs`) now also hashes `/scripts/**` for + packages that have it. `packages/spec`'s generators live there and were in none of + the previous input sets, so an edited generator kept a digest that had not moved — + and a stamp written by the OLD generator would then acquit a tree the new one + emits differently. Widening a digest can only ever WITHHOLD an acquittal, never + grant one, so the two `dist/` stamps become strictly more honest as well; the + first build after this lands re-stamps all three. +- 2fc092b: fix(spec): record the shipped `sys_job` / `sys_report_schedule` IANA narrowing in the ADR-0087 ledger (#16421) + + Clause-②: no + + `#16296` gave `sys_job.timezone` and `sys_report_schedule.timezone` the + `valueDomain: 'iana_time_zone'` declaration. That is a write-time narrowing — a + string these columns used to accept is now refused with the ADR-0114 field code + `value_domain` — and it shipped with no breaking-change marker at all, so the + repo's own detector classified it non-breaking and asked for no ADR-0087 + disposition. Measured on the shipped changeset, not inferred. + + The ledger now carries a `semantic` entry for it + (`platform-timezone-columns-iana-domain-refused`, protocol 18). Nothing is + re-released and nothing is ratified in silence: the entry states what narrowed, + the one-line fix per offending row (write the canonical zone id, or clear the + column), and the fact that a stored non-member is still readable and still + returned unchanged — it fails only on the row's next write. For + `sys_report_schedule` that refusal is the point: a non-member zone was silently + discarding the cron expression and falling back to `interval_minutes` forever. + + No authorable key, export, config field or stored shape moves, and no DDL is + planned — this is a record of a change that already shipped, published so that + `objectstack migrate meta`'s consumers can read it. + + Maintainer ruling, director summon #17, decision batch #2 item 1, option B + (#16421 comment 5572145955, 2026-09-07), quoted verbatim and untranslated: 「同意」. +- 2dfe070: docs(spec): the `organizationId` describes on `GetMetaItemRequestSchema`, `GetMetaItemLayeredRequestSchema` and `GetMetaItemCachedRequestSchema` no longer promise that a supplied organization is always consulted (#16524) + + Clause-②: no + + Each of the three published `describe()`s opened with "Selects the org partition in the ADR-0005 overlay read order" and closed with an absent-only statement ("Absent = environment-wide read …"). Read together, an integrator completes that as *present ⇒ consulted*, and it is not: on `getMetaItem`, `getMetaItemLayered` and `getMetaItemCached` a supplied organization is dropped wherever no org partition applies, and the read resolves environment-wide exactly as if none had been sent. + + The corrected text takes the same shape as the sibling `GetMetaItemsRequestSchema.organizationId` describe: the parameter selects the org partition **when an org partition applies**, and supplying a value "does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide". On `GetMetaItemCachedRequestSchema` the ETag sentence ("Also folded into the ETag, so a scope switch never returns a stale 304 from another scope's cached representation") is true and is kept byte-for-byte. + + Prose only. No key is added, removed or renamed, no export moves, no accept set changes and no runtime behaviour changes — a supplied `organizationId` is still accepted on all three requests, and the runtime still drops it where no org partition applies. What ships is the JSON-Schema `description` of the existing `organizationId` key on the three requests and the matching rows in the generated API reference. +- d4a1a28: `ObjectNavItem.recordId`'s docblock said it was "Mutually exclusive with `viewName`" — the guard tolerates that exact pair, deliberately + + The docblock read *"Mutually exclusive with `viewName` (viewName is ignored if + both are set)"*. The parenthetical was the tell: *"ignored if both are set"* + describes a **precedence**, not a refusal, so the sentence's own second clause + contradicted its first — and the code agrees with the second clause. + `recordId` + `viewName` parses clean through `NavigationItemSchema`; it is the + one legacy combination `objectNavTargetExclusivity` lets through, and that + guard's own docblock says so in as many words. + + **The harm direction is silent in both directions.** An author (or an agent) + who read "mutually exclusive" would avoid a combination the platform accepts, + or file a bug when it parses. Two docblocks in one file described one rule and + disagreed; the guard's was right. + + ⛔ **No behaviour changes, and the asymmetry is not "unified".** The tolerance + is a recorded decision, and `app-nav-target-exclusivity-export.test.ts` already + pins `recordId` + `viewName` as accepted precisely so that making the target + fields pairwise exclusive goes red. This changeset corrects the **prose** only: + no schema, no guard, no accept set, no authorable key, no export moves. The + `.describe()` strings — the ones that reach `content/docs/references/` — are + untouched. + + The corrected docblock now says the pair is tolerated rather than refused, + names the guard that tolerates it, and points at the test that pins it. The + same test file gains a fifth leg asserting the docblock against the accept set + it describes, so the next copy of this sentence goes red instead of shipping: + prose is the only place the tolerated pair is documented, so nothing else was + watching it. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/spec`'s published `files[]` carries both + `dist` and `src/**/*.zod.ts`, and `src/ui/app.zod.ts` matches that glob — the + edited file is shipped as source verbatim. Measured on the built artifact as + well: the new sentence is present in **18** built files under `dist/` and the + old spelling in **0**, with two untouched sentences from the same region + (`navigate straight to the detail page`, and the `filters` docblock's own TRUE + exclusivity claim over `recordId` / `viewName`) present in **18** each as the + lit controls, so the zero is a reading and not a mistyped anchor. The + declaration files do not carry it — this is a field-level docblock inside a Zod + shape — which is why the reach is stated as the bundles and the shipped source + rather than as `.d.ts`. +- d34f9b6: The `agent.tools` rejection now says why ADR-0064 binds, so its `Proposed` status does not read as "not yet in force" + + An author who writes the retired `agent.tools` key gets the tombstone's + prescription, which rests the rule on **ADR-0064** (*"an agent's tool set is the + union of its surface-compatible skills' tools"*). Following that citation lands + on a record whose own header reads `**Status**: Proposed (2026-06-22)` and + carries a `🔶 Cloud-owned — superseded in part by cloud ADR-0025` callout. From + the record itself an author cannot tell that the rule still binds them — the + weaker reading is the one the metadata invites. + + ADR-0064 stays the cited authority, because it is the record that states the + invariant the key violated; **ADR-0109** (`Accepted — implemented (Phase 1)`) + names `agent.tools` nowhere and only *builds on* that invariant, so retargeting + the citation would send the author to a record that does not contain the rule + they broke. The message instead gains one clarifying clause: the `Proposed` / + cloud-owned status scopes the **runtime** half (tool resolution, which lives in + cloud `service-ai`), while the **authoring** half is in force in this repo and + ADR-0109 is the in-repo record carrying it. + + Prose only — the rejection, the retirement and the accept set are unchanged. +- aaacf1d: Say what the install-time granted permission set actually does: it is REGISTERED at load and refuses nothing. + + Four shipped sentences claimed the structured `manifest.permissions` / `granted_permissions` set was enforced. Measured on `9bd4344e4`: `SecurePluginContext` — the only reader of `PluginPermissionEnforcer`'s service and hook gates — has zero production construction sites, and `enforceFileRead` / `enforceFileWrite` / `enforceNetworkRequest` are called by nothing at all, `SecurePluginContext` included. So #13457's binding registers a consented set that nothing queries, and the `fs` and `network` classes have no enforcement surface even in principle. + + Corrected, each to the same truthful split ("registered at load · queried by nothing · refuses no operation"): the `registerGrantedPermissions` docblock, the `PluginPermissions` schema docblock, the `manifest.loading` tombstone prescription, and the ADR-0087 D3 entry that ships that prescription into `docs/protocol-upgrade-guide.md`. The hand-written plugin development guide gains the same note beside its permission table. + + `plugin-runtime-tier-truthful-text.test.ts`'s coordination pin — which held the permissions half verbatim so it would go red the day that half was corrected — has been discharged and replaced by pins on the truthful text, in both carriers, each with the negative assertion that keeps the retracted sentence from returning beside it. + + New in `@objectstack/core`: `granted-permissions-not-enforced.pin.test.ts` pins the MEASUREMENT as well as the words, so the claim cannot rot in either direction. It fails the day a production `SecurePluginContext` construction site appears — i.e. the day the ADR-0025 materialize seam lands — and names every text that then becomes false. + + No behaviour changes: no accept/reject, no registration, no gate is added or removed. +- 9dacf61: Corrects the ADR-0087 D2/D3 house-rule prose across the migration chain's doc comments (`packages/spec/src/migrations/types.ts`, `packages/spec/src/migrations/registry.ts` and `packages/spec/src/migrations/spec-changes.ts`) so it states the ruled convention: every retirement family gets one D3 `semantic` entry, even when a lossless D2 conversion also exists for it, and D2 carries the mechanical data repair only (#17152). + + Clause-②: no + + The shape ruled superseded (director-seat class-one self-adjudication on #17152, authority: the maintainer's ruling on #15954) was stated in several places, each in its own words: `types.ts`'s module docblock said "breaks with no lossless mapping"; `types.ts`'s `SemanticMigration`/`MigrationStep`/`reason` doc comments said "A non-lossless change", "Why this is not losslessly convertible" and "Non-lossless changes authored for this major"; `registry.ts`'s module docblock said "the non-lossless residue D2 could not express"; and `spec-changes.ts`'s `SpecMigratedSchema` said "A semantic (non-lossless) migration" with a `rationale` `.describe()` of "Why it is not losslessly convertible". All are corrected to the same meaning: a D3 entry is owed per retirement family regardless of whether D2 is lossless, and `reason`/`rationale` now say why the consumer still owes a judgment rather than why the change cannot be losslessly converted. + + No entry, gate or runtime behaviour changes; this is a doc-comment correction, filed as `patch` because the `types.ts` prose ships verbatim into the published `dist/index.d.ts` / `dist/index.d.mts` (measured: `grep` after a real build finds each corrected sentence there, with `MigrationStep` and `MIGRATION_SUPPORT_FLOOR` as positive controls proving `dist` is readable). `registry.ts`'s docblock does not ship to `dist` at this head (same positive controls, zero hits either wording). `spec-changes.ts`'s `.describe()` text ships in the runtime bundles (`dist/index.js`, `dist/index.mjs`, `dist/browser/*`), not in the `.d.ts`, `json-schema/**` or `spec-changes.json` (measured the same way, with `SpecMigratedSchema` as the `.d.ts` positive control). `check:spec-changes` / `check:upgrade-guide` / `check:authorable-surface` all report their generated artifacts unchanged. Both are corrected for the same reason — each is prose the card and the ruling target. +- e0e4a56: fix(spec): the `etl-pipeline-layer-retired` D3 entry stops promising that connector-attached sync is EXECUTED + + The entry's `replacement` string is an ADR-0087 D4 projected field: it ships verbatim in + `packages/spec/spec-changes.json` (twice — the flat entry and the composed record), which is + in this package's `files[]` and therefore in the published tarball, and it renders into + `docs/protocol-upgrade-guide.md`. It is the advice an author displaced by the ETL layer's + retirement actually reads, and it said connector-attached synchronisation is + `ConnectorSchema.syncConfig`, "which IS parsed and executed". + + Parsed is true. Executed never was, and this tree measures it: + + - `AutomationEngine.registerConnector` / `registerDegradedConnector` + (`packages/services/service-automation/src/engine.ts`) run `ConnectorSchema.parse(def)` and + store the parsed definition in the engine's connector map. Only `actions` is read back off + it; `syncConfig` is never read. + - `syncConfig` has no reader outside `packages/spec` at all — the only non-spec occurrences in + `packages/` are two comment lines in the D7 expression-conformance ledger. That is the same + measurement that retired `syncConfig.schedule` in 18 under ADR-0049, and it is already + stated at the schema (`integration/connector.zod.ts`). + + The corrected sentence says what the block IS and what actually happens to it — parsed and + validated, then inert — and then names the surface that IS executed, so the reader still has + somewhere to go: a connector's `actions`, dispatched by a flow's `connector_action` node, + which resolves the registered handler and awaits it. + + Nothing about the ETL retirement itself changes: no key moves, no accept set moves, no schema + changes. The registry, `spec-changes.json` and the upgrade guide were regenerated by their + generators, and the corrected claim is pinned in `migrations.test.ts` beside the other + projected-string corrections so it cannot regress. +- 7aae005: `ComponentPropsMap['object-grid'].exportOptions` names all five members the renderer reads, not two + + The entry is `z.unknown()`, so nothing about this key is parsed, refused or + stripped: a member that does not exist draws no error and has no effect, and a + member that does exist cannot be discovered from the schema. That makes the + `.describe()` string the entire account of the key's shape rather than a summary + of an enforced one — and it projects straight into + `content/docs/references/ui/component.mdx`, which is what an author (or a + generating model, ADR-0033) reads. + + It named two members, `formats` and `streaming`. The only renderer reads five. + + Measured at the `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` + — objectui `packages/plugin-grid/src/ObjectGrid.tsx`, through the + `schema.exportOptions` expression and the `exportConfig` local bound to it, with + objectui's own scanner (`ObjectGrid.exportOptionsKeys.test.ts`, whose + comment/string stripping is what stops a prose mention of a key being counted as + a read): `formats` 2 read sites, `streaming` 2, `maxRecords` 1, + `includeHeaders` 1, `fileNamePrefix` 1, and an absent-name control + (`zzzNotAMember`) 0 on the same instrument — which is what makes those five + counts readings rather than a matcher that matches anything. The same instrument + answers the same five, with the same per-member counts, at objectui + `3fbdd4a2dae1`, so the set is not an artefact of the pin's age. + + The three missing members are `maxRecords`, `includeHeaders` and + `fileNamePrefix`. An author reading the old string learned that + `exportOptions` takes `{ formats, streaming }` and had no way to reach the other + three short of reading the renderer's source — the shape objectstack#8010 + closed for this same key one layer out, when `streaming` was read for releases + while no schema declared it. + + ⛔ The key is unchanged: it stays `z.unknown()` and no accept set moves in either + direction. Giving `exportOptions` a real shape is a separate and much larger + change with its own review requirements; this is the docs half only. + + The new list is not restated in prose that can drift on its own. A pin holds the + describe string's member enumeration equal to the members + `ListViewExportOptionsSchema` declares — the spec's own five-key declaration of + this same authoring block, reached through `ListViewSchema.exportOptions`'s + object branch and itself derived from that same read set. Both spellings reach + one renderer, so narrowing or widening the declared block now reds the + `z.unknown()` prose instead of leaving it quietly behind: the declared side has + parse failures to catch drift, this side had nothing. The pin also records that + the key is unvalidated today, so the day it grows an accept set is a deliberate + decision rather than a silent one. + + `content/docs/references/ui/component.mdx` is regenerated from the string + (`gen:schema` then `gen:docs`) and carries the same one-line change. +- ada2869: fix(metadata-protocol): `insertManyData` reports the dropped-field union at BATCH level instead of naming rows it cannot identify (#17290) + + + + **BREAKING** — `@objectstack/metadata-protocol`'s `insertManyData` no longer hangs + `droppedFields` on each entry of `outcomes`; the response itself carries it, beside + `outcomes`, exactly as `createManyData` already does. A TypeScript consumer that read + the per-row member stops compiling, and the compiler names the site. The set reported + is the same set — what is gone is a per-row attribution that could not be computed + here and was wrong whenever it mattered. Nothing authored or stored changes shape. + + **What it got wrong.** Every create-side strip is the engine's, and its + `onFieldsDropped` event is the UNION over the batch — the listener signature + carries no row index. This seam reconstructed a row set from that union by + asking which rows SUPPLIED each dropped name + (`[...engineDropped].filter((f) => f in supplied)`), on the stated premise that + "the strip only removes keys the ROW ITSELF supplied, so a dropped name belongs + to exactly the rows whose supplied payload carried it". Maintainer ruling C + falsifies the premise: the static-`readonly` strip runs INSIDE `engine.insert`, + AFTER the `beforeInsert` hooks, and exempts keys a hook itself assigned — + recorded per row (`hookWrittenKeys: rowHookWrittenKeys[i]`). So in a batch where + a hook stamps a protected key on some rows and not others: + + - row A supplied `approval_status`, no hook write ⇒ stripped, enters the union; + - row B supplied `approval_status`, its hook re-assigned it ⇒ **kept and + written**; + - and row B's outcome carried `droppedFields: [{ fields: ['approval_status'] }]` + on a record that still held `approval_status`. + + A row the batch culled before the strip ran (a per-row validation failure) was + named on the same test, having dropped nothing at all. + + ⇒ A wrong attribution costs the reader a wrong investigation, and the import + surface — which prefers this path over `createManyData` — is the consumer most + likely to act on it while reconciling what landed. + + **Why not attribute per row instead.** The honest set is `{rows whose payload + carried N}` minus `{rows whose beforeInsert hook assigned N}`, and the second + half is computed per row upstream but does not cross this seam. The outcome's + own `record` cannot stand in for it: a stripped `readonly` field is RE-DEFAULTED + over exactly the keys the strip took, and a stripped `autonumber` is refilled by + `applyAutonumbers` — so on both, the key is PRESENT on the row that really did + drop it, and a post-hoc "is the key still there?" check would delete true + attributions while leaving the hook-exempt false one standing. Comparing values + fails on the very case `hookWrittenKeys` exists for: the hook assigning the + value the caller also sent. Restoring row precision means giving the engine's + drop report a per-row channel, not a reconstruction at the call site. + + **Prose corrected with it**, by CLAIM rather than by spelling — the docblock + that authorised the inference is the thing that re-authorises the next author: + `insertManyData`'s own docblock and `createManyData`'s parenthetical + (`@objectstack/metadata-protocol`), `mergeDroppedFieldEvents`'s closing + sentence, `engine.insertMany`'s docblock claim that "a caller holding the input + rows can attribute each name back to the rows that carried it" + (`@objectstack/objectql`, TSDoc emitted into its published `.d.ts`), and + `CreateManyDataResponseSchema.droppedFields`'s `.describe()` parenthetical + (`@objectstack/spec`, a string printed AT the customer). + + **Unchanged.** `updateManyData` and `batchData` keep per-row `droppedFields`, + and they always could: each row is its own `engine.update` / `engine.insert` + call, so that call's events are that row's — earned mechanically, not inferred. + `createManyData`'s aggregated shape is untouched. No strip changes, no row + changes, and the same field names are reported. +- d88a47d: A retirement prescription is the top-level message a `PUT /api/v1/meta/view` 422 carries, instead of sitting buried in `invalid_union` sub-errors + + `ViewMetadataSchema` is the union behind the runtime write door — the one an + MCP/AI author reaches, with no CLI anywhere on the path. A shape-level refusal + raised inside one of its four branches did not become the union's message: the + top level read zod's bare `Invalid input`, and the upgrade prescription sat at + `error.issues[0].errors[k][j].message`. Every retirement this platform wrote for + list and form views was therefore invisible at the one door its intended reader + uses — shipped behaviour since 17.0.0 for `virtualScroll`, `striped` and + `bordered`, not a recent regression. + + The lift is family-wide rather than per case. `retiredKey()` raises one declared + issue shape — `code: 'invalid_type'`, `expected: 'never'`, with the prescription + as its `message` — so the union's existing `.check()` now lifts that message + verbatim from the branch the body claims. The next retirement on this shape is + surfaced without anyone remembering to wire it, which is what a per-case fix + could not promise. + + What does not move: the accept/reject verdict of every body (the lift runs after + the union has reached its verdict and writes one string), the issue codes, the + nested `errors` array and its order, and the message of every refusal that is + not a retirement — a plain shape error still reads `Invalid input`, and a + curated unknown-key refusal still reads exactly as it did. That boundary is + measured, not asserted: `strictObject()` closes a shape with a `z.never()` + catchall, so the union's members reach 67 `never` leaves of which only 8 are + tombstones — zod folds a rejecting `never` catchall into `unrecognized_keys`, so + the other 59 never raise the lifted shape at all. +- 2d34f32: The seven converged rule-array `filter` doors name the ViewFilterRule array form when they refuse the record form + + Seven `filter` doors converged on `z.array(ViewFilterRuleSchema)` in the + objectui#6206 family — `ElementDataSourceSchema.filter` (`ui/page.zod.ts`) and + the `object-grid` / `object-metric` / `object-kanban` / `object-calendar` / + `element:number` / `element:record_picker` rows of `ComponentPropsMap` + (`ui/component.zod.ts`). Each previously accepted the MongoDB-style record + (`{ status: 'active' }`), and each now refuses it — measured on the built + artifact, with exactly one issue apiece: `invalid_type` at `filter`, *"Invalid + input: expected array, received object"*, and nothing else. + + The prescription for that transition was already written down twice, in two + places a parse never reaches: every one of the seven `.describe()` strings, and + in full in the three `18.*-filter-rule-array` semantic migration entries. + Nothing bridges `.describe()` into a zod issue and this package installs no + global error map, so the one population whose metadata the convergence broke — + the authors, human and AI, who wrote the previously-legal form — received the + single sentence that does not say what to write instead. + + Each of the seven now answers that value with the new spelling, through the + zod-v4 `{ error }` param this package already uses for targeted guidance + (`shared/expression.zod.ts`, `ui/view.zod.ts`, `shared/strict-object.ts`): + + > `filter` on this `object-grid` takes the ViewFilterRule ARRAY form + > `[{ field, operator, value }, ...]`, and this value is the MongoDB-style + > record form this door took before the one-filter-orthography convergence. + > Write one rule per record key — they AND — so this filter becomes + > `[{ field: 'status', operator: 'equals', value: 'active' }]`. Legacy operator + > shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse. + > Full conversion table: migration + > `element-data-source-and-object-block-filter-rule-array`. + + Following `strictObject`'s model rather than transcribing a sentence seven + times: the rule shape is read from `ViewFilterRuleSchema`'s own shape, the + canonical operator is `normalizeFilterOperator('eq')` — the same fold the door + itself runs — and the worked rewrite is computed from the author's own record, + so the example names their fields. A pin holds each door's `migration` id equal + to a real registry entry and each door's `surface` equal to the one its own + `strictObject` declaration registered. + + ⛔ No accept set moves. The doors refuse exactly the shapes they refused + before, the generated `json-schema/` and `authorable-surface` artifacts are + byte-identical after the change, and the map returns `undefined` for everything + that is not a plain record — so an array author's element-level issues + (`filter.0: Invalid option: expected one of "equals"|…`) and a non-record value + (*"expected array, received string"*) still arrive in zod's own words. + + **Shipped, which is why it carries a changeset rather than `skip-changeset`.** + Measured on the built artifact after both tsup passes finished: the new message + text is present in **18** published files of `npm pack --dry-run`'s 2012, the + test-only text is present in **0** (negative control), and a pre-existing + shipped string reaches **62** as the lit control proving the scan reaches. + `src/ui/page.zod.ts` and `src/ui/component.zod.ts` are also shipped as source + by `files[]`'s `src/**/*.zod.ts`. +- 9e3c485: `date-macros.zod.ts`'s module header states the ADR-0053 D-D upper-bound rule the platform implements, instead of the rule it replaced + + The header's "Out of scope" block told an author that on a `datetime` column + `<= {current_year_end}` **stops at midnight on the 31st**, and prescribed the + half-open `< {next_year_start}` as the fix. That is the pre-ADR-0053 reading. + The platform rule has been the opposite since #3777: a bare `YYYY-MM-DD` used + as an upper bound denotes the WHOLE day, compiled half-open to the next + calendar day. It is stated once, in + `packages/spec/src/data/calendar-day.ts` (ADR-0053 D-D), whose own operator + table reads: + + | Operator | A bare `YYYY-MM-DD` on a `datetime` column means | + |---|---| + | `$gte` / `$gt` / `$lt` | that day's `00:00:00.000` — already correct as written | + | `$lte`, a `$between` max, a `dateRange` end | the WHOLE day → compile `< nextUtcCalendarDay(day)` | + + and which `packages/spec/src/data/temporal-conformance.ts` pins cross-driver: + the case *"datetime: bare-day `$lte` keeps the whole final day"* expects + `d_mid` (09:15 on the boundary day) and `e_late` (21:40 on it) as members. + + **Why this header and not a note.** It is the doc comment on the vocabulary an + AI author reaches for, and it is the one place in the tree that says what a + `*_end` token does on the right-hand side of an operator. Both the old + prescription and the correct spelling parse, run and return rows, so nothing + downstream reports the mismatch — the author simply carries the wrong model + into every later filter. + + **What the correction does.** The load-bearing first clause is kept verbatim: a + `*_end` token IS the period's last calendar DAY. What follows now **cites** + `calendar-day.ts` rather than restating the rule, so the two statements cannot + drift apart again, and the half-open detour is refused by name for the reason + it is now wrong — the widening is already applied. + + ⛔ No behaviour changes. The diff is comment lines only; no schema, accept set, + authorable key or published payload moves. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/spec`'s published `files[]` lists + `src/**/*.zod.ts`, so this file ships verbatim as source, and the header is the + first thing in it. + + The generated reference page `content/docs/references/data/date-macros.mdx` + carried the same sentence — it is rendered from this header and is marked + AUTO-GENERATED — and is regenerated here with + `pnpm --filter @objectstack/spec gen:schema && … gen:docs`. +- e1796ad: **Clause-②: no** — no schema key moves, no accept set widens or narrows, no export changes. This is the liveness ledger stating what the renderer actually does with an authored `chartConfig`, at one verdict per key instead of one blanket verdict for fourteen. + + `packages/spec/liveness/dashboard.json`'s `widgets.chartConfig` row is **drilled**: it now carries `children`, one status + evidence per `ChartConfigSchema` key, re-measured against this checkout's own `.objectui-sha` pin `53ded82bf7a4`. The row ships — `packages/spec` publishes `liveness/` whole — so this changeset is a measurement, not a convention: `npm pack --dry-run` puts `liveness/dashboard.json`, `liveness/README.md` and `liveness/state-counts.md` in the tarball (38 files under `liveness/`), and the fourth changed path, the undrilled-containers baseline under `scripts/`, is not in it (0 files under `scripts/`). + + Per-key verdicts, all pinned in the renderer repo: + + - **12 live.** Nine chrome keys are lowered onto the chart schema by `chartConfigPresentation`, one guard each — `title`, `subtitle`, `description`, `colors` (split two ways into the positional palette and the per-category map), `height`, `showLegend`, `showDataLabels`, `annotations`, `interaction`. `xAxis`, `yAxis` and `series` join them by a different route: `mergeAuthoredPresentation` merges their **presentation** onto the bindings the dataset selection derived, dropping exactly the two binding keys `ChartAxis.field` and `ChartSeries.name` so that series membership and the plotted column stay with the dataset. + - **2 dead.** `chartConfig.type` parses and does nothing on a dashboard widget — the widget's own `type` picks the chart family — and `chartConfig.aria` has no reader on either face: the chart implementation declares no `aria` prop and the ARIA injection reads the flat `ariaLabel` / `ariaDescribedBy` / `role`. Both are pinned as **negatives** by name in the renderer's own tests, which is what makes them re-askable rather than merely asserted. + + Neither `dead` verdict is acted on here. Recording a verdict is what feeds the ADR-0049 enforce-or-remove worklist; executing one moves a published accept set and is a separate, ruled piece of work. + + The drill also makes six containers one level further down visible for the first time (`xAxis`, `yAxis`, `series`, `annotations`, `interaction`, `aria` — 39 child keys). They are **recorded** in the shrink-only undrilled-containers baseline rather than drilled: fanning this row's verdicts down over them would manufacture verdicts with no evidence behind them, which is the one thing the drill rule forbids by name. +- c9eb773: The liveness ledger's published README no longer declares a one-level drill — the walk follows a nested `children` map as deep as the ledger declares, and says so + + `check-liveness.mts` read `led.children[ck]` and never recursed into a child's + own `children`. A `children` map written at **depth two** was therefore accepted + by the file format and then ignored in silence: no evidence path resolved, no + key reported unclassified, no container reconcile, and no line of output saying + any of it was missing. Because the enforce-or-remove channel acts on this gate's + `dead` verdicts, a silently skipped subtree could retire a key that was alive. + + The walk now descends as far as the ledger nests, the reverse (orphan) direction + follows it down, and a drilled child that is itself a container owes the same + declared disposition — drilled, deferred or recorded — that its top-level peers + already owed. `MAX_DRILL_DEPTH` is a tripwire rather than the working limit: + every key below it is reported **UNCLASSIFIED**, which fails the gate, because a + depth limit the instrument does not announce would rebuild the same defect one + level lower. + + **No verdict moved.** Before and after: live 850, planned 10, dead 93, + experimental 5, live-elsewhere 1 — the full per-type `byStatus` map is + byte-identical. Nothing flipped to or from `dead`, so no retirement is in + question. What did move is the census the gate publishes about its own + completeness: 54 containers became visible at once, every one of them already + riding on a blanket verdict below a drilled container where a one-level walk + could not see it. Three are genuinely classified elsewhere (`app/navigation`'s + NavigationItem keys) and resolve as deferrals; the other 51 are recorded debt. + + **Why this carries a changeset rather than `skip-changeset`.** The tool, its + tests and its baseline all live under `packages/spec/scripts/`, which is absent + from the package's published `files[]` — measured at 0 entries in the packed + tarball, against `liveness/` ships at 38 as the lit positive control. But + `files[]` ships the `liveness` directory whole, and `liveness/README.md` is the + ledger's authoring contract: its "Granularity — drill one level" section is what + an author reads before writing a `children` map, and that sentence is now wrong. + The published bytes that change are that section, the depth rule that replaces + it, and the re-stated census. No ledger verdict file changed. +- fbc12be: Two published `describe` sentences that the `.objectui-sha` bump to objectui `87af769e9a3e` makes false are corrected against the new pin (#17429). + + `Clause-②: no` + + Both are claims about what the SHIPPED console renderer does, so the pin is what dates them — and both were re-read by executing the pinned tree, not by refreshing a sha. + + - `Dataset` measure `format`: the clause said "a datetime value ignores it". objectui#8352 lands inside the `53ded82bf7a4...87af769e9a3e` range and makes the datetime arm of `formatMeasureDate` honour the same two words the date arm does — `relative` through `formatRelativeDate`, `short` through `formatDateTime(v, { locale, style: 'compact' })`. A date PATTERN is still ignored on both arms, which is the half of the sentence that survives. + - `FormField.span`: the clause said only the widest container-query tier's class is emitted, so a `'full'` field took one cell of two at the 720px modal width (objectstack#17328). objectui#9244 / objectui#9253 (objectui `bd09957380`) are also inside the range: `spanLadderFor` now emits one clamped col-span class per multi-column tier, so `'full'` is the whole row at every multi-column tier. The `'auto'` half — textarea, markdown, html, richtext and repeater — re-measured unchanged at the new pin. + + No key, default, enum member or export moves: the same authored metadata is accepted and refused as before, and `content/docs/references/ui/{dataset,view}.mdx` are regenerated from these two sentences. +- ec2ede0: `view/layout-without-binding` — the `calendar` warning no longer tells an author the renderer falls back to `start_date` / `end_date`, because objectui deleted those floors; it names the refusal screen the renderer shows instead, and the key that clears it (#17445). + + Two carriers asserted the same deleted behaviour: `VIEW_BINDING_BLOCKS`' calendar row in `src/kernel/functional-completeness.ts` (stated as *measured*, against "the built console 17.2.0") and the body of the warning `checkViewCompleteness` emits on that route. Re-measured on objectui `main` at `0cf2d6644` (2026-09-21), both halves of the path a `type: 'calendar'` list view takes: + + - `packages/plugin-list/src/ListView.tsx`, `case 'calendar'` — the two literal floors are gone (objectui#7029); the branch restates only bindings the view declared. + - `packages/plugin-calendar/src/ObjectCalendar.tsx` — `getCalendarConfig` resolves `null` with neither a `calendar` block nor a flat `startDateField`, and the component renders its "Calendar configuration required" refusal screen, which names `startDateField` (objectui#8170 corrected that screen: `titleField` is not required). + + So the old body was wrong twice — there is no fallback to literal field names, and the failure is not silent. What it was right about is the remedy, and that is the half the new body keeps: it names `calendar.startDateField`, `CalendarConfigSchema`'s one required key, and records that the event title resolves through the ADR-0079 display-name chain when `titleField` is omitted. The `fix` hint is unchanged. + + - **The message is now per type.** `VIEW_BINDING_MESSAGE` carries an entry for a type whose measured outcome is not the generic literal-fallback sentence; the other five types receive the generic body unchanged. A type that stops flooring gets an entry, never a reworded universal — the two carriers drifted apart once, and the map is what makes correcting both one edit. + - **No severity moves — the severity is already ruled.** #16577 ruled **B** on 2026-09-11 (comment `5634033966`, card closed `completed`): the `type: 'calendar'` route stays warning-class under ADR-0078 §1, and it stays there *because* both doors are loud — loud at `os validate` (this warning) and loud at render (objectui#7029 deleted the `start_date` / `end_date` floors; `getCalendarConfig` returns `null` and the named refusal screen is reachable). That is exactly the premise re-measured here, so the corrected row is the evidence the standing ruling rests on, not a change whose severity consequence is pending. + - ⚠️ **The same reading found three sibling rows stale, and they are recorded rather than corrected** — out of this card's scope, and each changes what its row's severity rests on: `gantt`'s four floors are gone (objectui#7070, objectui#7499) and `ObjectGantt` refuses; `timeline`'s `created_at` floor is gone (objectui#7070) while its `titleField || 'name'` stands; `map`'s `locationField || 'location'` is gone on both faces (objectui#8169). The table now carries that re-measurement note so the three are not reused as current fact, and `kanban` / `tree` were re-read at the same ref and still say what they say. + + No schema moved, no export moved and no accept set moved: this corrects prose and one warning string. `Clause-②: no` +- 4342c99: fix(spec): three more lookups refuse an off-vocabulary key instead of handing back an `Object.prototype` member + + `BASE_ALIASES` / `DIALECT_ALIASES` (`canonicalizeSqlType`), + `DEFAULT_VALUE_TOKEN_SUGGESTIONS` (`suggestDefaultValueToken`) and + `CONTEXT_TOKEN_SUGGESTIONS` (`classifyFilterToken`) are plain object literals, so + all three inherit `Object.prototype`, and every lookup into them was a bare + index. Measured by importing the BUILT artifact (`dist/data/index.mjs`) on the + repo's Node 22 baseline (v22.22.2) and driving each function — the same way the + two landed siblings in this family were measured — over a fixed population of + five: `constructor`, `toString`, `valueOf`, `__proto__` and a plain unknown word. + + | call | before | after | + |:--|:--|:--| + | `canonicalizeSqlType('varchar')` | `'text'` | `'text'` — unmoved | + | `canonicalizeSqlType('timestamptz', 'postgres')` | `'datetime'` | `'datetime'` — unmoved | + | `canonicalizeSqlType('constructor')` | the `Object` **function**, out of a signature that admits only `CanonicalSqlType` string literals | `'unknown'` | + | `canonicalizeSqlType('constructor', )` | the `Object` **function** | `'unknown'` | + | `canonicalizeSqlType('__proto__')` | `'array'` | `'array'` — unmoved; the array-notation rule answers ahead of either table | + | `canonicalizeSqlType('toString' / 'valueOf' / 'nope')` | `'unknown'` | `'unknown'` — unmoved | + | `suggestFieldTypeForSqlType('constructor')` | **`TypeError: Cannot read properties of undefined (reading 'suggested')`** | `undefined` | + | `isCompatible('constructor', 'text')` | **`TypeError: … (reading 'exact')`** | `'lossy'` | + | `suggestDefaultValueToken('currentuser')` | `'current_user'` | `'current_user'` — unmoved | + | `suggestDefaultValueToken('constructor')` | the `Object` **function** | `undefined` | + | `suggestDefaultValueToken('__proto__')` | `Object.prototype` — an **object** | `undefined` | + | `classifyFilterToken('{current_user}').suggestion` | `'current_user_id'` | `'current_user_id'` — unmoved | + | `classifyFilterToken('{constructor}').suggestion` | the `Object` **function**, in a field declared `ContextToken` | `undefined` | + | `classifyFilterToken('{__proto__}').suggestion` | `Object.prototype` | `undefined` | + + The two `TypeError` rows are the sharpest consequence and were not previously + recorded: a non-`CanonicalSqlType` reaches `CANONICAL_TO_FIELD[canonical]`, which + is `undefined`, so both published sibling accessors threw on the member read + rather than merely returning something off-contract. `canonicalizeSqlType`'s + `rawType` comes off live database introspection, which is where an + attacker-free, entirely accidental `constructor` actually comes from. + + `classifyFilterToken`'s half is the one a type-checked consumer meets: the + declared `suggestion?: ContextToken` was a compile-time guarantee that was false + at runtime, and nothing in the type system would ever have flagged it. Its + wrapped-token regex captures `[^{}]+` — anything but braces — so the reachable + key set is not the identifier-shaped one; what bounds it is the `toLowerCase()`, + which leaves exactly the lower-case-stable prototype members (`constructor`, + `__proto__`) namable today. `toString` / `valueOf` were quiet by that casing + accident alone, not by a guard. + + All three sites now go through an `Object.prototype.hasOwnProperty.call` check + returning each function's own already-declared refusal value — `'unknown'`, + `undefined`, and an absent `suggestion` respectively. No declared signature + changes. This narrows and widens nothing an author can reach: every legal + spelling is an own key of its table, so nothing accepted before is refused now, + and only answers that were never inside the declared return types move. + + A null-prototype table was the other available shape and is not taken, for the + reason the two landed siblings measured rather than assumed: a `__proto__: null` + object literal does not type-check against the `Record<…>` annotation at all + (TS2353), and the `Object.assign(Object.create(null), …)` spelling that does + compile silently costs that annotation's exhaustiveness check (TS2741 stopped + firing for a table missing a member). A quiet failure is worse than a loud one. +- 132dd13: `KnowledgeSourceSchema`'s docblock stops claiming it is stored as metadata "exactly like a view or a flow", and says where a knowledge source actually lives + + The docblock above `KnowledgeSourceSchema` declared, verbatim: + + > Canonical KnowledgeSource. Stored as metadata, versioned, and + > environment-scoped exactly like a view or a flow. + + None of the three is true, measured on the tree this changeset lands on: + + - `listMetadataTypeSchemaTypes()` returns **26** governed metadata types and + **none is knowledge-shaped**. Controls that fire: `view`, `flow`, `skill`, + `agent` and `tool` are all present; a `zzz_nonsense` dark control is absent. + - `ObjectStackDefinitionSchema` has **44** top-level keys, none knowledge-shaped + (controls present: `skills`, `agents`, `tools`, `views`, `flows`). + - `defineStack({ knowledgeSources: [...] })` is refused with the **generic** + unrecognized-top-level-key message — byte-identical to the message for + `zzz_nonsense`. Lit control: `defineStack({ skills: [] })` is + accepted on the same base, so the probe does find an authoring route for a + type that has one. + + So an author who followed the sentence reached for a mounting that does not + exist and got a rejection that pointed nowhere — the authoring trap, not a + wrong example. + + **The prose was the outlier, not the schema.** No ADR in this repo mentions + `KnowledgeSource` at all, and the rest of the contract is already consistent: + `IKnowledgeService` declares `registerSource` / `unregisterSource` / + `listSources` / `getSource`, `KnowledgeServicePlugin` takes a `sources` option + at kernel wiring and calls `registerSource` for each, and the implementation + holds them in a process-lifetime `Map`. The `agent.knowledge` liveness row says + the same thing from the other side — *"restrict retrieval at the + knowledge-service/source level; describe grounding in `instructions`"*. + + The replacement docblock states what the schema is (the shape of a runtime + registration), names both routes a source actually arrives by, and says the + retrieval restriction is per-source at the service level. + + ⛔ No behaviour, no key and no accept set changes: the diff is one docblock. + Running `gen:schema` and `gen:docs` afterwards produced no artefact change — + `content/docs/references/ai/knowledge-source.mdx` mirrors the file-level header + docblock, not this per-schema one. + + **Why this is not `skip-changeset`.** `@objectstack/spec`'s published `files[]` + ships `dist` *and* `src/**/*.zod.ts`, so this text is published twice over: the + old sentence was measured in the built `dist/knowledge-document.zod-*.d.ts` and + `.d.mts` (1 occurrence each) before the edit, and the source file is shipped + verbatim. Both move. +- dfeba25: `element:record_picker`'s `filter` docblock now says what the `object-*` blocks actually declare + + The docblock on `ElementRecordPickerPropsSchema.filter` (anchor: + `Filter rules narrowing which records the picker offers`) carried a + parenthetical claiming *"the four `object-*` blocks declare `filter` as + `z.unknown()`, no orthography at all"*. Measured on the file itself: there is no + `filter` key anywhere in `packages/spec/src/ui/component.zod.ts` declared + `z.unknown()` — zero occurrences, against 61 occurrences of `z.unknown()` in the + same file on the same instrument, so the zero is a reading and not a broken + matcher. All eight Zod `filter` declarations in the file are + `z.array(ViewFilterRuleSchema).optional()`; the one remaining `filter:` line is a + `KeySetGuidance` prose entry, not a declaration. + + The `object-*` family in `ComponentPropsMap` has **six** entries. **Four** of + them carry a `filter` door — `object-grid`, `object-metric`, `object-kanban`, + `object-calendar` — and all four declare `z.array(ViewFilterRuleSchema)`. The + other two, `object-form` and `object-master-detail-form`, declare no `filter` + key at all. The corrected parenthetical states both numbers and names all six, + and keeps the `#15449` citation, which is accurate as provenance for when those + four doors moved onto the array form. + + **Why this is worth a patch rather than a silent tidy.** The sentence sat in the + one docblock that tells an author what the sibling `filter` doors accept, and it + told them those doors accept anything. The record form it thereby invited — + `{ field: { $eq: ... } }`, the MongoDB-style shape this very docblock says the + picker moved OFF — is refused at parse by all four. Prose only: no declaration + moves and no accept set changes. +- 9059a94: fix(spec): the three shipped confirmation-gate prescriptions state the gate in the present tense — they were denying a door that exists (#17487) + + Clause-②: no + + No accept-set change and no export moves. `ToolSchema` still refuses + `requiresConfirmation` with a located parse error, `ActionSchema` still accepts + `ai.requiresConfirmation` in both directions, and `check:authorable-surface` / + `check:api-surface` are byte-identical across this diff. What moves is text. + + Three customer-facing prescriptions were written while the runtime confirmation + door was a separate, unlanded change, and each said so in the present tense. The + door has since landed on `main` — `actionConfirmationRefusal`, called pre-dispatch + by `invokeBusinessAction` in `@objectstack/runtime`, with the `confirm` member + grown on the MCP `run_action` tool in the same change. From that moment the + published prose DENIED a door that exists, and it denied it in the dangerous direction: an author who + reads it concludes the safety flag stops nothing and either arranges a human in + the loop some other way or stops setting the flag — losing the gate exactly when + it starts working. That is the ADR-0049 false-compliance defect with the sign + flipped. + + **The three carriers**, all of them shipped text rather than comments: + + 1. the `requiresConfirmation` entry of `TOOL_RETIRED_KEY_GUIDANCE` + (`ai/tool.zod.ts`), which reaches consumers as the parse error on the + `.strict()` `ToolSchema` — the one channel every consumer bumping + `@objectstack/spec` is guaranteed to hit; + 2. the ADR-0087 D3 entry's `replacement`, and + 3. its `acceptanceCriteria` — what `spec-changes.json`, + `docs/protocol-upgrade-guide.md` and `os migrate meta` project to consumers. + + FROM → TO, on the sharpest of the three (the acceptance criterion): + + ``` + was: Do NOT try to "prove the gate" by invoking the operation without the + confirmation member: ... before that ships the call is not refused, it + RUNS the destructive operation. + now: ... that gate is PERFORMED: invoking the operation over an AI-exposed + door without the confirmation member is REFUSED with + ACTION_CONFIRMATION_REQUIRED (428) and nothing runs, so that call is a + real check you can make rather than a destructive experiment. + ``` + + **The corrections carry the door's BOUNDS, because over-promising here is the + same defect in the other direction.** Each prescription now states, as the door + itself declares them: the refusal is `ACTION_CONFIRMATION_REQUIRED` / 428 naming + the action and the member `confirm: true`; it is a GATE, not a queue — nothing + is parked and a refused call did not run, no record read and none written; the + enforced set is the doors that enforce the author's `ai.exposed` opt-in, today + the action door reached from the MCP `run_action` tool, while REST `/actions` is + not `ai.exposed`-gated and sits outside the gate; only the author's declared + `ai.requiresConfirmation: true` refuses, while the wider listing heuristic + advises and never refuses; and `confirm: true` is an unverifiable caller claim, + so the gate makes FORGETTING loud without proving a human. + + `ai/tool-confirmation-prescription-tense.pin.test.ts` is the tie that was + missing the first time: it reads the three shipped strings AND the runtime door, + so a prescription that re-acquires a not-yet-shipped denial fails, and a door + that is removed, narrowed off the DECLARED flag, unhooked from + `invokeBusinessAction`, or widened onto REST `/actions` fails naming both files. + The denial predicate is fed the three retired sentences verbatim, so it cannot + pass by the prose merely falling silent. + + **On release ordering.** The door ships in the same release this correction + does: the runtime changeset that carries it (`action-confirmation-gate-enforced`) + is still pending alongside this one, and one `changeset version` run consumes + both. A release cut before this lands is the failure this card exists to end — + the runtime refusing calls while the published spec text tells authors the flag + stops nothing. +- 0a88a80: docs(spec): the structural-condition ruling and the ADR-0087 entry both name the NODE slot (#17493) + + Two places in `packages/spec` still described the world as it was before the + blank structural condition became a defect. Neither changes behaviour: this is + the notification half of a refusal that has already shipped. + + **The ADR-0087 D3 entry `flow-edge-condition-evaluated-slot-source-required` + named only the edge key.** Its `surface` and `acceptanceCriteria` told a + consumer replaying the chain to sweep `edges[].condition` and nothing else — + so a deployment carrying a blank `config.condition` on a flow node was never + told to look, even though `AutomationEngine.registerFlow` refuses it since + #17322 and `objectstack validate` since #17495. Both fields now name both + structural slots, the node key's own locator + (the phrase the structural pass builds, e.g. `node 'gate' (start) condition`) is + stated beside the edge's `flows.N.edges.N.condition`, and the sweep carries the + warning that removing a `condition` from a `start` node opens the trigger gate + rather than preserving it. The entry's `id`, `replacement` and `reason` are + untouched, and no new entry is added: this is one decision reaching its second + slot, not a second decision. + + **`structuralConditionRefusal`'s docblock stated a ruling that had become + false.** It admitted a whitespace-only string on the ground that such a + condition "is consistent on both sides and is ruled correct, not a defect" — + the ground #15807 removed at the edge door and #17322 ruled on. The admission + itself is unchanged and still correct, because this function answers the SHAPE + question only and the blank is refused beside it by the imported + evaluated-slot rule; what the docblock now records is which card removed the + ground, which door each refusal lives at, and why the two refusals are kept + distinct. + + It also records, without answering, the question one slot over: the ledger + `predicate` slots (`config.conditions[].expression`, + `screen.fields[].visibleWhen`) still admit a whitespace-only string, pinned as + correct by #15572 on the same ground. Narrowing them re-judges that pin and + moves a published accept-set, so it is a ruling and stays open on #17493. +- c199772: `SelectOptionSchema`'s six row properties carry a JSON Schema `title`, so Studio's property panel stops printing raw machine keys as the column headers of a field's `options` table (#17506). + + Clause-②: no + + Studio renders a `type: 'repeater'` form field as a table whose column headers read `items.properties[k].title ?? k` off the JSON Schema derived from the metadata type schema. `SelectOptionSchema` carried no `title` on any row property, so the fallback arm ran and the maker saw `label` / `value` / `description` / `color` / `default` / `visibleWhen` inside an otherwise translated panel — **in every locale, English included**. Titles are hard-coded English by design: `system/translation.zod.ts` states that a row property renders from `items.properties[k].title`, and `resolveMetadataFormSchemaTitles` only ever REPLACES a title that is already there, so an untitled property has no layer for a translation to overlay. + + - **One edit clears two carriers.** `field:options` and `object:fields.options` resolve to the *same* `SelectOptionSchema` object — `FieldSchema.options` is `z.array(SelectOptionSchema)` and `object.fields` is a `z.record(..., FieldSchema)` of that same `FieldSchema` — verified by object identity (`===`) against the schemas `getMetadataTypeSchema('field')` and `getMetadataTypeSchema('object')` actually return, with `FormSelectOptionSchema` as the firing control that the probe can tell two schemas apart. Both entries are deleted from the shrink-only `repeater-item-titles` ledger in the same change; `object.zod.ts` needed no edit. + - **Nothing the schema accepts or refuses moved.** `.meta({ title })` is presentation metadata: the generated `authorable-surface/` artifacts are byte-identical, and the pinned accept/refuse suites for this shape (`editability-boundary`, `visible-when-alias-guidance`, `form-select-option`, `evaluated-slot-population`) pass unchanged. + - **The form-view face inherits the titles for free.** `FormSelectOptionSchema` is a shape-level Omit that reuses the same property schema instances, so the five keys it keeps arrive titled too, and its `default`-refusal is untouched. +- f5a7250: The row properties of the `view.columns`, `view.tabs` and `view.sort` repeaters carry a JSON Schema `title`, so Studio's property panel stops printing raw machine keys as the column headers of those three tables (#17507). + + Clause-②: no + + Studio renders a `type: 'repeater'` form field as a table whose column headers read `items.properties[k].title ?? k` off the JSON Schema derived from the metadata type schema. None of the 25 row properties of the three view repeaters carried a `title`, so the fallback arm ran and the maker saw `field` / `width` / `isDefault` / `order` in every locale, English included. + + - **`view.columns`** — the 14 row properties of `ListColumnSchema` (`Field`, `Label`, `Width (px)`, `Alignment`, `Hidden`, `Sortable`, `Resizable`, `Wrap Text`, `Renderer Type`, `Pinned`, `Summary`, `Prefix`, `Primary Link`, `Click Action`). + - **`view.tabs`** — the 9 row properties of `ViewTabSchema` (`Name`, `Label`, `Icon`, `List View`, `Filter`, `Display Order`, `Pinned`, `Default Tab`, `Visible`). + - **`view.sort`** — the list view's INLINE `{ field, order }` sort entry (`Field`, `Direction`). It is not the shared `SortItemSchema`, so titling that schema never reached this table; the titles mirror it. + - **Nothing the schema accepts or refuses moved.** `.meta({ title })` is presentation metadata: the generated `authorable-surface/` artifacts are byte-identical. The repeater-title ledger loses its last three entries and is now empty, so every repeater a form declares is fully titled and a new untitled one fails its own PR. +- 2eb4724: `ApproverType` qualifies `manager` in its `.describe()` instead of offering it as a bare allowed value + + `ApproverType` carried **no** `.describe()` at all, so the generated reference + page rendered `## ApproverType` with nothing but an `### Allowed Values` list: + `manager` — the one rung an author cannot operate on a stock install — read + exactly like the nine members that work. `{ type: 'manager' }` resolves + `sys_user.manager_id`, and that column still has no product write surface + (re-measured on this tree: the identity write guard's managed-update whitelist + for `sys_user` is `{name, image, locale}`; the column carries `readonly: true`; + no `packages/plugins/plugin-auth` source writes it). An author who chose it got + a chain that passed `validate` and `lint` and then stalled on its first + submission. + + The new describe says what is true about `manager` and **points** at the remedy + rather than restating it: `MANAGER_ONLY_REMEDY` / `MANAGER_ONLY_ROUTES` in + `packages/lint/src/validate-approval-approvers.ts` remain the single + authoritative copy of the population routes, and that file's `DEPENDENCY` + docblock now names this new string among the lines that go stale if the column + ever gains a write surface. A pointer cannot drift into disagreement with what + it points at, which is why no third copy of the 667-character remedy was added. + + ⛔ No member is added, removed or renamed, and no behaviour changes: the enum's + accept set is byte-identical and `check:api-surface` is green on the rebuilt + `dist/*.d.ts`. + + **Why this ships, and why `patch`.** `@objectstack/spec`'s published `files[]` + carries `dist`, `json-schema` and `src/**/*.zod.ts`, and the new string is + measured in all three on the built tree — `dist/automation/index.js` and + `.mjs` (2 files, against a lit control of an existing describe from the same + module, also 2), four `json-schema/` documents (`ApproverType.json`, + `ApprovalNodeApprover.json`, `ApprovalNodeConfig.json`, `objectstack.json`) and + the shipped `approval.zod.ts` source. Prose only, no surface widening ⇒ + `patch`. + + The `packages/lint` half is a docblock comment and is deliberately **not** + graded: that package publishes `dist` only, and the new sentence is absent from + it (0 files) while a runtime string from the same source file is present in 4 + and a pre-existing comment from the same docblock is absent in 0 — so comments + are stripped by construction and nothing published moves there. +- 6b97a20: fix(spec): the 17 → 18 chain now NAMES the bare `element:filter` / `element:form` node it leaves behind, instead of ending schema-invalid in silence (#17594) + + `element:filter` and `element:form` were retired whole at element grain, and the + two ADR-0087 D2 conversions that carry the retirement — `element-filter-removed` + and `element-form-removed` — strip every authorable key and **deliberately leave + the bare component node**: deleting an authored page node changes a page's + layout, which a mechanical conversion must not decide. That residue was inert + until both names joined `RETIRED_PAGE_COMPONENT_TYPES` and the parse began + refusing them by name — at which point deleting the node stopped being optional + and became a required step of the upgrade. + + The chain never said so. Measured on a stack carrying both nodes, before this + change: + + ``` + os migrate meta --from 17 --to 18 + + --json schemaValid: false + human path "Migrated stack does not yet pass schema validation — + resolve the manual changes above" + the 115 step-18 todos 0 name `element:filter`, `element:form`, + `ElementFilter` or `ElementForm` + ``` + + ADR-0087 D3 requires a structured TODO "rather than silence" for a migration + step that cannot be expressed declaratively, and this is one: only the author + knows what their region should hold once the node is gone. The new + `element-filter-and-form-node-refused` semantic entry supplies it — surface, the + two replacements (`userFilters` for the filter, the object-bound `object-form` + block for the form) and an `os validate`-clean acceptance criterion — so + `os migrate meta` and the generated upgrade guide both name the thing to delete. + + ⛔ Nothing about either conversion's behaviour changes: they still strip the keys + and still leave the node, and no node is deleted for the author. + + +- 497655f: fix(spec): `requiresFeature` refuses a blank-`source` CEL `visible` instead of composing a predicate that can never parse (#17631) + + Clause-②: no + + `lowerRequiresFeature` lowers the `requiresFeature: ''` sugar into the canonical `visible` CEL predicate, and its own docblock states the ADR-0078 rule it enforces: a composition that could never take effect is a loud parse error, not a silent one. The guard that enforced it tested the TYPE of `source` (`typeof existing.source !== 'string'`), so a whitespace-only `source` — legal on `ExpressionSchema`, which is the persistence contract and whose `min(1)` whitespace clears — passed it and the gate was composed AROUND a blank operand: + + ``` + visible: { dialect: 'cel', source: ' ' } + requiresFeature: 'organization' + → { dialect: 'cel', source: '( ) && features.organization != false' } + ``` + + That predicate parses on no scope at all (`celEngine.evaluate` answers `kind: parse`, `Unexpected token: RPAREN`), so at render the gate faults instead of gating: fail-soft surfaces show the element regardless of the flag, fail-closed surfaces hide it regardless of the flag. Either way the flag decides nothing — the parses-clean-changes-nothing arrival the guard exists to reject, produced by the guard's own composition step. + + The lowering now refuses a `source` that is blank after trimming, on the same leg as the AST-only refusal one line above, with a refusal that names the composition it would have produced and both exits (drop the blank `visible` and the sugar emits the gate alone; or write the predicate the gate should compose with). The notion of blank is `source.trim()` — the one the engine's own helpers apply — so a `source` that is merely padded around real text still composes verbatim. + + - **Refused at the producer, not tolerated at a consumer.** No renderer gains a fallback for the unparseable predicate; the lowering stops emitting it. + - **Both slots that compose the sugar inherit it** — `ActionSchema.visible` and `ActionParamSchema.visible` — because the rule lives in the shared lowering rather than in either slot's declaration. + - **`ExpressionSchema` / `ExpressionInputSchema` are NOT narrowed.** They remain the persistence contract, and a blank-`source` `visible` with no `requiresFeature` beside it still parses exactly as before. What is refused is the COMPOSITION, which is the thing that could never work. + - **Nothing that functioned stops functioning.** The only authoring this refuses is one whose output faulted at CEL parse on every scope, so the migration is the refusal's own prescription and there is no working shape to port. +- 092d460: **ADR-0137 makes field-rule predicate fault semantics part of the contract** (#17778): what SUBMIT and RENDER do when a predicate cannot run. + + Clause-②: no + + **The predicate fault-semantics contract is recorded, not enforced, by this release.** ADR-0137 states what a field-rule predicate does when it cannot RUN: at SUBMIT a faulting predicate refuses the write and names the field and the rule (D2); at RENDER visibility stays fail-OPEN, so a rule that could not run never hides a control and lets the form write `null` over a column the user never saw (D3); a blank or faulting GATE predicate is diagnosed, never a silent `true` (D4); and the evaluation helper's fallback stays freely specifiable (D5), because fault-to-flag and fault-to-throw both exist only because it is a parameter. Those are consequences CONSUMERS deliver — `packages/spec` carries no business logic — and they land in the ObjectUI half. D1's authoring refusal (an `ast`-only envelope and a blank `source` are refused at authoring) is ruled by decision batch #122 item 2 and ships with the evaluated-slot narrowing that owns it, under that change's own ADR-0087 entry. + + **ADR-0089 gains an addendum, not a reopening.** It unified the `visibleWhen` / `visibleOn` / `visibility` family under one name; ADR-0137 owns what that family does when a predicate cannot run, and ADR-0089 itself is unchanged by this release. + + **Not carried by this entry: the `cel` / `expression` return-type narrowing to `EvaluatedExpression`.** This card touched that signature too, but main shipped the identical narrowing first, under #18638 (card #15811) — see that release's own changeset for the `EvaluatedExpression` story and the TS2322 it fixes. Restating it here would announce, a second time, a fact this release has already shipped under a different entry. +- 00c332b: Three duration keys now name their unit in the `.describe()` prose that reaches the published output, not only in the key name and the JSDoc above them: `PluginLoadingEvent.durationMs` (`kernel/plugin-loading.zod.ts`), `AppInstallResult.durationMs` (`system/app-install.zod.ts`) and `MigrationPlan.estimatedDurationMs` (`system/deploy-bundle.zod.ts`). + + The first carried no `.describe()` at all, so the generated reference row for `durationMs` rendered an empty description cell; the other two said `Installation duration` and `Estimated execution time`, naming a duration with no unit. All three JSDoc blocks already said milliseconds, and all three key names already carry `Ms`. Only the channel an author — very often a model (ADR-0033) — actually reads was missing it. + + ⛔ Not a rename, and no key moves: the unit is already in the key name, which is what the #14478 rule asks for. This is the describe-only remediation of Ruling A on #15939, and it is the one of the seven remediations that needs no ADR-0087 conversion, no tombstone and no published-key rename. + + **The published surface was measured rather than assumed**, because a changeset is owed only if the changed text actually ships. Measured after `pnpm --filter @objectstack/spec build`, over the paths this package's `files[]` actually publishes: + + - **The changed text ships.** `Duration in milliseconds` reads 24 occurrences across 12 `dist/` bundle files and 6 across `json-schema/`; the other two read 8 in `dist/` and 2 in `json-schema/` each. The generated reference pages under `content/docs/references/**` render all three rows and are regenerated in this change. + - **Positive control that ships**: the neighbouring describe `Objects created/updated` — `dist` 4, `json-schema` 2. + - **Negative control that does not ship**: `no exemption by blindness`, a sentence that exists only in `packages/spec/scripts/`, a path outside `files[]` — 0 across every published path, 1 in its own unpublished file. + - **Dark control**: a fabricated needle reads 0 everywhere, so a zero above is a reading rather than a broken instrument. + + One measured refinement worth recording for the next author, since it cuts against the obvious reading of "published output": **`dist/` alone does not discriminate the two prose channels.** JSDoc text and even a `//` line comment ride into the emitted bundles verbatim (`Objects created or updated`, JSDoc-only, reads 4 in `dist/`). What separates the channels is `json-schema/`, which carries describe prose and 0 comment prose. So `dist` presence is necessary and not sufficient evidence that a string reached the governed channel; the `json-schema/` reading is the one that decides it. +- d93400f: docs(spec): the AI Operations note said the slot 404s when no AI service is mounted — it has answered 501 since the shared `capabilityUnavailable` exit landed (#17847) + + Clause-②: no — prose only. No schema key moves, no accept set widens or narrows, no export changes, and no runtime behaviour is touched; `packages/runtime` is not in this diff. + + `src/api/protocol.zod.ts` ships inside this package (`files[]` carries `src/**/*.zod.ts`, and `npm pack --dry-run` lists `src/api/protocol.zod.ts` among its 2016 entries), so the sentence an author reads is a published byte. It said: + + > this repo's dispatcher only proxies `/api/v1/ai/**` to whatever `buildAIRoutes()` mounted, or 404s "AI service is not configured" + + Both halves were stale. `packages/runtime/src/domains/ai.ts` reaches the shared `capabilityUnavailable(deps, 'ai')` exit, which answers **501 Not Implemented** — `/ai/*` IS mounted, so the request reaches a handler with nothing behind it, and 404 would claim the path does not exist. And the quoted body is no longer a local string: it comes from the shared `serviceUnavailableMessage`, the same sentence `discovery.services.ai` reports for the slot, so the 501 body and the discovery entry cannot drift into naming different remedies. The literal `AI service is not configured` survived nowhere in the tree except in that stale comment. + + The replacement is the same three-arm text the other three live sites carry after #16211 / PR #17844 (`packages/client/src/index.ts`, `packages/runtime/src/route-ledger.ts`, `packages/runtime/src/domains/ai.ts`), because an unqualified "`/ai/*` answers 501" would manufacture a second inaccurate statement: + + - an **anonymous** caller is refused **401** first (`ANONYMOUS_DENY_STATUS`), ahead of the slot being consulted — neither the 501 nor the courtesy below is owed to a caller who has not authenticated; + - **`GET /ai/agents` answers 200** with an empty list (`{ agents: [] }` under the envelope's `data`) — a deliberate console courtesy, so polling does not log an error on every navigation; + - every other `/ai/*` route answers **501** carrying the shared remedy sentence. + + All three arms were measured rather than copied: `packages/runtime/src/domains/ai-anonymous-deny-ordering.test.ts` pins each of them and passes 13/13 on this tree. +- 5c28cc7: `ResumeFailureReport`'s docblock no longer invites a caller to parse that member with `ResumeFailureDetailsSchema` — the one path that deletes the report's `code`, silently. + + The docblock said two things in one paragraph: that a caller "that parses this member with `ResumeFailureDetailsSchema` reads the same three facts it reads off that door", and that `code` is the one member a success envelope cannot leave to its envelope, because on a success answer nothing else names the failure class. Each sentence is true on its own; together they route a reader into losing exactly the member the second one calls indispensable. `ResumeFailureDetailsSchema` declares `runId` / `status` / `repairable` and not `code`, and it is a plain non-strict `z.object`, so the key is stripped — measured on this tree, `safeParse` of a full report answers `success: true` with `error: undefined` and hands back an object with no `code` at all. No refusal, no `unrecognized_keys` issue, nothing logged. + + - **Prose only — no schema moves, deliberately.** `ResumeFailureDetailsSchema` is the wire schema of the automation resume door's `400 FLOW_FAILED` `error.details`, where the registered code rides on the `error` envelope it is parsed beside. Declaring `code` on it would put a second spelling of the failure class on that door's answer, widen a published accept surface, and break the "declared ONCE" identity the contract pin asserts — the report minus its `code` IS `ResumeFailureDetails`. The defect is in the sentence that misdirects, not in the schema, which is correct where it is actually used. + - **What a consumer does instead:** read `code` off the report. It is typed `ErrorCode`, required, and needs no parse. That schema stays the right reader for the three shared members, and the right reader on the resume door. + - **Both halves are pinned** in `contracts/resume-failure-report.pin.test.ts`: that the strip is silent (parse succeeds, no issue raised, no `code` in the output), and that the docblock carries the warning and no longer carries the invitation. Prose is unassertable except by reading it, so the contract source is read — the pattern that file already uses for the absence rule. + + Clause-②: no +- a83dbb6: A package whose `manifest.permissions` carries the ADR-0025 capability grant is now NAMED when the audience-binding reconciler skips it, instead of vanishing; and both halves of the `permissions` key now point at each other in the spec (#18031). + + `permissions` has two incompatible readings and the package registry stores both in the same slot. At the AUTHORING stage `ManifestSchema.permissions` is the capability grant a plugin requests — the legacy flat `string[]`, or the structured `{ services, hooks, network, fs }` block (ADR-0025 §3.2). At the ASSEMBLED stage the collection wins and the same key is the ADR-0090 `PermissionSet[]` collection (`AssembledPackageBodySchema`, ADR-0130 D4). `SchemaRegistry.installPackage` records whichever stage its caller handed it. + + - **`collectDeclaredSuggestions` reports the reading it cannot use.** It wants the assembled one. Handed the authoring one it returned an empty list and logged nothing: the structured arm is an object, so `Array.isArray(manifest.permissions)` was false and the value never entered the loop; every member of the legacy arm is a bare string, so `consider`'s `typeof ps !== 'object'` line dropped all of them. A package declaring the other reading produced no `sys_audience_binding_suggestion` row, no prompt and no log. It now warns once per engine per package and arm, naming which arm it found, what is lost if the author meant permission sets (no admin is ever prompted to bind the set, and the deployment goes on looking healthy), and where the sets belong — the package's own `defineStack({ permissions: [ … ] })`, which is what the assembled body carries. + - **`warn`, not `error`, and deliberately.** Nothing here claims to have persisted anything, so this is a functional degradation — a prompt that is not offered. Same reasoning, one step weaker, as the write-refusal report beside it, and the same sink (`SuggestionDeps['logger']`, which declares no `error`). + - **Reported once per engine per package+arm.** The pass runs at boot, after every package-door `permission` publish and on every list call, while a manifest's shape is fixed for as long as that package is installed; an undeduplicated line would repeat on every console page load and be skimmed past. + - **The spec half is declaration text only — no key, export, arm or accept-set moved.** `ManifestSchema.permissions` now says it describes the AUTHORING stage and names the assembled-stage counterpart; the stack collection `permissions` names the manifest-stage grant; and `InstalledPackageSchema.manifest` says it is the authoring STAGE rather than "whatever was stored", pointing at `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` for the stage a `defineStack()` host installs. + - ⛔ **The union at the key was NOT widened, and must not be.** Widening a manifest key into a union of both stages is road C of #14242, rejected by name by the maintainer on 2026-09-02 in favour of road B — declare the assembled stage rather than widen the authoring one — because a union at the key makes neither stage checkable (Prime Directive #12). That ruling is why the fix here is a report and a cross-reference rather than a schema change. +- d3a2331: fix(spec,core): every ADR-0049 tombstone names the npm release that actually carries its removal, and a gate keeps it that way (#18048) + + Clause-②: no + + Thirty-six sites across fifteen files dated a removal to the next npm major of + `@objectstack/spec` — a bare **18** attached to the package name. + There is no npm 18, and under ADR-0087's level ruling (Amended 2026-09-13) there + will not be one as the carrier for a retirement: *"A tombstone names the npm + release it ships in, ⛔ never the protocol major […] a retirement shipping + `minor` lands in `17.x.y`"*. An author who met one of these was sent to a + version that does not exist. A sibling repository had already hung a cleanup + schedule on "the PR that pushes the spec package to its next major" — an event + that will never come. + + **The number was determined per site from `packages/spec/CHANGELOG.md`, not + pasted.** The sites split cleanly in two, and the two halves take different + spellings because different things are known about them: + + - **Already shipped ⇒ the release that carries it.** The three + `PluginHealthCheck` restart keys (`b72db01`), `HotReloadConfig.stateStrategy` + / `distributedConfig` (`4635f3e`), `HotReloadConfig.watchPatterns` + (`ee3595c`) and the form-view `options[].default` narrowing (`c459da6`) all + landed in **`17.3.0`**, which is published. These say `17.3.0` — the version + an upgrading reader greps in the CHANGELOG. + - **Not shipped yet ⇒ the bare published major `17`.** The seven cron-typed + positions, the `scheduled` cache-warmup strategy and the three + `PluginStartupResult` members are still unreleased changesets, so the carrier + minor is unknown at authoring time and any digit would be a guess — the same + guess that produced this defect. ADR-0087 guarantees the major: a pre-GA + retirement ships `minor`, so the carrier is some `17.x.y`. Bare `17` asserts + exactly what is known, cannot go stale as minors accumulate, and is the house + form already on 588 other sites. + + **Why `Clause-②: no`.** Every affected string is a docblock, a doc page, or a + `retiredKey()` / `guidance` MESSAGE. The key is refused before and after, so the + accept/reject result does not move for any input. The control that decides it: + the phrase has 0 hits across `packages/*/api-surface` and + `packages/*/export-origins`, so no published declaration baseline carries these + sentences and none moves. + + **No protocol-major reference is altered.** `toMajor: 18`, `step18` and the + `PROTOCOL_VERSION` ladder are correct and untouched — ADR-0087: *"the two move + independently"*. + + **Three sites are deliberately left saying 18**, because they quote the wrong + number in order to forbid it: the ADR-0087 ruling itself, and the two + `docs/v17-docs-sweep.md` rows that carry this class's detection fingerprint. + Four more say `99` on purpose — a synthetic "next major" fixture that must name + a version that does not exist. + + **A gate lands with the prose**, because this is the class's second appearance: + ten sites of it were corrected by hand in July with no gate, and the card closed + `completed`. `pnpm check:future-spec-major` derives the class from + `packages/spec/package.json` at runtime — a major above the published one, never + a hardcoded 18 — joins string-concatenation, JSDoc and plain-wrap line breaks + before matching, and reads the backticked package name, because each of those is + an independent way for a matcher to read zero and print green. +- abb01f1: `latencyMs` and `frequencyHours` name their unit in the published describe, and `check:duration-unit-keys` refuses the agreement shape + + `AIUsageRecord.latencyMs` carried no `.describe()` at all, and + `DatabaseLevelIsolationStrategy.backup.frequencyHours` described `'Backup + frequency'`. Both keys already carried their unit in the key NAME and in a JSDoc + block above it — and neither of those is a channel the published JSON Schema or + `content/docs/references/**` prints. So the reference page published + `frequencyHours | integer | Backup frequency` and left the reader to infer the + unit from the key name, which on a duration is a guess with a 3600x error on the + other side of it. Both describes now name the unit, and the `description` in the + shipped JSON Schema moves with them. + + **Ruled 2026-09-18 (decision batch #158 item 5, letter A).** The AGREEMENT shape + — a unit in the key name, the SAME unit in the JSDoc, none in the describe — IS + an offence. `check:duration-unit-keys` carried a carve-out + (`!jsdocUnits.some((u) => keyUnits.includes(u))`) that spared it for one release + while the question sat open, together with two self-test cases pinned as + DEFERRED and a header note recording shape (b) as repealed. The carve-out is + gone, those two cases are POSITIVE controls, and shape (b) is a base refusal + again. Agreement between a key name and a source comment is agreement between + two channels the published page does not print; it says nothing about the one + it does. + + ⚠️ **This also makes an already-published sentence true.** The changeset for + #15939 states that the gate refuses a key whose JSDoc names a unit its describe + does not, *"or there is no describe at all"* — which over-claimed by exactly the + two rows above while the carve-out stood. The two rows are remediated and the + carve-out is removed, so the claim now holds of the gate; nothing is edited in + place to make it hold. + + The `EpochMs` instant exemption reads the JSDoc channel too, riding the same + ruling. It refused a describe that contradicted the schema but never a JSDoc + that did, while the duration-type exemption beside it refused all three + channels — the same lie with two answers depending on which exemption class the + key fell into. No row in the tree carried the shape; a fixture pair pins it. + + ⛔ No published key, accept set, default or runtime behaviour moves. The two + changes to shipped artefacts are `description` strings. + + Clause-②: no +- 02bdeaa: `collectFlowGraphs` no longer hands out a `FlowGraph` whose `edges` can hold a non-record — the sibling list #16752's repair did not reach (#18102). + + `FlowGraph.edges` is declared `readonly FlowEdgeParsed[]`. The walk forwarded it untouched, four lines from the node-side member filter the same walk has carried since #16752, and a nested region's edge list is admitted on `Array.isArray` alone — which proves the LIST and never its MEMBERS. A YAML `edges:` list item left empty deserialises to `null`, and a region its own schema refused is left RAW for `validateControlFlow` to name, so the producer handed out an array holding a member its own declared element type excludes. Measured on `main`: + + ``` + collectFlowGraphs({ nodes: [start, loop{ body: { nodes: [], edges: [null] } }], edges: [] }) + graph[1] scope="loop 'lp' body" edges=[null] declared readonly FlowEdgeParsed[] + ``` + + - **The junk member is DROPPED, per list**, through the same one predicate the node side uses (`isRegionDict`), so the two lists the walk hands out cannot drift from each other. Copy-on-write per list: a well-formed flow is handed back the very same arrays. + - ⭐ **The real edge beside it is still HANDED OUT**, and so is the node list. "No non-record members" is half a contract — a filter that emptied `edges`, or reached into `nodes`, would satisfy it. Both are pinned. + - **This is a drop in the producer, not a refusal.** No authoring door's accept set moves: `FlowSchema.safeParse` still returns an envelope rather than throwing, the region `safeParse` refusal in `validateControlFlow` still owns and still reports the malformed region, and `FlowGraph.path` still indexes the RAW node list so a Zod issue stays anchored where the author wrote it. ⛔ Not a looser signature either — the declared element type is unchanged and is now true. + - **Latent, not live — measured, and not for the reason the filing gave.** There are THREE `graph.edges` consumers on the tree, not two. The two in `packages/lint` coerce through `recordsOf` (#16910). The third is `packages/services/service-automation`'s registration pass, which reads `.id` / `.source` / `.target` straight off each member with no guard, and is shielded only by call ORDER — `validateControlFlow` refuses the malformed region a few frames earlier in `registerFlow`. So no throw is reachable today, by one belt more than was counted. After this change the declared type carries it, and the next consumer needs neither a coercion nor a call-order argument. + - **`analyzeRegion` is not one of those consumers.** It throws a `TypeError` on a `null` / `undefined` edge member (measured), but nothing routes producer output into it: its in-repo callers hand it post-`safeParse` region data. It reads an edge list, it does not read `FlowGraph.edges`. + - **No behaviour changes on well-formed metadata.** The only input whose handling moves is input whose declared type already said it could not exist. + + Clause-②: no +- bb9794a: The liveness ledger's published README no longer claims the metadata-type registry is "exactly the set of authorable metadata types" — the governance denominator is now that set, and every run prints it + + `check-liveness.mts` built its coverage denominator from + `listMetadataTypeSchemaTypes()` under a comment stating that function returns + "exactly the set of *authorable* metadata types", and the ledger README carried + the same sentence. It is false in a specific, load-bearing way: that function + deliberately does not enumerate `UNREGISTERED_KIND_SCHEMAS` — enrolling those + entries there "would claim a status this change is careful not to grant" — while + the kinds bound in that map are authored on every boot through their stack + collections (`connectors:`, `sharingRules:`, `analyticsCubes:`, `webhooks:`) and + on every write through `PUT /api/v1/meta/:type/:name`, whose `resolveOverlaySchema` + resolves them through `getMetadataTypeSchema()`. + + So `connector`, `sharing_rule` and `analytics_cube` sat in **neither** `GOVERNED` + **nor** `PENDING_GOVERNANCE`, and a type in no bucket produces no row in any of + this gate's lists. The blindness was therefore invisible in the gate's own + output: `ungoverned: []` read exactly the same whether the gate had looked and + found nothing or had never looked at all. + + The denominator is now `authorableTypes()` — the registered kinds UNION + `listUnregisteredKindSchemaTypes()`, the enumeration helper that exists so a check + can read that map and which grants nothing by listing a name. The registry itself + is untouched: no kind is registered, no enum grows, no create seed is demanded and + no accept set moves, and the same split already landed one gate over as + `reachabilityRootTypes()` in `build-schemas.ts`. The three newly visible types are + recorded as declared debts with a reason and an issue number apiece, which is what + the ratchet asks for and what the README now says; the direction of travel is out + of that map and into `GOVERNED`. + + Every run also prints the denominator and its composition unconditionally. That + line used to appear only when `PENDING_GOVERNANCE` was non-empty, so the one state + worth reporting — "N authorable types looked at, none unaccounted for" — rendered + as nothing at all, which is the same silence an unseen type produces. +- 340b6dc: docs(translation): the two `TranslationData` / `TranslationItem` `@example` blocks stop teaching a `messages` id that cannot resolve (#18190) + + `messages` is declared a flat `Record` (`translation.zod.ts` — `messages: z.record(z.string(), z.string())`), while `t()` resolves a key by walking its dot path segment by segment. Both implementations do this, identically: + + - `packages/core/src/fallbacks/memory-i18n.ts` — `resolveKey()`, `key.split('.')`, walked by `t()`; + - `packages/services/service-i18n/src/file-i18n-adapter.ts` — a second `resolveKey()` with the same body, walked by `t()` through `resolveFromLocale()`. + + So an id that merely *contains* a dot is one flat key named `common.save`, and `t('messages.common.save', …)` looks for a nested `common` object, finds a string or nothing at the first hop, and returns the key itself. Both docblock `@example` blocks on this schema demonstrated exactly that id — the doorway an author (or an authoring agent) copies from. + + - The JSON example on `TranslationDataSchema` and the TypeScript example on `TranslationItemSchema` now author `commonSave`, the single-segment spelling `content/docs/protocol/kernel/i18n-standard.mdx` already prescribes and `packages/plugins/plugin-audit/src/translations/messages.ts` already applies to its own bundle. + - Both docblocks now state the rule, so the counter-example is named as one rather than demonstrated. + + ⚠️ **The schema still accepts a dotted id** — nothing is narrowed here and no key is retired. Whether the door should refuse a dotted `messages` key narrows a published accept set and rides its own card; this change is the doorway half only. + + For authors: a `messages` id containing a dot never resolved, so re-spelling one single-segment (`'common.save'` → `commonSave`, looked up as `messages.commonSave`) turns a key that was returning itself into one that translates. No key that resolved before stops resolving. +- 0f1cd83: The one `timeDimensions[].dateRange` refusal sentence names an EMPTY bound for what it is, instead of handing its author back the shape they just wrote (#18278). + + `AnalyticsDateRangeSchema`'s array arm is `z.tuple([z.string(), z.string()])` — it judges arity and bound TYPE, never a bound's VALUE — so `['', '']` is **accepted** at every schema door and refused past it, by each face's own empty-bound check (`service-analytics`' `date-range-array-arm.ts`, `driver-memory`'s `memory-analytics.ts`). That is the residue `analyticsDateRangeUnrecognizedError`'s header in `@objectstack/core` already named. Measured at `ObjectQLStrategy.dateRangeBounds` before this change, its author read: + + ``` + … ; received a two-element array. Refused past the schema door, by the analytics reader + that received it (ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400). + ``` + + — the arity they had written, with the value never echoed on this path and nothing said about what was wrong with it. After: + + ``` + … ; received a two-element array whose bounds are both empty strings. … + ``` + + - **Named at the bound that is empty** — `['', b]` and `[a, '']` say `whose start bound is an empty string` / `whose end bound is an empty string`, because the sentence never echoes the value, so *which* bound is a clause only this builder can supply. + - **A bound that is not a string keeps its TYPE description.** `['', 3]` reads `an array with a non-string bound`: the fault the arm itself refuses is named first, and the arities (`[]`, `['a']`, `[a, b, c]`) are untouched. + - **`a two-element array` survives as the LIT control** — the description for a two-bound window with nothing this clause can name, refused for something it cannot see (an unparseable bound VALUE carries its own `DATASET_INVALID` envelope). The empty-bound clause is not claimed when it is not true. + - ⛔ **Not an accept-set change.** The tuple arm still accepts `['', '']`; only the sentence the faces raise past it changed. The comment that asserted *"the only way such an array reaches a refusal is a bound that is not a string"* — false the whole time this residue was reaching it — is corrected in the same edit, since a false explanation is what kept the case unexamined. +- 2b80461: fix(spec): the four `z.unknown()` navigation doors in `ComponentPropsMap` list all seven `NavigationModeSchema` modes (#18459) + + Clause-②: no + + `object-grid`, `object-map`, `object-gantt` and `object-tree` declare `navigation` + as `z.unknown()`, so the `.describe()` on each is the WHOLE published account of + what a mode may be — nothing else in the protocol narrows those four doors, and + the generated reference renders their type as `any` beside that sentence. Three + of them listed six of the seven `NavigationModeSchema` values (no `new_window`) + and `object-grid`, the precedent the other three copied, listed five (no + `popover` either). An author reading the shipped reference was told a value the + platform honours does not exist. + + **Re-measured at the current `.objectui-sha` pin `87af769e9a3e`, not at the pin + the finding was taken at.** All four blocks hand `schema.navigation` straight + into the shared `useNavigationOverlay` hook; that hook types its own mode union + AS this package's `NavigationModeSchema` (its own docblock: *"the seven modes + this hook switches on are exactly the seven the exported union publishes"*, + held by a parity test on the objectui side); its click router carries a + `new_window` branch that delegates to `onNavigate` and otherwise falls through + to a `window.open`, and `object-gantt` additionally implements that action + itself. `popover` is an overlay mode in the same router and every one of the + four passes it an anchor. So all seven modes reach all four doors. + + **Why `object-grid` is in the same change.** It is the row the other three were + copied from and it understates by two rather than one; correcting three while + leaving the source of the pattern intact would leave the family in the state + that produced the defect. All four now name the schema as well as the values, + so the next member added to `NavigationModeSchema` has a named edge into these + rows instead of four independently drifting lists. + + **Why `Clause-②: no`.** The doors stay `z.unknown()` — a `navigation` value is + accepted before and after this change, whatever its `mode` reads. Nothing is + added to, removed from or narrowed on any authorable surface: the diff is four + description strings and the reference page regenerated from them, and + `check:authorable-surface`, `check:api-surface` and `check:export-origins` all + pass with no artifact to regenerate. What moves is what an author is TOLD, which + is why this ships as a `patch` rather than as no changeset at all: the sentence + is published bytes — `src/ui/component.zod.ts` ships verbatim under this + package's `files[]` entry `src/**/*.zod.ts`, and the compiled string ships in + `dist/ui/index.js` and `dist/ui/index.mjs`. +- 2bdb81f: fix(spec): the `ComponentPropsMap` objectui read-point records are re-measured at the live pin and now assert it (#18459) + + Clause-②: no + + `packages/spec/src` carries READ-POINT RECORDS: docblocks that say "this key is + LIVE, and here is the objectui `file:line` that reads it". A record anchors + itself to the objectui tree its numbers were counted in, and + `check:objectui-pin-citations` recognises two spellings for that anchor — an + ASSERTING one (`.objectui-sha` = ``, checked against the pin file on every + run, so a pin bump reds on it) and a HISTORICAL one (`.objectui-sha` pin + ``, a dated record that a later bump does not falsify, and that nothing + re-checks). + + Eleven records — ten in `src/ui/component.zod.ts`, one in its sibling test — + were in the historical spelling naming the RETIRED pin `53ded82b`, although + every one of them is a live read-point record whose whole purpose is to stay + re-checkable. Each was re-READ + against objectui at `87af769e9a3e` and converted to the asserting spelling, so + the next pin bump fails on them instead of carrying them. + + **The drift was real, not hypothetical, and three anchors could not have been + repaired by refreshing numbers:** + + - `ObjectMap.tsx`'s array-shorthand head inside `getDataConfig` is DELETED + (objectui#8348); an authored `data` array now reaches that renderer through + the React props channel alone, never through the record-source ladder. + - `ObjectTree.tsx`'s `?? schema.titleField` rung is DELETED (objectui#8841). + The flat-spelling prescription that names `titleField` stays TRUE on its + other half — `ListView`'s flatten still resolves `treeCfg.titleField` into + `labelField` before emitting — and that is now what the record cites. + - `object-kanban`'s navigation read no longer carries the `(schema as any)` + cast the record quoted, while its `object-calendar` twin still does. + + A fourth is a count rather than an anchor: the `plugin-tree` registry shell's + `ElementDataSourceGate` control reading. It was re-taken at BOTH ends of the hop + by ONE method — occurrences of that identifier in each control's own + `src/index.tsx` — and nothing moved: 3 each for `plugin-map`, `plugin-gantt`, + `plugin-grid` and `plugin-calendar`, 0 for `plugin-tree`, at + `87af769e9` and at `53ded82bf` alike, so the ZERO that discriminates is the + whole reading. The `7 each` the record used to carry is reproducible at neither + pin by that method, nor by a whole-package count (7 / 5 / 11 / 5). ⛔ A count is + a reading only with its METHOD beside it — without one, re-stating the carried + number is exactly what survives a re-measure. + + `plugin-timeline/src/renderer.tsx:1215` is at the same number with the same + content at both pins — and it is not alone there: + `plugin-timeline/src/index.tsx:333` (`limit: 'limit',`), an anchor of that same + record, is too, read by the same method at both pins. Which is exactly why a + number that did not move is no more a reading on its own than one that did. + + Three further records in the same blocks cited an objectui sha WITHOUT naming + `.objectui-sha`, so they sat outside the gate's population entirely — neither + asserting nor historical, simply unseen. They are re-measured and spelled so + the gate can see them. + + **Why `Clause-②: no`.** Every changed line is a comment. No schema, describe + string, export, key or refusal text moves, so no input's accept/reject verdict + can change; `check:generated` reports all fifteen spec artifacts up to date + with nothing to regenerate. It ships as a `patch` rather than as no changeset + because the bytes are published: `src/ui/component.zod.ts` ships verbatim under + this package's `files[]` entry `src/**/*.zod.ts`, measured against the packed + tarball with a positive and a negative control. +- c7448dc: `FormField.span`'s `'auto'` description now names the field types the form renderer actually widens, instead of three that it does not (#18516). + + `Clause-②: no` + + The clause told authors that wide widgets *"like textarea/richtext/json/file/subform take the whole row"*. Three of those five names were wrong and two real ones were missing. Re-measured against objectui at the `.objectui-sha` pin `53ded82bf7` by executing that tree's own `mapFieldTypeToFormType`, `isWideFieldType` and `resolveColSpan` over the whole `FieldType` population (**49** members, agreed by two instruments — the executed `.options` and the enum's source tokens with comments stripped): + + - `WIDE_FIELD_TYPES` is **ten** entries — `textarea` / `markdown` / `html` / `grid` / `richtext`, each bare and `field:`-prefixed — in `plugin-form/src/autoLayout.ts` and again in its `plugin-detail` twin. + - `json` and `file` **are** spec field types, and both resolve to **one cell**, not the row (`json` maps to `field:code`, `file` to `field:file`). `subform` is not a spec field type at all. + - `markdown` and `html` **are** widened, and the sentence named neither. + - The set that resolves to the full column count is **five**: `textarea`, `markdown`, `html`, `richtext` and **`repeater`**. The measurement has to follow the path the form actually walks — every site that assigns `FormField.type` maps the spec name through `mapFieldTypeToFormType` first — and on that path `repeater` becomes `field:grid`, which is a member of `WIDE_FIELD_TYPES`. Measured only on the bare spec name the set is the four long-form types, which is what objectui's own pin asserts at that sha ("its spec-facing surface is EXACTLY the long-form family"); that reading is true of the bare path and is not the one an author's field takes. The literal `grid` stays unnamed because it is an objectui-local metadata key rather than a `FieldType`, so `type: 'grid'` is refused — but `repeater` is the spec spelling that reaches the same widget, and it is accepted. + + An author reads that sentence to decide a form layout, so the cost of a wrong name is a layout decided on a type that behaves the opposite way — in either direction. + + What the clause says now: those five resolve to the full **column count** — the number `resolveColSpan` really returns — leaving the sentence beside it to state how far down the container-query tiers that span is emitted. At this pin only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid, whose container class is `grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3`), so a wide field is one cell of two at the `@md` tier. Promising a whole row at every tier would be the same defect with its sign flipped. + + Both readings are of one pin, so the citation moves from the historical spelling to the **asserting** one (`` `.objectui-sha` = `` ``): `check:objectui-pin-citations` compares an asserting citation against the pin file, so the next pin bump reds on this sentence and it cannot rot silently. That matters here — objectui `bd09957380` is already ahead of the pin and emits one clamped class per multi-column tier, which makes this sentence's tier half wrong the moment a bump absorbs it. + + No key, default, enum member or export moves: the same authored metadata is accepted and refused as before. +- 0b31d90: fix(spec): label every producer claim in the `build-progress` docblock — measured, ruled, or inferred (#18552) + + Clause-②: no + + The module docblock on `ai/build-progress.zod.ts` stated three producer claims + as MEASUREMENTS. It ships in this tarball (the published `files[]` carries the + `.zod.ts` sources) and is rendered verbatim into the generated reference page, + and for a CLOSED vocabulary it is the audit trail the "re-measure before you + move the array" discipline reads. One of the three was false, and a reader + deciding whether a fifth phase is warranted would have read all three as + readings. + + Each producer claim now carries exactly one of three labels, defined at the top + of the module: **measured on a named reachable source**, **declared by ruling**, + or **inferred**. + + - Membership is no longer described as uniformly measured. `structure`, `data` + and `done` stay **measured** — the objectui reader's own union and coercion + default, cited with the tree they were read against. `verify` is **declared by + ruling** (cloud#2172, objectui#7388): at the read tree the chat panel has zero + occurrences of `'verify'` against a control of four files for `'structure'`, + and this repository emits no frame at all. That is a good reason for the + member; it is not an observation, and the docblock no longer says it is. + - The cloud#1838 window — "111 seconds and 9 tool calls", "one of them + `verify_build`" — is **inferred**: that record is not reachable from this + repository, so the figure is carried, not measured, and which tools those + calls were is recorded nowhere reachable. What is measured is narrower and + stated as such: `verify_build` is a registered platform tool. + - "A turn that seeds no sample data never reports `data`" and "`apply_edit` + turns need not report `structure`" are **inferred**. The consumer guidance + around them is unchanged and does not rest on them: treat every phase as + optional and compare by value. + + A new `## Liveness watch` section records that `verify`, `hop` and `tool` are + declared ahead of any code that uses them, that cloud#2172 and objectui#7388 + block 2 are the named carriers meant to close that, and that no gate watches it + — `BuildProgressFrame` is not a registered metadata type, so the ADR-0049 + liveness ledger never sees it. + + No schema, export or parse behaviour moves: `BUILD_PROGRESS_PHASES`, + `BuildProgressPhaseSchema` and `BuildProgressFrameSchema` accept and refuse + exactly what they did before. +- 4b58dcf: The unknown-key suggester no longer answers an axis-silent key with one arbitrary end of a range — `dateField` on a calendar, timeline or gantt config is told about `startDateField` **and** `endDateField` instead of being sent to the end of the event (#18572). + + Clause-②: no + + `findClosestMatches` ranks by edit distance and nothing else. On a shape that declares both ends of a range, a key naming neither end is therefore answered with whichever end is spelled more cheaply — re-derived here rather than taken from the card: + + ```text + authored `dateField` (9 chars, budget max(2, 9/3) = 3) + -> `endDateField` distance 3 INSIDE the budget <- answered + -> `startDateField` distance 5 outside the budget <- unreachable + ``` + + `end` is a three-letter token and `start` a five-letter one; that spelling accident was the whole reason the protocol told an author to bind the **end** of the event. And the suggested key is a declared key the runtime honours, so an author who copied the remedy got a document that **parses**, with the axis silently on the wrong date. ⛔ Nobody had declared that mapping — a generic fuzzy matcher picked one sibling out of two. + + - **The fallback's answer is screened; a declared `aliases` entry never is.** When the guessed candidate carries an axis token the authored key does not, and the shape also declares its opposite-pole sibling, the rename is replaced by a prescription naming both ends: *"`dateField` does not say which end of the range it binds, and this surface declares both `startDateField` and `endDateField` — opposite ends of one axis. Write the one you mean: both parse, so guessing binds the wrong end silently."* A human-written alias is a statement about one spelling and outranks this; only a coin flip is replaced. `this field` keeps answering `length` with `maxLength` exactly as it declares. + - ⛔ **No alias was added and the accepted key set does not move.** `dateField` was refused before this change and is refused after it; what changed is the sentence the refusal carries. Naming both ends rather than picking one is the answer `field.zod.ts` already writes by hand for `visible` — 「the two answers have opposite polarity … Naming both is the only answer that cannot be acted on wrongly」 — generalised to the keys nobody thought to enumerate, which is the set a fuzzy suggester answers. + - **The guard separates an omission from a typo, and that condition was measured.** It fires only when the authored key is at least as close to the candidate MINUS its axis token as to the candidate itself. Without it `axLength` — one dropped character in `maxLength`, with `minLength` declared beside it — would lose a perfectly good suggestion. With it, `axLength` reads as the typo it is (distance 1 vs 2) and `dateField` as the axis-silent key it is (distance 3 vs 0). + - **Census, not just the filed case.** Over **389 unique authoring surfaces** — the population `alias-integrity.test.ts`'s forcing walk registers, deduplicated by its own key (surface + alias table + sorted shape keys); the same walk also yields 421 raw `strictObject` registrations and 388 distinct surface strings, which are different facts — the trap occurs four times, all four fixed here: `dateField` on the calendar, timeline and gantt configs, and `baselineField` on the gantt config (`baselineStartField` / `baselineEndField`). The axis vocabulary is held to the shapes: `alias-integrity.test.ts` now fails on an axis row no surface declares both ends of, the same dead-entry judgement it already applies to `aliases` and `guidance`. +- 559041d: `liveness/connector.json` and `liveness/analytics_cube.json` — the last two governance debts the liveness ratchet declared are paid, so `PENDING_GOVERNANCE` is empty and every authorable metadata type now has a ledger (#18582). + + The ledgers ship inside this package, so these are the files an upgrading reader greps to learn whether a key they are about to author does anything. Both types are authored through real doors — `defineStack({ connectors })` / `defineStack({ analyticsCubes })` and `PUT /api/v1/meta/{connector,analytics_cube}/:name` — and neither had ever been walked: they were in neither `GOVERNED` nor `PENDING_GOVERNANCE` until #18133 widened the denominator, so their silence read as "nothing to report". + + - **`connector` — 74 properties: 20 `live`, 1 `planned`, 53 `dead`.** One schema, two doors: the ledger's entry exists for the AUTHORING doors, while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a plugin or an ADR-0097 provider factory builds in code. The keys an authored entry can actually reach are the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields, `loadPackageFile` is host-injected rather than authored, and `provider` never reaches the context yet decides on the authoring door whether the entry is materialized at all and which factory does it; `type` and `icon` reach that context and are dropped by all three shipped provider factories. The 53 dead are four declared subsystems with no engine — `syncConfig`, `fieldMappings`, `retryConfig`, `health` — plus `triggers` (the schema's own docblock already said so, #3197), the connector's nested `webhooks`, `status`, both timeouts, and four `retiredKey` tombstones. `authentication` is `planned` because the key is accepted and inert rather than refused: the schema declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, so it parses and the accepted value reaches no consumer, while the #7990 cross-field rule loudly rejects every non-`none` value and names `auth: { type, credentialRef }` as the mechanism to use instead. ADR-0097 §3 ("Credentials are references") backs that refusal of inline secrets — it does not refuse the key. + - **`analytics_cube` — 29 properties: 17 `live`, 12 `dead`.** The query path is genuinely consumed (`sql` is both the FROM table and the object whose RLS read scope is injected; `measures.type` picks the aggregate; `joins[].name` the joined table). What is not: the caching block (`refreshKey`), the `public` access flag that gates nothing, `joins[].relationship` and the REQUIRED `joins[].sql` — the ON clause is synthesised as a foreign-key equality and an authored one is never consulted — and the inner `name` on each of `measures`/`dimensions`, where the record key is the identity. #10238 (is cube authoring live end to end?) is a separate measurement and is not prejudged here. + - **Two prior in-repo claims were falsified and are corrected in the ledgers.** A comment in `src/conversions/registry.ts` says `retryConfig` "and the timeouts beside it are untouched — they are live"; the word does not occur outside `packages/spec` at all. And `bootstrapDeclaredWebhooks` documents itself as materializing each "stack/connector-authored webhook", while its source is `readDeclared(…, 'webhook')` — metadata items the decomposition registers from the top-level `webhooks:` collection, which a connector's nested array never becomes. + + No schema changed and no verdict moved on an existing ledger: `check:liveness` walks two more types and reports the same 583 repo-local evidence paths resolving, with 39 governed types indexed by the README table. + + Clause-②: no +- e0d0553: `liveness/sharing_rule.json` — the sharing-rule authoring surface is now a governed liveness type: every authorable key of `SharingRuleSchema` carries a status, the evidence that settles it and the producer that populates it (part of #18582). + + The ledgers ship inside this package (`files[]` includes `liveness`), so this is a new file in the tarball and two changed ones — `liveness/README.md`'s index row and the generated `liveness/state-counts.md`. Nothing else moves: no schema accepts or refuses anything it did not before, no export changes, and no CLI author warning is added (no entry is marked `authorWarn`). + + - **Why it was ungoverned.** `sharing_rule` is bound in `UNREGISTERED_KIND_SCHEMAS`, which `listMetadataTypeSchemaTypes()` deliberately does not enumerate, so it sat in **neither** `GOVERNED` **nor** `PENDING_GOVERNANCE` and produced no row in any of the gate's lists while the report read complete. Widening the governance denominator to the authorable set made it visible as a declared debt; this pays that debt. `connector` and `analytics_cube` are still owed. + - **Every row cites a producer, because the authoring shape is not the enforced shape.** ADR-0057 D6 makes the `sys_sharing_rule` row canonical — `object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level` — and `bootstrapDeclaredSharingRules` translates each authored key into it at boot. Nothing re-parses `SharingRuleSchema` at enforcement time, so a consumer pointer alone would prove only that a column is read, never that the authored value reaches it. + - **Nine keys are `live`; one is `planned`.** `type` is the `SharingRuleType` discriminator: one member, `criteria`, whose only reader in this repo is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. It is deliberately **not** `dead` and therefore not an enforce-or-remove candidate — the key is required, so removing it would break every authored rule to delete nothing, and the schema keeps it as the discriminant for a future enforced rule type. + - **`sharedWith` is drilled**, so the two recipient keys carry their own verdicts and the change adds no row to the undrilled-container baseline. + + For an author, the practical read: `name`, `object`, `active`, `accessLevel`, `condition` and both `sharedWith` keys change what the runtime grants; `label` and `description` are display-shaped and are shown in Setup; `type` has exactly one legal value and, today, no dispatch behind it. +- 5100c42: `AnchorBindingContext`'s boot half names the stack's capability DECLARATIONS, not the `sys_capability` rows the seeder has not written yet + + The docblock named two sources for `declaredCapabilities`: at boot 「the + `sys_capability` rows carrying `managed_by: 'package'` provenance」, at authoring + time the stack's own `capabilities` array. The boot half carried an ordering + precondition the sentence never stated, and a caller following it literally + lands on the defect the input exists to remove. + + `runBootstrap` (`@objectstack/plugin-security`) awaits `bindBaselineToEveryone` + — the ADR-0090 D5 anchor binding, the boot call site that consults + `describeHighPrivilegeBits` — BEFORE it calls `bootstrapDeclaredCapabilities`, + the seeder that WRITES those `managed_by: 'package'` rows. The order is fixed by + two other constraints stated at that call site: the binding must follow the + seeding of the `everyone` anchor it binds to, and precede the audience-binding + suggestion reconciliation. So on a first boot the table is EMPTY at exactly the + moment the docblock said to read it, and this docblock's own 「omission refuses」 + property turns that emptiness into a silent refusal of every declared token — + the app's own `isDefault` set unbindable at the `everyone` anchor, which is the + defect #17811 introduced the input to remove. + + The boot half now names the DECLARATIONS, read through the seeder's own two-step + — the ObjectQL registry first, the metadata service as the fallback — which is + what `readDeclaredCapabilityContext` (`@objectstack/plugin-security`, #18535) + already implements, so the contract text and its one runtime consumer now + corroborate each other instead of contradicting. The `sys_capability` rows stay + a valid source, qualified: only once the seeder has written them, which is where + an admin-surface or post-boot caller reads them. + + ⛔ No behaviour changes. The diff is comment text: `git diff` against the branch + point over `src/security/high-privilege.ts` changes **0** non-comment lines (the + same predicate reads 33 on that file's own #17811 commit, which is the control + proving it fires). No predicate, no type, no export, no accept set moves. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `src/security/high-privilege.ts` is NOT shipped as source — + `@objectstack/spec`'s published `files[]` takes `src/**/*.zod.ts`, and this file + is not one (`npm pack --dry-run` lists 2021 files and excludes it, with the + sibling `src/security/permission.zod.ts` present as the lit control). Its + published reach is the emitted declarations, and they move: the new clause is + present in `dist/security/index.d.ts` and `dist/security/index.d.mts`, both in + that same shipped list, with the superseded spelling absent from every built + declaration file and the docblock's unchanged neighbouring sentence present in + the same two as the lit control. +- 00b38d7: `src/conversions/registry.ts` — the `connector-rate-limit-config-removed` entry no longer asserts that `retryConfig` and the connector timeouts "are live" (#18614). The assertion was measured false; the ledger seeded by #18582 had already recorded the correction on the other side. + + The comment conflated two different statements. That the rate-limit retirement left those keys *in place* is true and is kept — it is what the fixture's single notice demonstrates. That they are *live* was never measured by that entry and is false: the read-probe for `retryConfig`, `connectionTimeoutMs` and `requestTimeoutMs` finds no consumer anywhere outside `packages/spec` (the sibling key `providerConfig`, on the same schema, fires on the identical probe), no retry loop reads a strategy or a backoff, every timeout occurrence outside the spec is a write of the literal `30000` so a def satisfies the post-parse `Connector` type, and `ConnectorProviderContext` carries none of the three — so a provider factory cannot read them either. `liveness/connector.json` classifies all ten rows `dead` and is now cited as the authority. + + Nothing is retired here and no schema moved: ADR-0049 owes these keys a decision, which the corrected comment states rather than pre-empts. The text ships — `tsup` preserves comments, so these bytes reach `dist/index.js`, `dist/index.mjs` and the `shared`/`browser` bundles inside the published tarball, which is why this is a `patch` and not `skip-changeset`. + + Clause-②: no +- 47a9002: docs(spec): the `RETIRED_KEYS_BY_MAJOR` Lifecycle docblock names both rejected states, and stops contradicting check (b3)'s printed remedy + + `RETIRED_KEYS_BY_MAJOR`'s docblock is shipped text — it reaches consumers in `dist/index.d.ts` — and since check (b3) landed, two of its sentences were false: + + - **「The one state the gate rejects」**. Check (b3) rejects a *second* state: a NESTED row whose def this build emits but whose dotted path it does not. That state has no aging clock behind it (a nested key never reaches `authorable-surface/` at all), so it is not the aged-out steady state the paragraph described. + - **「Entries are permanent」**, against check (b3)'s own refusal text, which ends `… or delete the entry from packages/spec/src/migrations/registry.ts`. An author following the docblock would not delete; an author following the gate would — two shipped instructions in this repo pushing two people who each did as they were told in opposite directions. + + The Lifecycle paragraph now: + + - scopes the aging-out steady state to a **top-level** tombstone, and says why a nested row can never be in it; + - lists **both** rejected states with the check that owns each and the remedy that check prints — still-LIVE (b2), nested-and-unresolvable (b3) — and states the routing rule that decides which one a row is judged by (a row is read as a path only when its `name` half carries a dot AND this build emits no top-level property of that exact name, so a live dotted top-level key such as `@odata.context` stays on (b2)'s map); + - reconciles permanence with deletion instead of leaving them to contradict: a row that was ever TRUE of some build is history and is never deleted, while a row (b2) or (b3) refuses was never true of any build, so deleting it removes a false claim rather than a record; + - repeats (b3)'s own ⛔ — it cannot yet tell a wrong row apart from every truthful one, and for the shapes it names the remedy is to teach the check, never to delete a row that is telling the truth. + + The `## What reads it` bullet for check (b) and the `@see` roster gain (b3) for the same reason: it reads this table, and neither named it. + + **No behaviour moves.** No gate, schema, export or registry entry is touched — the set of metadata that validates is byte-for-byte what it was. What changes is the text an author reads when a gate refuses their row. +- 922923b: `ToolExecutionContext.userMessageText` now cites the cloud decision as `cloud ADR-0025`, not as a bare number that resolves to this repo's plugin-packaging ADR + + The docblock read `(cloud, post-ADR-0025)`. The parenthetical says the layer is + cloud, but the id was spelled bare — and a bare id resolves against *this* + registry, where `ADR-0025` is + [Plugin Package Distribution](../docs/adr/0025-plugin-package-distribution.md): + a real record about `.osplugin` artifacts, code-plugin trust tiers and + marketplace install. Nothing in it decides who owns the agent route. + + That is worse than citing a number nobody has. A dangling id stops a reader; an + id that resolves lets them believe they read the right page and walk away with + the wrong decision. AGENTS.md Prime Directive 13 is explicit — an ADR "lives in + the repository whose code it governs", and a cloud decision is cited as + `cloud ADR-NNNN`, "never as a bare number". + + The line now reads `(cloud, post-cloud ADR-0025)`, which is verbatim what the + sibling member `confirmedBlueprintIdentity` two declarations below already says. + The two were deliberately inconsistent while this was open; they are consistent + again. + + Docblock prose only — no type, no export and no runtime behaviour changes. The + published `.d.ts` carries the comment, which is why this ships as a patch rather + than silently. +- 062f5cd: `BatchUpdateRequestSchema`'s cap comment no longer calls the batch-size cap "DEPLOYMENT policy". It is embedder-only, and this correction narrows the claim onto what is actually reachable. + + `packages/spec/src/api/batch.zod.ts` ships in this package's tarball (`files[]` carries `src/**/*.zod.ts`), so the sentence a reader finds beside `records` is published text. It told them the cap — `RestServerConfig.batch.maxBatchSize`, 1..1000, default 200 — was deployment policy, i.e. something an operator deploying this platform could move. No shipped boot path makes that true. + + **What the comment says now.** The cap keeps its span and its default as schema facts; the reachability sentence says who can write it. A `RestServerConfig` is the ARGUMENT a host passes when it constructs the server, and there is exactly one door: `createRestApiPlugin({ api })`. Neither shipped boot path opens it with a `batch` config — `os serve` forwards exactly two keys out of the stack config's `api:` block (`api.enableProjectScoping`, `api.projectResolution`) and the dev plugin calls `createRestApiPlugin()` with no config at all. A CLI-started deployment therefore always gets the default of 200, and no flag, config file or CLI option moves it; only the embedding host reaches anywhere in the 1..1000 span. + + **Nothing executable moves.** No schema key is added, removed or renamed, no accept set widens or narrows, no export changes, and no runtime behaviour is touched. `records` still carries shape only, the cap is still enforced at the route, and `.min(1)` is still absent. The diff is comment text inside one `lazySchema` factory. + + **Why this shipped as its own correction.** The same false claim had four other carriers, all already corrected under the same 2026-09-07 ruling: this package's `RestServerConfigSchema` docblocks and WHO CAN WRITE THIS CONFIG header, `enforceBatchSize` in `@objectstack/rest`, and the `data-api` and `http-protocol` reference pages. This was the fifth, and it carried the exact phrase struck from `enforceBatchSize` one package over. The wording is copied from those landings rather than invented, so the five now read the same way — as does the per-key REACHABILITY row in `liveness/batch_endpoints.json`, which also ships here. + + Clause-②: no — comment text only. No authorable key moves, no export is added or removed, and no accept set changes in either direction. +- 43f4766: `liveness/sharing_rule.json` — the file `_note` stops quoting the `declarative-rbac-seeding` proof-registry entry VERBATIM, so the pointer it hands a reader survives the next rewrite of that entry's prose (#18801). + + The ledgers ship inside this package, so this is a pointer a consumer can actually follow. The note said the entry's `blockedReason` "reads" a specific sentence and quoted it. PR #18797 (`ac720a9865`) rewrote that reason — correctly, because #18587 had made its premise false — and the quoted sentence stopped existing in the very file the note sends a reader to. Measured repo-wide with a fold-proof predicate (whitespace folds and TypeScript `' + '` concatenation seams dissolved before matching, because the registry splits every reason across source literals mid-phrase): the quoted string read **0** on `main`, while the entry id `declarative-rbac-seeding` read **18** in the same run. + + - **The judgement was never wrong; the quotation was.** The seeding does falsify the entry's original premise, and the rewritten reason on the entry now records exactly that — as a real ADR-0054 §3 binding candidate held back by the adoption act. The note still asserts it, in its own words. + - **What replaces the quote is an id, not a better sentence.** `declarative-rbac-seeding` is the entry's key: exactly **1** of the registry's **42** `id:` declarations spells it, and it reads 6 occurrences across 5 lines of `scripts/liveness/proof-registry.mts` — so a reader who greps it lands on the entry rather than on nothing. Quoting prose that changes is what rotted; an id does not rot on someone else's schedule. ⚠️ Measured, not assumed: nothing *asserts* those ids unique — the one other declaration of this id in the tree is `packages/qa/dogfood/test/authz-conformance.matrix.ts`, which names the same proof on purpose. + - **The old premise is paraphrased, deliberately not re-quoted.** A paraphrase of a premise that has already been retired cannot rot: the text it describes is frozen in history and nothing will rewrite it again. + - **The two sibling ledgers already wrote it this way.** `liveness/api.json` and `liveness/qa.json` cite `proof-registry.mts` by name and claim, and quote none of its prose. + + No verdict moved. Every `status`, `verifiedAt`, `evidence`, `producer` and per-row `note` in the file is byte-identical to `main`; the only changed field is `_note`, and `check:liveness` reports `sharing_rule 17 classified (live 16, planned 1)` before and after. +- 8e8ea99: Correct `ListMapConfigSchema`'s account of what the map renderer does with an + undeclared key in `map`. + + The docblock said the renderer "validates `schema.map` against a local zod + schema with exactly these keys, so an extra key here would be dropped there", + and that sentence was the stated rationale for the block being strict. + Re-measured at the `.objectui-sha` pin `53ded82b` by executing the pinned + declarations: that local schema (`ObjectMapConfigSchema`) is a plain `z.object`, + not strict, so an undeclared key parses clean there with no issue and no + warning; `getMapConfig` consults its `safeParse` only to decide whether to + `console.warn` and returns a spread of the authored block. What does drop an + undeclared key on the path this block actually takes is a different instrument + — the hand-listed `FLAT_MAP_CONFIG_KEYS` whitelist in `ListView` / `ObjectView` + — and it drops it in silence. + + The schema is unchanged: same keys, same `strictObject`, same accepted + documents. Only the rationale is corrected, and it is restated so it stands on + its own — nothing downstream reports an undeclared key, so this parse is the + only diagnostic an author ever gets, which is an argument for the strictness + rather than against it. The record's seven objectui anchors now quote the line + they were read at, so `check:objectui-pin-citations` verifies their content + against the pin instead of only checking the sha label. + + Clause-②: no +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- dbd4744: `$orderby` is declared twice — `ODataQuerySchema.$orderby` and `QueryTransportParamsSchema.$orderby` now cross-reference each other, and a pin holds the two accept sets apart (#18977). + + Clause-②: no. No accept set moves and no export is added, removed or renamed: the change is two docblocks in published source (`src/api/odata.zod.ts`, `src/data/data-engine.zod.ts`) plus a new pin test. Measured — `check:generated` reports all 16 generated artifacts up to date, `check:api-surface` and `check:authorable-surface` included. + + The two declarations are **complementary refusals**: each accepts exactly what the other rejects, and neither pointed at the other, so reading one of them carefully and completely still produced the wrong answer about the other. + + | `$orderby` value | `ODataQuerySchema` | `QueryTransportParamsSchema` (`DataEngineSortSchema`) | + |:---|:---|:---| + | `'name desc'` / `'-created_at'` | accepted | REFUSED | + | `['name desc', 'email asc']` | accepted | REFUSED | + | `[{field, order}]` | REFUSED | accepted | + | `{name: 'asc'}` / `{name: 1}` | REFUSED | accepted | + + - **Which one grades a query bag**: `QueryTransportParamsSchema`, reached from `FindDataRequestSchema.query` through `QueryWithTransportSchema` — the schema `POST /data/:object/query` parses its body against. `ODataQuerySchema` grades no runtime door: measured on this tree, its only consumers are the `OData.buildUrl` helper in its own file and its own unit test. + - **The refusal on the transport side is deliberate and stays** — `#18704` settled it: lowering an OData sort *expression* means PARSING, and a second parser beside the door's is how one rule gets two implementations that disagree. Widening either side to close the gap is a decision, not a tidy-up, so this change closes the **reader's** half only. + - **The string forms are not unserved.** `normalizeSortNodes` (`@objectstack/metadata-protocol`) reads `'name desc'`, `'-created_at'` and the `string[]` form at the shared ingress behind `GET /data/:object`, the export route and in-process `findData`. A querystring spelled the OData way works; the same bag sent as a `POST /data/:object/query` body answers `400 VALIDATION_FAILED`. The difference is the door, and neither door is `ODataQuerySchema`. + - **The cost this repairs was already paid.** objectui#9554 was filed, triaged, graded and dispatched against a shipped `object-grid` producer that had been sending the canonical shape all along, because the filing seat read the OData declaration and quoted it correctly. + + `src/api/odata-orderby-dual-declaration.test.ts` is the mechanical half: 25 cases pinning each side's accept set, their disjointness (with the lit control that neither set is empty), and which of the two `FindDataRequestSchema.query` is graded by. Widening or narrowing either declaration turns it red and lands the author on the cross-reference. +- b146102: docs(spec): the connector header no longer teaches `retryConfig` as the remedy for a rate-limited upstream (#18983) + + `packages/spec/src/integration/connector.zod.ts` ships inside this package — + `files[]` carries `src/**/*.zod.ts`, and the file is present in the published + tarball — so its header TSDoc is text consumers read, and the generated + reference page is rendered from it. That header ended its "no outbound rate + limiting" paragraph with "what L3 does declare for a rate-limited upstream is + `retryConfig` — whose `retryableStatusCodes` default `[408, 429, 500, 502, 503, + 504]` includes `429` — and `health.circuitBreaker`", which reads as a remedy. + + It is not one. `packages/spec/liveness/connector.json` records all eight + `retryConfig` sub-keys and every `health.circuitBreaker` sub-key as `dead` + (verifiedAt 2026-09-17), and outside `packages/spec` nothing reads either: no + retry loop consumes the strategy, the backoff, the jitter or that status-code + list, so the `429` in it never causes a retry, and no breaker ever opens. An + author who followed that sentence wrote configuration that parses, stores, and + is then silently ignored. + + The sentence now carries the wording PR #18979 landed for the same claim in + `packages/spec/docs/SYNC_ARCHITECTURE.md`: both keys are **declared but + currently unimplemented**, with a pointer to the liveness ledger, and they are + explicitly neither retired — both are still declared and still parse, so an + author writing them sees no error — nor left to the host, since + `ConnectorProviderContext` carries exactly `name`, `label`, `description`, + `icon`, `type`, `providerConfig`, `auth` and `loadPackageFile`, and a provider + factory is therefore never handed either key. + + **Prose only — zero behaviour change.** No schema, declaration, default or + accept set moves, and the keys' fate stays ADR-0049's to rule on rather than + being prejudged here. The generated reference page + `content/docs/references/integration/connector.mdx` follows from `gen:docs`; it + is not published by any package in this workspace. +- 75c0dac: docs(data): `ResolveApiOptions.userExportAllowed` no longer documents itself as "always `true` this phase" — the user-level export bit is wired, and it is a real opt-in grant that can be `false` (#18991) + + `Clause-②: no` + + ⛔ **No behaviour change.** `isLegacyDerivable`, `computeOperations` and `resolveEffectiveApiMethods` are byte-identical; the omitted-option default is still `true` (`opts?.userExportAllowed !== false`), and not one assertion in `api-derivation.test.ts` moved. What changes is two docblocks in `packages/spec/src/data/api-derivation.ts` that made a **false present-tense claim**. + + Both carriers said the same untrue thing, and they said it in a direction that invites reintroducing a defect: + + - `ResolveApiOptions.userExportAllowed` — "Always `true` this phase (there is no user-level export permission bit yet); wiring a real bit in is a zero-contract change". + - the `API_METHOD_DERIVATION` table docblock — "`export` is `list`, additionally gated by the user-level export slot (…, always `true` this phase — the real permission bit is a follow-up, wiring it changes no contract here)". + + The bit exists. `PermissionSetSchema.allowExport` (`src/security/permission.zod.ts`) declares the user-level export axis as an **opt-in grant** — `true` grants export, UNSET or `false` means no export — and the two statements cannot both be true. It is not an aspiration either: `plugin-security`'s `permission-evaluator` resolves `export` as `list ∧ userExportAllowed` and returns `false` from that branch, `plugin-hono-server`'s `/me/permissions` computes the bit and hands it to `resolveEffectiveApiMethods`, and this package's own suite has pinned the `false` arm all along (`export gated off when userExportAllowed=false`). + + An author who trusted the old text would read the parameter as inert and could legitimately simplify it away as dead weight — which is the same defect one level upstream of where it was last found, with no consumer left to notice. Both docblocks now state the axis as it is, name `PermissionSetSchema`'s `allowExport` as the authority on its semantics, and keep the one thing that *is* still true distinct from the one that is not: omitting the option resolves to `true` because a resolve carrying no permission context must not narrow the object's own exposure — that is what lets `apiExposureDenialReason` remain a pure function of `enable` — while a caller holding permission context passes the resolved bit explicitly. + + **Why this publishes rather than taking `skip-changeset`.** One entry of this package's `files[]` moves. `dist/` ships, and the emitted declaration reproduces both corrected docblocks: the packed `dist/data/index.d.ts` carries the `ResolveApiOptions.userExportAllowed` member text and the `API_METHOD_DERIVATION` table text verbatim. A consumer reading it reads different bytes after this change, so the corrected sentence is what reaches them. +- 7e0bfce: The error-code ledger's file docblock (`src/api/error-code-ledger.zod.ts`, shipped in the `.d.ts` declaration bundle and in the package's `src/**/*.zod.ts`) no longer names a retired PM-process label. The sentence now reads: registering a code widens the published contract face and is therefore a Clause-② change, door or no door; a code present in `dist` and absent from the ledger is a protocol gap, not a tier question. Documentation only — no code, type, value or export moves, and the generated reference page follows the source (#19061). +- c120dbd: `packages/spec/src/api/odata.zod.ts` — the module docblock's `@example Programmatic Use` block is now type-checked by `check:skill-examples`: it carries an `os:check` marker and the `ODataQuery` import it needs to compile standalone (#19065). + + Clause-②: no + + That block ships twice — inside the tarball as `src/api/odata.zod.ts` (this package's `files[]` lists `src/**/*.zod.ts`; measured with `npm pack --dry-run`) and on the generated reference page `content/docs/references/api/odata.mdx` — and nothing compiled it. An example whose keys contradict the schema declared in the same file could therefore stay green indefinitely, which is the defect #19028 found and #19058 corrected in text only. + + - **The reference page moves by exactly one line.** The marker is machinery and `build-docs.ts` drops it before rendering, so the only visible change on the page is the `import type { ODataQuery } from '@objectstack/spec/api';` line the block needs in order to stand alone. + - **No schema, no accept set and no behaviour moves.** `ODataQuerySchema` is byte-identical. Whether it should refuse undeclared keys instead of stripping them is a separate question about a published accept set and is deliberately untouched here. + - **The sibling `@example OData Query` block stays unmarked, and that is a measurement rather than an omission.** It is an HTTP request under a bare fence, not TypeScript, and the gate recognises only ts/tsx/typescript fences — a marker above it is reported as an orphan, so it would fail the gate rather than check the block. +- aeaaa44: JSON Schemas converted from a `lazySchema()` reference now carry the `description` the schema authored, the same as an `OS_EAGER_SCHEMAS=1` run (#19101). + + Clause-②: no + + zod reads `.describe()` / `.meta()` from its registry by node identity. A lazily built schema is referenced through a Proxy, while the metadata sits on the real instance behind it, so `z.toJSONSchema` found nothing and dropped the text. The published result depended on the evaluation mode. The Proxy now answers the real instance's metadata to that lookup, less `id`, which stays on the real instance so that zod's duplicate-id refusal is never triggered. + + What changes: descriptions reappear. Nothing else does. Measured lazy against eager, leaf by leaf: + + - `@objectstack/spec/openapi.json`, and the `GET …/openapi.json` document served from it, gains 2 (`ListRecordResponse.data[]` and `BulkRequest.records[]`); + - the `os generate` IDE schema gains 445; + - the approval-node and schemaless node-config schemas are unchanged. + + No other key differs in any of them, and the eager outputs are byte-identical before and after. The accept set does not change: `description` is an annotation, never a validation keyword. +- 44a2332: `ActionSchema.undoable` — the published description now names the WRITTEN set, not `patch` alone (#19148). + + **FROM** — "`operation: 'update'` is the declared form of that action — its `patch` names exactly the fields whose prior values are captured." + + **TO** — "`operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set, so nothing anchors the capture there." + + An `operation: 'update'` action writes two sources: the static `patch` AND whatever its `params` collect. On any params-carrying action, "exactly the `patch` fields" is a strict subset of what the action writes, so an Undo built to the old sentence restores part of the change and reports the action as undone. + + - **Prose only — no schema change, no accept/reject outcome moves.** The same author input parses the same way before and after; `Clause-②: no`. + - **The executor already captured the union.** `executeDeclarativeUpdateAction` keys `undoData` off `Object.keys(data)`, `data` being `declarativeUpdateWrite`'s merged bag `{ ...patch, ...params }`. The sentence was the outlier, and the EXECUTOR CONTRACT doc block ~200 lines above in the same file already read "exactly the fields written". + - **One operation, one rule.** The `operation` enum carries exactly one member, `'update'` (`'delete'` and `'custom'` are refused with their reason), so the per-operation capture rule is a one-row rule and is written as one. + - The describe text renders into three generated reference tables (`ui/action`, `data/object`, `kernel/metadata-plugin`), regenerated here; the hand-written protocol page `content/docs/protocol/objectui/actions.mdx` carried the identical claim and is corrected in the same edit. +- f34dda6: fix(spec): `declaresCollection` reads a `pipe` on the side the author writes, so a `z.preprocess`-wrapped collection key cannot silently leave the `objectConflict: 'merge'` refusal set (#19150) + + Clause-②: no + + `objectCollectionKeys()` derives — never transcribes — the object-level keys `composeStacks({ objectConflict: 'merge' })` refuses to combine (#14848), and the reason it derives them is written into its own docblock: a hand-written list "would fail in the silent direction: a collection key added to the object schema tomorrow would fall back to the wholesale replacement this rule exists to refuse". The walker behind it reintroduced exactly that silent direction through the derivation itself. + + `declaresCollection`'s `pipe` arm read only `def.in`. Two constructs compile to the same `pipe` node with OPPOSITE authorable sides: `a.transform(fn)` keeps the accepted input shape in `in`, while `z.preprocess(fn, schema)` puts the transform STAGE in `in` and the real, validated schema in `out`. A preprocess-wrapped collection key therefore resolved to a `transform` node, fell through to `default: return false`, and left the refusal set with nothing anywhere reporting it — the failure shape being a wholesale replacement where a refusal was owed. + + The arm now reads `out` only when `in` unwraps to a transform stage, which is the rule four sibling walkers in this tree already run (`pipeAuthorableSide` in `scripts/lib/zod-graph.ts`, `kernel/metadata-authoring-lint.ts`, `system/metadata-form-zod-reconciliation.test.ts`, and `packages/lint`'s `validate-predicate-path-refs.ts`) rather than a fifth dialect. + + - **`in || out` was measured and declined.** For a genuine `a.transform(fn).pipe(b)` the author writes `a`; reading either side pulls a key whose authored value is a scalar into a refusal set that then names it a collection. The landed rule leaves every `.pipe()` verdict where it was, by construction rather than by fixture choice. + - **No authored metadata changes meaning and no key changes its verdict on today's shape.** Measured over all 43 top-level keys of `ObjectSchema`: exactly one compiles to a `pipe` (`titleFormat`, an `a.transform(fn)` pipe carrying a scalar), and the derived refusal set is byte-identical under the old reading, the landed one and the declined candidate. The invariant is asserted, not claimed: `compose-stacks-collection-pipe-arm.test.ts` fails the day it stops holding. + - **`fields` keeps its exclusion by name.** It is the one collection `'merge'` merges by shallow spread, so its own reading cannot move the set either way. +- 15f9284: `liveness/field.json` — `field.relatedListFilter` is `live`, and drops the `authorWarn` that had become a false sentence. + + Clause-②: no — no schema key moves, no accept set widens or narrows, no export changes. `FieldSchema.relatedListFilter` accepts exactly what it accepted before; what changes is the ledger's verdict about it and the author-facing advisory the ledger drives. + + The ledgers ship inside this package (`files[]` includes `liveness`), so the changed tarball bytes are the ledger row, the generated `liveness/state-counts.md` counts and the `liveness/README.md` Notes cell. + + - **The row falsified itself.** #8704 seeded `relatedListFilter` `planned` + `authorWarn` as the contract-first spec half of objectui#4664, and wrote the flip condition into its own note: flip to `live` and drop `authorWarn` when that consumer lands. It landed — objectui `d796c8dde` (objectui PR #6946), which `git merge-base --is-ancestor d796c8dde 53ded82bf7` places inside this repo's `.objectui-sha` pin. Both pointers were re-measured AT THAT PIN, the #10068 discipline, not on objectui main: `deriveRelatedLists` puts the authored value on the derived descriptor as `filter`, and `RecordDetailView` writes it onto the synthesized `record:related_list` node, which AND-composes it with `{ [referenceField]: parentId }` while the tab strip's count probe composes the same pair. + - **For an author, the practical read: nothing you write changes, and one warning stops.** `os lint` had been saying 「the auto-derived related list does not apply this filter yet」 about a key the pinned console applies — a true warning costs an author nothing, a false one steers them off a usable key. Authors who trimmed a `relatedListFilter` on that advice can put it back. + - **A `planned` row fails in the one direction no citation check can see.** A `live` row rots when its pointer moves and the gate's file/line/symbol/key-mention checks catch that. A `planned` row cites no consumer, so nothing can rot and nothing re-asks; only the consumer landing falsifies it, and only a reader who follows the sibling repo notices. That asymmetry, not this one key, is what the flip records. + - **`field` now carries no `authorWarn` row at any depth**, which gates `packages/lint`'s field walk off entirely (`if (fieldWarn.size > 0)`). The two ledger-driven pins that used this key as their witness are re-dispositioned in the same change: a silence pin plus an anti-vacuity guard for the verdict case, and a narrowed claim on the #11385 field-walk case. +- a4ca69a: `pagination.pageSize` states what the renderer owes on a view with no pager + + On a kanban, gallery or timeline view there is no pager, so `pagination.pageSize` is the fetch + ceiling. Its description now says so, and names the renderer's two obligations there: bound the + fetch at that number, and, when the filtered set is larger than it, show a visible truncation + signal saying what is on screen is not the whole set. + + The key's accept set and its default (`25`) are unchanged, and no export or authorable key moves + relative to the last published release. + + Clause-②: no +- 1ff3a8f: fix(spec): state the row-cap guard `ElementDataSourceGate` implements (#19228) + + Prose and pins only — zero accept-set movement, zero export movement. The same documents parse + to the same values before and after. ⛔ No `.default()` moves. + + ## What the published text said, and what an author can actually reach + + `ObjectKanbanPropsSchema.limit` tells authors that a bound view's `pagination.pageSize` fills it + 「only when unset」. Measured first-hand at the objectui pin this repo builds against + (`.objectui-sha` = `87af769e9`), that sentence is exactly right for this face, and the describe + now says WHY rather than leaving it to look narrower than the mechanism. + + The gate's branch is `if (!fromView || !isUsableRowLimit(authored))` + (`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`), and `isUsableRowLimit` is + `typeof v === 'number' && Number.isInteger(v) && v > 0` (`:192-194`). Every cap this key ACCEPTS + is one that predicate already calls usable — the accept set is a subset of the usable set — so + across the whole accept set the guard has exactly two outcomes and 「set but not usable」 is + empty. The extra arm, a cap displaced and reported because it is zero, negative or fractional, + is reachable only for a node this contract refuses, so it is recorded in the docblock rather + than in an author-facing sentence. + + The view half is `pagination.pageSize` ALONE on this face. `savedViewLimit` does fall back to a + flat `view.limit` (`core/src/data-scope/element-data-source.ts:237-241`), but that names a + saved-view RECORD as the adapter's `listViews()` returns it — a third face, not an authored view + document. Measured on this tree: `ListViewSchema` REFUSES a flat `limit` with + `unrecognized_keys: ["limit"]`, the verdict a bogus key gets, while the same minimal document + parses with `pagination.pageSize: 50`. No view document + declares a flat `limit` and none carries a tombstone for one. +- b971924: `liveness/README.md` — the `authorWarn` section's pointer to the lint that emits author warnings named a path that does not exist; it now names the module's real home, `packages/lint/src/lint-liveness-properties.ts` (#19269). + + The ledgers and this README ship inside this package (`files[]` includes `liveness`), so the pointer an upgrading reader follows is this one. It read `packages/cli/src/utils/lint-liveness-properties.ts`, measured at zero in a full tree listing, while the module it describes — the one that reads these ledgers, emits the advisory warning and never fails the build — sits in `packages/lint/src/`. Nothing else moves: no schema, no export, no verdict, no ledger entry, no runtime behaviour. + + - **This grid has no mechanical reader, which is why it rotted quietly.** `check:liveness` resolves the `evidence` paths inside ledger *entries*; a path written in README prose is checked by nobody, so the pointer stayed wrong through the move with every gate green. The lit control is the rule registration itself: `packages/lint/src/authoring-rules.ts` carries `source: 'packages/lint/src/lint-liveness-properties.ts'` as data, and that is the path this sentence now agrees with. + - **The second pointer in this file is deliberately left alone.** The closing paragraph of the type table says "see lint-liveness-properties.ts" — a bare filename with no directory. It is not stale (the basename resolves uniquely in the tree), and a bare filename carries no directory to rot; giving it one would newly expose it to exactly the failure this change repairs. The full path is stated once, here, where a reader who needs the directory gets it. +- e37ea4d: fix(spec): the published `effae80` changelog entry states ONE `undoable` capture set — the one that shipped (#19301) + + Clause-②: no + + `packages/spec` ships `CHANGELOG.md` inside its npm tarball (it is named in + `files[]`), so an entry there is a published surface — the text an upgrading + agent greps. The single entry for `effae80: feat(spec): a row action gets the + declarative single-record field write` stated BOTH capture sets, four lines + apart: + + > `undoable` now has its anchor — the patch names exactly the fields whose prior values are captured. + + > `undoable` captures the prior values of exactly the fields written. + + **The second is the one that shipped.** `declarativeUpdateWrite` builds the + write bag as `{ ...patch, ...params }`, and `executeDeclarativeUpdateAction` + keys `undoData` off `Object.keys(data)` over that bag — so on any + params-carrying action the capture is strictly wider than `patch` names. The + same entry's own `patch` bullet already says the static values are "merged + UNDER the values `params` collects (a param of the same name wins)". The first + sentence was wrong when it was written; it is not a record of behaviour that + later changed. + + The first sentence now names the merged write bag, in the wording the settled + `ActionSchema.undoable` description uses. Nothing else in the entry moves and + no other entry is touched: the correction is an **amendment in place**, not an + erratum in a later entry — a reader who greps the old promise lands on this + entry and nowhere else. + + - **Prose only.** No key, export, accept set, refusal, tombstone or generated + artifact moves; the same author input parses the same way before and after. +- fa29803: `ObjectFieldGroup.collapsed` — the deprecated alias's `describe()` now states what the key maps to **on its own**, so the published reference page no longer leaves an author to guess whether `collapsed: true` also needs `collapsible` beside it (#19311). + + `collapse` (ADR-0085) replaced the `collapsible` / `collapsed` boolean pair, and `ObjectSchema.parse` still folds the old pair onto it. That mapping has always been total — a group authored `collapsed: true` and nothing else parses to `collapse: 'collapsed'`, which the enum's own describe spells out as *collapsible, starts closed* — but the alias's describe said only `` Boolean pair with `collapsible`; use the `collapse` enum. ``, and that sentence is what `content/docs/references/data/object.mdx` publishes. The sibling `defaultExpanded` already spelled its mapping out (`true → 'expanded', false → 'collapsed'`); these two aliases did not. + + Measured against the built package, `ObjectSchema.safeParse` on one field group: + + | authored on the group | `collapse` after parse | + | :--- | :--- | + | `collapsed: true` | `'collapsed'` | + | `collapsed: false` | `'none'` | + | `collapsible: true` | `'expanded'` | + | `collapsible: false` | `'none'` | + | `collapsed: true` + `collapsible: false` | `'collapsed'` — `collapsed` outranks | + | an explicit `collapse` | wins; the aliases are not read | + + - **Text only.** No key is added, removed or re-typed, no accept set moves and the normalizer is untouched: the nine probe inputs above parse to the same nine results before and after. + - **`collapsible`'s own describe is left unchanged** and still carries the mirror-image silence about what `collapsible: true` alone means (`'expanded'`). It is reported rather than ridden along on a card that names `collapsed`. + - **This is the object-level `fieldGroups` pair only.** The form-view `sections[].collapsible` / `sections[].collapsed` pair is a different, non-deprecated surface with no alias mapping behind it, and nothing here touches it. +- b01bdbc: `FormSection.collapsible` / `FormSection.collapsed` — both keys now carry a `.describe()`, so the published reference page no longer prints two empty Description cells for two authorable booleans (#19311). + + They were the only keys in `FormSectionSchema` with no contract text at all, sitting between neighbours that have it, and the dependency between them was published nowhere. **Both** are described rather than only `collapsed`: the sibling silence is what made the gap ambiguous in the first place, and describing one of a pair recreates it one key over. + + Measured against the built package, `FormSectionSchema.safeParse` on one section: + + | authored on the section | `collapsible` after parse | `collapsed` after parse | + | :--- | :--- | :--- | + | neither | `false` | `false` | + | `collapsible: true` | `true` | `false` | + | `collapsed: true` | `false` | `true` | + | `collapsed: true` + `collapsible: false` | `false` | `true` | + | both `true` | `true` | `true` | + | both `false` | `false` | `false` | + + - **Parse does NOT normalize the pair, in either direction.** `{ collapsed: true }` parses to `{ collapsible: false, collapsed: true }` verbatim, and `safeParseAsync` agrees. So the implication `collapsed` ⇒ `collapsible` — ruled 2026-09-18, letter A — is a **renderer** rule applied from the declaration, and the describes say exactly that rather than implying a fold the schema does not perform. A consumer reading the parsed `collapsible` is reading what the author typed, never whether a disclosure control renders. + - **This is the opposite of the `ObjectFieldGroup` pair**, where a parse-time mapping really does fold the old booleans onto the ADR-0085 `collapse` enum. The two surfaces share key names and share nothing else; the describes say so. + - **Text only.** No key is added, removed or re-typed, no accept set moves and no refinement changes: `check:authorable-surface` and `check:api-surface` are both green with no delta, and the wizard-step and `group` co-declaration refusals parse identically before and after (only `true` is refused in either place; `false` is accepted in both). +- 6cc8dcd: `RecordStagePackageBodySchema`, `AssembledInstalledPackageSchema` and `ObjectStackClient.packages.list` now say, in their published docblocks, that `manifest`'s static type is deliberately an index signature and that the runtime schema is the enforced contract (#19324) + + Clause-②: no + + `AssembledInstalledPackage['manifest']` is `RecordStagePackageBodySchema`, declared `z.ZodType, Record>`. So its published type is an index signature: an authoring-stage `InstalledPackage` assigns to `AssembledInstalledPackage`, and a row whose `manifest` belongs to neither stage type-checks as an `InstalledPackageAtEitherStage`. The maintainer ruled that this is the accepted static contract (#19324, letter 丙). The three declarations now say so where a TypeScript reader meets them: + + - **The runtime schema is the enforced contract.** `InstalledPackageAtEitherStageSchema.safeParse()` refuses a `manifest` that belongs to neither stage. Tell the two stages apart by parsing, never by the static type. + - **Why the type is not inferred.** `tsc` refuses to print the whole metadata vocabulary into the declarations that embed it (TS7056). Dropping the record and artifact stages' annotations and the `ZodRawShape` cast fails the declaration build with TS7056 at `PackageApiContracts`. A named alias would turn `stack.zod` into a shared declaration chunk, the heap failure #14513 recorded. + - **The precise form, if the schema depth ever allows it,** is the one #19324 measured as A2, with its cost recorded at `RecordStagePackageBodySchema`. + + **`@objectstack/client`**: the `packages.list` TSDoc used to call this asymmetry "a KNOWN GAP rather than a design", tracked on #19324, and cited a `stack.zod.ts` line number. It now calls it the accepted static contract, cites `RecordStagePackageBodySchema` by name, and keeps its advice unchanged: narrow a row by parsing it with a `@objectstack/spec` schema, and never by `Array.isArray(pkg.manifest.objects)`. + + This settles what the `@objectstack/client` read-door changeset (#17536) calls "a known gap, tracked as #19324". The gap is not closing under #19324: it is the accepted static contract, and the client pin that records it stays. + + ⛔ No behaviour changes. No type, schema, accept set, authorable key or export moves. Only TSDoc and source comments change, and they ship: + + - `@objectstack/spec`'s published `files[]` carries `dist`, where the TSDoc is emitted into the `.d.ts` / `.d.mts` declarations, and `src/**/*.zod.ts`, so both edited files also ship as source. + - `@objectstack/client`'s published `files[]` carries `dist`, where the rewritten paragraph lands in `index.d.ts`, `index.d.mts`, `index.js` and `index.mjs`. +- ba77509: `PackageInstallBodySchema`'s residual docblock splits clause 1: a manifest missing `version` is refused by the install door now, and only the `type` half is still residual + + Clause-②: no + + The docblock lists the bodies `POST /api/v1/packages` answers `201` to while + the declaration refuses them. Its clause 1 recorded "a manifest missing `type` + and/or `version`" as ONE class. Since #19326 the door parses + `ManifestSchema.shape.version` by reference, so a manifest missing `version` + answers `400` / `VALIDATION_ERROR` and installs nothing; a manifest missing + `type` still answers `201`. Half of the clause had become false. + + The clause is now split: **1a** (missing `version`) is marked CLOSED by #19326 + and names the door-side pin, and **1b** (missing `type`) stays an open residual. + The count of five classes is unchanged and still true, because class 1 stays + open through its `type` half; the count sentence now says so. The paragraph + that quotes the runtime's two door drives is updated too. It quoted the + duplicate-id drive as `{ id: 'pkg-a', name: 'A' }`, but that drive has posted a + `version` since #19326 and the reverse-domain id `com.example.pkg-a` since + #19473, and the door answers `400` to `pkg-a`. The docblock now quotes the body + the drive posts, `{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }`, + which the declaration refuses on `type` alone and the door answers `201`. + + ⛔ No behaviour changes. No schema, accept set, export or runtime code moves, + and the other four residual classes are untouched. + + **Why this carries a changeset and not `skip-changeset`.** `@objectstack/spec`'s + `files[]` ships `src/**/*.zod.ts` verbatim, and the docblock is also emitted + into `dist/api/index.d.ts` and `dist/api/index.d.mts`. The published content + changes, even though no line of code does. +- 7e1b048: `kernel/InstallPackageRequest.enableOnInstall` no longer tells authors the in-process primitive ignores the key — it now states the three states that primitive really applies (#19339). + + The declaration's published description read "this protocol primitive does not read it". That was true when it was written and stopped being true when `MetadataProtocol.installPackage` started honouring the key (`482d584121`): `true` enables, `false` disables, and an ABSENT key makes no lifecycle call at all. Nothing went red, because `check:docs` holds the generated reference page equal to the `.describe()` and the two still agreed with each other — internal consistency, not truth. + + Clause-②: no + + **What moves** + + The `.describe()` text of one key, the doc block above it, and the two reference pages generated from that text (`references/api/protocol.mdx`, `references/kernel/package-registry.mdx`). It now reads: "restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive honours it on the registry row: true enables, false disables, absent makes no lifecycle call". + + The scope word "on the registry row" is load-bearing and is spelled out in the doc block: the durable disabled-package file is keyed by environment, which an `InstallPackageRequest` does not carry, so this seam moves the registry row for the life of the process and `POST /api/v1/packages` still owns the record that survives a restart. + + **What does not move** + + No key is added, removed, renamed or retyped, and no default changes — the accept set is byte-for-byte what it was, and `api-surface`, `authorable-surface` and `authorable-defaults` are all unchanged. `PackageInstallRequestSchema` (`api/package-api.zod.ts`) remains the one authority for this key, and the parity pin that holds the copy to it is untouched. +- 342808c: The install door's doc block no longer denies that the in-process protocol primitive reads `enableOnInstall` (#19339). + + `PackageInstallRequestSchema.enableOnInstall` (`api/package-api.zod.ts`) carries the map to the other two declarations of this key, and its entry for the kernel copy read: "its own implementation does not read it, and this door does not forward it down that seam". That was true when it was written and stopped being true when `MetadataProtocol.installPackage` started honouring the key (`482d584121`). Nothing went red — no gate compares a sentence against an implementation — and the text ships: `src/**/*.zod.ts` is in this package's `files[]`, and the comment survives into `dist/api/index.js` and `dist/browser/api/index.mjs`. + + Clause-②: no + + **Only one half of the sentence was false.** It is a compound claim about two layers, and they were re-derived separately from the source rather than rewritten together: + + - `MetadataProtocol.installPackage` (`packages/metadata-protocol/src/protocol.ts`, the `requestedEnabled` arms) now reads the key: `true` enables, `false` disables, an absent key makes no lifecycle call at all. That half is corrected, and scoped — the primitive moves the **registry row**, for the life of the process. + - "this door does not forward it down that seam" is **still true** on `main` and is kept: `handlePackages` (`packages/runtime/src/domains/packages.ts`) calls `installPackage({ manifest, settings })` and performs the enable/disable flip itself, then writes the durable record from the row it returned. Correcting that clause would have swapped one false sentence for another. + + The scope words are load-bearing: the durable disabled-package record is keyed by environment (`setPackageDisabled(environmentId, …)`), which an `InstallPackageRequest` does not carry, so `POST /api/v1/packages` still owns the half that survives a restart. + + **What does not move.** No key is added, removed, renamed or retyped, and no default changes: the accept set is byte-for-byte what it was, `check:api-surface` and `check:authorable-surface` are green with no diff, and no generated reference page changes — this text is a TSDoc block, not a `.describe()`, so `check:generated` reports all 15 artifacts up to date without a regeneration. The declaration is `no` on both limbs: nothing is widened and nothing is retired. +- 5c5b67f: fix(spec): the `/packages` list-door tombstone enumerates all three filters that door reads (#19407) + + `limit` and `cursor` on `ListInstalledPackagesRequestSchema` both raise + `PACKAGES_LIST_PAGINATION_REMOVED`, and that prescription enumerated the serving + door's filters as `status` / `type`. The door reads a **third**, `enabled` + (`readEnabledFilter`, `packages/runtime/src/domains/packages.ts`), so the + prescription named two of three and pointed an upgrader at a narrower answer + than the route actually offers. + + **Incomplete, not wrong — and only the enumeration moves.** The sentence's + load-bearing claim, *no page was ever withheld and no continuation token was + ever minted*, is untouched and stays true: `enabled` filters rows, it does not + paginate. The removability argument, the `.default(50)` passage and the + `hasMore` passage are byte-identical. Nor did the sentence ever assert that + `enabled` was unavailable — it enumerated, it did not exclude — so nothing here + reverses a claim. + + ``` + FROM … the serving door filters on `status` / `type` and then returns every + remaining row … + … Filter with `status` and `type` instead of asking for a window. + + TO … the serving door filters on `status` / `type` / `enabled` and then + returns every remaining row … + … Filter with `status`, `type` and `enabled` instead of asking for a window. + ``` + + The same repair lands on every carrier of the sentence inside the spec: the + tombstone string, and the ADR-0087 D3 semantic entry + `packages-list-pagination-retired` in both its `replacement` (what to use + instead) and its `reason` (what the door reads). The generated migration + registry and the generated reference page follow from the repo's own + generators. + + No accept set moves, no key is added or removed, and no type changes: `limit` + and `cursor` stay `retiredKey()` tombstones typed `never`, and `enabled` was + already declared and already published as a filter on this request. +- 48c91e9: One published `describe` sentence that dates itself to the `.objectui-sha` pin is re-pointed to the pin this release builds against, objectui `62597c588072`, after being re-read there (#19503). + + `Clause-②: no` + + - `FormField.span`: the `'auto'` clause says that at the pin this repo builds against, only textarea, markdown, html, richtext and repeater resolve to the full column count. It named `87af769e9`. Re-read at `62597c588`, the claim still holds: `plugin-form`'s `WIDE_FIELD_TYPES` is unchanged (it shifted one line when an import was added above it; repeater still reaches it through `field:grid`), and `form.tsx`'s `spanLadderFor` is byte-identical, so `'full'` is still the whole row at every multi-column tier. Only the pin the sentence names moves. + + No key, default, enum member or export moves: the same authored metadata is accepted and refused as before, and `content/docs/references/ui/view.mdx` is regenerated from the sentence. +- 0f057b6: `packages/spec/src/migrations/registry.ts` — the shipped ADR-0087 semantic entry `filter-between-blank-endpoint-refused` told an upgrader that `FieldOperatorsSchema.safeParse` **and re-saving the document** both make the sweep mechanical, over a carrier list of six slots. Measured: re-saving is mechanical on **none** of the stored carriers, so an upgrader who re-saved every dashboard, dataset and report found no blank endpoint refused and concluded the sweep was done. The entry's `surface`, `reason` and `acceptanceCriteria` now say what is actually judged where, and the sibling entry `filter-between-field-reference-endpoint-refused` — measured to carry the identical clause in its own spelling — is corrected the same way (#19523). + + Clause-②: no + + No behaviour moves: nothing about what the platform refuses changes. No schema or accept set is touched, and no export is added, removed or retyped (`check:api-surface` reads the public surface unchanged); every line this change edits in `registry.ts` is a string literal inside the two step-18 entries that the exported `MIGRATIONS_BY_MAJOR` carries, so what moves in `dist` is prose. What changes is what the document tells a human to do about it. + + - **The split the entry now draws.** (a) Refused at save: the enforced `FieldOperatorsSchema` / `RangeOperatorSchema` copy itself, reached by a caller that validates a filter against it directly, and the `NormalizedFilter` AST. (b) Not judged at save: every stored metadata carrier — and that is **both** authoring dialects, not only the loose one. A dashboard widget filter, a dashboard options-source filter, a dataset filter, a dataset measure filter, a report `runtimeFilter`, a rollup `summaryOperations` filter and a `relatedListFilter` are typed `FilterConditionSchema`; a view, page or component filter **rule** is `ViewFilterRuleSchema`, whose value check judges arity and not blankness. For (b) the detectors are the grep the entry already prescribes and **executing** the surface, where the engine comparand-shape door answers `INVALID_FILTER` / 400 naming the index and the side. + - **What those same slots DO judge.** `FilterConditionSchema` carries `.superRefine(checkBarePresetOrderingComparands)`, so a bare date-range preset name in an ordering position is refused on this carrier set: measured at this head, `{ close_date: { $gt: 'today' } }` is refused at `close_date.$gt` and `{ close_date: { $between: ['today', '2026-12-31'] } }` at `close_date.$between.0` — the latter through an otherwise green `DashboardWidgetSchema` document at `filter.close_date.$between.0`, where the blank endpoint on that same widget stays green. This carrier set is not the edge of the rule, because the refinement rides the schema rather than these slots: `QuerySchema` refuses `where: { close_date: { $gt: 'today' } }` at `where.close_date.$gt`, and `BlueprintSummaryOperationsSchema` refuses the same map as its `filter` at `filter.close_date.$gt`, while `$gt: '2026-01-01'` stays green through both. What these carriers never judge is a `$between` endpoint for **blankness** or for **arity**, and that narrower clause is what the entry now carries; the sibling entry `filter-preset-ordering-comparand-refused` states the same rule from its own side. + - **The card's own control was wrong in the safe direction, and it was re-derived rather than inherited.** `ViewFilterRuleSchema.safeParse({ field, operator: 'between', value })` reads green on `['2026-01-01', '2026-12-31']` and green on `['2026-01-01', '']`, and refused on `['2026-01-01']` and on `['2026-01-01', { $field: 'x' }]` — so the rule dialect admits a blank endpoint, with two firing controls proving the same door is live. The same four verdicts hold through a fully-green `ListView` document at `filter.0.value`. + - **The sibling entry carried the identical clause, and it is corrected here too.** `filter-between-field-reference-endpoint-refused` described the same `FilterConditionSchema` slots as a shape “that never judges an operator map”. Measured at this head through an otherwise-green `DashboardWidgetSchema` document: a `{ $field }` reference endpoint in `$between` parses GREEN — as do a one-element, a three-element and an empty `$between` — while a preset endpoint on that same widget is refused at `filter.close_date.$between.0` and a preset `$gt` comparand at `filter.close_date.$gt`. Its clause now names what those carriers never judge — a `$between` endpoint for a COLUMN REFERENCE or for ARITY — with the live preset rule named beside it, and its `acceptanceCriteria` stops offering “those slots are `FilterConditionSchema`” as the reason a document parses green. Reach, counted over the four bundles: the bare `never judges an operator map` read 4 before this change and 0 after, and no entry carries it any more: in the tree it survives only in this changeset, which quotes the removed clause — while `a $between endpoint for a COLUMN REFERENCE or for ARITY` and the widget sentence quoting `contract.start` each moved 0 → 4, against a dark control (`… or for BLANKNESS`) reading 0 on both sides. + - **Why this is a publishing change at all.** `src/migrations/entries/**` is generator input: it is not in this package's `files[]` (only `entries/README.md` ships) and nothing imports it but the generator and two pin tests. The registry is what `chain.ts` and `index.ts` import and what `dist` is built from, so of the two, only the registry carries the corrected prose into `dist`: measured on this branch, the old sentence read 4 hits across `dist/index.js`, `dist/index.mjs`, `dist/browser/index.js` and `dist/browser/index.mjs` before the regeneration and 0 after, and two sentences unique to the corrected text moved 0 → 4 in the same four files. +- 95fb417: **The declared `zod` floor moves from `^4.4.3` to `^4.6.1`**, because on zod below 4.6.1 the three standard error formatters — `z.treeifyError()`, `error.format()` and `error.flatten()` — cannot render a refusal these packages actually emit (#19581). + + Clause-②: no + + **What breaks below the new floor.** All three formatters walked an issue's `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming a member of `Object.prototype` was answered by the prototype and no node was ever created. Two different failures follow: + + | path shape | what happened on `^4.4.3` | + |:---|:---| + | terminal element (`['assignments','__proto__']`, `['x','toString']`) | the inherited member is adopted as the node, then `node._errors.push(...)` runs on it — `TypeError: Cannot read properties of undefined (reading 'push')` | + | non-terminal element (`['__proto__', …]`) | the walk continues **into** `Object.prototype` and writes the next segment onto it — the message is silently dropped from the returned tree and the process gains a global prototype key | + + **Why it reached this platform's consumers.** `@objectstack/spec` refuses a `__proto__` key on its open-key authoring surfaces, and that refusal's issue path is `['assignments','__proto__']` — precisely the terminal shape. Anything that formatted one of these refusals for display crashed on it, and the crash was in the formatter, not in the guard. The guards themselves are unchanged and still necessary: 4.6.1 still drops a `__proto__` key from `z.record()` and `.catchall()` output, which is what they exist to refuse. + + **What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. + + `@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. + + ## The second half the floor move needs: an unknown key refuses TERMINALLY again + + From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no + longer aborts the shape that raised it. Two things follow, and both were + measured on this package with the same bodies on 4.4.3 and 4.6.1: + + 1. **A closed shape's own refinements now run after the refusal**, adding a + second complaint that contradicts the first. + 2. **A union containing that shape loses its envelope.** zod's + `handleUnionResults` returns a single non-aborted member's issues + *unwrapped* instead of raising `invalid_union`, so the union's message + becomes whichever branch zod judged closest. + + At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong + branch's prescription. Writing `type: 'page'` on a ViewItem answered: + + ``` + Unrecognized key(s) on this view container: `viewKind`, `config`. + • `viewKind` belongs to a single VIEW, not to the container. Wrap it: … + ``` + + — naming neither `page` nor its removal. It now answers, as it did before: + + ``` + config.type: 'page' was removed from the list-view `type` enum in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … + ``` + + **What an upgrading consumer must do.** Nothing. No key or value changed + status: everything this package accepted before it accepts now, and everything + it refused it still refuses. What changed is which of several competing + complaints an author reads, and that a refusal behind a union is again + reported as `invalid_union` with its branches, which is what `z.treeifyError()` + and this package's own `formatZodError` expand. + + ⚠️ A closed shape declared with a bare `z.object(…).strict()` or + `z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT + get this and will still collapse its union. Build closed authoring shapes with + `strictObject`, or re-declare an existing one through `closedObject`. +- 2cf9db7: `view/layout-without-binding` — the `gantt`, `timeline` and `map` warnings no longer tell an author the renderer falls back to literal default field names, because objectui deleted those floors; each now names the refusal screen the renderer shows instead, and the keys that clear it (#19630). + + Re-derived at the objectui pin this repo builds against (`87af769e9`), not at objectui's head, both halves of each path a list view takes: + + - **`gantt`** — `ListView.tsx`'s `case 'gantt'` restates only declared bindings (objectui#7070 deleted the `start_date` / `end_date` floors, objectui#7499 the `progress` / `dependencies` ones); `ObjectGantt`'s `getGanttConfig` returns `null` without both dates and the component renders "Gantt configuration required". The body now names `gantt.startDateField`, `gantt.endDateField` and `gantt.titleField`, the three keys `GanttConfigSchema` requires. + - **`timeline`** — the `startDateField || 'created_at'` floor is gone (objectui#7070 step three) and `ObjectTimeline` renders "Timeline date axis required"; the `titleField || 'name'` default still stands, and the body says so. It names `timeline.startDateField` and `timeline.titleField`, the two keys `TimelineConfigSchema` requires. + - **`map`** — `locationField || 'location'` is gone on both faces (objectui#8169): `ObjectMap` no longer guesses coordinate field names and its `hasCoordinateBinding` gate renders "Map configuration required", for an absent `map` block and for a declared block that names neither coordinate form alike. Both `map` messages — the absent-block body and the block-present one, which had quoted `locationField || 'location'` as the renderer's read — now describe that refusal and name `map.locationField` or the `map.latitudeField` + `map.longitudeField` pair. + + `kanban` and `tree` were re-read at the same pin and still floor (or infer) a binding, so they keep the generic body. The `VIEW_BINDING_BLOCKS` docblock rows carry asserting pin citations, so the next `.objectui-sha` bump reds on them instead of leaving them to go stale. + + No severity moves and no finding appears or disappears: every route stays `warning`, consistent with #16577's ruling B for the `calendar` route (both doors loud — `os validate` and a named refusal at render), which these rows now measure too without extending or reopening it. No schema, export or accept set moved: this corrects prose and three warning strings. `Clause-②: no` +- dc1b986: The object designer's quick-add grid no longer offers two formula `returnType` members that `FieldSchema` refuses (#19677). + + `FieldSchema.returnType` declares four members — `number`, `text`, `boolean`, `date`. The `fields` repeater in `object.form.ts` declared an inline `options` list of **six**, adding `datetime` and `currency`. An author who added a formula field from the object designer and picked Datetime or Currency wrote a value the parse rejects: the select is populated from that inline list, nothing reconciled it against the enum, and the refusal arrived later from the save door naming a key the author never typed. + + The control is narrowed to the four declared members. This is a pull-back to a spelling that already existed in this package twice, not a decision about what a formula may return: + + - **The enum is unchanged** — `FieldSchema.returnType` accepted exactly these four before this change and accepts exactly these four after it. No authorable value is removed, because neither `datetime` nor `currency` was ever accepted; what is removed is an offer with nothing behind it. + - **The sibling control already spelled it correctly.** The field designer's own `returnType` select in `field.form.ts` carries the same explicit four-member list. The object designer was the lone divergent carrier; three now agree, counting the published reference doc. + - **The producer side agrees with the enum too.** Authoring stamps `returnType` from the inferred CEL type, and that inference is typed `number | text | boolean | date | unknown`, so there is no path by which the platform stamps `datetime` or `currency`. + + Whether any stored field carries `returnType: 'datetime'` or `'currency'` today is not measured here and is unaffected either way: the parse that refuses those values is the one that already ran. + + The regression test derives its expected set from `FieldSchema` at runtime rather than hard-coding four strings — a test pinned to literals rots exactly the way this defect did — and judges the offer at value level: every offered value must survive a full parse on a formula field. +- 655e8c0: A metadata form's option-value refusal now says what to do: `defineForm`'s module-load refusal of an inline option `value` that fails the system-identifier grammar names the derive path, and the form field's `options` describe states the rule it belongs to (#19678, #19907). + + Clause-②: no + + A form option `value` is a lowercase system identifier — `FormSelectOptionSchema` reuses `SelectOptionSchema.value` by reference — so an enum member carrying a hyphen or a capital (`object.managedBy`'s `system-data`, `action.openIn`'s `new-tab`, `action.execution`'s `perRecord`) cannot be written as an inline option at all. That bound stays. An enum-typed metadata-form row may still carry an inline `options` list, to give its members human labels or to offer a deliberate subset. A row whose members cannot be spelled as option values omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. + + - **The refusal names the remedy.** `defineForm` still throws a `ZodError` at module load with the same issues and codes (`invalid_format` for the pattern, `too_small` for the two-character floor). The grammar message on an inline option's `value` is kept, and now carries the derive path after it, for a row whose members cannot be spelled as option values. Only schema-bound forms built by `defineForm` get this sentence. The grammar message where it is declared (`SystemIdentifierSchema`) is unchanged, because it also bounds object-field options and three object-storage names, where omitting `options` is not the answer. + - **The describe states the rule** on `FormFieldSchema.options`: an inline list is allowed on an enum-typed row, and the derive path is named for a row whose members cannot be spelled. That text is served in the JSON Schema and on the generated reference page. + - ⛔ **No accept-set change.** Every value refused before is still refused, and every value accepted before is still accepted. No key, export or schema shape moves. +- 041c8cf: **Fix:** `FieldSchema.format`'s description said only `Format string (e.g. email, phone)`. It offered two example words without saying which field type they apply to or what reads them, and it shipped in the JSON Schema, in `dist`, in the published `src/**/*.zod.ts` and in `content/docs/references/data/field.mdx`. Followed onto an `autonumber` field, it produced `email1` as a business identifier. The value parsed, it was stored, and nothing reported it. + + `Clause-②: no`: the key is still `z.string().optional()`. Nothing is split, narrowed, retired or gated by type. No accept set moves in either direction, and no consumer is touched. Only the sentence changes. + + **What the description now says, reader by reader.** Each point was measured, not recalled, and each is stated as what a reader does rather than as a claim that nothing else reads the key. + + - On an `autonumber` field the key is the record-number **pattern**, the shorthand that predates `autonumberFormat`. `resolveAutonumberFormat` takes the canonical key first, then this one, then the declared default `{0000}`. The ObjectQL engine's `applyAutonumbers` and `driver-sql` both mint through it, and the build-time autonumber lint in `@objectstack/lint` reads the same pattern. Measured against this build: `format: 'INV-{0000}'` gives `INV-0001`; `format: 'email'` gives `email1`, because a value with no `{...}` token is literal text with the bare counter appended; `{ autonumberFormat: 'A-{000}', format: 'email' }` gives `A-001`. + - On any other field type the server does not act on the key. It picks no column type from it, coerces no value by it and runs no check from it. The write-time record validator's built-in email, url and phone checks key on the field `type`. + - The Studio UI reads the key as a display hint, using words and defaults that its renderers own. The description names two examples. The `date` and `datetime` cells read a display style. On a plain-text field, the shared cell-renderer resolver reads a small word set that promotes the cell to a richer renderer; at the pinned objectui, `{ type: 'text', format: 'phone' }` renders a `tel:` link. The words themselves are deliberately not copied into the spec. They belong to those renderers, and a copy here would go stale without anything going red. + - The spec declares no vocabulary for the key and checks nothing except that it is a string, so any string parses on any field type. + + To constrain a **value**, the description points to the field `type` or to a `format` validation rule (`{ type: 'format', field, format: 'email' }`), whose own `format` key is the closed set `email | url | phone | json`. + + The wider question is deliberately left alone here. One `z.string()` key is read differently by different readers, and nothing checks that they agree. Whether any of those readings should become a declared vocabulary is a contract-shape decision. +- e3277c3: The doc comment on `InstallPackageRequestSchema`'s `enableOnInstall` key in `kernel/package-registry.zod.ts` no longer says `ManifestSchema` is declared in that file (it is declared in `kernel/manifest.zod.ts`), and its import-cycle reason now rests on the import that makes the cycle: `api/package-api.zod.ts`, which declares `PackageInstallRequestSchema`, imports `InstalledPackageSchema` from `kernel/package-registry.zod.ts` (#19748). Doc comment only; the directive against spelling `PackageInstallRequestSchema.shape.enableOnInstall` there is unchanged. +- 9df3934: `packages/spec/src/migrations/registry.ts`: the shipped ADR-0087 semantic entry `filter-preset-ordering-comparand-refused` listed a page filter and a component filter among the `FilterConditionSchema` carriers, and said the schema door and the `@objectstack/lint` `filter-preset-comparand` rule refuse a bare date-range preset there at publish. Both are `ViewFilterRuleSchema` rule arrays, and a rule array carries no preset check, so an upgrader who swept stored pages with a schema parse found nothing and concluded the sweep was clean. The entry's `surface`, `reason` and `acceptanceCriteria` now put each carrier under the door that actually refuses it. + + Clause-②: no + + No behaviour moves. No schema, accept set or lint rule is touched, and no export is added, removed or retyped. Every line this change edits in `registry.ts` is a string literal inside that one step-18 entry, which the exported `MIGRATIONS_BY_MAJOR` carries, so what moves in `dist` is prose. + + - **The groups the entry now draws.** They list the carriers measured, not a closed partition; the entry's grep sentence is the catch-all. Each was measured against the built `dist` with a preset comparand (`last_30_days`, and `today` as a `between` endpoint), and an ISO-date dark control reads green in every cell. + 1. Slots typed `FilterConditionSchema`: `DashboardWidgetSchema.filter`, `GlobalFilterOptionsFromSchema.filter`, `DatasetSchema.filter`, `DatasetMeasureSchema.filter`, `ReportSchema.runtimeFilter`, `JoinedReportBlockSchema.runtimeFilter`, `FieldSchema.relatedListFilter` and `FieldSchema.summaryOperations.filter`. A parse of the declaring schema refuses each one at the comparand's own path, and the lint rule reports each one as well. + 2. Filters under a key the lint walks whose declared type carries no preset check. These are `ViewFilterRuleSchema` rule arrays (a view's `filter`, a page element's `dataSource.filter`, a page component's `filter` prop) and a Mongo-shape record typed as a loose record rather than `FilterConditionSchema` (a flow `get_record` / `update_record` / `delete_record` node's `config.filter`). These parse green, and the lint rule alone refuses them. + - **Two more false sentences are narrowed.** + - The `replacement` called the dashboard date-filter positions "the only place any layer ever resolved" a preset name. An analytics query's `timeDimensions[].dateRange` accepts and resolves the names too. + - The `reason` said equality and membership "are NOT judged". That holds for the schema door only. The lint rule refuses a preset in an equality or membership position on a field it can resolve to a declared `date` or `datetime`, while `this_quarter` on a `select` field stays green. Where the filter binds to no object, such as a widget whose `dataset` names no dataset, that arm does not fire. + - **Reach.** Counted over `dist/index.js`, `dist/index.mjs`, `dist/browser/index.js` and `dist/browser/index.mjs`: + - The removed carrier list `page filter, component filter, rollup filter` and each of the four other removed claims read 4 before and 0 after. + - The unchanged dark control `compared false against every row: HTTP 200` reads 4 on both sides. +- 6696056: The shipped ADR-0087 semantic entry `filter-preset-ordering-comparand-refused` drops, from its `surface` and its `reason`, the text that said a page's `interfaceConfig.filterBy` and a lookup field's `lookupFilters` are refused by neither door at publish, and drops, from its `acceptanceCriteria`, the by-hand search it prescribed for those two keys. The `@objectstack/lint` `filter-preset-comparand` rule now walks both keys, so `os lint`, `os validate` and the runtime publish gate (on `page` and `object` writes) refuse `{ field: 'close_date', operator: 'gt', value: 'last_30_days' }` in either one, while the schema parse still accepts it. + + Clause-②: no +- 2548ba5: The Studio view form (`viewForm`, served by `METADATA_FORM_REGISTRY.view`) now offers `pagination` for every view type, not only grids. + + `pagination.pageSize` is the row bound every view type carries. The form used to place `pagination` inside the grid-only `Table options` section (shown when `type` is `grid` or unset), so an author editing any other view type could not see or set it without editing the metadata by hand. It now has its own collapsed `Pagination` section with no visibility condition. `Table options` keeps `resizable`, `compactToolbar`, `rowHeight` and `selection`, still for grids only. + + No schema changed: every view type already accepted `pagination`. `@objectstack/platform-objects` ships the new section's label and description in its metadata-form translation bundles (en, zh-CN, ja-JP, es-ES). +- 9282578: fix(spec): `composeStacks` refuses a malformed `actions` with the ADR-0112 envelope instead of crashing in its action-key collision pass + + `composeStacks` checks the composed stacks for cross-stack action-key collisions before it binds each standalone action to its object. That check read every input's top-level `actions` entries, and every composed object's own `actions`, with no shape guard. On a hand-built input stack it therefore crashed before the bound-action merge could refuse the same input. `defineStack` already refuses these shapes at its own door, with or without `strict: false`, so a stack it built never reached this crash. Measured before this change: + + | malformed input stack | before | after | + | :--- | :--- | :--- | + | top-level `actions: [null]` or `[undefined]` | bare `TypeError` reading `objectName`, `code` and `status` both `undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['actions', index]` | + | an object's `actions: 5` (or `'abc'`, `{}`, `true`) | bare `TypeError: (obj.actions ?? []).entries is not a function` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['objects', i, 'actions']` | + | an object's `actions: [null]` or `[undefined]` | bare `TypeError` reading `name` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['objects', i, 'actions', index]` | + | two stacks that each carry a non-object top-level entry (`['x']`) | refused as `STACK_COMPOSE_ACTION_KEY_COLLISION` on the key `global:undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry | + + The collision check now skips anything that declares no action key: a non-object entry, and an object's `actions` that is not an array. It does not word a refusal of its own. Every such input still reaches the bound-action merge, whose existing guard gives the one refusal for this condition. The index in each issue path is the entry's index in the composed artifact. Nothing that used to be accepted is refused now, and nothing that used to be refused is accepted. Well-formed stacks compose exactly as before, and a real cross-stack collision is still refused with `STACK_COMPOSE_ACTION_KEY_COLLISION`, including one that sits beside a skipped entry. + + Fix: author every `actions` as an array of action definitions, or run each stack through strict `defineStack` to have the shape refused where it is written. + + No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`. + + Clause-②: no +- ecf90b2: A top-level report now corrects the same ten misspelled keys a joined-report block already corrects. Before this change, `measures:` on a plain report was rejected with no suggestion, while the same key on a block was told to use `values`. + + Clause-②: no + + `ReportSchema`'s alias table says it is kept parallel to `JoinedReportBlockSchema`'s, but ten of the block's entries were missing from it. The rejection now names the target key, and every target is a key the report declares: + + | authored key on a report | before | now | + | :--- | :--- | :--- | + | `measures`, `metrics` | no suggestion | ``Did you mean `measures` → `values`?`` | + | `dimensions`, `groupBy`, `groupings` | no suggestion | ``Did you mean `dimensions` → `rows`?`` | + | `sort`, `sortBy` | no suggestion | ``Did you mean `sort` → `order`?`` | + | `orderBy` | `order`, found by edit distance | `order`, from the alias table | + | `objectName`, `object` | no suggestion | ``Did you mean `objectName` → `dataset`?`` | + + **Every accept/reject verdict is unchanged.** The same reports are refused, with the same `unrecognized_keys` issue. Only the prescription in that issue's message is new. Nothing authorable is added, removed or renamed. +- 9347c1f: A row-level or sharing-rule predicate comparing a field against a list with `!=` / `==` is refused at the CEL lowering instead of lowering to a filter that widens on driver-mongodb, and driver-mongodb refuses `$ne` with an array comparand (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/driver-mongodb`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-list-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS reads on MongoDB and RLS write checks.** A policy written `record.status != ['closed', 'archived']` (or `!(record.status == [...])`, or `!=` against a `current_user` membership set) lowered to `{ status: { $ne: [...] } }` (or `$not` around a bare-array equality). The RLS `using` clause is composed into the query after the engine's comparand-shape check, and driver-mongodb passed the shape to the server, where it selects every scalar row: the read returned the rows the policy was written to hide. A `check` written `!=` against a membership set admitted every write. + + - `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose comparand is a list (`unsupported`): a list literal, or a `current_user` variable that resolves to an array. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports the literal; a resolved array is refused per request. + - `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403). A CEL-authored `check` gets this 403; the `INVALID_FILTER` / 400 of `matchesFilterCondition` remains for a filter passed to it directly. + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. + - `@objectstack/lint`: the list-literal form is reported (`rls-predicate-unenforceable`, `sharing-rule-unlowerable-condition`). The RLS reference pass probes each kernel-resolved `current_user` key with its runtime type. + - `@objectstack/driver-mongodb`: `translateFilter` refuses `$ne` with an array comparand at any depth, with `INVALID_FILTER` / 400, as driver-sql and driver-memory already do. + - `@objectstack/spec`: the migration registry carries the entry. + + **What to change.** "One of these values" is `record.status in ['open', 'pending']`; "none of these values" is `!(record.status in ['closed', 'archived'])`. In a raw filter, use `$in` / `$nin`. `in`, scalar `==` / `!=`, `null` and field-to-field comparisons are unchanged. + + +- c164186: `matchesFilterCondition` refuses an array comparand under `$ne` and in the equality position (`{ field: [...] }`, `$eq: [...]`) with `INVALID_FILTER` / 400, before any record is judged (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula` and `@objectstack/plugin-security` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `rls-predicate-array-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for row-level write checks.** This evaluator is what `@objectstack/plugin-security` runs against the post-image of an insert or update to enforce a row-level `check`. It compared strictly, and no stored value ever equals an array, so: + + - a `check` written `record.status != ['closed', 'archived']`, or `!=` against a `current_user` membership array, lowered to `{ status: { $ne: [...] } }` and matched **every** post-image; + - a `check` written `!(record.status == ['closed', 'archived'])` lowered to `{ $not: { status: [...] } }` and did the same. + + Every write such a policy was written to refuse was admitted and stored. The positive `record.status == ['open', 'pending']` already refused every write (403). + + The message withholds the field, the operator and the value, because the filter is usually an access policy the caller did not write, and the comparand may be a resolved membership set. + + **What to change.** A `check` or `using` predicate that means "one of these values" or "none of these values" is spelled with `in`: `record.status in ['open', 'pending']`, or `!(record.status in ['closed', 'archived'])`. Those, scalar `!=` / `==`, `null`, `Date` comparands and `{ $field }` references evaluate exactly as before. + + +- 4d7e740: A row-level or sharing-rule predicate whose comparison is handed something other than one value is refused at the CEL lowering or at the write-check evaluator, instead of admitting writes and reads it was written to refuse (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing`, `@objectstack/lint` and `@objectstack/objectql` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS write checks and reads.** Each shape below was measured through the real plugin-security on driver-sql and driver-memory: + + - `!(record.status in [['closed', 'archived']])` (a list nested in an `in` list) admitted and stored every write the `check` was written to refuse, and a `using` read returned every row on driver-memory. + - `current_user.org_user_ids != 'x'` and `current_user.org_user_ids > 'a'` (a membership set on a comparison with no field) folded to "no restriction": every write admitted, every row read, on every driver. + - `record.status > ['m']` compared the list as the string `'m'` on the write check, while the analytics read scope bound the whole list as one SQL parameter. `record.reviewer_id > current_user` compared the whole caller object as a string and admitted and stored every write; in this release the RLS compiler's comparand faces (#20212) already drop that policy, and this change refuses it at the lowering for every caller of the compiler. + - `record.status != record.tags`, its negation `!(record.status == record.tags)`, and the mirror `record.tags != record.status`, with `tags` a `json` field or a `multiple` lookup, admitted and stored every write. + + What changes: + + - `@objectstack/formula`: `compileCelToFilter` refuses, with `unsupported`, a list comparand under every comparison (the ordering operators now included, and on the constant-fold branch, whichever side), the `current_user` root or a key resolving to an object under an ordering operator, and an `in` list whose member is itself a list. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports each literal form; a resolved value is refused per request. `matchesFilterCondition` refuses, with `INVALID_FILTER` / 400, an array under `$gt` / `$gte` / `$lt` / `$lte`, an array member of `$in` / `$nin`, and a `{ $field }` comparison (`$eq`, `$ne` or an ordering operator) whose column holds a list or an object on the record being judged, on either side. The message withholds the field, the operator and the value. + - `@objectstack/plugin-security`: the RLS compiler drops a policy the compiler refuses and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, and `getReadFilter` hands the analytics read scope the deny scope). A `check` comparing a field with a list-holding column is refused 400 and stores nothing. + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. + - `@objectstack/lint`: the literal forms are reported as `rls-predicate-unenforceable`, and an ordering comparison against a membership set through the reference pass. + - `@objectstack/objectql`: a `having` comparison against a `{ $field }` column whose aggregated row holds a list is refused 400 where the row carries the list itself (driver-memory); driver-sql rows carry the stored JSON text and compare as before. + - `@objectstack/spec`: the migration registry carries the entry. + + The stage 2a changeset's sentence that `{ $field }` references evaluate as before no longer holds for a column holding a list or an object: that comparison is now refused. + + **What to change.** "One of these values" is `record.status in ['open', 'pending']`, and "none of these values" is `!(record.status in ['closed', 'archived'])`, with the list flat. An ordering takes one bound (`record.status > 'm'`); a range is two comparisons joined by `&&`. Compare against one key of the caller (`record.reviewer_id > current_user.id`). A field compared with a `json` or `multiple` field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook. In a raw filter, use `$in` / `$nin` with flat lists and one bound per ordering operator. + + Not changed: a field compared with a `json` or `multiple` field still lowers and is not reported at authoring time, because the lowering sees the predicate's text and not the object's field types; driver-memory still answers a `{ $field }` comparison on a read without evaluating the reference. + + +- de091b5: A row-level write check that orders a field against a bound (`>`, `>=`, `<`, `<=`) is refused with `INVALID_FILTER` / 400 when that field holds a list or an object on the record being written, instead of comparing the list's string form and admitting the write (#19886). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula` and `@objectstack/plugin-security` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `rls-predicate-stored-list-ordering-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS write checks.** Measured through the real plugin-security on driver-sql and driver-memory: `record.tags > 'a'`, with `tags` a `json` field holding `['m']`, compared `'m' > 'a'` and admitted and stored the write. `record.meta < 'a'` with `meta` holding `{ a: 1 }` compared `'[object Object]' < 'a'` and did the same, and so did a `multiple` lookup. `using` stands in as the check for a policy that declares no `check`, so the same predicate in `using` was enforced the same way on writes. No shipped row-level or sharing-rule predicate orders a field at all. + + What changes: + + - `@objectstack/formula`: `matchesFilterCondition` refuses `$gt` / `$gte` / `$lt` / `$lte` and `$between` on a field whose value on the record is a list or a plain object, whatever the comparand, with the same `INVALID_FILTER` / 400 and the same message as the stage 2d refusals. The refusal is per record: a record whose `json` field holds one scalar is compared as before. `null` and `Date` values are unchanged, and so is every equality against a stored list (`$eq`, `$ne`, implicit equality, `$in`, `$nin`). `$between` is not produced by the CEL lowering, so it reaches this only through a filter passed to `matchesFilterCondition` directly. + - `@objectstack/plugin-security`: a check insert or by-id update whose post-image holds a list or an object in an ordered field is refused 400 and stores nothing. That includes a by-id update that edits another field of a row whose stored `json` column holds a list, because the post-image merges the stored row. + - `@objectstack/spec`: the migration registry carries the entry. The stage 2a entry `rls-predicate-array-comparand-refused` now ends "Scalar != and ==, null, Date comparands, and { $field } references between single-valued columns evaluate exactly as before", which is true since stage 2d. + + Three moves, named: + + 1. **The write check now matches driver-sql's read.** driver-sql refuses every ordering comparison, and `$between`, on a column it stores as JSON text, by declared type (400, #7398). The in-process check now refuses the same predicate on the same row (400). + 2. **driver-memory's read parts from the write check.** driver-memory, a test driver, compares a stored list element by element on a read and keeps returning those rows (`record.tags > 'a'` reads a row holding `['m']`), while the check now refuses writing it. This is declared on #15104, as for stage 2d's `{ $field }` half. + 3. **A list written into a scalar field under an ordering check now answers 400.** `status: ['m']` into a `text` field under `record.status > 'a'`, or `amount: [500]` into a `number` field under `record.amount > 10`, was admitted, and driver-sql stored it as the text `'["m"]'` / `'[500]'`. It is now refused before anything is stored. + + **The explain answer.** `security/explain` evaluates the business RLS predicate in-process on the fetched record, so it now answers `INVALID_FILTER` / 400 where the record holds a list or an object under an ordering predicate (this stage). It already answered 400 for a `{ $field }` comparison against a list-holding column (stage 2d). For both, per operation: + + | explain operation | driver | the enforced operation answers | same as explain's 400? | + |---|---|---|---| + | `read` | driver-sql | 400 `INVALID_FILTER` (the driver's refusal) | yes | + | `update` | driver-memory | 400 `INVALID_FILTER` (the post-image check) | yes | + | `update` | driver-sql | 403 `PERMISSION_DENIED`: the pre-image gate fails closed on the driver's 400 | no — both deny | + | `read` | driver-memory | the rows its element-wise read admits | no — the test driver's read | + + Explain itself is unchanged. + + **What to change.** Order a single-valued column (`record.priority > 2`), or test membership in the list with `in` (`record.status in ['open', 'pending']`). A `json` or `multiple` field has no ordering. + + +- ae7a35a: A scheduled run no longer skips a screen whose field is named `schedule`, `jobId` or `flowName` (#19900). + + The schedule trigger starts each run with three seeds of its own in `params` — `jobId`, `flowName` and `schedule` — and there is no caller behind the run. It stated nothing about that, so a `screen` node's headless verdict inferred who supplied each field from `params`, read those three seeds as the caller's answers, and continued past a screen whose field shared one of the names: the run completed with the trigger's value (for `schedule`, the cron descriptor) as the answer. + + The trigger now sets `AutomationContext.callerParamKeys: []` — "the caller supplied nothing" — which the verdict reads instead of inferring. A screen in a scheduled flow pauses, whatever its fields are named. The three seeds stay in `params`; flows that read them are unaffected. + + `@objectstack/spec`: the `callerParamKeys` TSDoc now names the schedule trigger as a producer that states the empty list, and no longer lists it among the producers that leave the key absent. No type changes. + + This supersedes one sentence of the `callerParamKeys` entry (#19846): the schedule trigger no longer leaves the key absent. Record-change, time-relative and webhook triggers still do. +- 2274894: fix(spec): the `OS_EAGER_SCHEMAS=1` rollback no longer crashes the `@objectstack/spec/api` and `/data` entries at import + + `OS_EAGER_SCHEMAS=1` is the documented emergency rollback of the lazy-schema + memory optimization. With it set, a process whose first spec import was + `@objectstack/spec/api` or `@objectstack/spec/data` threw at import: the bundles + failed with `Cannot read properties of undefined (reading 'optional')`, and the + source with `Cannot access 'FilterConditionSchema' before initialization`. The + default lazy path was not affected. + + The cause was an import cycle. `shared/suggestions.zod` held a value import of + `FieldType` from `data/field.zod`, only to feed `suggestFieldType`, and + `shared/strict-object` (which nearly every closed schema imports) imports + `suggestions.zod`. So `data/filter.zod` pulled `field.zod` in before it had + finished loading, and `field.zod`'s eagerly built `FieldSchema` read + `FilterConditionSchema` too early. + + `suggestFieldType` now lives in its own module, and `suggestions.zod` imports no + schema module. **Nothing an author or a consumer writes changes:** + `suggestFieldType` is still exported from `@objectstack/spec` and + `@objectstack/spec/shared` with the same signature and the same answers, and no + schema accepts or rejects anything differently. Every published entry now + imports cleanly as the first spec import under the flag, and a test pins that + for each subpath in the `exports` map. +- b5853da: fix(spec, lint): the RLS `check` → `using` default is stated per operation across the applicable policies, as the runtime applies it, not per policy (#19953) + + Clause-②: no + + Text only. No schema shape, accepted value or runtime behaviour changes. + + `RowLevelSecurityPolicySchema.check` said the clause "defaults to USING clause if not specified", which reads as a rule for each policy on its own. The write gate decides the default once per write operation, across every applicable policy: + + - When any applicable policy for the operation declares `check`, only the declared checks decide, OR-combined. A policy with only a `using` beside them adds nothing to the check. + - Only when none declares `check` does each applicable policy's `using` stand in as its check, OR-combined. + + A policy is applicable when it is not `enabled: false`, its `object` is the written object or `'*'`, its `operation` is the write's own or `'all'`, and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated. + + When this text change was written, the check ran on the new row of a single-record insert and of a by-id update only, and the texts said so. The same release extends it: every row of an array insert and of a `multi: true` update is judged (#19964, #19950), and a by-id update is also judged on the row its `beforeUpdate` hooks leave (#19989). The texts now state that every row an insert or an update writes is judged (#19967). + + - **`@objectstack/spec`**: the `check` describe and TSDoc state this composition and that scope. The `rowLevelSecurity[].priority` refusal no longer gives "applicable policies OR-combine (most permissive wins)" as its reason, which is not true of the write check, and the file overview limits "OR-combine" to reads. The generated reference pages (`references/security/rls`, `references/security/permission`) are regenerated from the describe. + - **`@objectstack/lint`**: the `rls-predicate-*` findings on a `using` now also say what the dropped `using` does to an insert. On an `insert` or `all` policy, when no applicable policy for the insert declares a `check`, that `using` is also the single-record insert check. If nothing else in that set compiles, every single-record insert the policy governs is refused with `PermissionDeniedError`. The findings on a `check` now say the refusal is a blanket one only when no other applicable policy declares a `check` that compiles. +- 4ac9319: Studio's view property panel names the columns of its Columns, Sort and Tabs tables in the author's language, not only in English + + Clause-②: no + + `view.form.ts` now enumerates the row properties of the `columns`, `sort` and `tabs` repeaters, each with a `label` equal to its item schema's own `.meta({ title })`, so `os i18n extract` emits a `metadataForms.view.fields` key for each row property. The `en`, `zh-CN`, `ja-JP` and `es-ES` platform catalogs carry those keys. The translated catalogs reuse the word they already use for the same concept where they have one (`Label` → 显示名称 / 表示名 / Etiqueta, `Direction` → 排序方向 / 並び方向 / Dirección). + + The row children declare no `type`, so each row input's widget is still derived from the schema. The view schema itself is unchanged. +- 560b724: A row-level or sharing-rule predicate comparing with `!=` / `==` against the bare `current_user` root is refused at the CEL lowering instead of lowering against the whole caller context object (#19959). + + **BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-variable-root-comparand-refused`. + + Clause-②: no (narrowing) + + **Security fix for RLS write checks.** A policy written `record.owner_id != current_user` (or `== current_user`, or `!(record.owner_id == current_user)`) named the variable root alone, which resolved to the whole caller context, and lowered to `{ owner_id: { $ne: } }` (or the bare object, or `$not` around it). A strict compare never equals an object, so a `check` so written admitted and stored every insert and by-id update it was written to refuse, a USING-only such policy admitted every insert, and explain reported the read as narrowed with the caller's membership sets echoed in its `readFilter`. A constant comparison such as `current_user != 'guest'` folded to no restriction. + + - `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose operand is the bare variable root (`unsupported`), in both of its modes, so the authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports it before any request. A variable that resolves to an object is refused per request; a `Date` still passes. + - `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, explain answers `denies`). + - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is still skipped at bootstrap, now with reason `unsupported` instead of `unresolved-variable`. + - `@objectstack/lint`: the shape is reported as `rls-predicate-unenforceable` on either RLS clause, where it was silent, and as `sharing-rule-unlowerable-condition` on a sharing condition, where it was `sharing-rule-runtime-variable-condition`. + - `@objectstack/spec`: the migration registry carries the entry. + + **What to change.** Compare against the key the predicate means: `record.owner_id != current_user` becomes `record.owner_id != current_user.id` (or `current_user.organization_id`, `current_user.email`); a membership test is `record.owner_id in current_user.org_user_ids`. Scalar keys, `in`, `null`, literals and field-to-field comparisons are unchanged. + + +- 3f86dc5: fix(spec): the RLS `using` / `check` texts say what the write gate enforces today — every row an insert or an update writes is checked, a check-only `update` policy is legal, and an `insert` policy's `using` is its check when none is declared (#19967) + + Clause-②: no + + Text only. No schema shape, accepted value or runtime behaviour changes. + + - **`RowLevelSecurityPolicySchema.check`**: the describe and TSDoc said the check ran on the new row of a single-record insert or a by-id update, and that an array insert and a `multi: true` update were not post-image checked. Every insert and update shape is now judged: each row of an insert, an array insert included, as its `beforeInsert` hooks leave it, and each row an update changes, by id or `multi: true`, as the prior row merged with the final payload after its `beforeUpdate` hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the change set as sent. + - **`RowLevelSecurityPolicySchema.using`**: the describe called it a filter for SELECT/UPDATE/DELETE and "optional for INSERT-only policies", and the TSDoc said UPDATE requires it. It now says, per operation, which rows it admits, that it stands in as the check on an insert or update when no applicable policy declares `check` (on an `insert` policy that is its only effect), that a `select` or `delete` policy needs it, and that an `insert`, `update` or `all` policy may declare `check` alone. + - **The "at least one of `using` or `check`" refusal**: its head is unchanged. It no longer says an UPDATE policy must provide `using` or that an INSERT policy must provide `check`. It names what each operation takes. + - **OR-combination**: the schema overview, the `priority` tombstone notes and the `os migrate meta --from 16` prose for the `priority` removal no longer say applicable policies OR-combine with "most permissive wins" without qualification. That holds on reads. On a write, the check is chosen per operation across the applicable policies first, and the chosen predicates then OR-combine. + - **Default deny**: the overview now limits "default deny" to the policies that apply. A caller to whom no policy applies is not restricted by the policies; the tenant wall still applies. + - The generated reference pages (`references/security/rls`, `references/security/permission`) and `docs/protocol-upgrade-guide.md` are regenerated from these sources. +- e0f17a3: Every example predicate in the `ConditionalValidationSchema` TSDoc docblock (all seven Use Cases, plus the ObjectStack side of the "Salesforce Pattern Comparison" block, above the schema in `packages/spec/src/data/validation.zod.ts`) is rewritten in the CEL the evaluator actually accepts, so an author or agent who copies a documented example gets a rule that evaluates instead of one that refuses every write it guards (#20026). + + Clause-②: no + + 17 of the 19 predicates used `=` for comparison (or `AND`/`NOT`/`REGEX(...)` word-form operators) — a CEL parse error, since `@marcbachmann/cel-js` rejects a lone `=` as an unexpected character; CEL equality is `==`, and CEL has no `AND`/`OR`/`NOT` keyword form — and the other 2 (`order_total > 10000`, `approval_amount > 50000`) named a bare field with no `record.` root, an unknown-variable type error. `REGEX(tax_id, "…")` is rewritten to the evaluator's registered `matches(record.tax_id, "…")` stdlib function — the CEL form it accepts, not a literal transliteration — with the escape-free character class `[0-9]` rather than `\d`: a `\d` inside the CEL string literal parses fine as written in this TSDoc comment (comments are not JS-escape-processed), but a reader who pastes the same text into a real `.ts` string literal gets ONE level of JS unescaping the comment never applied, so `\\d` in the comment becomes `\d` at runtime and cel-js refuses it (`Invalid escape sequence: \d` — this was this round's own rework: a prior revision of this changeset claimed a passing result for that predicate without measuring it on the copied literal). The Salesforce-formula half of the comparison (`IF(ISPICKVAL(...), AND(...), FALSE)`) is deliberately untouched: it documents Salesforce's own syntax, not CEL. + + Measured through `ExpressionEngine.evaluate` (`@objectstack/formula`), taking each predicate as the RUNTIME STRING a reader gets by pasting the docblock's literal into real `.ts` source — the literal is extracted from the source file byte for byte and handed to Node's own parser to unescape, never hand-retyped — against a record that makes each rewritten predicate true and one that makes it false; both branches evaluate as expected for all 19 (plus one extra disjunct check on the regex branch) — transcript in the PR body. The strings ship in the published `dist/object.zod-*.d.ts`, confirmed before and after this change with a lit/dark control: every rewritten string is present exactly once and every original broken string is absent. + + | old (fails) | new (evaluates) | + |:--|:--| + | `account_type = "enterprise"` | `record.account_type == 'enterprise'` | + | `approval_status = null` | `record.approval_status == null` | + | `requires_shipping = true` | `record.requires_shipping == true` | + | `shipping_address = null OR shipping_address = ""` | `record.shipping_address == null \|\| record.shipping_address == ''` | + | `order_total > 10000` | `record.order_total > 10000` | + | `manager_approval_id = null` | `record.manager_approval_id == null` | + | `payment_method = null` | `record.payment_method == null` | + | `region = "EU"` | `record.region == 'EU'` | + | `gdpr_consent_given = false` | `record.gdpr_consent_given == false` | + | `tos_accepted = false` | `record.tos_accepted == false` | + | `country = "US"` | `record.country == 'US'` | + | `state = "CA"` | `record.state == 'CA'` | + | `tax_id = null OR NOT(REGEX(tax_id, "^\d{2}-\d{7}$"))` | `record.tax_id == null \|\| !matches(record.tax_id, "^[0-9]{2}-[0-9]{7}$")` | + | `is_taxable = true` | `record.is_taxable == true` | + | `tax_code = null OR tax_code = ""` | `record.tax_code == null \|\| record.tax_code == ''` | + | `user_role = "manager"` | `record.user_role == 'manager'` | + | `approval_amount > 50000` | `record.approval_amount > 50000` | + | `type = "enterprise"` (Salesforce comparison) | `record.type == 'enterprise'` | + | `amount > 100000 AND approval = null` (Salesforce comparison) | `record.amount > 100000 && record.approval == null` | + + `packages/spec/src/data/validation.test.ts`'s `ConditionalValidationSchema` fixtures move with the docblock (parse-only — `ValidationRuleSchema.parse(...).not.toThrow()`, no CEL evaluation, so no behaviour change): the six exact copies of Use Cases 1-3's original seven strings; `manager_approval = null` (the `order_value_validation` fixture whose message text and structure mirror Use Case 3's manager-approval example one field-name spelling apart), now `record.manager_approval == null`; and, under this round's rework, ten more fixtures whose surrounding test name and message text are verbatim copies of Use Cases 4, 5, 6 and 7's docblock text — `is_taxable = true` / `tax_code = null` (`tax_validation`, Use Case 6, the `tax_code` fixture now the full `record.tax_code == null || record.tax_code == ''`), `region = "EU"` / `gdpr_consent_given = false` / `tos_accepted = false` (`regional_validation`, Use Case 4), `user_role = "manager"` / `approval_amount > 50000` (`role_based_validation`, Use Case 7), and `country = "US"` / `state = "CA"` / `tax_id = null` (`nested_validation`, Use Case 5 — the `tax_id` fixture now the full `record.tax_id == null || !matches(record.tax_id, "^[0-9]{2}-[0-9]{7}$")`, escape-free for the same copy-paste reason as the docblock). 17 fixture strings moved in total. No schema, behaviour, or public export changes — TSDoc text only, in the same two files the strings already lived in. +- 0bf85ea: One published `describe` sentence that dates itself to the `.objectui-sha` pin is re-pointed to the pin this release builds against, objectui `f8a9d0fb0596`, after being re-read there (#20029). + + `Clause-②: no` + + - `FormField.span`: the `'auto'` clause says that at the pin this repo builds against, only textarea, markdown, html, richtext and repeater resolve to the full column count. It named `62597c588`. Re-read at `f8a9d0fb0596`, the claim still holds: `plugin-form`'s `autoLayout.ts` (`WIDE_FIELD_TYPES`, `resolveColSpan`) is byte-identical, and `form.tsx` changed only in its registration's input list (a `children` slot input), with `spanLadderFor` byte-identical, so `'full'` is still the whole row at every multi-column tier. Only the pin the sentence names moves. + + No key, default, enum member or export moves: the same authored metadata is accepted and refused as before, and `content/docs/references/ui/view.mdx` is regenerated from the sentence. +- 03d6cb0: The currency-chain text shipped in `@objectstack/spec` states that `currencyConfig.defaultCurrency` is read only under `currencyMode: 'fixed'`, and no longer cites ADR-0053 (the date / datetime record) for currency (#20126) + + Clause-②: no + + A `dynamic` currency field (the default mode) has no currency of its own. Its amounts display in the tenant default currency (`localization.currency`), or as a plain number when none is set, and its `defaultCurrency`, including the parse default `CNY`, is not read. Each reader does this: the analytics relay (`AnalyticsServicePlugin`'s `sourceFieldMeta`), objectui's `resolveFieldCurrency`, and the spec's own precision refinement in `CurrencyConfigSchema`. Several published texts still described an unconditioned `defaultCurrency` step, or called the chain "ADR-0053": + + - **`CurrencyConfigSchema.defaultCurrency` describe** (also regenerated into `content/docs/references/data/field.mdx`): + - FROM: `Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)` + - TO: ``The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency.`` + - **`AnalyticsResultResponseSchema` `data.fields[].currency` describe**: + - FROM: ``Resolved ISO 4217 code for a MONETARY measure (explicit measure `currency`, then source-field default, then tenant default). Absent on non-monetary columns, which must never render a symbol.`` + - TO: ``Resolved ISO 4217 code for a MONETARY measure (explicit measure `currency`, then the source field's fixed currency — its `currencyConfig.defaultCurrency`, read only under `currencyMode: 'fixed'` — then the tenant default). Absent on non-monetary columns, which must never render a symbol.`` + - **TSDoc**: `AnalyticsResult.fields[].currency` (`contracts`), the `AnalyticsResultResponseSchema` docblock and the percent-scale module header name the chain "the currency chain" and state its middle step as the field's fixed currency. + - **Liveness ledger** (`liveness/dataset.json` `currency`, `liveness/field.json` `currencyConfig`): the evidence is re-worded and re-anchored to the fixed-mode readers, and `verifiedAt` is re-dated. Both verdicts stay `live`. + + ⛔ No behaviour changes. No type, schema, accept set, default, authorable key or export moves. Only describe strings, TSDoc and ledger text change, and they ship: `dist` carries the describes and the emitted TSDoc, `src/**/*.zod.ts` carries the two edited schema files as source, and `liveness` carries both ledger files. +- 9e7824a: docs(spec): the `currencyConfig.currencyMode` description says what the runtime does with each mode, and no longer calls `dynamic` "user selectable" (#20126) + + Clause-②: no + + The old text read "dynamic (user selectable) or fixed (single currency)". No key, stored value or runtime path lets anyone pick a currency per record. The value is a bare number in both modes (ADR-0104 D1), and the currency code lives in field config. What the runtime does, and what the description now says: + + - **`fixed`**: the field has one currency, `defaultCurrency`. + - **`dynamic`** (the default): the field has no currency of its own. Amounts display in the tenant default currency, which is the `localization.currency` setting. When that setting is not set, amounts display as a plain number. `defaultCurrency` is not read. + + The field faces follow this rule, and so do analytics dataset measure columns and the publish-time precision check. + + Only the description text changes. The accepted keys and values, the defaults (`dynamic`, `CNY`) and parse output are the same as before. The generated reference page `content/docs/references/data/field.mdx` is regenerated from the new text. +- 6ac33a5: `liveness/app.json`: every `AppSchema` row that cited objectui's `AppSidebar.tsx` as read evidence now cites the reader that is actually mounted, and `branding.logo` is `planned` instead of riding on its container's `live`. Ledger evidence and comment prose only. ⛔ No schema, parse or accept-set change. + + The ledgers ship inside this package (`files[]` includes `liveness`), and `@objectstack/lint` reads them to decide which authored keys draw an advisory warning, so the rows an upgrading reader or tool consults are these. + + - **Twelve rows re-pointed, and the file `_note`.** `label`, `description`, `icon`, `branding`, `active`, `hidden`, `navigation[].id`, `navigation[].visible`, `areas[].id`, `areas[].label`, `areas[].icon` and `areas[].navigation` cited `AppSidebar`. That component was deprecated, never mounted (the console renders `UnifiedSidebar`), and objectui has since removed it, so it was never evidence of a live reader. Each row now names its mounted reader, file and symbol: `AppSwitcher`, `UnifiedSidebar`, `AppHeader`, `ConsoleLayout` into `AppShell`'s `useAppShellBranding`, `HomeAppsStrip`, `filterActiveApps`, `NavigationRenderer`, `NavMenuRenderer` and `AppManagementPage`. All are read at the `.objectui-sha` pin f8a9d0fb, and each reads its key unchanged at objectui main fb91ac9b0. + - **`branding.logo` is `planned`.** Plugin-designer's app wizard and branding editor write it, and nothing mounted renders it: the console's branding handoff passes `primaryColor`, `accentColor` and `favicon` and never `logo`. The row names the objectui card that will render it (objectui#10827). It is not `authorWarn`'d: a logo URL is display metadata, and an author who sets it now loses nothing, because the value takes effect when that renderer lands. The `branding` row is drilled so `primaryColor`, `accentColor` and `favicon` keep their own `live` verdicts. Its entry in `undrilled-containers.baseline.json` goes away with the drill, and `state-counts.md` is regenerated (`app`: 56 → 59 classified, one `planned`). + - **`description` is still `live`, but narrower.** Its one mounted reader is the admin app list at `/system/apps`, which shows each app's description and searches it. The sidebar presentation this row used to describe has no successor. + - **Two corrections found while re-reading.** The `active` row's note said the sidebar's active-app lookup spans all apps. It skips `active: false` apps, and what keeps an inactive app routable by direct URL is the console's route resolver, which matches against every app. The two `AppSidebar` mentions in `ui/app.zod.ts` (the `recordId` template-variable note and the `AREA_ORDER_RETIRED` note) now name `NavigationRenderer` / `UnifiedSidebar`. +- b09ce67: `ChartDrillDownSchema`'s refusal text for `drillDown.mode` names the one objectui block that reads it, not all three it used to + + An author (or an AI) who writes `drillDown.mode` on a chart gets this guidance + sentence back from `objectui validate` / `safeValidateSchema`. It used to read: + + > `mode` (`'filter'` | `'record'`) is a TABLE / PIVOT / METRIC drill key, not a + > chart one: … + + `object-metric` has refused `drillDown.mode` at its TypeScript door since + objectui#9002 (PR objectui#10681, merged, on the maintainer ruling recorded + there, comment `5643445104`), and `object-pivot` has refused it since + objectui#10685 (PR objectui#10710, merged). Measured on objectui `origin/main`: + neither block's renderer ever read `mode`; only `object-data-table` does. So + the old sentence sent an author who followed it straight into a second + refusal on two of the three blocks it named — right on `object-pivot` and + `object-metric` (both now refuse the key by name), and a stored JSON config + that carries it there is silently ignored with no diagnostic. + + The sentence now names the one block that actually reads `mode`: + + > `mode` (`'filter'` | `'record'`) is objectui's `object-data-table` drill + > key, not a chart one: … + + The reasoning clause and "Delete the key" are unchanged. The neighbouring + `report` guidance entry (a METRIC / PIVOT widget capability) is untouched — + its blocks were not remeasured on this card. + + ⛔ No accept/reject behaviour changes. `mode` remains refused on + `ChartDrillDownSchema` exactly as before; only the refusal text changes. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/spec`'s published `files[]` ships both + `dist` and `src/**/*.zod.ts`, and `chart.zod.ts` is one of the latter — so the + sentence ships as source verbatim, and also as a runtime string built into + `dist/ui/index.js` / `dist/ui/index.mjs` (measured: present at 1 occurrence + each after a rebuild, with the old "TABLE / PIVOT / METRIC" spelling absent + from both). +- 733822c: `DecisionConfigSchema` refuses `mode` on a `decision` that also declares a non-empty `conditions` list (#20168). `mode` belongs to the edge-branched decision alone: a `conditions` list is first-match on its own, so a `mode` beside one would be accepted and never read, and `mode: 'inclusive'` there would promise every matching branch while the run takes one. + + Clause-②: no + + This corrects a key that has not been released yet, so it narrows no published accept set. Timing, measured when this landed: the npm registry's `latest` `@objectstack/spec` is `17.4.0`, and its `json-schema/automation/DecisionConfig.json` declares `conditions` only, with `additionalProperties: false`. No `mode` was published. The Version Packages PR (`chore: version packages`) is open and unmerged. `mode` reaches its first release together with this refusal, so no ADR-0087 entry is owed. + + - **The refusal**: one issue at `mode`, for either member. It reads "`mode: 'inclusive'` is not valid on a decision that declares a `conditions` list — `mode` belongs to the edge-branched decision alone." and names the two ways out: + - delete `mode` and keep the list; + - or move the branches onto the out-edges (a `condition` on each branch edge, `isDefault: true` on the fallback), delete `conditions`, and keep `mode`. + - **Left alone**: `mode` on an empty `conditions` list, `mode` with `conditions` absent, and a `conditions` list with no `mode` all parse as before. A `mode` outside `'exclusive' | 'inclusive'` still gets its own value refusal first. + - **Where it binds**: the doors that parse `DecisionConfigSchema`. Today that is a direct parse, including the `SCHEMALESS_NODE_CONFIG_SCHEMAS.decision` handle. `decision` config is still export-only, so a flow's registration and `os validate` do not run it yet. The published JSON Schema cannot state the rule, because no arm of the closed refinement projection fits it. `automation/DecisionConfig` therefore joins `dropped-refinements.baseline.json` and carries the site as `x-dropped-refinements`. +- bea6d2e: fix(driver-turso)!: `new TursoDriver` refuses `syncUrl` under a forced `mode: 'remote'`, and `sync` with no `syncUrl`, instead of building and ignoring them + + Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, and no exported symbol moves. Two driver configurations that the turso driver used to build and then ignore a key of are now refused when it is built. + + `new TursoDriver()` accepted `syncUrl` beside a forced `mode: 'remote'`. Remote mode sends every read and write straight to `url`, and the remote client is created without `syncUrl`, so no replica is built and no sync ever runs. Measured on the built driver before this change, a `libsql://` or `file:` url under `mode: 'remote'` with `syncUrl` and `sync` constructed and connected, and `isSyncEnabled()` answered `true`. No sync interval started, and the sync call rejected with `SYNC_NOT_SUPPORTED` (`SyncNotSupported("File")` on the `file:` url). It also accepted `sync` with no `syncUrl`, in any mode, where nothing reads it. `@objectstack/spec`'s `TursoConfigSchema` already refused both at authoring. A datasource row stored before that, or a config a host builds itself, reached the constructor unparsed and ran with a sync setting that did nothing. + + **BREAKING** accept-set narrowing on a published constructor, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). Refused now with `VALIDATION_ERROR` / 400, before any client or database is opened: + + - `syncUrl` under a forced `mode: 'remote'`. A remote url beside `syncUrl` with no `mode` was already refused, as a replica on a remote url; + - `sync` with no `syncUrl` (or with an empty one), in local, replica and remote mode alike. + + Each refusal's message is the spec contract's issue message for that key, byte for byte, so authoring and boot say the same thing. A test holds the copies equal. The spec's `syncUrl` message said the driver "runs no sync, so the setting changes nothing". It now reads "the turso driver refuses this configuration when it starts", like its sibling refusals, and the driver's own `TursoConfigSchema` mirror follows (`@objectstack/spec` patch: message text only). The ADR-0087 entry `turso-config-transport-mismatch-refused` now also records that the constructor refuses these two shapes at boot. + + Left accepted on purpose: `mode: 'replica'` on a `file:` url with no `syncUrl` (and no `sync`). It still runs as a plain local database. Refusing it in the constructor alone would refuse a config both schemas accept, so it is tracked separately. + + ### Migration: FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `url: 'libsql://my-db.turso.io', mode: 'remote', syncUrl: …` (with or without `sync`) | a remote database: drop `syncUrl` and `sync`. An embedded replica: `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io'` and no `mode` | + | `url: 'file:./data/app.db', mode: 'remote', syncUrl: …` | the same two ways out | + | `sync: { … }` with no `syncUrl` | name the remote in `syncUrl` (with a `file:` url), or drop `sync` | + + A datasource row stored with one of these shapes is not re-parsed when it loads, so it now fails when the driver is built. `factory.create` throws the refusal. The connection service records the datasource as `failed-degraded` with the message, and a test connection answers `ok: false` ("Failed to build driver: …"). Under ADR-0062 D5, the boot fails fast when objects bind to that datasource or are routed to it, or when it is boot-critical, unless `OS_ALLOW_DRIVER_CONNECT_FAILURE` is set. Otherwise it is left unconnected with a warning. Before this change the same row booted, reported sync as enabled and never synced. The way out is the table above: drop `syncUrl` / `sync` from a remote config, or use a `file:` url with the remote in `syncUrl`. + + Blast radius, measured on this tree: no example, template, published skill or hand-written doc authors either shape, and no in-repo caller reads `isSyncEnabled()` or calls the driver's sync outside `@objectstack/driver-turso`'s own tests. Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero. + + +- f415bcf: fix(spec): every protocol-18 retirement family now carries its D3 entry, including the 25 whose data repair is a lossless D2 conversion (#20201) + + Clause-②: no + + ADR-0087 D3 requires one semantic (D3) entry per retirement family, even when a + lossless D2 conversion already repairs the data: D2 carries the mechanical repair + only, and the D3 entry says what the consumer still has to decide. Twenty-five + protocol-18 families shipped a D2 conversion and no D3 entry, some of them + justified by "lossless, so no semantic residue". `MIGRATIONS_BY_MAJOR[18].semantic` + gains one entry per family, so `os migrate meta` lists each as a TODO on the + 17 → 18 hop, with its reason and acceptance criteria. Among them: + + - the seven duration renames (`hook.timeout`, `job.timeout`, `apis[].cacheTtl`, + `dashboard.refreshInterval`, the connector health / trigger durations, the memory + driver's `autoSaveInterval` and the turso `timeout`). The rename keeps the value, + so only the author can say whether it was ever written in the unit the new key + names. + - `object.tenancy.organizationField`, `view.owner` / `view.hidden`, + `permission.objects.*.allowRestore` / `allowPurge` and the list-view `page` mount. + Each delete is lossless, and each leaves a belief the author held that the + platform never honoured. + + No accept set moves and no conversion changes. The registry's own test now fails + when a protocol-18-or-later step graduates a conversion that no D3 entry of that + step names. The prose that justified the missing entries is corrected, and the + protocol-17 docblock no longer calls that step's `semantic` list empty. +- 31d281d: `PackageInstallBodySchema`'s docblock records its measured residual as closed: the install door answers every body the declaration refuses with `400`, not `201` + + Clause-②: no + + The docblock listed the bodies `POST /api/v1/packages` answered differently + from the declaration: a manifest with no `type`, unknown keys on either body + form, a string-typed `enableOnInstall` / `overwrite`, install options spelled on + the bare form, and (in the other direction) a whitespace-only `id`. It still said + the door answers `201` to the first four. Since the door parses its whole body + through `PackageInstallBodySchema` (#20218), it answers each of them `400` / + `VALIDATION_ERROR` and installs nothing. The whitespace-only `id` was already + refused by both, because `ManifestSchema.id` carries `MANIFEST_ID_PATTERN`. + + The section now records every class as closed, names the door-side pin for + each, and says what the declaration's parsed value still does not describe: + the door stores the manifest it was SENT, so parse-time defaults (`scope`, + `defaultDatasource`) are not stored, and an unknown key nested in a manifest + block the declaration leaves in strip mode is stored as sent. + + Two more sentences are corrected. The bare-form paragraph said the runtime's + two door drives post a manifest with no `type`; both have carried + `type: 'app'` since #20218 and parse green. The `enableOnInstall` docblock said + the door "reads the raw body"; it reads the key off the parsed wrapped request. + + ⛔ No behaviour changes. No schema, accept set, export or runtime code moves. + + **Why this carries a changeset and not `skip-changeset`.** `@objectstack/spec`'s + `files[]` ships `src/**/*.zod.ts` verbatim, and the `PackageInstallBodySchema` + docblock is also emitted into `dist/api/index.d.ts` and `dist/api/index.d.mts`. + The published content changes, even though no line of code does. +- 9e9bb46: fix(spec): `os migrate meta` guidance for the `datasource-*`, `filter-*`, `action-*`, `data-*` and `element-*` migration entries states each lesson in words instead of citing tracker numbers + + Clause-②: no + + The ADR-0087 semantic entries of the `datasource-*` family (the publish-time credential, + placeholder and URL refusals, and the bound-secret pairs a mongo datasource cannot use), + the `filter-*` family (the retired `$regex`, the `$between` endpoint refusals and the + comparand shapes the save door now refuses), the `action-*` family (the retired + descriptor key, the `resumeAuthority` default flip, the action-session rename, the bulk + dispatch contract and the engine facade's query envelope), the `data-*` family (the + retired driver and engine contract members, the retired field-changed event and two + duration keys renamed with their unit) and the `element-*` family (the filter rule array + at the page binding and the element and block doors) are printed by `os migrate meta` as + the header, `why:` and `verify:` lines of a manual change. Their text sent the reader to + issue-tracker, decision-batch and ruling-record numbers — some of which no longer + resolve, and some in other repositories — for what a ruling, measurement or fix had + decided; it now says what was decided, in the sentence being read. ADR ids are kept. + + Text only: no entry id, `surface`, `from` / `to`, conversion or matching logic changes, + and the chain rewrites exactly what it rewrote before. The generated migration registry, + `spec-changes.json` and the protocol upgrade guide carry the same text. +- 0d7ed5a: fix(spec): `os migrate meta` guidance for the `driver-*`, `kernel-*` and `system-*` migration entries states each lesson in words instead of citing tracker numbers + + Clause-②: no + + The ADR-0087 semantic entries of the `driver-*` family (the driver query-argument + narrowings, the inert capability bits, the SQL driver's unresolvable-column and + cross-row upsert refusals, and the retired Turso config keys), the `kernel-*` family + (preview mode, and the kernel duration keys that now carry their unit in the key name) + and the `system-*` family (the system duration keys renamed under the same rule) are + printed by `os migrate meta` as the header, `why:` and `verify:` lines of a manual + change. Their text sent the reader to issue-tracker and decision-batch numbers — some + of which no longer resolve — for what a ruling, measurement or fix had decided; it now + says what was decided, in the sentence being read. ADR ids are kept. + + Text only: no entry id, `surface`, `from` / `to`, conversion or matching logic changes, + and the chain rewrites exactly what it rewrote before. The generated migration registry, + `spec-changes.json` and the protocol upgrade guide carry the same text. +- 2aa25ef: fix(spec): `os migrate meta` guidance for the `engine-*` migration entries states each lesson in words instead of citing tracker numbers + + Clause-②: no + + The five ADR-0087 semantic entries about the data engine's query and write + options (`engine-dotted-projection-refused`, `engine-find-formula-filter-refused`, + `engine-find-formula-order-by-refused`, `engine-update-upsert-retired`, + `engine-dotted-filter-refused`) are printed by `os migrate meta` as the + replacement, `why:` and `verify:` lines of a manual change. Their text sent the + reader to issue-tracker numbers for what a ruling or a fix had decided; it now says + what was decided, in the sentence being read. ADR ids are kept. + + Text only: no entry id, surface, `from` / `to`, conversion or matching logic + changes, and the chain rewrites exactly what it rewrote before. The generated + migration registry, `spec-changes.json` and the protocol upgrade guide carry the + same text. +- 0e1afe8: fix(spec): `os migrate meta` guidance for the `field-*`, `export-*`, `api-*`, `dataset-*`, `hook-*` and `metadata-*` migration entries states each lesson in words instead of citing tracker numbers + + Clause-②: no + + The ADR-0087 semantic entries of the `field-*` family (the runtime `field` write door, the + `maxLength` / `minLength` / `scale` / `precision` refusals, `scale` on a currency field, + `multiple` on a type that holds one value, and predicates that read through a reference), + the `export-*` family (the export permission axis, the eight constraint keys retired from + `ExportFieldMeta` and the retired export-job API family), the `api-*` family (the runtime `api` write door, + the split API entry and two duration keys renamed with their unit), the `dataset-*` family + (the aggregate × field-type refusals and the nested-relation list refused at save), the + `hook-*` family (the retired hook-session `roles` and the two `registerHook` refusals) and + the `metadata-*` family (the retired customization protocol, the re-partitioned endpoint + switches, the metadata-manager cache keys and the retired `additionalTypes`) are printed by + `os migrate meta` as the header, `why:` and `verify:` lines of a manual change. Their text + sent the reader to issue-tracker, decision-batch and ruling-record numbers — some of which + no longer resolve, and some in another repository — for what a ruling, measurement or fix + had decided; it now says what was decided, in the sentence being read. ADR ids are kept. + + Text only: no entry id, `from` / `to`, conversion or matching logic changes, and the chain + rewrites exactly what it rewrote before. One entry's `surface` (the header line of + `dataset-measure-aggregate-field-type-refused`) drops the two tracker numbers it carried and + names nothing else differently. The generated migration registry, `spec-changes.json` and + the protocol upgrade guide carry the same text. +- 288611e: fix(spec): `os migrate meta` guidance for the `rest-*`, `analytics-*`, `view-*`, `package-*`, `object-*`, `sharing-*`, `audit-*`, `flow-*` and `http-*` migration entries states each lesson in words instead of citing tracker numbers + + Clause-②: no + + The ADR-0087 semantic entries of the `rest-*` family (the retired OpenAPI 3.1 block, the + endpoint `handlerStatus` marker, the REST server's dead config keys and the REST plugin + durations renamed with their unit), the `analytics-*` family (the retired query envelope, + the unknown-key refusals on cubes, and the closed date-range vocabulary and two-bound + window), the `view-*` family (the filter value shaped by its operator and its array and + absent-value refusals, the retired view-management protocol, the page-size default and the + judged overlay `options` bag), the `package-*` family (the explicit all-tenants uninstall, + the retired unmounted contract-map entries and rollback response, and the strict wrapped + install body), the `object-*` family (the array `sort` on object blocks, the converged grid + `data`, the rule-array `defaultFilters` and the index unknown-key refusal), the `sharing-*` + family (the retired `SharingExecutionContext` type and the reconciled rule recipients), the + `audit-*` family (the audit-log action values no writer produced), the `flow-*` family (the + retry count, the decision-branch and edge refusals, first-match edge branching and blank + predicate slots) and the `http-*` family (the retired error counter and server runtime + vocabulary) are printed by `os migrate meta` as the header, `why:` and `verify:` lines of a + manual change. Their text sent the reader to issue-tracker, decision-batch and ruling-record + numbers — some of which no longer resolve, and some in another repository — for what a + ruling, measurement or fix had decided; it now says what was decided, in the sentence being + read. ADR ids are kept. Two entries of other families are corrected the same way: + `api-error-retry-after-unit-in-key` now dates the population ruling its clause describes, + and `inline-grid-column-currency-scale-refused` names its two currency rulings by date + instead of by record number. + + Text only: no entry id, `from` / `to`, conversion or matching logic changes, and the chain + rewrites exactly what it rewrote before. One entry's `surface` (the header line of + `flow-edge-condition-evaluated-slot-source-required`) drops the two tracker numbers it + carried and names nothing else differently. The generated migration registry, + `spec-changes.json` and the protocol upgrade guide carry the same text. +- dfd8e39: fix(spec): `os migrate meta` guidance for the `ui-*` and `plugin-*` migration entries states each lesson in words instead of citing tracker numbers + + Clause-②: no + + The ADR-0087 semantic entries of the `ui-*` family (component props rows, form-field + and list-view refusals, the react-tier `ListView` aliases, and the retired + interaction, notification, embed, widget and i18n vocabularies) and of the `plugin-*` + family (the plugin manifest, runtime, health-monitor and security-scanner retirements) + are printed by `os migrate meta` as the header, `why:` and `verify:` lines of a manual + change. Their text sent the reader to issue-tracker numbers — some of which no longer + resolve — for what a ruling, measurement or fix had decided; it now says what was + decided, in the sentence being read. The same holds for the two `surface` headers that + carried a number. ADR ids are kept. + + Text only: no entry id, `from` / `to`, conversion or matching logic changes, and the + chain rewrites exactly what it rewrote before. The generated migration registry, + `spec-changes.json` and the protocol upgrade guide carry the same text. +- e6b7d8c: Every example predicate in `packages/spec/src/data/validation.zod.ts` now reads fields through the `record.` root and describes the violation, so an author or agent who copies one gets a rule that evaluates and fires on the records it names as invalid. The fix covers the three `cross_field` "Salesforce Examples" in the `CrossFieldValidationSchema` TSDoc, the header `script` example, and the `.describe()` example on `CrossFieldValidationSchema.condition` (#20252). + + Clause-②: no + + - Example 1, "Close Date Must Be In Current or Future Month": `MONTH(close_date) >= MONTH(TODAY()) AND YEAR(close_date) >= YEAR(TODAY())` becomes `date(record.close_date) < addDays(today(), 1 - today().getDate())`. The old string did not parse (`AND` is not CEL, and `close_date` had no `record.` root). It was also inverted: a `cross_field` condition that evaluates TRUE is the violation, and the old condition was TRUE on the records the rule should accept. The new condition is TRUE when the close date falls before the first day of the current month. It uses only the formula stdlib's `date()`, `today()` and `addDays()` plus CEL's built-in `getDate()` timestamp accessor. + - Example 2, "Discount Validation": `discount > (amount * 0.40)` becomes `record.discount > (record.amount * 0.40)`. The bare fields were unknown variables. The direction is unchanged. + - Example 3, "Opportunity Must Have Products": `products = null AND stage = "closed_won"` becomes `isBlank(record.products) && record.stage == "closed_won"`. A lone `=` is a CEL parse error and `AND` is not CEL. The direction is unchanged. + - The header `script` example: `discount_percent > 0.40` becomes `record.discount_percent > 0.40`. The bare field was an unknown variable. The direction is unchanged. + - The `CrossFieldValidationSchema.condition` description: its example `record.end_date > record.start_date` refused every valid end-after-start range, because a TRUE condition is the violation. It becomes `record.end_date < record.start_date`, and the description now says that a TRUE condition fails validation. + + The Salesforce-formula side of each example is unchanged. This changes documentation text only (TSDoc and one `.describe()` string, with the generated reference page regenerated to match). There is no schema, behaviour or export change. +- 7e36a3c: `ObjectTimelinePropsSchema.items`' describe no longer says "the author owns the item shape". `items` stays `z.array(z.unknown())` — no schema-shape change — but the sentence now names the actual owner: each element is objectui's declared timeline element, `@object-ui/types`'s `TimelineFeedItem` (`variant` absent / `vertical` / `horizontal`) or `TimelineGanttItem` (`variant: 'gantt'`), the arm this node's `variant` selects. + + The element union is declared entirely inside objectui's `packages/types` (`TimelineFeedItem` / `TimelineGanttItem`, plain TypeScript interfaces) — nothing in this package imports or re-declares it, so this is a documentation-only correction, not a value-tightening. Value tightening (declaring the arms in this schema instead of `z.unknown()`) stays a later ratchet with its own inventory. +- 5a6267f: `os test` reports the suite and scenario names an author writes, and selects scenarios with `--tags` (#20289) + + Clause-②: no + + A Quality Protocol suite's `name`, each scenario's `name` and `description`, and scenario `tags` were parsed at load and then read by nothing: the report headed each suite with its file's basename, printed every scenario by its `id`, and `os test --tags critical` failed with `Nonexistent flag: --tags`. + + - **Names in the report.** The suite heading is now the suite's `name` followed by its file — `📄 Running suite: Accounts smoke (accounts.test.json)` — and each scenario line is its `name` with the `id` in brackets — `✅ Scenario: An account can be created [acct-create] (12ms)` (the id alone when the two are equal). A failed scenario's `description` is printed under its line, before the error. A suite whose file fails to load is still headed by the file alone, since no name was parsed. + - **`--tags TAG[,TAG...]`** runs only the scenarios carrying AT LEAST ONE of the listed tags (any-of, exact, case-sensitive) — the comma-list reading of Odoo's `--test-tags` and the everyday use of Playwright's `--grep @a|@b`. With the flag, an untagged scenario is left out. Left-out scenarios are **deselected**: not run, counted on the summary (`--tags smoke selected 1 of 4 scenarios; 3 deselected (not run, not counted as passed).`), never counted as passed. A requested tag that no loaded scenario carries is named on the summary. An empty entry (`--tags smoke,`) is refused before anything runs. Without the flag nothing changes: every scenario runs. + - **Exit status.** A selection that matches no scenario takes the posture an empty pattern already has: exit `0` with `No scenario matched --tags …`, and exit `1` under `--fail-on-empty`, whose description now covers both cases. The `Found N test suites.` line and the `SUCCESS: All N scenarios passed.` / `FAILED: …` summary lines keep their spelling. + - **`@objectstack/core`:** `QA.TestResult` gains `scenarioName` and `description` on every result, and `suiteName` on every result `runSuite` produces (absent only from a lone `runScenario` call, which has no suite). + - **`@objectstack/spec`:** the liveness ledger (`liveness/qa.json`) moves the four keys above to `live`, citing their readers. `TestScenario.requires`, the family's fifth key, is checked in this same release and has its own note: an unmet `params` or `services` entry skips the scenario with its reason, and `requires.plugins` is retired into `requires.services`. +- 826f327: Liveness ledger: three rows that were graded `planned` are now `live`, because objectui reads them at the `.objectui-sha` pin this repo builds against. The prose these flips made false is corrected too. Ledger data, one author hint and comments only. ⛔ No schema, parse, `.describe()` or accept-set change. + + The ledgers ship inside this package (`files[]` includes `liveness`), and `@objectstack/lint` reads them, so the rows an upgrading reader or tool consults are these. `@objectstack/lint` warns on a `planned` row only when the row sets `authorWarn`, and none of the three flipped rows does, so the set of warnings does not change. One warning's hint text does change (see `flows` below). + + - **`action.onSuccess.navigate` and `action.onSuccess.openIn` are `live`.** The console's action runner performs the declared post-success hop after an `api` or `script` action succeeds. It interpolates `navigate` with the `${param.*}`, `${ctx.*}` and `${result.*}` scopes, refuses a URL that is neither http(s) nor relative, and opens a new tab only on `openIn: 'newTab'`, so the materialized `'self'` default has one source of truth. The action renderers and the declared-actions bar forward the block to the runner, and the console wires the runner's navigation to its router. Both rows had been `planned` since the contract landed spec-first ahead of this reader. + - **`translation.flows.screens` is `live`.** The console's screen-flow runner draws each screen's heading and each field's `label` / `placeholder` from `flows.FLOW.screens.NODE_ID` in the active language, and falls back to the authored string key by key. The bundle reaches it through the translations route and the console's language loader. + - **`translation.flows.label` stays `planned`, and the `flows` group keeps its `authorWarn`.** Nothing reads the flow's own label yet. So `os lint` still warns when a bundle authors `flows`, and the i18n coverage demand for `flows.*` stays held back. The warning's hint used to say no shipped runner reads the group. It now says the runner reads `screens`, and that only the flow `label` is stored and never shown. + - **Prose corrected.** The `onSuccess` JSDoc in `ui/action.zod.ts` now names the console reader (`ActionRunner`'s `navigateOnSuccess`). The `flows` JSDoc in `system/translation.zod.ts` and the `translateFlow` docblock in `system/i18n-resolver.ts` now say `screens` is read client-side by `FlowRunner` and the flow label is not. The liveness README's translation cell says the same. These are comments only: a comment-stripped transpile of the three source files is byte-identical to before. + - `state-counts.md` is regenerated: `action` has 46 live and 0 planned (was 44 and 2); `translation` has 23 live and 1 planned (was 22 and 2). +- 7e5246d: Liveness ledger: `flow.description`, `hook.label` and `hook.description` are now `live`, not `dead`. Studio already shows all three to a human. Ledger data, one README cell per type and one gate test fixture only. ⛔ No schema, parse, `.describe()` or accept-set change. + + The ledgers ship inside this package (`files[]` includes `liveness`), and `@objectstack/lint` reads them to decide which authored keys draw an advisory warning. None of the three rows sets `authorWarn`, so the set of warnings does not change. + + - **What shows them.** These are display keys, so under the ledger's "Designer previews count as consumers" ruling, being shown to a human is the whole of their claimed effect. Neither `flow` nor `hook` registers its own list columns in the Studio metadata admin, so the Studio metadata list page falls back to its default columns: name, `label` and `description`. The Studio metadata quick-find indexes and shows every item's `label` and `description` too. Each row cites that reader at the `.objectui-sha` pin `dd3f7e1be`. + - **Where the values come from.** Each row names its producer: the Studio route that mounts the list page, the metadata client's `GET /api/v1/meta/:type` read, and this repo's shared list answer (`createMetaListAnswer`), which adds no projection for either type that would drop the keys. A booted read of the showcase app confirms it: `GET /api/v1/meta/flow` served 30 flows and `GET /api/v1/meta/hook` served 4 hooks, each with its authored `label` and `description` and the showcase's project-scoped package id. + - **Still kept, still not warned.** The re-grade reverses no ADR-0033 decision. All three rows stay docs-shaped annotation, deliberately kept and exempt from enforce-or-remove. Each row keeps the note it carried while `dead`, as history. + - The regenerated liveness counts are the `liveness/state-counts/flow.md` and `liveness/state-counts/hook.md` shards. `flow` has 35 live and 5 dead (was 34 and 6). `hook` has 21 live and 1 dead (was 19 and 3). The README's `flow` and `hook` Notes cells no longer list these keys as dead. The liveness gate test that borrowed `flow.description` as its sample `dead` row now uses the `flow.active` tombstone, which the gate holds at `dead`. +- aeb0557: fix(security)!: the RLS write check refuses a field-to-field comparison the read refuses — one comparison class, one answer per policy (#20355) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on the row-level write check, shipped as `minor` + under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; + breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the + level). The hand-migration prescription is registered under protocol major 18 as + `rls-predicate-cross-class-field-comparison-refused`, one ADR-0087 D3 entry for the whole + family: the authoring arm `os validate` gained in #20347 and this write-check arm. + + **What changed.** A row-level policy that compares two fields of no shared comparison + class — `record.status != record.amount` (text and a number), `record.status != + record.photo` (text and a file field), `record.status != record.is_open` (text and a + formula field), `record.status != record.meta` (text and a json field) — already had + every read it scopes refused with `INVALID_FILTER` / 400 on the SQL drivers, because + driver-sql compiles a column-to-column comparison only within one class. The write + check did not know the rule: it compared the two raw values in-process, so an insert + or update the policy's `check` judges (or its `using`, standing in as the check) was + admitted and stored whenever that comparison happened to hold. Measured through + plugin-security and ObjectQL on SQLite, sqlite-wasm and PostgreSQL. The write check + now refuses the comparison too, with the read's envelope, `INVALID_FILTER` / 400, for + every insert (single or array), by-id update and predicate update it judges, and + nothing is stored. The same-class comparisons it always compared are compared as + before. The 400 names no column of the policy; the server log names the policy and + both columns. A comparison against a json or `multiple` field is refused by its + declared type now, where it used to be judged by the value each record held. + + **`@objectstack/formula`.** `matchesFilterCondition(record, filter, options?)` takes an + optional third argument: `options.fields`, the object's declared columns (`type` and + `multiple` per field name). Given it, every `{ $field }` comparison between two + declared columns is judged by `crossFieldComparisonVerdict` from + `@objectstack/spec/data` before any record is read, and one the platform defines no + answer for throws `INVALID_FILTER` / 400. Without it the evaluator behaves exactly as + before. Two new exports go with it: `findCrossFieldClassRefusal(filter, fields)`, the + pure judgement, and `crossFieldClassRefusalCarriedBy(error)`, which reads the refused + comparison off the error for a server-side log. + + **`@objectstack/driver-sql`.** `crossFieldComparisonClass` reads the same export + (`crossFieldColumnVerdict`) instead of keeping its own copy of the classification, and + layers above it only its internal type aliases. Every read answers as before. + + **`@objectstack/lint`.** The `rls-predicate-unenforceable` finding for such a + comparison now states the write answer the runtime gives: the in-process write check + refuses it by the same classification and stores nothing. + + **If a policy of yours is refused.** The platform defines no comparison between those + two columns on any path, so the policy never protected a read either. Compare a field + only with a field of the same class — a number with a number, text with text, a + boolean with a boolean, a date with a date, a datetime with a datetime, a time of day + with a time of day — or, if the two columns do hold comparable values, correct the + declaration of the one declared with the wrong type. `os validate` names every such + comparison. +- 05077d4: `liveness/state-counts.md` is replaced by `liveness/state-counts/.md`: the generated liveness counts are one shard per governed type, and no total is committed anywhere. + + Clause-②: no — no schema key moves, no accept set widens or narrows, no export changes. What changes is the layout of a generated table that ships in the tarball, and no count in it moves. + + The ledgers ship inside this package (`files[]` includes `liveness`), so the changed tarball bytes are: `liveness/state-counts.md` removed, forty `liveness/state-counts/.md` shards added (each carries exactly the row that file published for its type), and the prose in `liveness/README.md`, `liveness/book.json` and `liveness/translation.json` that named the removed file. + + - **Where a count now lives.** A type's row is `liveness/state-counts/.md`, byte-for-byte the row the single file carried. The table's total is not in any file: `pnpm --filter @objectstack/spec check:liveness` sums the shards when it reads them, prints the sum on its success line, and carries it in `--json` as `countsTotal`. At this release the sum is the total the removed file published: 940 live · 5 experimental · 1 live-elsewhere · 148 dead · 9 planned = 1103 classified. + - **Why.** Every change that moved a liveness verdict rewrote the single file's total row, and GitHub's server-side merge runs no custom merge driver, so any two such changes in flight conflicted on that one line. With one file per type, changes that move different types touch different files. + - **Anything that read `liveness/state-counts.md` from the published package** reads the shard for the type it wants, or sums the shards for the total. `gen:liveness-counts` rewrites only the shards whose counts moved and deletes the removed file if a merge brings it back; `check:liveness` fails while it is present. +- de8c973: Reword the `CurrencyConfigSchema.precision` clause in two major-18 D3 migration entries (`os migrate meta`) — the key it describes as "unchanged" is retired in this same protocol major (#20379). + + `18.field-scale-precision-integer-refused.ts` and `18.ui-form-field-precision-scale-integer-refused.ts` each carried a sentence distinguishing `CurrencyConfigSchema.precision` from the field/row-level `scale`/`precision` keys those entries retire, saying the currency key is a different surface and is unchanged. `currency-config-precision-removed` (D3 `currency-config-precision-retired`) retires that same key in this same major, so the sentence became false the moment that conversion landed. Reworded only that clause in each entry to say the key was retired in this same protocol major by `currency-config-precision-removed`. + + The form-field entry's other clause was also wrong on its own terms: it named a "gantt `scale` enum" that `GanttConfigSchema` does not declare (its granularity key is `viewMode`; `strictObject` refuses `scale` there). Corrected it to name the surface that actually carries an enum-valued `scale` — `TimelineConfigSchema.scale`, still unchanged — and to say plainly that the gantt view has no `scale` key. + + Clause-②: no + + No schema, key, conversion or verdict changes — text only. `packages/spec/src/migrations/registry.ts` regenerated with `gen:migration-registry` so the printed `os migrate meta` text matches. +- 65352b7: **plugin-auth: under the `open` audience posture, the deployment can turn email verification off** + + Clause-②: yes + + Under `audience.posture: 'open'`, an explicit `emailAndPassword.requireEmailVerification: false` + declared by the **deployment** is now honoured instead of refused at config entry. The deployment + declares it through its stack config (the `AuthManager` constructor), host code calling + `AuthManager.applyConfigPatch()`, or the `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false` env override + of the `auth.require_email_verification` setting. A sign-up is then signed in at once, with no + verification mail. This is for a deployment with no mail transport that trusts its sign-ups, + such as a pre-production environment, which otherwise dead-ends every new account at the verify + page. + + Nothing changes for anyone who does not opt out: + + - `open` with the value absent or `true` still forces verification on. + - `email_domain` still refuses an explicit `false`, from any source, with the same message. The + domain allowlist is the only gate there, so an unverified sign-up could claim a colleague's + address. + - `invite_only` is unchanged. + - A `false` stored only through the settings console is still refused under `open`. The console + can agree with the deployment's opt-out, never make one. + + The opt-out is loud. `AuthPlugin` logs one warning at boot naming the posture and the + consequence: anyone can register an address they do not control, and an organization invitation + sent to that address can then be accepted by that account. `getPublicConfig()` reports + `requireEmailVerification: false`, the value actually wired, because the wiring and the + advertisement now read one resolver. + + `AuthManager.applyConfigPatch()` takes an optional second argument, + `{ requireEmailVerificationFrom: 'deployment' | 'console' }`. It defaults to `deployment`; the + settings binding passes `console` for a stored value and `deployment` for an env override. +- c7ad16f: fix(driver-sql): a declared index that can never be built is logged at `error` and reported in drift + + **Clause-②: yes (widening)**: the exported `DriftOp` union gains one member, `unbuildable_index`. + No accept set changes. Nothing an author could write before is refused now. + + A declared index names a column that no declaration will ever create when: + + - the name is not a field of the object, for example a misspelling that the Studio save door + admits (`os validate` / `os build` already refuse it); or + - the name is a virtual `formula` field, which is computed on read and has no column. The same + applies to a field-level `unique` on a formula field. + + The SQL driver skips such an index at every sync. It used to say so at `warn`, and the drift + report dropped the index from the expected set, so `os migrate plan` showed nothing. For a + `unique` index, the declared constraint was not enforced and duplicate rows were accepted, + while everything looked normal. + + - **The sync logs the skip at `error`**, on the same durability channel as the duplicate-row + refusals in the same loop. One line per skipped index per sync names the object, the index, + each missing column with its reason (not a field of the object, or a formula field), and + whether the index is `UNIQUE`. The structured meta carries `index`, `missing` and `unique`. + - **Drift reports it** as a report-only entry: `kind: 'index_mismatch'`, `actual: '(absent)'`, + `category: 'needs_confirm'`, `severity: 'error'` for a unique index and `'warning'` otherwise. + Its op is the new member: + + ```ts + { type: 'unbuildable_index'; table: string; column?: string; indexName: string; + unique: boolean; missingColumns: string[] } + ``` + + `missingColumns` lists only the columns that will never materialize. A declared column that + is merely not added yet is pending additive work, not this finding. + + **What a consumer that reads `op.type` now sees.** A new value, `'unbuildable_index'`. It has + no reconciler arm, and none can exist, because there is no column to build over. The remedy is + a metadata edit. It is in `INDEX_DRIFT_OPS`, so `isIndexDriftOp` answers `true` and it never + triggers a SQLite table rebuild. `applyMigrationEntries` reports it `skipped` on every dialect. + `os migrate plan` lists it under "Needs confirmation", addressed by its index name. `os migrate + apply` counts it like any `needs_confirm` entry (so it asks for `--yes`), and then reports it + skipped. The artifact-pinned boot warns about it and still starts, because + only `destructive` entries refuse a boot. A `switch` over `op.type` that treats unknown values + as "not applied" needs no change. An exhaustive `switch` with a `never` check gets one more case + to handle. + + **The object form's help text follows.** The `indexes` → Fields help in the Studio object form + said the skip left "a warning in the server log". It now says an error, in English and in the + zh-CN, ja-JP and es-ES translations. Nothing else in the text changes. + + **The lint message follows too.** `object-field-ref-unknown`, on a misspelt `indexes[].fields` + name, said the SQL driver skips the index "with only a warning, and drift drops it too". It now + says the skip is logged at error and `os migrate plan` reports the index as unbuildable. The rule, + its severity and its prescription are unchanged. + + **Upgrade note:** on a database that already carries such an index, `os migrate plan` now + reports one entry per index, and so does the boot's drift warning. That entry clears only when + the metadata names stored fields or drops the index. +- 48efe91: `hook.form.ts`'s `condition` row now declares `language: 'expression'`, matching the CEL predicate `HookSchema.condition` actually is. + + Clause-②: no + + The row previously declared `language: 'javascript'` — the same declared language as a real script row (`body.source`) — over a field that is `EvaluatedExpressionInputSchema`, a CEL predicate. A consumer keyed on the row's declared language could not tell the predicate apart from a script. The `helpText` moves from "Optional formula — skip the hook when this evaluates to false" to "CEL predicate — the hook runs only when TRUE", matching the phrasing every sibling predicate row (`field.form.ts` / `object.form.ts`'s `visibleWhen` / `readonlyWhen` / `requiredWhen`, and the formula `expression` row) already uses. + + No key is added, removed, narrowed or widened, and no parse verdict changes — `type: 'code'` and `language` are already-declared form-DSL vocabulary. `metadata-form-declared-rows.pin.test.ts` pins the new value, with a control against a sibling predicate row. +- 8255a51: fix(spec): correct `GanttConfig.timeZone`'s describe — persisted gantt drops on a `date` field are not "real instants" (#20466) + + Clause-②: no + + The `GanttConfig` `timeZone` member's `.describe()` said "persisted data stays real + instants" for every field. That is false for a `Field.date` column: per the spec's own + storage rule (`temporalStorageForm` / ADR-0053), a gantt drop on a `date` field writes the + calendar day it landed on, as a timezone-naive `YYYY-MM-DD`, while a `datetime` field + still writes the real instant. Only the false clause is replaced — "a datetime value is + still written as the real instant, and a date value as the calendar day it was dropped on + in this zone's calendar (`YYYY-MM-DD`)" — the rest of the describe, and every other + member, is unchanged. + + No key moves and no verdict moves: this corrects a published describe's prose to match + the contract it already had, it does not add, remove or re-scope anything authorable. The + JSON Schema and reference docs regenerate from the corrected source. +- d1c01ff: fix(runtime): `DELETE /packages/:id` refuses an uninstall that names no organization before it touches the running registry (#20492) + + Clause-②: no + + A caller holding `manage_metadata` with no active organization — a member removed from an organization whose session still names it, or a caller who never selected one — sent `DELETE /api/v1/packages/:id` and was answered `400 TENANT_SCOPE_REQUIRED`. The dispatcher had already run the registry uninstall by then, so the package and every object it registers had left the running process for everyone it serves, while its stored rows still said it was installed. The state lasted until a restart re-seeded the registry. + + The door now asks the persisted delete's organization-scope question first, from the same organization value it hands `deletePackage`, and only when a persisted delete will run. The same refusal (`400 TENANT_SCOPE_REQUIRED`) now arrives before anything changes: the package stays served, listed and registered, and its stored rows are untouched. The refusal's message names what an HTTP caller can do, which is to select an organization they are a member of and retry. + + - **Unchanged:** a caller acting in an organization uninstalls exactly as before. A read-only package is still refused `422 WRITABLE_PACKAGE_REQUIRED` first. A host with no persisted delete (no `deletePackage` on its `protocol` service) still uninstalls from the registry alone, because there is no refusal to mirror there. The protocol keeps its own refusal as a second line. + + - **`@objectstack/spec`:** `PROVENANCE_WAIVERS` (the error-code ledger) gains one entry: `@objectstack/runtime` stamps `TENANT_SCOPE_REQUIRED`, which stays registered under `@objectstack/metadata-protocol`. The door mirrors `deletePackage`'s refusal and does not emit a second vocabulary. The registered code union and `ErrorCode` are unchanged. +- 9e1689f: `activityMilestones[].type`'s `.describe()` now states the real default: an unset `type` keeps the update row's kind, `updated` (#20494) + + Clause-②: no + + No behaviour changes and no schema shape change. `object.zod.ts`'s `activityMilestones[].type` field described its default as `"completed"`; the runtime never wrote that. `audit-writers.ts` starts `activityType` from `activityTypeFor(action)`, and a milestone can only fire on the UPDATE branch (`create` / `delete` return their own summary before the milestone match ever runs), so an unset `type` has always emitted `updated`. `milestone.type` overrides it only when the author actually sets it — that half of the describe was correct and is unchanged. + + The corrected string is the published half: it ships in `packages/spec/dist/*.d.ts`, in the JSON Schema under `packages/spec/json-schema/`, and in the generated `content/docs/references/data/object.mdx` (regenerated with `gen:docs`, never hand-edited). A repo-wide search for the old wording found no other hand-written copy; `object.form.ts`'s `activityMilestones.type` help text ("Unset: updated.", shipped with PR #20485) already stated the real default and is unchanged. + + `packages/plugins/plugin-audit/src/activity-type-vocabulary-enforcement.test.ts` already measured the runtime's real answer — its title and docblock are corrected in the same PR to stop describing a divergence and stop saying the finding was "filed separately" (this card, #20494, is where it was filed). Its assertions are byte-for-byte unchanged. +- a362e0e: fix(spec): the `agent.tools` liveness row is `dead` — it claimed `live` on a key the schema tombstoned + + `liveness/agent.json` ships inside this package, and its `tools` row read: + + ```json + "tools": { "status": "live", "evidence": "cloud: packages/service-ai/src/agent-runtime.ts", "note": "legacy direct-tool fallback." } + ``` + + `agent.tools` was removed in protocol 17 (#3894). `src/ai/agent.zod.ts` declares it + `retiredKey(...)`, which types the key `never` and rejects any authored value with the + upgrade prescription, and the ADR-0087 conversion `agent-tools-to-skills` deletes it from + stored rows and built artifacts when the chain is replayed at rehydration. So nothing can + carry a value for the key and no consumer in any repo can read one — while the ledger's own + vocabulary defines `live` as "Has a runtime consumer". + + The verdict moves `live` -> `dead` with **no key added or removed**: the classified total + stays at 1094 and the accept set is byte-identical, because a liveness row is a claim about + the schema rather than the schema. `dead` is the status the ledger's own convention already + gives this class — of the 40 tombstoned top-level keys across the 36 governed types, 39 + were already `dead` and this was the only outlier — and it is what puts the key on the + ADR-0049 enforce-or-remove worklist it should have been on since protocol 17. `live-elsewhere` + is refused rather than left undeclared: that status needs a genuine foreign enforcer, and a + key nothing can carry a value for has nothing to enforce. + + Nothing changes for authors: writing `agent.tools` failed `tsc` and failed the parse before + this change and fails both after it. What changes is that the ledger, which ships in this + tarball and is the input to the retirement worklist, no longer certifies a consumer that does + not exist. + + Also in this change: the stale `evidence` pointer is deleted rather than repointed (a `dead` + row's pointer lives in its `note` by the gate's own design), the ledger's own `_note` + sentence saying the row was deliberately left unstamped is corrected to record the landed + re-grade, `liveness/state-counts.md` is regenerated, and a contract test pins the class — + a `[REMOVED]` tombstone's ledger row says `dead`, on a measured population of 40. +- f26fb8e: Correct six `edit distance cannot reach` citations that are measurably false, and pin the role each alias entry actually plays. + + `aliases` has two jobs, not one: filling a gap the distance fallback leaves empty, and overruling a hit the fallback reaches and gets wrong. The lookup is `aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, budget, 1)[0]` — the table is consulted first and wins outright — and the budget is `Math.max(2, Math.floor(key.length / 3))`. A sentence saying distance "cannot reach" the cited case denies the second job, and in three places the cited case is itself an example of it. + + - **`latitude` → `lat` is an OVERRULE, not a gap** (`data/field-value.zod.ts`, `data/default-value-shape.ts`, `data/field-value.test.ts`, `data/default-value-shape.test.ts`, objectql `validation/record-validator.ts`). `latitude` is 8 characters, so the budget is 2; `lat` is 5 edits away and out of reach, but the declared `altitude` is exactly 2 — so without the curated entry the bare fallback answers `latitude` → `altitude` and points an author who wrote a GPS latitude at the elevation member. Four docblocks cited this pair as proof that aliases exist only where distance reaches nothing. + - **`postal_code` → `postalCode` never involved an alias at all** (`data/default-value-shape.ts`). Scoring folds case and separators on both sides, so it is 1 edit against a budget of 3 — the worked example rendered in that docblock is the fallback's own answer, not the `AddressValueSchema` table's. + - **`uri` → `url` is reachable and agreeing** (`data/driver/turso.zod.ts`). The block was headed "the spellings edit distance cannot reach"; that is true of five of its six rows and false of `uri`, which is 1 edit from `url` against a budget of 2. The row is a pin on an answer the fallback already gets right, not a gap-filler. + + Prose plus new pins. No alias is added or removed, no schema, key list, strictness, suggestion or error message changes: `Clause-②: no`. The three roles are now asserted — `longitude` (gap), `latitude` (overrule, with the negative half), `altitud` (a plain typo still riding the fallback) in `data/field-value.test.ts`, and `dsn` (gap) beside `uri` (reachable) in `data/driver/turso.test.ts`. +- 0da638c: fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) + + + + **BREAKING** for an in-process caller that reaches an analytics face PAST the + schema door with a string the closed vocabulary does not contain: it used to be + answered, and is now refused. Shipped as `minor` under the repo's launch-window + convention. The driver half of #16041, whose spec change closed + `AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen + dashboard preset names; every value affected here was already refused at + `POST /analytics/query` and `/analytics/sql` when that landed. + + ## What was wrong + + #16041 closed the contract; the faces behind it never aligned, so the defect it + abolished simply moved onto the newly-blessed vocabulary. Measured on the built + `driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, + 2099): + + | input | before | after | + |:--|--:|--:| + | `today` | 1/5 | 1/5 | + | the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | + | `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | + + `driver-memory` recognised exactly `today`: every snake_case preset missed its + `startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose + two bounds were the preset's own NAME, which matched every `Date`-typed row + under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered + the same names — and unrecognised strings, and `today` — to the point window + `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is + whatever the dialect decides a vocabulary word compares as. So a dashboard + asking for one month got all of history on one backend and a nonsense + comparison on the other, at HTTP 200 on both. + + ## What it does now + + - **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / + `resolveAnalyticsDateRangeString` resolve every declared preset to + `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens + handed to the existing macro resolver, so `dateRange: 'this_month'` and a + `{month_start}` filter token cannot answer differently, and the anchoring on + `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) + come from that resolver rather than from each face. + - **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 + envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own + `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door + answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call + it, so "memory and SQL refuse identically" is one function rather than an + agreement. + - **The upper bound keeps #16179's separation.** A window a face RESOLVED is + compared exclusively (`$lt` / `<`) for the ten calendar presets and + inclusively for the three rolling `last_N_days`, whose bound is NOW; an + explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. + - The fifteen `driver-memory` date-range pins #16041 retired are reinstated in + preset form (DST cells re-measured under calendar semantics, not re-spelled), + and one cross-face conformance fixture holds all FOUR faces to the same + windows and the same refusal. + - **The draft-preview evaluator is the fourth face**, and it is in that fixture + for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — + the Live Canvas preview over a pending seed draft) carried the identical + `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, + silently, while the published chart beside it answered a real window — across + a publish boundary the preview exists to make continuous, since publish + materialises the same seed. + + ## FROM → TO + + Unchanged from #16041's — the spelling that is refused here is the spelling that + was already refused at the door. + + | you wrote | write instead | + |:--|:--| + | `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | + | `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | + | `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | + | `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + + The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's + code stays registered under `@objectstack/runtime` (the door that names the wire + vocabulary), and the waiver records that the shared constructor spelling it + lives one package over. +- 8a5240a: docs(spec): the `dashboard.widgets[].chartConfig` liveness row is re-anchored to the current objectui pin — 12 of 14 keys reach the renderer, not 9 (#17385) + + `packages/spec/liveness/dashboard.json` ships inside this package, so its rows are part of what an author reads. The `widgets.children.chartConfig` row was measured on 2026-08-09 against `objectui @230ffd875` and both halves of that reading are now superseded — re-measured by hand against this checkout's own `.objectui-sha` pin `53ded82bf7a4`. + + **The citation moved repos-internally.** `chartConfigPresentation` was lifted out of `plugin-dashboard` into `@object-ui/core`'s `chart-presentation` module, so the old pointer at `packages/plugin-dashboard/src/DatasetWidget.tsx:380-429` — still byte-exact at the commit it names — lands on the re-export block at that range in the pinned tree, while the nine `if`s it describes are in another package. A foreign path is counted and never resolved by `check:liveness`, deliberately, so nothing mechanical could have caught this: only a hand re-measurement does. + + **The count changed.** `xAxis` / `yAxis` / `series` were recorded as unforwarded on the grounds that they are derived from the dataset selection. They are forwarded today: the dataset keeps series MEMBERSHIP and the column each binding reads (`ChartSeries.name` and `ChartAxis.field`, dropped on the way through) while every other key on those objects merges onto the derived binding with the explicit binding winning. `type` and `aria` remain the two keys that do not reach this face. + + Evidence text only — no verdict moves, no schema key changes, and the row still carries no per-key `children`. The per-key drill, the `type` / `aria` dispositions and the authored-versus-derived precedence the protocol does not yet state stay open on #17385. +- c7af6bd: docs(spec): `options.stageOrder` no longer documents a chart type that cannot be built, and says plainly that only `funnel` reads it (#17344) + + `DashboardWidgetOptionsSchema.stageOrder` is an ungated member of the open widget `options` bag, so its one sentence of prose is the whole author-time surface: nothing warns, nothing refuses, and a widget carrying the key renders with the authored order simply absent. That sentence said *"Explicit category order for ordered-sequence charts — `funnel` / `pyramid` stages above all"*, and it was wrong twice over. + + - **`pyramid` is not a widget type.** It was removed from `ChartTypeSchema` as a variant that only ever rendered as `funnel`, and `chart.test.ts` pins that refusal alongside its fallback-only siblings — so the headline example in the option's own documentation could not be authored at all. + - **The plural framing promised more than the renderer delivers.** "ordered-sequence charts" and "stages above all" read as a statement about ordered marks generally. It is not one: `funnel` is the only type whose branch consults the forwarded order, measured against this repo's pinned objectui renderer. + + The corrected JSDoc and `.describe()` name `funnel` only, state outright that no other widget type reads the key, and send the other types to `sortBy` / `sortOrder`, which lower into the dataset query itself. The generated reference page (`content/docs/references/ui/dashboard.mdx`) is regenerated from the new `.describe()`. + + No schema shape changes: `stageOrder` still parses exactly as before, on every widget type. Whether the key should be *gated* to the type that honours it is ADR-0049 enforce-or-remove on an accepted key — a published-surface narrowing, and deliberately not this change; it stays open on #17344 together with the locale-dependent order/colour drop, which lives in the objectui renderer rather than here. +- 80aef80: fix(spec): the date-range preset prescriptions now name a one-day window for the one-day presets (#17014) + + `DATE_RANGE_PRESET_MACRO_WINDOWS` maps each dashboard date-range preset to the `{date-macro}` window a refusal PRESCRIBES to an author who wrote the preset name as a bare filter comparand (`bareDateRangePresetComparandMessage`). Two of its thirteen entries prescribed a window wider than the preset they name — a filter that parses, runs and returns rows over the wrong range, with no second error to correct against. + + - **`yesterday`** was `['{yesterday}', '{today}']` — an end naming the day AFTER the window. The pair is written for `$between`, which is `$gte min` and `$lte max`, and a bare-day upper bound means "through that whole day", compiled half-open to `< nextUtcCalendarDay(max)` (ADR-0053 D-D). So the prescription resolved to `>= yesterday 00:00 AND < tomorrow 00:00`: yesterday **and** today. It is now `['{yesterday}', '{yesterday}']`. + - **`today`** was `['{today}', null]`, the open `$gte`-only arm, so the prescribed filter had no upper bound at all and also selected every day after today on a column carrying future dates. It is now `['{today}', '{today}']`. + + Both entries now name their own last day, matching the convention the other eight closed entries already used and matching both executable mappings — objectui's `PRESET_RANGES` and `@objectstack/core`'s analytics date-range resolver, which independently spell `today` and `yesterday` as one-day windows. + + The convention that decides an end token was nowhere written down, which is what let one table carry two readings. It is now stated as a rule on the table: **`start` names the window's first calendar day and `end` names its last, inclusive — never the day the window stops before**, and `end: null` is the open arm reserved for exactly the three rolling `last_N_days` windows. Tests pin the resolved extent of every window against a frozen reference day and require a stated extent for every declared preset, so a preset added later cannot silently pick the other reading. + + No schema, type or export changes: the refused shapes and the vocabulary are exactly as before, and only the window text a refusal quotes back moves. +- 65ad77d: fix(spec): the driver-config registry refuses an off-vocabulary id instead of answering with a truthy non-schema + + `DRIVER_CONFIG_JSON_SCHEMAS`, `DRIVER_ID_ALIASES` and `DATABASE_DRIVER_ALIASES` + are plain object literals, so all three inherit `Object.prototype`, and every + lookup into them was a bare index. Measured against the built artifact + (`dist/data/index.mjs`) on the repo's Node 22 baseline (v22.22.2), an id that + names an inherited member resolved that member and was handed onward as if it + were a driver: + + | call | before | after | + |:--|:--|:--| + | `getDriverConfigJsonSchemaById('memory')` | the JSON Schema | the JSON Schema — unmoved | + | `getDriverConfigJsonSchemaById('constructor')` | `{}` — an EMPTY JSON Schema that accepts every config | `TypeError` naming the id and the legal vocabulary | + | `getDriverConfigJsonSchemaById('toString')` | `'[object Object]'` — a **string**, where the signature promises an object | `TypeError` | + | `getDriverConfigJsonSchemaById('valueOf')` | the registry object itself | `TypeError` | + | `getDriverConfigJsonSchemaById('__proto__')` | `TypeError: … is not a function` | `TypeError`, now naming the id | + | `getDriverConfigJsonSchemaById('nope')` | `TypeError: … is not a function` | `TypeError`, now naming the id | + | `resolveDriverId('constructor')` | the `Object` **function** — truthy, not a driver id | `undefined` | + | `resolveDriverId('__proto__')` | `Object.prototype` — a truthy object | `undefined` | + | `resolveDatabaseDriverId('constructor')` | the `Object` **function** | `undefined` | + | `driverHasLocalDefault('constructor')` | `undefined`, out of a function declared `boolean` | `true`, as its doc promises for an unknown id | + | `resolveDriverId('pg')` / `resolveDriverId(' PostgreSQL ')` | `'postgres'` | `'postgres'` — unmoved | + + `getDriverConfigJsonSchemaById` handing back `{}` is the worst of these: an + empty JSON Schema validates anything, so a Studio connection form or a + `DriverDefinitionSchema.configSchema` consumer that asked "what shape must this + config have" was told "any shape at all" and reported success. + + The resolvers' half is reachable without a plain-JS consumer. The CLI refuses an + unclaimed operator selection with `if (driverType && !kind)` after calling + `resolveDatabaseDriverId`, so `OS_DATABASE_DRIVER=constructor` produced a truthy + `kind` that is not a driver id and walked past the refusal. + + All three lookups now go through an `Object.prototype.hasOwnProperty.call` check. + This narrows and widens nothing: every legal spelling is an own key of its table, + so no value accepted before is refused now, and only answers that were never + inside the declared return types move. The declared signatures are unchanged — + `getDriverConfigJsonSchemaById` stays `(id: BuiltinDriverId) => Record` + and both resolvers stay `(driver: unknown) => BuiltinDriverId | undefined`. + + A null-prototype table was the other available shape and was measured rather than + assumed: a `__proto__: null` object literal does not type-check against the + `Readonly>` annotation at all (TS2353), and the + `Object.assign(Object.create(null), …)` spelling that does compile silently costs + that annotation — a table missing a driver stopped failing to compile (TS2741). +- a54ecaa: feat(objectql)!: refuse a text operator aimed at a field whose DECLARED type can never store a string — `INVALID_FILTER` 400 at the engine's field-aware door (#15773) + + + + **BREAKING** for a caller that aims `$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike` at a numeric, boolean, temporal or structured-JSON field: the call used to be answered (with `[]`, with every row for `$notContains`, or with a dialect accident) and is now refused with `400 INVALID_FILTER`. Shipped as `minor` under the repo's launch-window convention. Execution lane (2) of the maintainer ruling on #15661 (decision batch #43, option C-deny); lane (1) is the contract it consults, `@objectstack/spec/data`'s `filter-text-operator-declared-type.ts` (#15804). + + ## What was wrong + + Measured on `origin/main` `59db8a02cb` with a real `ObjectQL`, the lane-1 fixture registered and a recording driver beneath — the filter reached the driver verbatim every time: + + | filter | before | after | + |:--|:--|:--| + | `{ f_number: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_summary: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_json: { $contains: 'a' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_date: { $startsWith: '2026' } }` | `400 INVALID_FILTER` — from the #8690 TEMPORAL door, about the COMPARAND | `400 INVALID_FILTER`, naming the field's declared type | + | `{ f_text: { $contains: 'a' } }` | driver read | unchanged — driver read | + + What the driver then answered is #14079's option-A row: no row for a positive operator, EVERY row for `$notContains`. Neither answer is wrong beneath the door — it is the declared answer — and neither carries any signal that the field can never hold a string, which is the cell this closes. + + ## What it does now + + - **One door, at the engine's single filter collection point** (`lowerWhereFilterArray`), third in the ladder: comparand shape (#5869) → materializable field (#8296 / #8371) → **declared type (this)** → temporal comparand (#8690). It runs before the temporal gate deliberately: a text operator over a `date` field was already refused there, with the same wire envelope but a message about the comparand, which sends the author to fix a value that could never have made the filter runnable. + - **The refused classes are DERIVED, never re-listed**: the verdict is `@objectstack/spec/data`'s `textOperatorDoorVerdict`, over `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES`. A type added to any of those sets is refused with no change in this package. String-valued classes pass unchanged — `STRING_VALUE_TYPES`, `autonumber`, option codes (single AND multi, so `tags` keeps its substring filter), reference ids and the file classes. + - **No vocabulary is minted.** `INVALID_FILTER` already exists (`StandardErrorCode`) and is this package's filter envelope; the refusal carries `code`, `status` and `httpStatus` per ADR-0112 D5, and names the field, its declared type and the operator. + - **Both filter forms and every verb**: the object form and the `FilterArray` sugar, on `find` / `findOne` / `count` / `aggregate` / `update` / `delete`, plus the per-aggregation `filter` position (#10576's second filter slot on `aggregate`) — a door that spoke on `where` alone would answer one mistake two ways within one verb. + - **Beneath the door nothing moves.** A direct driver call never passes this seam and keeps answering `FILTER_TEXT_CASES`' option-A row (#14079), as does `having` — both pinned. + + ## Deliberately unjudged + + - **A dotted key** (`f_address.city`) — `filter-dotted-head`'s subject, whose structured-JSON heads are deliberately unjudged there (#8371). The door steps over it rather than re-closing that carve-out. + - **An unknown filter field** — the engine keeps its registry-less tolerance; this door adds no second opinion about a name. + - **A registry-less host** (`schema.fields` absent) — a door that cannot see the field map invents no verdict, the same early return both neighbours make. + - **`formula`** — judged one door earlier. `assertFilterIsMaterializable` (#8296) refuses every filter over a `formula` field with `INVALID_FIELD` 400, for the broader reason that no driver materialises a column for it, so a formula's declared `returnType` is never the deciding fact at this seam. Not reordered around: that would answer ONE condition with TWO wire codes chosen by `returnType`. The divergence from lane (1)'s formula rows is pinned by name in `engine-text-operator-declared-type-door.test.ts` rather than dropped. + + ## The ADR-0087 ledger entry, and why this is `registered` rather than `not-required` + + `@objectstack/spec` carries one new semantic migration entry, `filter-text-operator-declared-type-refused` (protocol 18) — the `patch` bump above is that entry and nothing else; no schema, no export and no published set moved. + + It is a real registration because the refused shape has an AUTHORED, STORED surface, measured on the tree rather than assumed. Nothing rejects a stored filter at load — `FilterConditionSchema` constrains no field type, and `ViewFilterRuleSchema` takes `field: z.string()` with `contains` in its operator enum — so a filter body written before this change still parses, still loads, and answers `400` the next time it is executed. Carriers measured to reach this seam: + + | stored surface | how it reaches the door | + |:--|:--| + | `sys_saved_report.query_json.filter` | `report-service.ts` runs `engine.find(report.object_name, { where: q.filter })` verbatim; every `sys_report_schedule` row reaches the same body through `report_id` | + | `FieldSchema.summaryOperations[].filter` | `summary-aggregate.ts` ANDs it with the parent-FK match and calls `engine.aggregate` | + | `ListView.filter`, tab filters (`ViewFilterRuleSchema`) | `contains` / `not_contains` / `icontains` / `starts_with` / `ends_with` lower to the same operators through `AST_OPERATOR_MAP` | + | dashboard widget / `GlobalFilter`, dataset `filter`, report `runtimeFilter`, `FieldSchema.relatedListFilter` | `FilterConditionSchema` carriers, executed through the same engine seam | + + **Not** on that list, deliberately: an RLS / sharing / tenant predicate. Those are composed onto the AST by the middleware chain AFTER this door, so the door never judges one — a policy filter cannot become a 400 nobody can act on. + + No mechanical rewrite exists, which is exactly what a `semantic` entry is for: `{ amount: { $contains: '5' } }` may have meant `$eq: 5`, a range, or a different column, and `objectstack migrate meta` must not choose. The entry ships the repair procedure and its acceptance criteria instead. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `where: { amount: { $contains: '500' } }` | `where: { amount: { $eq: 500 } }` (or `$gte` / `$lte` for a range) | + | `where: { created_at: { $startsWith: '2026' } }` | `where: { created_at: { $gte: '2026-01-01', $lt: '2027-01-01' } }` | + | `where: { is_open: { $contains: 'true' } }` | `where: { is_open: true }` | + | `where: { address: { $contains: 'Berlin' } }` | filter a stored text field, or `where: { 'address.city': { $contains: 'Berlin' } }` (a dotted path stays unjudged) | + | `where: { tags: { $contains: 'urgent' } }` | unchanged — option codes are strings and still pass | +- 44c917a: The error-code ledger's TSDoc stops naming a retired verdict as a live mechanism, and states the published-face rule it is actually held to. + + `packages/spec` ships `src/**/*.zod.ts`, so `api/error-code-ledger.zod.ts`'s header is published prose — a consumer reads these sentences out of the tarball. Two of them stopped being true when `check-dispatcher-error-vocabulary`'s face refusal widened from `packages/spec/src/**` to every published package's `src/` and the dispatcher vocabulary's `boot-refusal` verdict retired with it (#16649). + + The first said the `boot-refusal` verdict **records** reachability for codes not yet registered, and pointed at the module the verdict was being deleted from. That is a claim about where a live mechanism lives, not about a case that can no longer arise, so a reader following the pointer would have found nothing. It now records the retirement and names what replaced it: a `door: 'none'` code has no resting place short of a row in the ledger. + + The second opened `packages/spec/src/** is held to this mechanically`. True before the widening and an understatement after it — a reader would conclude only the spec tree is guarded, which is the "guarded a part" / "guarded it" confusion this whole class of gate exists to remove. It now states the published face, the stricter spec sub-face where `pending-registration` has no allowance, and the named, dated allowance outside it owed to #8846, with both finding kinds named. + + No schema, accept set, default or refusal moves. `ERROR_CODE_LEDGER` holds the same members before and after, and the generated reference page is regenerated from this prose rather than hand-edited. +- 613d35a: The reference-docs renderer now refuses an `@example CAPTION` with no code block beneath it, + instead of publishing an orphaned caption. + + `@example CAPTION` is declared to be *the caption of the fence beneath it*, and the renderer + acts on that reading: it promotes the tag into a bold lead-in on the assumption that a fence + follows. Nothing asserted that one did. When a module header captioned a listing and wrote its + rows as bare prose, the promotion still fired and the rows below collapsed into a single run-on + paragraph — consecutive non-blank lines are one markdown paragraph, and the docs site loads no + `remark-breaks`. Two customer-facing reference pages shipped that way. + + The assumption is now a precondition the generator checks before it emits anything. A module + description whose caption has no block under it fails the docs build with a message naming the + caption and the source-side fix, the way the renderer already refuses a heading it cannot + renumber. Deliberately a refusal in the generator rather than a separate gate: it makes the + wrong page impossible instead of detecting it afterwards, and it is scoped to the population + the renderer actually renders — module doc blocks — rather than to every `@example` line in the + package. + + ⛔ The check never asks whether a run of prose is "really" a table. Shape-sniffing is exactly + what this renderer refuses to do, and what an author writes instead of a fence is not knowable + from the text. It asks only the question the contract already states: is there a block beneath + the caption? An author who wants those words as ordinary prose writes them without the tag. + + Both code kinds satisfy it. An indented block reaches the page as a fence — the render loop + re-emits it as one — so a caption above one captions a fence by the time a reader sees it. All + twelve captions in the corpus are fenced today and are unaffected; no schema behavior changes. +- e08c8b0: fix(spec): the Expression contract is stated in the present tense — the M9.1 / M9.2 phase language is dropped (#17849) + + Clause-②: no + + No accept-set change. `ExpressionSchema` still accepts `source` OR `ast`, every + evaluated slot still requires a non-blank `source`, and no key is added, renamed + or retired. What moves is the text six citation sites carried. + + Those docblocks promised a two-phase roadmap — "Phase 1 (M9.1): `source` is the + canonical persisted form … Phase 2 (M9.2+): `ast` becomes required in build + output" — that no ADR ever chartered, and the refusal sentence an author reads + carried the phase id inside it. #17323 ruled the promise removed: `ast` stays an + accepted optional structured value with no promise of becoming required. The + contract is now written as it actually is: + + - `source` is the canonical persisted form — it is what the engine evaluates; + - `ast` is accepted beside it as an optional opaque structured value, and + carries no promise of becoming required; + - a slot whose value the engine RUNS requires `source`, which is what + `EvaluatedExpressionSchema` spells out. + + **The one published string that moves** is `EVALUATED_EXPRESSION_SOURCE_REQUIRED`, + the sentence an author reads when an evaluated slot refuses a non-evaluable + envelope. It loses four words and nothing else: + + > … the expression engine evaluates `source` (the canonical persisted form of + > phase M9.1) and cannot evaluate `ast` alone … + + now reads + + > … the expression engine evaluates `source` (the canonical persisted form) and + > cannot evaluate `ast` alone … + + Nothing parses that sentence for its content: every consumer imports the + constant by name, and the two pending changesets that quote it verbatim + (`flow-edge-condition-evaluated-slot`, + `blank-node-condition-refused-at-registration`) already carry the new wording, + so the quote stays a quote. + + The `packages/formula` half of the same ruling — `cel-engine.ts`'s AST-only arm + and `normalize.ts`'s header — is comment-only and publishes nothing from that + package (`@objectstack/formula` ships `dist` alone), so it is not graded here. +- 0ee32ed: fix(spec): `FieldSchema` no longer prescribes `required` for `notNull` / `not_null` — the flattened column-constraint spellings now name `storage: { notNull: true }` (#16867) + + Writing `notNull: true` (or `not_null: true`) on a field was refused — correctly — and then told to write `required` instead, via a rename row in `FieldSchema`'s alias table. `required` is the one key ADR-0113 exists to say is **not** the column constraint. `required`'s own description in the same file states the opposite of what the rename prescribed: *"NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`)"*. + + The failure mode was not the refusal — that fired, loudly, and did its job. It was the **remedy**: an author reaching for a NOT NULL column complied, wrote `required: true`, and received a nullable column plus a write-time gate, with nothing downstream to refuse it. The refusal read as though it had been satisfied. + + All three flattened spellings — `notNull`, `not_null`, and `storageNotNull`, which already carried the correct sentence — now get one prescription naming the real key: + + > physical column constraints live under `storage` — write `storage: { notNull: true }` (ADR-0113). There is no flat spelling of it: post-17 a column is NOT NULL because its author wrote that nested key, and for no other reason. It is NOT `required`, which is the WRITE contract (an insert must provide a value; an update may not null it out) and deliberately does NOT imply the column constraint — `required: true` alone leaves the column nullable. Write whichever of the two you meant, or both. + + Both halves are named on purpose: the defect being repaired is that the author cannot tell which of the two axes they are getting, so a prescription naming only the column half would have fixed the measured direction and opened the mirror-image one. + + **No accepted key moves.** `notNull` and `not_null` were refused before this change and are refused after it — a `guidance` / `guidanceSets` table decorates a rejection and never admits a key. Only the sentence attached to the refusal changed. `storage: { notNull: true }` parsed before and parses now; `isRequired` and `mandatory` are genuine spellings of the write contract, ADR-0113 moved neither, and both still rename onto `required`. + + One mechanical note for anyone repairing a table like this: the entry moved from `aliases` to `guidanceSets`, not to exact `guidance`. `aliases` is indexed by `aliasProbe` (case- and separator-folded, so one row covered `not_null` too) while exact `guidance` is matched case-sensitively on the authored spelling — a lone `guidance.notNull` row would have quietly dropped `not_null` onto the edit-distance fallback. The two spellings are pinned separately for exactly that reason. +- 58b36fa: fix(spec): project a union branch-by-branch, so five filter operators reach a published reference page + + `z.toJSONSchema()` refuses a whole schema the moment ONE node in it has no JSON + form, and `build-schemas.ts` applied that refusal per SCHEMA. `orderingComparandSchema` + is `z.union([z.number(), z.date(), z.string(), FieldReferenceSchema])`, so four + `data/filter.zod.ts` exports emitted nothing at all — and `$gt`, `$gte`, `$lt`, + `$lte` and `$between` reached no reference row. Not a blank Description cell: no + section. The ~2000 characters of `.describe()` on those slots — the #5685 comparand + contract, the #6571 endpoint contract, and the `{ "$gte": "2026-01-01" }` shape the + platform's own date-macro resolver produces — reached no reader. + + The generator now makes a third attempt when both strict directions refuse: it + projects with Zod's `unrepresentable: 'any'`, marks every node that came back with + no structural keyword, and DROPS the marked ones that are direct members of an + `anyOf` / `oneOf`. That is not a narrowing. These artifacts describe JSON + documents, a JSON document cannot carry a `Date` INSTANCE, so the set of JSON + documents that union accepts is unchanged by the drop. + + ⛔ A marked node anywhere else — an object property, a record value, an array item + — refuses the projection and the export is skipped with the message Zod threw, so + this cannot change WHY anything is skipped. Five exports leave + `unemitted-schemas.baseline.json` (23 → 18): the four filter exports, plus + `data/Hook`, whose only unprojectable member was the deprecated inline-function + handler branch — that puts 22 `data/Hook:` authorable keys under the key ratchet + for the first time. + + Published artifacts gain `json-schema/data/{ComparisonOperator,FieldOperators, + NormalizedFilter,RangeOperator,Hook}.json`, each carrying an + `x-unprojectable-branches` record naming exactly which branch the projection + dropped and where. +- d127f9b: `i18n.zod.ts` stops asserting a stale size for the inline-locale-map population. + + Two docblocks in this file each stated that the repo authors 31 inline locale maps — the + `INLINE_LOCALE_KEY` rationale ("Every inline map authored in this repo (31 of them, across + three platform pages) uses `en` / `zh-CN` / `ja-JP` / `es-ES`, so the constraint costs no real + authoring surface") and the `I18nLabelSchema` form-2 note ("Three published platform pages + author 31 of these"). The measured population is 45: 33 in `sys-user.page.ts`, 6 in + `sys-organization.page.ts`, 6 in `sys-position.page.ts`. + + The number is **dropped** at both sites rather than corrected to 45. Neither sentence's + argument needs a magnitude. The first turns on the universal — *every* authored map uses those + four tags — so the accept set is what makes the constraint free, not the size of the set. The + second turns on the map being authored on published platform pages *and* resolved by + `pickLocalized`; one authored-and-resolved map already refutes "a convention the runtime + ignores", so the count was never load-bearing there either. Writing 45 would buy one release of + accuracy in prose that is cited as evidence for a schema constraint, and the figure has already + drifted once with nothing noticing; deriving it would mean a permanent gate whose only job is + keeping a number in a comment true. + + The measured half survives untouched at both sites: three platform pages author these maps, and + that is still exactly three. No schema arm, bound, default, `.describe()` string or export + changes; nothing an author can write is affected. +- c17b494: `id_field` now gets a named answer instead of a bare refusal: `FIELD_KEY_GUIDANCE` declares it a retirement with **no successor**, which is the spec-side fact objectui's ingestion choke point needs before it can canonicalise the key (objectui#7650 ruling A — retired spellings are folded once, at ingestion, never at the consumer). + + The direction was a factual finding, not a preference, and it went the way the cheaper branch happens to point — so here is the evidence rather than the verdict alone. A lookup stores the referenced record's id, and which field holds that value is not an authored per-field choice: the picker resolves record identity itself. Nothing on `FieldSchema` names it, nothing in `objectql` / `runtime` / `metadata-protocol` reads a per-field id key, and the two places the platform does let a reference be stored by something other than an id are declared elsewhere — `APPROVER_VALUE_BINDINGS.valueField` (per approver type, e.g. `position` routing by `sys_position.name`) and a seed dataset's `externalId`, the channel lookup references already resolve through. So there is no member to fold onto, and the prescription says what to reach for instead: `displayField` for the candidate's label, a dataset `externalId` for a portable natural key. + + **The entry is keyed `id_field`, in snake_case, and that is deliberate.** The two channels this table feeds disagree about the key face. A `to` becomes a `strictObject` alias, matched through `aliasProbe` — case folded, separators stripped — so one camelCase row covers every spelling. A `why` becomes strict guidance, matched exactly and case-sensitively on the authored spelling. A camelCase row would therefore never be reached by the key authors write, and every existing test in the file would still pass, because none of them asks whether an entry is ever consulted. + + That gap is closed too. Three assertions read the channel that actually answers an authored field key — `FieldSchema.safeParse`, since the schema is strict and the authoring-key walker stays silent on a strict surface by its own posture rule — and pin that the refusal carries this table's sentence verbatim, that a retirement suppresses the rename channel, and that the same-named `idField` on the `inlineColumns` GridColumn mirror is a different schema that stays live. +- d414e2b: Scope the text-operator declared-type door's `formula` prose to the judgement it + actually states. The module declared that a `formula` with a readable + `returnType` is judged as the field type its return type names, but at the + door's only consumer — the engine's field-aware seam — a filter over a formula + field never arrives: the earlier materializability door refuses every one of + them with `INVALID_FIELD` 400, whatever the `returnType`. The verdict function, + its sets, the class table and every case are unchanged; only the prose now says + the formula rows are a contract answer no consumer currently reaches, and why + they are kept rather than retired. +- af98a04: `ManifestSchema.version`'s TSDoc no longer documents an `@example` its own regex refuses + + The key documented two examples and accepted only one: + + ``` + @example "1.0.0" -> /^\d+\.\d+\.\d+$/ accepts + @example "2.1.0-beta.1" -> /^\d+\.\d+\.\d+$/ REFUSES + ``` + + An author who copied the second example verbatim got a `ZodError` out of + `ManifestSchema.parse`. The prerelease example is corrected to `"2.1.0"`, a + value the regex accepts. + + **Nothing published moves except the comment.** The regex, the + `.describe('Package version (semantic versioning)')` string and the prose + `(major.minor.patch)` are byte-identical; no accept set, authorable key or + runtime behaviour changes. `@objectstack/spec` ships `src/**/*.zod.ts` in its + `files[]`, so this TSDoc line is itself published — which is why it carries a + changeset rather than `skip-changeset`. + + **The refusal was already the settled reading, which is why this is a comment + fix and not a schema change.** Three artifacts agreed before this change and + still agree: the regex, the prose `(major.minor.patch)`, and + `manifest.test.ts`, which pins `'1.0.0-beta'` in `invalidVersions` on purpose. + Only the `@example` line dissented, so it was the artifact in error. Widening + the accept set to admit prerelease or build metadata would contradict that pin + and is deliberately NOT done here. + + `PluginSchema.version` accepts a different grammar today; the two keys are + deliberately different and are not reconciled by this change. +- 43cbe14: Correct the `search-fields.ts` module docblock's ENGINE bullet: the `$search` expansion is not closed over the resolved set, and its clauses are not all `$icontains`. + + The bullet claimed `expandSearchToFilter` expands a `$search` term into a `$or` of `$icontains` clauses "over exactly this set". Since the pinyin-recall companion column landed, an object whose deployment provisioned the hidden `__search` companion gets one additional clause per latin term on that companion — a field `resolveSearchFields` never returns and no `$searchFields` override can name, so it sits outside the set the sentence called exact. That one clause is `$contains`, deliberately: the companion is already lowercase on both sides, so a case-sensitive operator over two folded values is exact rather than a case bug, and the engine carries an explicit instruction at the site not to align the two operators. The docblock now states both facts and cites that instruction, so a reader does not "repair" the deliberate split. + + Documentation only — no behaviour, schema or exported surface changes. +- c86d351: fix(spec): `JobSchema`'s own `@example` no longer opens with `id`, the key retired in 17.0.0 (#19184) + + The TSDoc `@example` on `JobSchema` opened with `id: "job_sync_meta"`. `id` was removed in + 17.0.0 (#4667, ADR-0049) and is tombstoned a dozen lines below the block that wrote it, so the + example the schema publishes was refused **by that schema** on a verbatim copy: + + ``` + JobSchema.safeParse() + → success: false + → unrecognized_keys: ["id"] + → "Unrecognized key(s) on this job: `id`. • `job.id` was removed in @objectstack/spec 17.0.0 …" + ``` + + The same object with the line deleted parses, so the one deleted line is the whole fix. An + `@example` is read by whoever copies it before they read the key table — an author, and every + agent writing job metadata from this schema — which is why a key the same file declares dead is + the one thing it must not open with. + + Nothing about what a job may be written as changes here: the accept set, the key table, the + tombstone and its prescription are all untouched, and the generated authorable-surface and + JSON-Schema artifacts are byte-identical across the change. What ships is the corrected example + itself — `src/**/*.zod.ts` is part of this package's published `files`, so the block travels in + the tarball an upgrading consumer reads. + + Scope, stated because the adjacent block invites it: the file's second `@example` (on + `defineJob`) carries no retired key and is untouched. The package-wide `@example` sweep is its + own card. +- c4d1759: docs(spec): record which axis the list-view calendar guard gates — and which it does not (#16577) + + `checkListViewCalendarVisualization` gates ONE way of asking for a calendar: `appearance.allowedVisualizations` includes `'calendar'`. A view can also ask for one by BEING one — `type: 'calendar'` — and that axis parses CLEAN at all three doors (`ListViewSchema`, `ObjectListViewSchema`, `VIEW_METADATA_MEMBERS.listOverlay`). The disposition was correct but undocumented, so it read as an oversight rather than a decision. + + **No behaviour changes.** Every parse verdict at every door is byte-identical before and after; the diff is a TSDoc block on the exported check (which ships in `dist/*.d.ts` and in `src/**/*.zod.ts`) plus pins in `view.test.ts`. + + What the docblock now records, all of it measured rather than inferred: + + - The `type:` axis is **not unwatched**. It is carried by `checkViewCompleteness`'s `VIEW_BINDING_BLOCKS` (`kernel/functional-completeness.ts`) at **warning** severity, under the same ADR-0078 §1 rubric this file's `page` note already cites — refuse what renders NOTHING, warn what degrades. The two doors have complementary coverage: the completeness check reads `type` only and is blind to `allowedVisualizations`; this check reads `allowedVisualizations` only and is blind to `type`. + - `viewType` is **not** a second spelling of `type`. The two authoring doors refuse it as an unknown key; the `.strip()`ed overlay write door (`PUT /api/v1/meta/view`) DROPS it, so the view parses as the defaulted `type: 'grid'` — an author who spells it reaches a grid, never a calendar. + + ⛔ Escalating the `type:` axis to a parse refusal is deliberately NOT done here: it would refuse a shape 17.3.0 accepts, which is a published-surface narrowing and belongs to a ruling — the same disposition the `timeline` scope pin has stated since #13817. +- f7a9740: The lookup-picker "who reads this" claims in `packages/spec` are re-measured against objectui and dated to the commit they were measured on. No schema, accept set, default or refusal moves — this is evidence prose, and every verdict it sits under is unchanged. + + Three claims had gone false, all in the same direction: they credited objectui's picker with reading a `snake_case` alias that objectui no longer reads. A stale *tolerance* claim fails in the dangerous direction — it tells an author a spelling is accepted downstream when it is not, so a value that will silently arrive as nothing looks supported by the spec's own prose. + + - **`liveness/field.json`, both `displayField` notes.** `/props/displayField` claimed the record picker "reads displayField || display_field"; `/props/inlineColumns/children/displayField` named the `snake_case` spelling flatly as *the* key the grid's lookup cells pass. objectui deleted that twin from `LookupFieldMetadata` with no deprecation window and no dual read. Both notes now name the read chain they actually have — `LookupField.tsx`'s `fieldMeta?.displayField || fieldMeta?.reference_field || 'name'`, and `GridField.tsx` handing the column's camelCase `displayField` straight through at all three lookup-cell call sites. Both entries stay `status: "live"`: `displayField` is live, and more exclusively so than the notes claimed. + - **`src/data/field.zod.ts`, the LOOKUP PICKER (forward) docblock.** It told authors that objectui's `LookupField` / `RecordPickerDialog` / `deriveLookupColumns` read "both these camelCase keys and their snake_case aliases" — a blanket claim over all seven keys declared beneath it. Measured, it holds for three: `lookupColumns`, `lookupPageSize` and `allowCreate` are each read as ` ?? `. The other four — `displayField`, `descriptionField`, `lookupFilters` and `dependsOn` — are read camelCase-only. The docblock now states that per key, keeps saying the truth for the three aliases that survive, and records that those three are objectui's own back-compat rather than a spelling this schema declares. + - **`liveness/field.json`, the `valueDomain` `evidence` string.** It described the shared membership predicate as one "the write path **will** call" while its own first clause already quotes the landed call site that calls it. Tense corrected; the pointer is unchanged. + + Each rewritten claim now names the objectui commit it is dated to, so a later reader can tell how old the evidence is instead of assuming it is current. That dating is prose by design: a gate over a pinned foreign tree would go stale at every pin bump and need its own anti-vacuity self-test, which is a worse trade than a dated sentence. +- 96451ec: fix(spec): the `manifest.namespace` refusal now names the leading-letter rule its pattern enforces + + Clause-②: no + + `manifest.namespace` is enforced by `^[a-z][a-z0-9_]{1,19}$`, so its FIRST character must be a lowercase letter. Its refusal sentence and its TSDoc `Rules:` line stated only the length and the charset, so `1leave` and `_leave` satisfied every clause an author was shown and were still refused, by a sentence that could not say why. + + - **The refusal sentence** is now `Namespace must be 2-20 chars, start with a lowercase letter, and contain only lowercase letters, digits and underscores`. It was `Namespace must be 2-20 chars, lowercase alphanumeric + underscore`. + - **The same sentence on the publish payload.** `PackageSchema.namespace` and `CreatePackageRequestSchema.namespace` (`marketplace/package.zod.ts`, and through them the scaffold-only `TemplateManifestSchema.namespace`) carried a byte-identical copy of the old sentence and carry the new one. A new pin holds all four fields to `manifest.namespace`'s sentence, not only to its verdicts. + - **The TSDoc `Rules:` line** now reads `2-20 characters, starting with a lowercase letter; lowercase letters, digits, and underscores only`. + - **Doors that surface the sentence verbatim** carry the new text with no change of their own. For example, `duplicatePackage`'s refusal of an explicit `targetNamespace` reads the declaration's message, so its rule clause now names the leading letter too. + + The accept set is unchanged: the pattern is byte-identical, and every value that parsed before still parses. Only the text shown on a refusal changes. A consumer that matched the old sentence verbatim needs to match the new one. +- 3cf6449: One published `describe` sentence that dates itself to the `.objectui-sha` pin is re-pointed to the pin this release builds against, objectui `dd3f7e1be356`, after being re-read there. + + Clause-②: no + + - `FormField.span`: the `'auto'` clause says that at the pin this repo builds against, only textarea, markdown, html, richtext and repeater resolve to the full column count. It named `f8a9d0fb0596`. Re-read at `dd3f7e1be356`, the claim still holds. `plugin-form`'s `autoLayout.ts` `WIDE_FIELD_TYPES` (`:58-69`) and `resolveColSpan` (`:154`) are byte-identical; its only change is one docblock line. `form.tsx`'s `spanLadderFor` (`:204-231`) is byte-identical, so `'full'` is still the whole row at every multi-column tier. Only the pin the sentence names moves. + + No key, default, enum member or export moves: the same authored metadata is accepted and refused as before, and `content/docs/references/ui/view.mdx` is regenerated from the sentence. +- 3cf6449: fix(platform-objects,spec): the delete actions and the flow builder's Delete Record node name the `trash` icon, which still renders after the console's lucide 1.43 upgrade + + Clause-②: no + + The console build at the new objectui pin ships `lucide-react` 1.43, whose runtime `icons` record dropped one key, `Trash2`. The console resolves an authored icon name through that record, so `icon: 'trash-2'` now resolves to nothing and the button draws no glyph. `trash` draws the identical glyph, which objectui measured node for node when it made the same repair in its own tree. + + Four delete actions in `@objectstack/platform-objects` (OAuth application, organization, SSO provider, team) and the `delete_record` entry of the flow builder's default node palette in `@objectstack/spec` now say `trash`. The `BulkAction.icon` description's example names `trash` too. No key, default shape or export moves. An author's own `icon: 'trash-2'` keeps validating as before, but draws no glyph in the console; write `icon: 'trash'` to get the same glyph back. +- 2bd53f1: docs(spec): the OData `@example Programmatic Use` bag is spelled with the `$` prefixes the schema actually declares (#19028) + + The file-level docblock of `src/api/odata.zod.ts` carried an `@example Programmatic Use` block that wrote every `ODataQuery` key unprefixed — `select`, `filter`, `orderby`, `top`, `skip`, `expand`, `count` — while every key the schema declares carries a `$`. Measured with `safeParse` on that bag verbatim: + + | bag | result | + |:---|:---| + | the documented bag, verbatim | `success: true`, `data: {}` — all seven keys stripped | + | the same bag with `$` prefixes | `success: true`, all seven keys retained | + | a bag holding one fabricated key | `success: true`, `data: {}` | + + So the documented bag and a bag of pure nonsense parsed identically: accepted, silently emptied, no error and no warning. An author who copied it got a query that asked for nothing — no projection, no filter, no ordering, no paging — with nothing anywhere to say so. + + The correct spelling was already ten lines above it in the same docblock: the `@example OData Query` block spells the URL conventions `$select=`, `$filter=`, `$orderby=`, `$top=`, `$skip=`, `$expand=`, `$count=`. Only the second example contradicted the schema, and only the second example moves here. + + **What reaches a consumer.** `@objectstack/spec` ships `src/**/*.zod.ts` in its `files[]`, so this docblock is in the installed tarball as well as on the generated reference page `content/docs/references/api/odata.mdx`, which the same docblock feeds. Both now show the seven prefixed keys. + + **What does not move.** Example prose only. `ODataQuerySchema` is untouched — same accept set, same optionality, same unknown-key behaviour: a key it did not declare is still accepted and stripped rather than refused, exactly as before. No export, no type, no runtime path changes, and no test assertion needed editing. Whether that stripping should instead be a refusal is a separate question, deliberately not answered here. +- 5f9f846: fix(spec): the one-app-per-package refusal cites the record it means, `ADR-0019 (app-as-consumer-unit) D3` + + `ADR-0019` names **two** records in this repository — `0019-app-as-consumer-unit` (D3 = a `type: 'app'` package defines at most one app) and `0019-approval-as-flow-node` (D3 = deprecating `ApprovalProcessSchema`). Both have a D3, and `stack.zod.ts` cited the bare number for both, so an author following the refusal's own citation was as likely to reach the wrong decision record as the right one. + + The three citations of the app-cap rule now name the record: + + - the `STACK_SINGLE_APP_VIOLATION` message — the only one an app author ever sees; + - the `validateSingleApp` docblock; + - the `StackSingleAppViolationError` docblock. + + Only the message tail changed: `An 'app' package must define at most one app, but found N (…)` is untouched, so any consumer matching on that prefix is unaffected. The rule, the refusal's condition and `defineStack`'s behaviour are unchanged. + + The approvals-side citations are deliberately left bare — repo-wide ADR-number disambiguation is tracked separately. +- 5bf2330: Correct the `permissions` alias table's justification for `hosts`, and pin the two aliases nothing measured. + + `PluginPermissionsSchema` (`kernel/manifest.zod.ts`) curates three aliases — `filesystem` and `paths` point at `fs`, `hosts` points at `network`. The block's only comment said edit distance cannot reach any of them, and it sat directly above all three. That is true of the two `fs` entries and false of `hosts`. + + The fallback budget is `Math.max(2, Math.floor(key.length / 3))` (`shared/suggestions.zod.ts`), so a 5-character key gets 2, and `hosts` differs from the declared `hooks` by exactly 2. Measured against the real `findClosestMatches` with the alias table out of the picture: `filesystem` and `paths` return nothing, `hosts` returns `hooks`. So without the alias an author writing `hosts` is answered ``Did you mean `hosts` → `hooks`?`` — pointed at lifecycle hooks on the one block that also grants network access. + + The alias is therefore better justified than the comment claimed: it overrules a confident wrong suggestion rather than filling a silent gap. Only the justification moves — the alias stays, the declared keys, the strictness and the union are untouched, and no message an author reads changes. + + `hosts` is also the only one of the three whose absence would be invisible, since it is the only one that changes a live suggestion, so `manifest-unknown-keys.test.ts` now pins both it and `paths` alongside the `filesystem` pin that was already there, asserting the offending key and the rename — and, for `hosts`, that `hooks` is not what comes back. +- d9e1587: `PluginSchema.version` now describes the grammar it actually enforces instead of calling itself `"Semantic Version"`. + + The key's regex accepts **every** SemVer 2.0.0-valid string and, additionally, eight strings SemVer 2.0.0 forbids: + + | SemVer 2.0.0 rule | Strings this key accepts anyway | + |---|---| + | §2 — numeric identifiers MUST NOT include leading zeroes | `01.1.1`, `1.01.1`, `1.1.01` | + | §9 — prerelease identifiers MUST NOT be empty or carry leading zeroes | `1.0.0-0123`, `1.0.0-alpha..1`, `1.0.0-alpha..`, `1.0.0-.` | + | §10 — build-metadata identifiers MUST NOT be empty | `1.0.0+.` | + + **No accepted value moved, in either direction.** The regex is byte-for-byte what it was; the `describe()` string is what changed. The leading-zero half is older than the recent widening — the original `/^\d+\.\d+\.\d+$/` admitted `01.1.1` too, because `\d+` always has — so tightening the key to the official SemVer regex would refuse plugin objects that load today, which the ruling on this key forbids. With the accept set frozen, the only side of the declared/enforced pair still free to move is the claim, and the bare `"Semantic Version"` was the false half: it named a standard this key does not implement. + + The replacement states the shape an author can predict a verdict from — `major.minor.patch` with an optional `-prerelease` and an optional `+build` suffix — and disclaims the standard it exceeds rather than merely dropping the word. This follows `ManifestSchema.version`, which already spells `(major.minor.patch)` explicitly rather than leaning on "SemVer". + + **What consumers see.** The `description` on `version` in the shipped `json-schema/` tree and on the generated `kernel/plugin` reference page. No `pattern`, no `type`, no accepted or rejected value changes, so a tool that validates against this schema behaves identically. + + All eight forms are now pinned as **accepted** — in `packages/spec` (`plugin.test.ts`) and in `packages/core` (`plugin-loader.test.ts`, `plugin-contract-enforcement.test.ts`) — so the honesty is enforced rather than narrated, and a future edit that "corrects" the grammar to be standards-compliant fails those pins on purpose. + + `@objectstack/core` is deliberately **not** listed above. Its `PluginLoader` predicate was renamed `isValidSemanticVersion` to `isSemverShapedVersion` in the same change, for the same reason, but the symbol is `private` and package-internal: measured against the built `dist/index.d.ts`, `import { isValidSemanticVersion } from '@objectstack/core'` is TS2305 (no exported member) and `loader.isValidSemanticVersion` is TS2341 (private), while a public member on the same class compiles. Nothing published moves. +- 143c715: fix(spec): the `protection` block's unknown-key refusal now names the surface, lists the declared keys and suggests the rename (#16845) + + `ProtectionSchema` (`shared/protection.zod.ts`) was a bare `z.object({ … }).strict()` with **no error map**, so an unknown key inside a `protection:` block was refused with zod's own default text and nothing else: + + ``` + AgentSchema.safeParse({ name: 'a', protection: { lockk: 'system' } }) + ✗ protection: Unrecognized key: "lockk" + ``` + + `lockk` is one keystroke from the declared `lock`, and the author — human or AI, whose whole correction loop is the error text — was told the key was wrong and given no surface name, no declared-key list and no rename. The block is mounted on very nearly every authorable metadata type in the platform (objects, views, dashboards, datasets, reports, apps, flows, webhooks, permissions, positions, email templates, agents, tools, skills), so that was the message everywhere a protection key was misspelled. + + It is now built with the `strictObject` helper — the same conversion #16328 made for the manifest `permissions` block — and answers: + + ``` + ✗ protection: Unrecognized key(s) on the `protection` block of this metadata item: `lockk`. + Did you mean `lockk` → `lock`? … The declared keys are `lock`, `reason` and `docsUrl`. + ``` + + Curated alongside it: prose-slot aliases (`description` / `message` / `explanation` / `lockReason` → `reason`), documentation-link aliases (`docs` / `link` / `url` / `href` / `helpUrl` / `documentationUrl` → `docsUrl`), a wrong-layer prescription for the field-level `readonly` / `readOnly` booleans (which map to a `lock` *policy*, not a boolean), and one prescription for the whole private `_lock*` envelope family. Two of those aliases correct a measurably **wrong** answer: the edit-distance fallback used to point `docs` and `link` — each two edits from `lock` — at the lock policy rather than at `docsUrl`. + + **Not a breaking change: the accept set does not move.** `strictObject(options, shape)` is `z.object(shape, { error }).strict()`, and a zod error map is consulted only for an issue already being raised, so it can neither admit a value that was rejected nor reject one that was accepted. Measured rather than argued — the same parse probe across the declared key set, every accepted input, and every rejection's issue `code` reads byte-identical before and after. +- 396eae3: docs(spec): state the retired `allowRestore` / `allowPurge` parse-time accept set exactly (#17425) + + Documentation only — no schema, no key, no exported symbol and no accepted value moves. What changes is what the tombstone's own prose claims about itself, in the three places a consumer reads it: the `permission.zod.ts` docblocks (published in the tarball, both as `dist/*.d.ts` and as the `src/**/*.zod.ts` sources this package ships), and the two hand-written permission docs pages. + + The prose said the retired bits are refused, and separately that "every other value" lands on the tombstone. Read together those two sentences describe a truthy/falsy split, and that is not what the schema does. Measured on this tree, `ObjectPermissionSchema` tolerates exactly ONE value: the boolean literal `false` the published 17.x toolchain materialized into every permission entry of every artifact it built, accepted as inert residue and silently stripped under the retired-defaulted-key class rule. Every other value of any type — including the string `"false"`, the number `0` and `null` — is refused exactly like `true`, with `code: 'invalid_type'`, `expected: 'never'` and the same guidance string, at the key's own path. + + The consequence consumers were missing is now stated with it: a successfully parsed permission entry can carry neither key on any input that came from JSON, so a post-parse guard against either bit is dead code — presence, truthiness and `=== true` alike can never be true on validated data. A `false`-versus-other distinction is observable only to pre-parse tooling reading raw sources, where the retired default is inert legacy residue and any other value is a hard ADR-0049 violation. + + One measured exception is documented and pinned, because it is the only post-parse observation that survives: an in-memory TypeScript input carrying an explicit `undefined` for either key parses and keeps the key as an own property whose value is `undefined`, so a presence check can be true there. JSON cannot spell it, and a serialize round-trip drops it again. + + +- de1a611: `AppPlugin` now supplies `SeedLoaderConfig.locale`, so the `Seed.locale` axis takes effect on the default boot path. + + The locale filter axis landed complete on the consumer side: the loader reads `Seed.locale`, composes it with `env` by conjunction, and names every dataset it drops. What it never had was a **producer** — no first-party call site passed `config.locale`, so `filterByLocale` returned its input on its first line and `dataset.locale` was never read at all. Authoring the key changed nothing. That is the same shape `Seed.env` spent releases in before framework#4704. + + - **The locale is resolved from the app's own `i18n.defaultLocale`** — the same envelope key, read the same way `loadTranslations` already reads it for `setDefaultLocale` — and threaded into all three `SeedLoaderRequest`s `AppPlugin` builds: the inline boot seed, the per-org replayer registered for tenant provisioning, and the dev hot-reload seeder. + - **An app that declares no locale sends no `locale` key at all**, rather than an `'en'` default. Absence is the loader's unrestricted spelling, so a stack that never opted in keeps loading every dataset exactly as before; defaulting would have turned a wiring change into a data change, silently dropping a `locale: ['zh-CN']` dataset on every stack without an `i18n` block. A blank or non-string `defaultLocale` is treated as absence for the same reason. + - **Resolved at the call sites, not inside `load()`.** The sibling `env` axis resolves itself in the loader off an ambient `NODE_ENV`; a locale has no ambient source, and the only layer that knows which locale a stack runs in is the app config the loader is never handed. So this axis needs a real producer, which is what this change is. + + `SeedLoaderService#warnOnUnresolvedLocaleScope` **stays**. It is not a signpost for an unwired state that has now gone away: three of this repo's six seed-request builders are publish/install-time paths that are handed no stack config and still pass no locale, embedding hosts build their own requests, and a stack may declare no `i18n` block at all. Every one of those still reaches `load()` with locale-scoped datasets and no `config.locale`, and the warning is what keeps that loud instead of silently inert. + + The liveness ledger row `seed.locale` moves `experimental` → `live` with a `producer` pointer naming this wiring, and records which call sites supply the locale and which do not rather than claiming the frontier away. + + ⚠️ **Release-note reconciliation, for whoever compiles this release.** The sibling changeset `seed-locale-axis.md` (from the PR that landed the consumer half) states in the present tense that no first-party call site supplies `config.locale`, that the axis is inert on the default boot path, and that the liveness ledger records `seed.locale` as `experimental`. All three sentences describe the state that changeset shipped into, and **this change ends all three**. If both land in one release, the notes must read them in order — or fold them into one entry — rather than publishing the earlier state as current. ⛔ That sibling changeset is deliberately not edited here: it accurately records what its own PR did, and release notes are compiled centrally. + + ⛔ Out of scope, unchanged: rows already written under a different locale stay resident. Every seed is an `upsert` and the loader only writes, so switching a stack's locale on a non-empty database does not remove the other market's rows. +- 4fba503: `Seed.locale` now states its own bound: the publish and install paths do not filter by locale — they load every dataset and warn. Wording only; ⛔ no behaviour changed. + + The loader evaluates this axis against `SeedLoaderConfig.locale`, which only the boot path supplies (`AppPlugin` reads the app's declared `i18n.defaultLocale`, #16595). Three publish/install-time request builders — package apply, draft publish and marketplace install — are handed no stack config and pass no locale, so a `locale`-scoped dataset reaching one of them is loaded for **every** locale and `warnOnUnresolvedLocaleScope` names each one it let through. That was already the published contract: `content/docs/data-modeling/seed-data.mdx` declared it verbatim for the embedding-host case. What it was not was discoverable from the key itself — an author reading `SeedSchema.locale` had no way to learn where the axis stops, and the ledger row's note still ended with a to-do. + + - **The `.describe()` and TSDoc carry the bound.** Both ship to consumers — `src/**/*.zod.ts` and `dist` are in this package's `files[]`, measured with `npm pack --dry-run` — so the sentence reaches an author's editor rather than only a docs page they may never open. + - **The liveness ledger row records a DECISION, not a to-do.** `packages/spec/liveness/seed.json`'s `locale` note ended 「Filed as its own card」. That card was ruled 2026-09-10 (option A 「不扩散」 — publish and install are locale-neutral acts, 「无违约、非缺陷」, because the docs page had already declared this bound and the runtime warns by name). The note now cites the ruling and states what would reopen it. + - **⛔ Deliberately not done.** Making the three call sites pass a locale, and turning the warning into a refusal, were both explicitly not ruled. The first would give one concept two sources of truth — the app's declared locale versus the platform default — and would silently stop loading a dataset that loads today; the second would turn a succeeding published path into a failing one. Neither buys safety while measured usage is zero. + + ⚠️ **The reading this rests on, and its reach.** Real first-party seed data using `locale`, measured repo-wide 2026-09-22 against `1f53b0b685`: **zero**. A structural scan of all 49 `defineSeed` call sites found 24 in real app data under `examples/` and none declaring the key; the scan is self-lit, because the same pass does find the two `locale`-declaring call sites on the docs page. That reach is **this repo only** — cloud, hotcrm and external customer apps stay unmeasured. A measured use of `locale`-scoped seed data in any of them reopens the decision. +- db76982: `ai/solution-blueprint.zod.ts` publishes its own sentence again, instead of a list of the symbols it happens to export. + + The file always carried a real module header — ADR-0033 §4 plan-first authoring, and how the `apply_blueprint` tool expands each entry into a proper metadata body. But only a blank line separated that header from `const SNAKE_CASE`, and TSDoc's own attachment rule says a block belongs to the declaration it immediately precedes. The header-zone selector reads that rule back, so the header counted as the regex constant's documentation and was disqualified as the module's. Both generators then fell through to their export-list fallback, and the row published into the `objectstack-ai` skill index read: + + ``` + - `…/ai/solution-blueprint.zod.ts` — Exports: BlueprintConditionSchema, BlueprintSummaryOperationsSchema, … + ``` + + A true statement about the file that says nothing about its subject — on the one row whose job is to send an agent to this source for exact field shapes. + + `SNAKE_CASE` now carries the one-line doc it always deserved. A comment is not a declaration, so the preamble ends there and the header becomes the module's own block. The published row and the public reference page both open on it: + + ``` + - `…/ai/solution-blueprint.zod.ts` — Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring) + ``` + + The selector is untouched. Under its own rule it was deciding correctly, and a census of every source under `packages/spec/src` found this file to be the only one of its kind: 19 shipped `*.zod.ts` sources have a header-zone block sitting against a declaration, and in the other 18 that block genuinely documents the symbol it sits against (`Transport Protocol Enum` against `TransportProtocol`, `Shared history for this file` against `AGENT_HISTORY`). Only here did a module header sit against a constant it says nothing about. + + Neither generator can see this class — each compares its artifact against itself, and each reproduced the selector faithfully, so a generator-only check passes on the defect. A pin now asserts the content of the published row directly. +- 5cf58eb: Provenance comments in `api/` were re-anchored + + Comment and docblock lines under `src/api` (all but `rest-server.zod.ts`) that + cited tracker numbers which no longer resolve on GitHub now cite the commit in + this repository's history that decided the matter, and say in their own words + what was decided. Comments only: no type, schema, export or runtime behaviour + changes. +- 66e266c: `app.zod.ts` docblocks: the navigation target-exclusivity guard now cites the commit that decided it, and says what that commit decided + + Two docblock sentences in `src/ui/app.zod.ts` (which ships as source through the + package's `src/**/*.zod.ts` entry) cited a tracker number that no longer resolves + on GitHub. They now carry the lesson in words and anchor to commit `4cfc93b802` + in this repository's history: the `filters` docblock deliberately states no + precedence order, because objectui's hand-written mirror copied one from this + docblock and ended up accepting a combination the schema refuses; and + `objectNavTargetExclusivity` is exported so a mirror chains the schema's own rule. + + Docblock text only. No schema, guard, accept set, export or `.describe()` string + changes. +- 03b19d9: Provenance comments in `data/` were re-anchored + + Comment and docblock lines under `src/data` (all but the files other open work + holds) that cited tracker numbers which no longer resolve on GitHub now cite + the commit in this repository's history that decided the matter, and say in + their own words what was decided. Comments only: no type, schema, export or + runtime behaviour changes. +- 6154165: Provenance comments in the rest of `data/` were re-anchored + + Comment and docblock lines in `src/data/object.zod.ts`, + `src/data/filter-logic-conformance.ts` and `src/data/object.form.ts` that cited + tracker numbers which no longer resolve on GitHub now cite the commit in this + repository's history that decided the matter, and say in their own words what + was decided. Comments only: no type, schema, export or runtime behaviour + changes. +- 199002b: A field-option docblock names both sibling issues by repository + + The docblock on `SelectOptionSchema`'s `visibleWhen` in `src/data/field.zod.ts` cited a pair + of objectui issues as `objectui#6110 + #6111`, which reads the second number as + this repository's. It now qualifies each number on its own, so both point at the + objectui records the sentence describes. Comment only: no type, schema, export + or runtime behaviour changes. +- ab450f4: docs(spec): `functional-completeness`'s three `objectql/engine.ts` citations name symbols instead of line numbers (#16960) + + The module doc block of `kernel/functional-completeness.ts` cited the runtime that + justifies each rule by line number. All three had rotted: re-measured on `origin/main` + `7ddf13dca` (`engine.ts` is 15,309 lines), the quoted texts live at 8630, 8978 and 921 + against cited 3001, 3191 and 346 — drifts of 5,629, 5,787 and 575. Each quoted text + occurs exactly once in `engine.ts`, so those are readings rather than artefacts. + + The citations are the only limb tying a rule's justification to the runtime that + implements it, and that limb is walked by a human reading it — nothing in the module can + notice the runtime moved. `:3191` was the dangerous one: the line it names today is + ordinary-looking `dispatch:` code, so a reader following it lands somewhere plausible and + never learns they were sent to the wrong place. + + Each now names the enclosing symbol in the repo-root `path#symbol` form + `packages/spec/liveness/field.json` already uses — + `packages/objectql/src/engine.ts#buildSummaryIndex`, `#planFormulaProjection`, + `#expandRelatedRecords` — beside the verbatim snippet. A corrected line number would rot + again on the next refactor; a symbol plus a unique snippet is greppable and survives + movement. The anchor form also moves these three from + `check-spec-docblock-symbol-anchors`' not-judged bucket into resolution (that gate now + reports `3 symbol (3 declaration)` where it reported `0`), so a rename reddens CI. + + Doc text only — no schema, export, type or runtime behaviour changes. It ships because + this block is emitted into the published `dist/kernel/index.d.ts`. +- 21ab410: Provenance comments in `kernel/` and `contracts/` were re-anchored + + Comment and docblock lines under `src/kernel` and `src/contracts` cited tracker + numbers that no longer resolve on GitHub. Each one now cites the commit in this + repository's history that decided the matter, or the ADR that records it, and + says in its own words what was decided. Where nothing could be anchored, the + sentence keeps its reason and the number is gone. Comments only: no type, schema, + export or runtime behaviour changes. +- 025588a: Correct `FieldReferenceSchema`'s first TSDoc `@example`: a `{ $field }` comparand names a column of the SAME row, never a relation path. + + The example spelled its comparand as `{ "$eq": { "$field": "order.owner_id" } }` and captioned it as a join ON clause, while the same docblock's "Execution support" prose states that a dotted path is refused by SQL push-down with `INVALID_FILTER` (HTTP 400). Copied as written it does not fail at the schema door — both spellings parse — so it fails later and quietly: the in-memory evaluator answers `false` for a flat row, and SQL push-down refuses. The ON clause it advertised no longer exists either; `query.joins` was removed and related records are read through `expand`. The example is now the same-table cross-field comparison both execution paths compile, and the docblock header no longer advertises a join surface. `@objectstack/spec` publishes `src/**/*.zod.ts`, so this docblock ships to authors and to IDE hover. +- a49e8ae: Say it out loud when a `.refine()` never reaches the published JSON Schema. + + `z.toJSONSchema()` has no arm for a `custom` check, so every rule written as a + `.refine()` / `.superRefine()` is enforced by the runtime and absent from the + `json-schema/` tree that ships inside this package — a published file that is + WIDER than the Zod type it was generated from, in the direction where an + author's (or an AI's) validator says yes and the platform then says no. Measured + on zod 4.4.3: 688 refinement sites across 240 published schemas, none of which + projected anything. + + Nothing about what the schemas accept changes. Each affected file now carries an + `x-dropped-refinements` annotation naming the paths whose rules it does not + state — `x-` keywords are ignored by every validator, so the accepted document + set is byte-for-byte what it was — and the generator reports the population on + every run and refuses to grow it silently + (`packages/spec/dropped-refinements.baseline.json`). + + Clause-②: no +- f3e3d59: Correct `aliases`' documented contract: it is not "only for what edit distance cannot reach". + + `strictObject`'s `aliases` option was documented as a universal in the three places an adopter reads — the module docblock in `shared/strict-object.ts`, the `StrictObjectOptions.aliases` JSDoc an editor shows on hover, and the same JSDoc on the published `strictUnknownKeyError`'s `StrictUnknownKeyErrorOptions.aliases` — all saying aliases are "semantic near-misses edit distance cannot reach". The word *cannot* denies the option's second job. + + The lookup is `aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, maxDistance, 1)[0]`: an alias is consulted **before** the distance fallback and wins outright. So an entry is equally right when distance *does* reach the key and answers with the wrong one — `hosts` is 2 edits from the declared `hooks` against a budget of `Math.max(2, Math.floor(5 / 3))` = 2, so on the plugin `permissions` block the entry is what keeps an author off lifecycle hooks. + + Neither role is rare, and the correction carries its own count rather than the hedge it replaces. Measured over every surface the `strictObject` registry records, 2026-09-11: **1910** alias entries, **1658** unreachable by distance, **252** reachable — 211 where the fallback would have answered identically, and **41** where it answers a different key the entry overrules. + + The failure mode the old sentence produced is precise and has a live carrier: an adopter with a reachable-but-wrong near-miss read "edit distance cannot reach", concluded `aliases` was not the tool for their case, and left the confident wrong suggestion in place. + + Prose only. No alias is added or removed, no schema, key list, strictness or error message changes, and `visibleWhen → visible` (verified still unreachable) stays as the proving case for the gap half. +- bbca441: `translateFlow` overlays screen nodes inside ADR-0031 regions, at any depth + + `translateFlow` (`system/i18n-resolver.ts`) read the flat `flow.nodes` array and + nothing else. But `FlowNode.config` carries ADR-0031 regions — + `loop.config.body`, `parallel.config.branches[].nodes`, + `try_catch.config.try`/`.catch` — each holding a full `nodes` array that nests + arbitrarily, and a `type: 'screen'` node inside one is a real screen: the + executor pauses on it and the client receives its `ScreenSpec.nodeId`. + + So `flows..screens..{title,fields.*}` was authored for such a + node, parsed (the bundle schema is keyed by node id and knows nothing about + depth) and was then silently never applied. The wizard step rendered its + source-locale heading and field labels while its siblings one level up were + translated. + + The descent now runs through `mapFlowNodeList`, a per-flow region-aware + copy-on-write walk shared with the ADR-0087 conversions' `mapFlowNodes`, which + reads `FLOW_REGION_SLOTS_BY_TYPE` — the single declaration of where a region + lives (`automation/region-slots.ts`). This resolver is therefore not a fifth + hand-rolled reader of that table; the fourth pass written against the flat + one-liner is the last one that had to be. + + Reference identity is unchanged and is pinned: a node that resolves nothing + comes back as the same reference, every container `config` and region `nodes` + array on the way down is copied only when a descendant actually changed, and a + flow the bundle does not carry is returned as the same object. + + ⛔ No wiring changed. `translateFlow` is still deliberately absent from + `translateMetadataDocument`'s dispatch table and no liveness row moved — that + decision belongs to the downstream runner card, as its docblock records. +- 7cd5874: docs(spec): the `field.valueDomain` liveness note stops claiming the settings door is "unchanged until then" + + The `valueDomain` row of the published `liveness/field.json` ledger ended on a sentence written + while the re-point was still in the future: + + > The settings door (`service-settings/value-domains.ts`) re-points onto the shared predicate in + > its own follow-up card and is unchanged until then. + + Both halves of the 2026-09-02 ruling have since landed — the settings half (#15434) and the engine + half (#15316) — and the engine half rewrote this note wholesale while carrying that sentence + forward verbatim. "Unchanged until then" therefore described a state that no longer existed: the + door it names had already re-pointed, one commit earlier. + + The sentence now says what is true of that door, read off its source rather than off a PR title: + its second copy of all three definitions is deleted, `firstRejectedDomainMember` asks + `isValueDomainMember` — the same call `record-validator.ts` makes — and what remains on that side + is the door's own business (which declarations it agrees to enforce, how a multi-value carrier is + walked, the fragments the env-override log line needs). A re-added local table reddens + `value-domains.shared-predicate.pin.test.ts`. + + Ledger-note text only. The row's `status` is untouched — it tracks the engine write path, and + `liveness/state-counts.md` is derived by `gen:liveness-counts` from the row states, none of which + move here (`check:liveness` reports the counts file current). +- 7887077: fix(spec): stop advertising `app` as an expression-scope root the shipping renderer mounts (#17203) + + Six prose faces of the UI schemas told an author that a CEL predicate could name `app` — that the shipping renderer mounts it alongside `features` and `os.user`. It does not, and it never contractually did. `@objectstack/formula`'s `SCOPE_ROOTS` has never declared `app`, and ADR-0068 has never ruled it; decision batch #67 (2026-09-07) ruled option B — the engine's `SCOPE_ROOTS` is the contract and ObjectUI aligns to it — and ObjectUI shipped that, so `buildExpressionScope` no longer binds `app`. The producer-side option-A card (widen `SCOPE_ROOTS` to match the old prose) was closed `not_planned` in the same ruling. + + The `app` token is deleted from all six. `features`, `os.user`, `data`, `current_user`, `record` and `user` all stay, in place and in their existing order, and the "renderer behaviour, NOT contract-guaranteed" framing is unchanged: + + - `ui/page.zod.ts` — the "Ambient roots" docblock, and the **published `.describe()`** on `PageComponentSchema.visibleWhen`, which republishes verbatim into `content/docs/references/ui/page.mdx` (regenerated here). + - `ui/action.zod.ts` — the param-level `visible` docblock, and the **action-level `visible`** docblock, which stated the same claim unbackticked (`record/user/app/features`) and was invisible to a probe shaped for the backticked token. + - `ui/component.zod.ts` — the `page:tabs` ambient-root name-resolution example, and its "also mounts the ambient …" sentence. + + Why this was worth correcting rather than leaving to rot: this `.describe()` is the surface an authoring tool and a metadata-generating agent read (ADR-0033 lists AI as a primary consumer), and it was the last place anywhere that could still teach either to write `app.tier == 'pro'`. The resulting predicate does not fail uniformly and is silent both ways — a field `visibleWhen` and a nav / area `visible` fail OPEN (the gate stops hiding), a conditional-formatting `condition` and a row-action `visible` / `disabled` fail CLOSED (the rule silently stops matching). + + No accept set moves: `SCOPE_ROOTS` is untouched, every schema parses exactly what it parsed before, and a predicate naming `app` is accepted and rejected precisely where it was. This narrows what the protocol advertises, and nothing else. A pin test now holds all six faces, published and TSDoc alike. +- 29dd1a6: Correct what `retiredFromLoadPath` declares about its own reach. + + The flag's docs said a retired conversion is "never at load" and that "the load + seam never sets this — only `objectstack migrate meta` (and the fixture CI) + replays it". Neither half held. Three data-at-rest call sites pass + `includeRetired: true` on purpose — `applyConversionsToStoredItem` (which pins + it rather than offering it), flow rehydration in the automation engine, and the + artifact-ingestion door `applyArtifactForwardConversions` — and `migrate meta` + does not reach the option at all: `applyMetaMigrations` looks each step's + conversion up by id and calls `apply` directly. + + What the flag actually governs is the **authoring** surface: it keeps the entry + off `normalizeStackInput`, the single funnel for `defineStack`, `validate`, + `lint`, `compile`, `info` and `doctor`, so a live author meets the tombstone + instead of a silent rewrite. That split is what ADR-0087's + `## Addendum (2026-07-31)` and the artifact-door ruling both bought. + + Documentation only — no behaviour, no schema key and no export moves. The + corrected text ships in `dist/*.d.ts`, and the split it describes is now pinned + by a test that drives `normalizeStackInput` and `applyConversionsToStoredItem` + over the same bytes, so the sentence and the behaviour cannot drift apart again. + + Authors setting this flag on a **default flip** (old and new shapes both legal, + meaning different things) should read the corrected doc: the flag does not + confine such a rewrite to history — the data-at-rest seams still apply it. + ## 17.4.0 ### Minor Changes diff --git a/packages/spec/package.json b/packages/spec/package.json index d08edbd1135..bd40d73e102 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "17.4.0", + "version": "17.5.0", "description": "ObjectStack Protocol & Specification - TypeScript Interfaces, JSON Schemas, and Convention Configurations", "license": "Apache-2.0", "main": "dist/index.js", diff --git a/packages/triggers/trigger-api/CHANGELOG.md b/packages/triggers/trigger-api/CHANGELOG.md index 585a095c550..61bfe96c2f9 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,508 @@ # @objectstack/trigger-api +## 17.5.0 + +### Minor Changes + +- 487a784: fix(trigger-api,service-automation): an `api` flow with no per-flow secret is refused, at arm time and at registration (#20529) + + Clause-②: no (narrowing) + + **BREAKING** — shipped as `minor` under the launch-window convention + (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by + this banner and the ADR-0087 disposition below, never by the level). + + ADR-0041's `trigger-api` acceptance criteria name a per-flow secret and HMAC + signature verification. The trigger used to arm a flow's inbound hook without a + secret, with only a warning, and that hook skipped signature verification. An + `api` flow whose start node carries no non-blank `config.secret` is now refused + in two places: + + - **At registration** (`@objectstack/service-automation`). `registerFlow` refuses + a flow whose binding resolves to the `api` trigger (`type: 'api'`, or a start + node with `triggerType: 'api'`) when the start node declares no non-blank + `config.secret`, whatever the flow's `status`. The error names the flow and + `config.secret`. The `/automation` create, update and clone doors answer it as + `400 VALIDATION_FAILED`, like every other registration refusal. At boot the + flow is skipped and the existing `[Automation] failed to register flow` warning + names it. + - **At arm time** (`@objectstack/trigger-api`). `ApiTrigger.start()` throws, + naming the flow and `config.secret`, before it stores a hook or subscribes a + queue consumer. The engine logs `Failed to bind flow` and the flow stays + unbound. This covers a host that binds the trigger without the engine. The + arm-time `armed WITHOUT a secret` warning is gone, since that state no longer + exists. Every armed hook verifies the signature on every post. + + **Fix.** Give the flow's start node a non-blank `config.secret` and sign each + post with it, as the `x-objectstack-signature` header already documents. A flow + that is only ever started explicitly (`engine.execute()`, or the `/automation` + trigger route) and is not meant to receive inbound posts is an `autolaunched` + flow. Declare it `type: 'autolaunched'`, with no `triggerType: 'api'` on its + start node, and it needs no secret. + + Unchanged: a flow that already carries a secret registers, arms and verifies + exactly as before. + + + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index d1c2d8ccd1e..b27d8854835 100644 --- a/packages/triggers/trigger-api/package.json +++ b/packages/triggers/trigger-api/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-api", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Inbound HTTP/webhook flow trigger for ObjectStack — per-flow HMAC-verified endpoints with queue-backed ingestion (ADR-0041)", "main": "dist/index.js", diff --git a/packages/triggers/trigger-record-change/CHANGELOG.md b/packages/triggers/trigger-record-change/CHANGELOG.md index 683fb3781c8..de57d5aba8b 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,465 @@ # @objectstack/plugin-trigger-record-change +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index ccfed35ef27..041bc19ad77 100644 --- a/packages/triggers/trigger-record-change/package.json +++ b/packages/triggers/trigger-record-change/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-record-change", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Record-change flow trigger for ObjectStack — auto-launches flows on object insert/update/delete via ObjectQL lifecycle hooks (ADR-0018)", "main": "dist/index.js", diff --git a/packages/triggers/trigger-schedule/CHANGELOG.md b/packages/triggers/trigger-schedule/CHANGELOG.md index 3efcff004cb..8849b4f3472 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,791 @@ # @objectstack/plugin-trigger-schedule +## 17.5.0 + +### Minor Changes + +- 863a775: A kernel can now carry its own scheduled-work policy. `AutomationEngineOptions`, `AutomationServicePluginOptions` (forwarded to the engine), `ScheduleTriggerPlugin` and `TimeRelativeTriggerPlugin` accept an optional `scheduledWorkPolicy`: a `ScheduledWorkPolicy` value, or a resolver called at each bind. When it is present, the engine's bind gate and each trigger's own gate read it instead of the deployment resolver. When it is absent, they call the zero-argument `resolveScheduledWorkPolicy()` exactly as before. + + It exists for a host that runs several kernels of different plans in one process. `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` is one reading for the whole process, so such a host could not turn scheduled work off for one kernel and leave it on for the kernel beside it: + + ```ts + const policy = { enabled: false, posture: 'single', requiresActingOrganization: false, runOwnership: 'unscoped' } as const; + kernel.use(new AutomationServicePlugin({ scheduledWorkPolicy: policy })); + kernel.use(new ScheduleTriggerPlugin({ scheduledWorkPolicy: policy })); + kernel.use(new TimeRelativeTriggerPlugin({ scheduledWorkPolicy: policy })); + ``` + + Give all three the same policy. The engine gates first, and each trigger keeps its own gate for hosts that drive it without the engine. If a trigger reads a different answer from its engine, the engine's audit reports that trigger's refusal as a binding failure. A hand-built value must keep the resolver's invariant, `requiresActingOrganization === (enabled && runOwnership === 'declared')`. + + A time-triggered flow that the per-kernel policy leaves unarmed is reported the same way as one the deployment leaves unarmed. `getTriggerBindingAudit()` and the `getFlowRuntimeStates()` row both give `SCHEDULED_WORK_DISABLED_REASON`, never a binding failure and never "add `requires: ['triggers']`". `ScheduleTrigger` and `TimeRelativeTrigger` also accept the same option in a new trailing constructor argument. The types `ScheduleTriggerPluginOptions`, `TimeRelativeTriggerPluginOptions`, `ScheduledWorkTriggerOptions` and `ScheduledWorkPolicySource` are exported from `@objectstack/trigger-schedule`. + + Nothing changes for a host that passes no policy. The deployment default keeps its meaning and its spelling, and `objectstack serve` is unchanged. This change only adds options, so there is nothing to migrate. + + Clause-②: no +- 0a56d3b: feat(spec,types,triggers)!: `group` runs package-authored scheduled work without a declaration, owning each run's writes per record (#18378) + + + + `Clause-②: yes (widening)` + + **ADR-0087 disposition — `not-required (already-registered)`, not `registered`.** + The ledger entry this change belongs to already exists + (`schedule-flow-acting-organization-required`, entry 18) and predates this diff + at the merge base, so `registered` would assert a registration this PR did not + make. The entry's `surface`, `replacement`, `reason` and `acceptanceCriteria` + each gained their `group` row here, the rejected bootstrap-organization arm + included — recorded because it is the one a later reader will re-propose. + + **Marked breaking (`!`) for the behaviour change, not for a narrowing.** Nothing + that worked stops working and nothing that was admitted becomes refused — the + accept set WIDENS in one cell. What earns the banner is the other direction: on a + `group` deployment with the switch already on, flows that were refused at bind + now arm and run, so clock-driven work appears where an operator had none. That is + worth reading before upgrading even though no consumer has to change anything. + + ## What changes + + With `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` on and tenancy posture `group`, a + time-triggered flow that declares no `config.organization` now **binds and + runs**, where it was previously refused at bind. The organization its writes + carry follows the record: + + | posture | declaration | a bound run's writes act as | + |---|---|---| + | `single` | not read | nothing — the install's one organization resolves beneath each write | + | `group` | **optional** | declared ⇒ the declaration; undeclared ⇒ **the swept record's own organization** | + | `isolated` | **required** | the declaration; undeclared ⇒ not armed, unchanged | + + A `timeRelative` sweep under `group` reads group-wide — inherent to the posture + (ADR-0105 D1) — and stamps each run it launches with that record's organization: + sweep contracts across four plants and each plant's contract yields a run acting + as that plant, whose notifications reach that plant's inboxes. + + ## Why this is not a fallback that guesses + + It is the order `sys_automation_run` was **already** ruled to use. + `ObjectStoreSuspendedRunStore` resolves a run's organization as + `organizationOf() ?? ctx.tenantId` — subject first, acting + context as the fallback and never the primary. Before this change those two + halves disagreed under `group`: the history row was stamped from the record while + the inbox and delivery rows followed an acting context that could not exist + there, so they were refused while the tick summarised itself as healthy. + + ⚠️ With one stated exception, because the two halves ask different questions: + the history row is STAMPED (`tenancy.organizationField` wins there) while the + run's acting organization is a WALL reading that never consults that key. They + agree on every object where the two coincide — which is every ordinary object, + since a declared stamp column is what makes them differ and one shipped object + declares one (`sys_api_key`, deliberately unwalled). Sweeping that object under + `group` stamps its history row while the run itself acts as nothing: the correct + pair of answers, not a residue of the old disagreement, and recorded rather than + smoothed over. + + ⛔ A record-less run under `group` that declared nothing still resolves + **nothing** and is refused at its first tenant-scoped write (`walled-posture`, + ADR-0112), loudly and by name. The rejected alternative was a fallback to the + bootstrap organization (`slug='default'`): under a wall that organization is + minted admin-keyed by the enterprise organizations runtime and may not exist at + all, and where it does it is whichever organization the platform owner + registered under — plausibly one plant of many, not the group's head office. + + ## Upgrading + + **Most deployments: nothing to do.** The switch this depends on is OFF by default + and ships unreleased alongside this change, so the `group`-is-walled behaviour + being amended has never appeared in a published version — no released consumer + can be relying on it. + + If you run posture `group` **and** turn the switch on, read your boot log: each + time-triggered flow's bind line now names which of the three shapes it bound as + ("as organization '…'", "with per-record acting organization", or "with NO + acting organization"). Two things to check: + + - A flow you expected to act as ONE organization but which binds per-record is + missing its `config.organization`. Add it — declaring still narrows, bounding + the sweep's query as well as its identity. + - A plain `schedule` cron flow that binds "with NO acting organization" has no + record to derive one from. If it writes notifications, inbox messages or any + other per-organization row, declare `organization` on its start node; the bind + line says so, and so does the refusal at the first tick. + + ## Which organization a record belongs to — the WALL question, not the stamp one + + `@objectstack/metadata-core` gains a second face on the record→organization + resolver, and the split is the point: `resolveRecordOrganizationField` / + `createRecordOrganizationResolver` answer **"who is this row ABOUT"** (the STAMP + question, whose `tenancy.organizationField` limb stays pinned to the three + sanctioned platform-row writers), while the new + `resolveRecordWallOrganizationField` / `createRecordWallOrganizationResolver` + answer **"what is this row WALLED by"** — `tenancy.enabled: false` ⇒ nothing, + then a declared `tenancy.tenantField`, then the kernel's `organization_id`. + + The sweep uses the WALL face, because "which organization does this run act as" + is a question about the wall. ⛔ It never reads `tenancy.organizationField`: that + key is declared on exactly one shipped object (`sys_api_key`, deliberately + unwalled, #8287), and reading it here would turn "the audit trail should follow + this row's own organization even though nothing walls it" into an acting + identity. A sweep over such an object resolves **nothing** and takes the + `walled-posture` refusal at its first tenant-scoped write, which is the honest + answer. Limbs 1 to 4 are one implementation shared by both faces, pinned as + such, so the half they agree on cannot drift apart. + + **API:** `ScheduledWorkPolicy` gains `runOwnership: 'unscoped' | 'per-record' | + 'declared'`, and `requiresActingOrganization` narrows from "any walled posture" + to `isolated` only. The two are deliberately separate axes: the boolean decides + whether BIND refuses, `runOwnership` decides what a run that DID bind carries. + Inside `@objectstack/trigger-schedule`, both triggers share one bind-line + vocabulary (`describeScheduleRunOwnership`) so they cannot describe one + deployment differently. ⚠️ That helper is module-level, NOT a package export: it + is not re-exported from the package barrel, whose own note says an export whose + only consumers live inside its own package belongs in a non-barrel module. The + new PUBLIC surface in this change is `ScheduledRunOwnership` and the + `runOwnership` key on `@objectstack/types`, plus + `resolveRecordWallOrganizationField` and + `createRecordWallOrganizationResolver` on `@objectstack/metadata-core` — and + those four are what put `Clause-②` at `yes`. Nothing existing is renamed or + re-typed: both stamp-face exports keep their names, their signatures and their + answers, limb 0 included. +- ecdfc94: fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization behind a tenancy wall, and both its query and its run are confined to it (#16659, narrowed by #17396) + + + + > ⚠️ **Read this banner with #17396's ruling applied — it NARROWS everything below, and the narrowing shipped in the same launch window, so no released version ever saw the wider rule.** Two deployment facts now sit in front of every statement here, and neither is metadata: (1) package-authored scheduled work is gated by `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` and is **OFF by default in every tenancy posture and every kernel** — while it is off NOTHING below happens, because nothing arms; (2) with it on, the declaration requirement below applies under a **walled** posture (`group` / `isolated`) only. Under `single` an armed time-triggered flow declares nothing, carries no organization, and resolves the deployment's one organization beneath it exactly as it did before #16659. ⇒ Wherever this banner says "a time-triggered flow MUST declare", read "under a wall, with scheduled work switched on". The lint finding it announces, `flow-schedule-organization-missing`, is **deleted**: lint can see neither fact. + + **Registered as an ADR-0087 semantic migration** + (`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable + is renamed, retired or re-typed — no `packages/spec` key changes its name, its + type or its optionality, no stored shape moves, and every flow, node and + start-node `config` that parses today parses byte-identically afterwards, + because the start node's `config` is an OPEN record (ADR-0018) and the new + `organization` key is an addition to a slot that already accepted anything. So + `objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a + value only the deployment holds, a `sys_organization.id` minted at runtime, with + no authored artifact and no stored representation a rewrite could act on — and + inventing one is precisely what the ruling forbids. ⚠️ That is the argument + against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a + migration that cannot be expressed declaratively gets a structured TODO + (surface, reason, acceptance criteria) rather than nothing, and what follows IS + a prescription in that sense — declare `config.organization` once per + organization, no fan-out, then act on the three consequences of the split named + below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — + behaviour-only, no shape moved, a deployment judgement no transform can make, + registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before + this narrowing landed, so the enforcement rides the 17.x line by the + launch-window convention while the prescription belongs at the major boundary + where `migrate meta` users look. + + **BREAKING** in the accept-set sense, and in TWO places rather than one — + landing in the launch window as `minor` on all four packages (the lockstep + convention: during the window the bump level is not the carrier, this banner and + the disposition above are). Nothing that was refused becomes admitted. ⚠️ #17396 + changes that last sentence in one direction: under `single` with the switch on, + a flow that this changeset would have left unarmed **binds and runs**. That is a + widening, it lands in the same window, and it is why #17396's own changeset is + also a `minor`. + + 1. **Bind time.** A `schedule` or `time_relative` flow that declares no + `organization` is no longer armed. + 2. **Run time — the DATA PLANE.** A time-triggered run now carries a + `tenantId`, and a `time_relative` sweep now carries one on its own query. + Where a run previously read, updated and deleted across every organization, + it is now confined to the one it declares. + + ⚠️ **Read (2) as a narrowing that can stop something that was working**, because + it is one. Two shapes to plan for, and neither is hypothetical: + + - **A deployment running ONE time-triggered flow to cover ALL organizations must + now declare one flow per organization.** That is the ruling + (「不允许跨组织的定时任务」) and it is the whole point, but it is migration + work: there is no fan-out, and a sweep wanted in N organizations is N + declarations. Nothing detects the shape for you — the flow simply starts + seeing one organization's rows. + + ⚠️ **And the split has three effects the sentence above does not carry.** Each + is deployment work, and none of them is detected for you either: + + 1. **A NULL-organization row fans out N-fold.** The driver's scope is + `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no + tenant column value stays visible to a *scoped* read — this PR's own + negative control fixture selects exactly that row under scope, on purpose. + After the split every `organization_id IS NULL` row in a swept object is + therefore matched **once per flow**: N runs, N notifications, each acting + as a different organization. Before the split it was matched once. ⇒ Either + backfill the tenant column on swept objects or declare the object + platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the + scope rather than multiplying under it. + 2. **The current window's dispatch claims are abandoned.** The dedup key + embeds the FLOW NAME — `schedule::` and + `time-relative:::` — so N differently-named + flows claim under N different keys. A window already delivered under the + old name can deliver again, once, under each new one. ⇒ Cut over at a + window boundary, or accept one duplicate window. + 3. **A run suspended before the upgrade is not retroactively confined.** + Resume rebuilds the run's context from `context_json` + (`suspended-run-store.ts`), and a row written before this change carries no + `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills + it. Not a regression (that is how it already ran), but the banner would + otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight + suspended time-triggered runs, or accept that the tail of them is + unconfined. + - **On a SINGLE-organization install a time-triggered flow WAS delivering** — + the #8844 guard derives the only organization there — and after this change it + is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` + that install loses nothing at run time once the line is added: the scope is + `org = :tenant OR org IS NULL` and its one organization is the only scope there + was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss + has no legal configuration.** That driver refuses *any* call handed a tenant + scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) + — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / + `bulk*` / `aggregate`, one call at a time, regardless of how many + organizations the install holds. So a time-triggered flow that touches + per-organization data on that driver is refused per call if it declares an + organization and unarmed at boot if it does not. The declaration is not what + breaks it — the driver has no row-level tenant isolation to offer either way — + but this change is what moves such a flow from the "no organization context at + all → served" case into the refused one. Multi-organization deployments use + `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are + genuinely platform-global can declare them so (`tenancy: { enabled: false }`, + ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the + refusal on data that really is per-organization. + + A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. + + Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 + + A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. + + - **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. + - **`@objectstack/lint`** ⚠️ **nothing, after #17396.** This changeset originally added `flow-schedule-organization-missing` at `warning`; that id is deleted in the same window and was never published. The reason is the rule family's own criterion — *is this stack enough to know the flow is dead?* — answered honestly: it is not, because the deployment switch and the tenancy posture decide it and neither is in any stack. The near-miss diagnostic it shared with the triggers stays at BIND, where both facts are readable. + - **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. + - **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. + + **What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. + + ⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. + + **Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. + + No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. + + **What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: + + - **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. + - **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. + + **The four flows this repo itself ships** — ⚠️ this paragraph is superseded by #17396 and kept for the record of what was measured. Their answer is now the deployment switch, not an authoring repair: off, they are listed as *disabled by deployment policy*; on under `single`, they run as written; on under a wall, they still need a declaration no package can carry. The original measurement follows. + + **They stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. +- f04be62: feat(types,triggers,service-automation,runtime,cli,spec,lint)!: package-authored scheduled work is a deployment decision — `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, off by default everywhere (#17396) + + + + Maintainer ruling, 2026-09-12, verbatim, untranslated: + + > schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,有没有可能定义为一个环境变量,根据环境变量控制? + + > 如果多租户暂时只接禁用定时任务,完整的考虑一下影响面。 + + > group 默认也关,云端每库一租户全局默认关 + + **A new deployment variable, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, decides whether this deployment runs PACKAGE-AUTHORED scheduled work at all** — time-triggered flows (`type: 'schedule'` with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. It is read at boot beside `resolveTenancyPosture` and is ⛔ **not** a metadata concept and ⛔ **not** a new spec key: whether a clock-driven workload is affordable is a fact about the deployment — its database, its tenants, its budget — that no package author can know, and a metadata key would ask them to. + + **OFF by default, in every posture and in every kernel.** Unset means off; `true` / `1` / `on` / `yes` (case-insensitive) means on. ⛔ Deliberately not the opt-out `!== 'false'` shape `OS_MULTI_ORG_ENABLED` uses, which reads a typo as "on" — here that would arm exactly the workload an operator meant to refuse. + + ⛔ **Platform-internal scheduled work is NOT gated** and runs either way: approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill. The boundary is **authored by a package**, not "runs on the job service" — the platform's own maintenance is part of the runtime a deployment asked for. + + **BREAKING**, in two directions, and both land inside the same launch window as #16659 / PR #17334, so no published version ever saw the rule this narrows. + + 1. **A NARROWING, and it is the one to plan for.** A deployment that upgrades and does nothing runs **no** packaged time-triggered flow and **no** packaged `defineJob`. Anything that was firing from a package stops. ⇒ Set `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` if you depend on it. Nothing detects the shape for you at authoring time, by design — but nothing is silent either: every such flow is listed in `getTriggerBindingAudit()` and the `os dev` / `os start` startup summary with a DISTINCT reason, **disabled by deployment policy**, ⛔ never as "binding failed"; the packaged-job loop says so once per app at `info` with the count; and `os doctor` prints the effective value in both states. + 2. **A WIDENING of what binds.** With the switch on and tenancy posture `single`, a time-triggered flow that declares **no** `config.organization` now binds and runs — under #16659 alone it was refused. That posture holds exactly one organization (a second is refused), so the run carries **no** organization and every tenant-scoped insert beneath it resolves that one the way a single-organization install always did; a `timeRelative` sweep there runs **unscoped**. ⛔ Nothing is invented: the key is OMITTED, never filled from the install, the platform organization, or the swept record's own `organization_id`. + + **Under a walled posture (`group` / `isolated`) the 2026-09-08 ruling on #16659 stands unchanged**: a time-triggered flow declares `config.organization` or it is not armed, there is no fan-out, and no organization is ever chosen for it. `group` is walled here for a measured reason rather than by analogy — `resolveSystemWriteOrganization` refuses an organization-less system insert under any wall and `TenancyService.defaultOrgId()` answers `null` (ADR-0093 D3), so an organization-less group-wide sweep could read the whole group while every row it inserts is refused. Which organization such a sweep's inserts belong to is not yet decided; until it is, `group` behaves as walled. + + **`flow-schedule-organization-missing` is DELETED** from `@objectstack/lint` (the id and its exported constant, `FLOW_SCHEDULE_ORGANIZATION_MISSING`; both are unreleased — they were introduced by the still-unconsumed #16659 changeset in this same window, so no consumer can be holding either). The rule family's criterion is *is this stack enough to know the flow is dead?*, and the honest answer here is no: the deployment switch and the tenancy posture decide it, and neither is in any stack. A finding that is false for the default deployment is noise. ⛔ The near-miss diagnostic did **not** go with it — `describeMissingScheduleOrganization` and its `organizationId` / `tenantId` / … scan still fire at BIND, the one door that can read both facts, and only where the key is actually required. + + **ADR-0087 semantic entry 18 (`schedule-flow-acting-organization-required`) is REWRITTEN, not added.** Its acceptance criteria required every time-triggered flow to declare; that is no longer the rule. It now prescribes the two decisions in order — decide the switch, then declare per organization under a wall — and records that `os lint` reporting nothing is the criterion being met rather than a check that was skipped. The unconsumed `.changeset/schedule-trigger-acting-organization.md` carries a banner saying the same, so a reader of either one cannot get the narrower half alone. + + **Where the switch is read, and where it is not.** Both triggers gate at `start()`, ahead of the descriptor and the declaration, so an operator on a deployment that was never going to run a flow is not sent to fix a descriptor nothing would have read. `AutomationEngine.activateFlowTrigger` reads the same resolver and does not call `start()` at all when it is off — that is what keeps the audit's reason precise, since a refusal arriving as a THROW can only be reported through the catch that says "Failed to bind". Neither read is cached: the resolver reads `process.env` live, so a host that rebinds after the environment changes sees the value current at the bind. The scope is `schedule` and `time_relative` only — `record_change` and `api` are fired by a caller that already exists and already carries an identity, and a kind added to `FlowTriggerKind` later is OUTSIDE the switch until someone decides otherwise, because a new capability that disappears on arrival is the worse default. + +### Patch Changes + +- ae7a35a: A scheduled run no longer skips a screen whose field is named `schedule`, `jobId` or `flowName` (#19900). + + The schedule trigger starts each run with three seeds of its own in `params` — `jobId`, `flowName` and `schedule` — and there is no caller behind the run. It stated nothing about that, so a `screen` node's headless verdict inferred who supplied each field from `params`, read those three seeds as the caller's answers, and continued past a screen whose field shared one of the names: the run completed with the trigger's value (for `schedule`, the cron descriptor) as the answer. + + The trigger now sets `AutomationContext.callerParamKeys: []` — "the caller supplied nothing" — which the verdict reads instead of inferring. A screen in a scheduled flow pauses, whatever its fields are named. The three seeds stay in `params`; flows that read them are unaffected. + + `@objectstack/spec`: the `callerParamKeys` TSDoc now names the schedule trigger as a producer that states the empty list, and no longer lists it among the producers that leave the key absent. No type changes. + + This supersedes one sentence of the `callerParamKeys` entry (#19846): the schedule trigger no longer leaves the key absent. Record-change, time-relative and webhook triggers still do. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [8cbc3c0] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [fe677ae] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [f6ceddc] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index ea06b61e243..94c28659226 100644 --- a/packages/triggers/trigger-schedule/package.json +++ b/packages/triggers/trigger-schedule/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-schedule", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Schedule flow trigger for ObjectStack \u2014 auto-launches flows on a cron/interval/once schedule via the IJobService (ADR-0018)", "main": "dist/index.js", diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md index f17319ea775..af4db02ecdf 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,880 @@ # @objectstack/types +## 17.5.0 + +### Minor Changes + +- 758ac40: refactor(types): one `isNativeErrorName` reader, so three doors cannot disagree about what a crash is (#17681) + + The predicate that decides whether a sandboxed body's `throw` is a business + REFUSAL (4xx, the author's words relayed) or a CRASH (5xx, the words withheld) + had **three byte-identical copies** — measured, one distinct 74-character regex + literal across three packages: + + | copy | package | its stated reason for being a copy | + |:--|:--|:--| + | `isScriptFaultMessage` | `@objectstack/rest` (`error-response.ts`, #7543) | the original | + | `isScriptCrash` | `@objectstack/objectql` (`hook-withheld-readonly-fault.ts`) | this package must not depend on `@objectstack/rest` for a regex | + | `sandboxRefusalMessage` | `@objectstack/runtime` (`sandbox/quickjs-runner.ts`, #17265) | rest declares one export subpath and re-exports nothing from `error-response` | + + ⭐ **Every reason is a statement about reaching `@objectstack/rest`, and none of + them survives moving the rule.** `@objectstack/types` now owns + `isNativeErrorName` — the name list, the `^` anchor, and the deliberate absence + of a bare `Error:`. All three packages already depend on it and it depends on + none of them, so this fold **adds zero dependency edges** and cannot cycle. + + ⚠️ The hazard was never style. One copy learning a new native error name and the + others not means the same throw is a refusal at one door and a crash at the + next — a crash message **leaked** at one boundary and **withheld** at another. + #16013's argument for extracting exactly this class applies verbatim: the + classification is the part nobody may get wrong, so one *tested* helper is worth + more than N correct copies that must each stay correct forever. + + ⛔ **No behaviour changes at any door, per case.** This is a pure refactor and + the three WRAPPERS are deliberately NOT folded, because they are not the same + shape and merging them would move a door's answer: + + - rest asks a trimmed message and answers a boolean; + - objectql asks **two** slots — `err.name` **or** `err.innerMessage.trim()` — + because a code hook and a sandboxed body carry the native name in different + places; + - runtime asks the trimmed inner message and answers the **message**, not a + boolean. + + What the three share is the predicate, so the predicate is what moved. Each call + site keeps its own slot choice and its own trimming, and `isNativeErrorName` + deliberately does **not** trim for its callers — a contract pinned in its test. + + **Shipped rather than `skip-changeset`**, measured on a real build: all four + packages publish `files[]: ["dist", …]`, and the built `dist` of each carries + the new call — `@objectstack/types` 4 files, `@objectstack/objectql` 4, + `@objectstack/rest` 3, `@objectstack/runtime` 2 — with `looksLikeInternalErrorLeak` + scoring 4 in `types/dist` as the lit control and a nonexistent symbol scoring 0. + The retired copies are gone from the artifacts too: the regex literal scores + **0** in `rest/dist`, `objectql/dist` and `runtime/dist`, and **2** in + `types/dist` (the ESM and CJS bundles). + + `@objectstack/types` takes **minor**: a new export is a purely additive widening + of a published surface, which is at least minor whatever the commit type says. + The three consumers take `patch` — their artifacts change, their behaviour does + not. +- c3ebe4a: A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. + + `ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. + + The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. + + **What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. + + **What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. + + **For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. +- 0a56d3b: feat(spec,types,triggers)!: `group` runs package-authored scheduled work without a declaration, owning each run's writes per record (#18378) + + + + `Clause-②: yes (widening)` + + **ADR-0087 disposition — `not-required (already-registered)`, not `registered`.** + The ledger entry this change belongs to already exists + (`schedule-flow-acting-organization-required`, entry 18) and predates this diff + at the merge base, so `registered` would assert a registration this PR did not + make. The entry's `surface`, `replacement`, `reason` and `acceptanceCriteria` + each gained their `group` row here, the rejected bootstrap-organization arm + included — recorded because it is the one a later reader will re-propose. + + **Marked breaking (`!`) for the behaviour change, not for a narrowing.** Nothing + that worked stops working and nothing that was admitted becomes refused — the + accept set WIDENS in one cell. What earns the banner is the other direction: on a + `group` deployment with the switch already on, flows that were refused at bind + now arm and run, so clock-driven work appears where an operator had none. That is + worth reading before upgrading even though no consumer has to change anything. + + ## What changes + + With `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` on and tenancy posture `group`, a + time-triggered flow that declares no `config.organization` now **binds and + runs**, where it was previously refused at bind. The organization its writes + carry follows the record: + + | posture | declaration | a bound run's writes act as | + |---|---|---| + | `single` | not read | nothing — the install's one organization resolves beneath each write | + | `group` | **optional** | declared ⇒ the declaration; undeclared ⇒ **the swept record's own organization** | + | `isolated` | **required** | the declaration; undeclared ⇒ not armed, unchanged | + + A `timeRelative` sweep under `group` reads group-wide — inherent to the posture + (ADR-0105 D1) — and stamps each run it launches with that record's organization: + sweep contracts across four plants and each plant's contract yields a run acting + as that plant, whose notifications reach that plant's inboxes. + + ## Why this is not a fallback that guesses + + It is the order `sys_automation_run` was **already** ruled to use. + `ObjectStoreSuspendedRunStore` resolves a run's organization as + `organizationOf() ?? ctx.tenantId` — subject first, acting + context as the fallback and never the primary. Before this change those two + halves disagreed under `group`: the history row was stamped from the record while + the inbox and delivery rows followed an acting context that could not exist + there, so they were refused while the tick summarised itself as healthy. + + ⚠️ With one stated exception, because the two halves ask different questions: + the history row is STAMPED (`tenancy.organizationField` wins there) while the + run's acting organization is a WALL reading that never consults that key. They + agree on every object where the two coincide — which is every ordinary object, + since a declared stamp column is what makes them differ and one shipped object + declares one (`sys_api_key`, deliberately unwalled). Sweeping that object under + `group` stamps its history row while the run itself acts as nothing: the correct + pair of answers, not a residue of the old disagreement, and recorded rather than + smoothed over. + + ⛔ A record-less run under `group` that declared nothing still resolves + **nothing** and is refused at its first tenant-scoped write (`walled-posture`, + ADR-0112), loudly and by name. The rejected alternative was a fallback to the + bootstrap organization (`slug='default'`): under a wall that organization is + minted admin-keyed by the enterprise organizations runtime and may not exist at + all, and where it does it is whichever organization the platform owner + registered under — plausibly one plant of many, not the group's head office. + + ## Upgrading + + **Most deployments: nothing to do.** The switch this depends on is OFF by default + and ships unreleased alongside this change, so the `group`-is-walled behaviour + being amended has never appeared in a published version — no released consumer + can be relying on it. + + If you run posture `group` **and** turn the switch on, read your boot log: each + time-triggered flow's bind line now names which of the three shapes it bound as + ("as organization '…'", "with per-record acting organization", or "with NO + acting organization"). Two things to check: + + - A flow you expected to act as ONE organization but which binds per-record is + missing its `config.organization`. Add it — declaring still narrows, bounding + the sweep's query as well as its identity. + - A plain `schedule` cron flow that binds "with NO acting organization" has no + record to derive one from. If it writes notifications, inbox messages or any + other per-organization row, declare `organization` on its start node; the bind + line says so, and so does the refusal at the first tick. + + ## Which organization a record belongs to — the WALL question, not the stamp one + + `@objectstack/metadata-core` gains a second face on the record→organization + resolver, and the split is the point: `resolveRecordOrganizationField` / + `createRecordOrganizationResolver` answer **"who is this row ABOUT"** (the STAMP + question, whose `tenancy.organizationField` limb stays pinned to the three + sanctioned platform-row writers), while the new + `resolveRecordWallOrganizationField` / `createRecordWallOrganizationResolver` + answer **"what is this row WALLED by"** — `tenancy.enabled: false` ⇒ nothing, + then a declared `tenancy.tenantField`, then the kernel's `organization_id`. + + The sweep uses the WALL face, because "which organization does this run act as" + is a question about the wall. ⛔ It never reads `tenancy.organizationField`: that + key is declared on exactly one shipped object (`sys_api_key`, deliberately + unwalled, #8287), and reading it here would turn "the audit trail should follow + this row's own organization even though nothing walls it" into an acting + identity. A sweep over such an object resolves **nothing** and takes the + `walled-posture` refusal at its first tenant-scoped write, which is the honest + answer. Limbs 1 to 4 are one implementation shared by both faces, pinned as + such, so the half they agree on cannot drift apart. + + **API:** `ScheduledWorkPolicy` gains `runOwnership: 'unscoped' | 'per-record' | + 'declared'`, and `requiresActingOrganization` narrows from "any walled posture" + to `isolated` only. The two are deliberately separate axes: the boolean decides + whether BIND refuses, `runOwnership` decides what a run that DID bind carries. + Inside `@objectstack/trigger-schedule`, both triggers share one bind-line + vocabulary (`describeScheduleRunOwnership`) so they cannot describe one + deployment differently. ⚠️ That helper is module-level, NOT a package export: it + is not re-exported from the package barrel, whose own note says an export whose + only consumers live inside its own package belongs in a non-barrel module. The + new PUBLIC surface in this change is `ScheduledRunOwnership` and the + `runOwnership` key on `@objectstack/types`, plus + `resolveRecordWallOrganizationField` and + `createRecordWallOrganizationResolver` on `@objectstack/metadata-core` — and + those four are what put `Clause-②` at `yes`. Nothing existing is renamed or + re-typed: both stamp-face exports keep their names, their signatures and their + answers, limb 0 included. +- 6e3462d: Host importer: a `link:` / `file:` install is now verified by the LOCATION the app declared, so a correctly linked package loads instead of being refused. + + The ESM fallback finder (`createHostImporter`) verifies the one directory it consults — `/node_modules/` — against what the host's own `package.json` declares. Until now it could only do that by NAME, and a `link:` / `file:` value promises no name, so the KEY stood in for one: a package linked exactly as the app asked, whose own manifest happens to be named something else, was refused with `declared-unresolvable` / `MODULE_NOT_FOUND`. Nothing was broken, and the only way out was to stop using a supported linking mode. + + Such a declaration does name something checkable — a directory — so the finder now checks that too: `realpath(node_modules/)` against `realpath(resolve(hostRoot, ))`, both sides canonicalised, compared exactly (no basename matching, no case folding). If they are the same directory, the host declared it and it loads. + + This is a second verification axis, not a looser first one. A directory the app declared neither by name nor by path is refused exactly as before, and the finder stays strictly tighter than the CommonJS resolution it backs up, which asks neither question. Unchanged: a plain version range licenses no path; an `npm:` alias is still checked by name; `github:` / tarball URLs and the bare `owner/repo` shorthand name no on-disk location, so they gain nothing; a package that publishes a `require` condition never reaches this fallback at all, so no load that succeeds today changes. + + Measured on pnpm 10.33: `link:` symlinks the key at the declared directory and verifies; a `file:` directory install routes through pnpm's virtual store (a copy), so it does not, and keeps today's refusal. The refusal's text now states what the location check compared instead of asserting a limit the finder no longer has. +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth was + `seed-tenancy-backfill`'s organization probe, which kept a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes recorded `'unknown error'` there rather than `''`; + that fallback was load-bearing — the site read an empty value as "the probe did not fail" — + and #17167 removed it in this same release, so all fourteen sites now record the channel as + is and that site carries its failure fact structurally. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. +- f04be62: feat(types,triggers,service-automation,runtime,cli,spec,lint)!: package-authored scheduled work is a deployment decision — `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, off by default everywhere (#17396) + + + + Maintainer ruling, 2026-09-12, verbatim, untranslated: + + > schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,有没有可能定义为一个环境变量,根据环境变量控制? + + > 如果多租户暂时只接禁用定时任务,完整的考虑一下影响面。 + + > group 默认也关,云端每库一租户全局默认关 + + **A new deployment variable, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, decides whether this deployment runs PACKAGE-AUTHORED scheduled work at all** — time-triggered flows (`type: 'schedule'` with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. It is read at boot beside `resolveTenancyPosture` and is ⛔ **not** a metadata concept and ⛔ **not** a new spec key: whether a clock-driven workload is affordable is a fact about the deployment — its database, its tenants, its budget — that no package author can know, and a metadata key would ask them to. + + **OFF by default, in every posture and in every kernel.** Unset means off; `true` / `1` / `on` / `yes` (case-insensitive) means on. ⛔ Deliberately not the opt-out `!== 'false'` shape `OS_MULTI_ORG_ENABLED` uses, which reads a typo as "on" — here that would arm exactly the workload an operator meant to refuse. + + ⛔ **Platform-internal scheduled work is NOT gated** and runs either way: approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill. The boundary is **authored by a package**, not "runs on the job service" — the platform's own maintenance is part of the runtime a deployment asked for. + + **BREAKING**, in two directions, and both land inside the same launch window as #16659 / PR #17334, so no published version ever saw the rule this narrows. + + 1. **A NARROWING, and it is the one to plan for.** A deployment that upgrades and does nothing runs **no** packaged time-triggered flow and **no** packaged `defineJob`. Anything that was firing from a package stops. ⇒ Set `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` if you depend on it. Nothing detects the shape for you at authoring time, by design — but nothing is silent either: every such flow is listed in `getTriggerBindingAudit()` and the `os dev` / `os start` startup summary with a DISTINCT reason, **disabled by deployment policy**, ⛔ never as "binding failed"; the packaged-job loop says so once per app at `info` with the count; and `os doctor` prints the effective value in both states. + 2. **A WIDENING of what binds.** With the switch on and tenancy posture `single`, a time-triggered flow that declares **no** `config.organization` now binds and runs — under #16659 alone it was refused. That posture holds exactly one organization (a second is refused), so the run carries **no** organization and every tenant-scoped insert beneath it resolves that one the way a single-organization install always did; a `timeRelative` sweep there runs **unscoped**. ⛔ Nothing is invented: the key is OMITTED, never filled from the install, the platform organization, or the swept record's own `organization_id`. + + **Under a walled posture (`group` / `isolated`) the 2026-09-08 ruling on #16659 stands unchanged**: a time-triggered flow declares `config.organization` or it is not armed, there is no fan-out, and no organization is ever chosen for it. `group` is walled here for a measured reason rather than by analogy — `resolveSystemWriteOrganization` refuses an organization-less system insert under any wall and `TenancyService.defaultOrgId()` answers `null` (ADR-0093 D3), so an organization-less group-wide sweep could read the whole group while every row it inserts is refused. Which organization such a sweep's inserts belong to is not yet decided; until it is, `group` behaves as walled. + + **`flow-schedule-organization-missing` is DELETED** from `@objectstack/lint` (the id and its exported constant, `FLOW_SCHEDULE_ORGANIZATION_MISSING`; both are unreleased — they were introduced by the still-unconsumed #16659 changeset in this same window, so no consumer can be holding either). The rule family's criterion is *is this stack enough to know the flow is dead?*, and the honest answer here is no: the deployment switch and the tenancy posture decide it, and neither is in any stack. A finding that is false for the default deployment is noise. ⛔ The near-miss diagnostic did **not** go with it — `describeMissingScheduleOrganization` and its `organizationId` / `tenantId` / … scan still fire at BIND, the one door that can read both facts, and only where the key is actually required. + + **ADR-0087 semantic entry 18 (`schedule-flow-acting-organization-required`) is REWRITTEN, not added.** Its acceptance criteria required every time-triggered flow to declare; that is no longer the rule. It now prescribes the two decisions in order — decide the switch, then declare per organization under a wall — and records that `os lint` reporting nothing is the criterion being met rather than a check that was skipped. The unconsumed `.changeset/schedule-trigger-acting-organization.md` carries a banner saying the same, so a reader of either one cannot get the narrower half alone. + + **Where the switch is read, and where it is not.** Both triggers gate at `start()`, ahead of the descriptor and the declaration, so an operator on a deployment that was never going to run a flow is not sent to fix a descriptor nothing would have read. `AutomationEngine.activateFlowTrigger` reads the same resolver and does not call `start()` at all when it is off — that is what keeps the audit's reason precise, since a refusal arriving as a THROW can only be reported through the catch that says "Failed to bind". Neither read is cached: the resolver reads `process.env` live, so a host that rebinds after the environment changes sees the value current at the bind. The scope is `schedule` and `time_relative` only — `record_change` and `api` are fired by a caller that already exists and already carries an identity, and a kind added to `FlowTriggerKind` later is OUTSIDE the switch until someone decides otherwise, because a new capability that disappears on arrival is the worse default. + +### Patch Changes + +- 7f62536: A **declared capability absence** — a 5xx answered because the deployment did not install an optional service — is now reported **once per route per process at `warn`**, naming the missing service, instead of one `error` line per request. Every other 5xx keeps the per-request `error` line #14310 shipped. + + Measured before the change, on a stock showcase boot: `GET /api/v1/ai/*` (the cloud-only AI service's declared `501 NOT_IMPLEMENTED`) printed one `error`-level line per request, and Studio opens it unprompted. A deployment that is working exactly as configured was training the channel built to mean "an operator must look" into noise — which is the failure mode `--log-level`-watching operators learn as "skim the errors". + + - **What counts as an absence** is the envelope the door composed: a producer-declared 5xx (`declaresServerFault` — the repo's existing declared-5xx predicate) whose ADR-0112 `code` is `NOT_IMPLEMENTED` or `SERVICE_UNAVAILABLE`. Nothing is invented to recognise one; the code the producer already declared *is* the declaration. + - **The predicate is applied inside the shared funnel** (`logServerFault`, `@objectstack/types`), not at each door, so `sendError`'s nested-envelope exit and the runtime dispatcher read one answer by construction. A door cannot opt in, opt out, or drift. + - **The dedupe key is (route, process).** A restart reports again, and a second, different route reports on its own — deliberately not a global "first N", which is the shape that hides the second route. A door that supplies no route coordinates is demoted to `warn` but never suppressed: an un-keyed bucket is that same hiding shape. + - **A thrown 5xx keeps its `error` line even when it declared `501`.** The thrown exit hands the funnel the throw and no envelope `code`, so it is not recognised as an absence — fail-loud for the half that carries a stack. + + ⛔ **No wire byte moves.** Status, `code`, `message` and body shape are unchanged at both doors; this changes a log level and a count. The response bytes are pinned in `packages/runtime/src/declared-capability-absence-warn-once.test.ts`, and that block runs green on the pre-change tree too, which is what makes it a before/after measurement rather than a claim. + + Operators who were alerting on `[5xx]` at `error` level for an uninstalled optional service will now see one `warn` line per route per process instead. The line says so in its own text: `(declared capability absence — reported once per route per process)`. +- 75237a9: fix(spec)!: `timeDimensions[].dateRange`'s array arm is exactly two string bounds, and each refusal ORIGIN gets a true sentence (#17598; ruling A, decision batch #117 item 3) + + + + **BREAKING** accept-set narrowing at `timeDimensions[].dateRange` — shipped as + `minor` under this repo's launch-window convention for breaking changes + (`scripts/check-changeset-no-major.mjs`), above the `patch` floor the `fix` + commit type sets, and the same grade the one comparable precedent took: the + STRING-arm closing on this same schema is #16041, and it shipped + `"@objectstack/spec": minor` (`packages/spec/CHANGELOG.md` 17.4.0, under Minor + Changes). ⚠️ Its driver half #16322 declares `"@objectstack/spec": patch`, but + that entry is — in that changeset's own words — "a `PROVENANCE_WAIVERS` row + only", not an accept-set narrowing, so it is not a grade this one is measured + against. The maintainer + ruling calls it a "major changeset"; under the launch window that phrase maps to + the protocol MAJOR the migration registers against (18), not to the changeset's + bump level, which `scripts/check-changeset-no-major.mjs` reserves. The semantic + prescription is registered under protocol major 18 as + `analytics-date-range-array-two-bounds-required`. + + ### What changed + + `AnalyticsDateRangeSchema`'s array arm was `z.array(z.string())` with **no length + constraint**, so `['2026-01-01']`, `[]` and `['a', 'b', 'c']` were schema-valid. + It is now `z.tuple([z.string(), z.string()])` — a tuple rather than a length + refinement, so the arity is stated to the author's compiler before any parse runs. + Preset names, two-bound windows and an absent `dateRange` parse byte-identically + to before. + + `analyticsDateRangeRefusalMessage(input)` becomes + `analyticsDateRangeRefusalMessage(input, origin)`, where `origin` is `'schema'` or + `'runtime'` and is **required** — there is deliberately no default. + + ### Migration: FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `dateRange: ['2026-01-20']` | `dateRange: ['2026-01-20', '2026-01-20']` — a single day is that day as both bounds, the shape the shipped #16322 table already prescribes | + | `dateRange: []` | no conversion. An empty array names no window: write the two bounds the widget was meant to show, or omit `dateRange` (it is optional, and absent means the query is not time-bounded) | + | `dateRange: ['a', 'b', 'c']` | no conversion. Decide which two bounds you meant and write them | + | `analyticsDateRangeRefusalMessage(value)` | `analyticsDateRangeRefusalMessage(value, 'schema')` at a parse door, `…(value, 'runtime')` past one | + + `os migrate meta --from 17` emits the first three as a structured TODO rather than + rewriting them: rewriting a one-element array to the same day twice at load would + be the platform deciding, silently, that the author meant one day rather than a + window whose end they forgot, and for the other two shapes there is nothing to + decide from. + + ### Why it is not a new class of breakage + + Since PR #17593 all four analytics faces (`ObjectQLStrategy`, `NativeSQLStrategy`, + the draft-preview evaluator, `DatasetExecutor.runCompare`) already refused anything + that is not exactly two bounds with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`, so + every stored range this narrowing refuses was **already failing at query time**. + The contract door was looser than every reader behind it; this moves the refusal + to authoring time and states it accurately. Blast radius is the WIDGET, not the + page: a stored dashboard carrying a now-refused range loses that widget with the + refusal shown and still loads. + + ### The wording half + + The shared sentence ended `"Refused at the schema"` and described every refused + array as `"received an array with a non-string bound"`. For a one-element window + refused by a face **both clauses were false** — every bound present is a string, + and it was refused past the schema, not at it — which is why + `@objectstack/service-analytics` had to overwrite the message rather than reuse it, + leaving one condition with two wordings. The origin is now a parameter and the + `received …` clause names the arity and the bad bound separately, so the sentence + is true for each origin both before and after the arm narrows. + + The same rule reaches the WIRE. Narrowing the arm to a tuple gave the union a + second voice: its arm answers `Too small: expected array to have >=2 items` for + the very arity the prescription just prescribed, and the ADR-0114 union + expansion emitted both as `fields[]` entries on `POST /analytics/query` and + `POST /analytics/dataset/query`. `fieldsFromZodIssues` (`@objectstack/types`), + the one mapper both doors report through, now drops the branch issues that land + at the union's OWN path for this refusal — recognised structurally through + `isAnalyticsDateRangeRefusalIssue`, never by message prose. A refusal that names + a DEEPER position keeps it: `dateRange: ['2026-01-01', 3]` still reports + `timeDimensions.0.dateRange.1`, because WHICH bound is not a string is a + location the prescription does not carry. Every other union expands exactly as + before. Client-visible effect: one `fields[]` entry for an arity refusal instead + of two, with the prescriptive one kept. +- 99fcb4a: `FlowRuntimeState` now declares `reason` — the optional sentence saying WHY a flow is not armed — and the automation engine populates it, so `GET /automation/_status` can tell a policy-disabled flow apart from a broken binding (#18235). + + Ruling G item 6 on #17396 names three surfaces that must each carry a DISTINCT reason for a flow left unarmed because package-authored scheduled work is switched off, and must never read as "binding failed". Two of them shipped: `getTriggerBindingAudit()` and the CLI startup summary. The third — a console — could not be built: Studio's only status door answers `FlowRuntimeState` rows, and that shape had no field a reason could travel in, so on the wire a policy-disabled flow was `enabled: true, bound: false, triggerType: 'schedule'`, byte-identical to one whose trigger is missing. + + **Clause-②: yes (widening)** — one new key on an already-published payload, so the shape a consumer reads against grows. Nothing previously emitted is removed or renamed, and no producer is required to write it. + + - **Optional, and additive by measurement.** Every producer of these rows — the engine, and the test doubles in `packages/runtime`, `packages/cli` and `packages/qa/dogfood` — writes `{ name, enabled, bound }` at minimum; a required key would have broken all of them and would demand a reason from rows that have none. The key is absent (not `undefined`-valued) on any row that is bound, disabled, or declares no trigger. + - **One vocabulary, not a new one.** The sentence is the one `getTriggerBindingAudit()` already answers for the same flow: both doors now read a single private `describeUnboundReason()` on the engine, so Studio and the boot summary cannot drift. A free-form string, matching the two surfaces that already carry this reason; ⛔ consumers render it, they do not parse it. + - **Read from the RECORD, never re-derived.** The policy sentence comes from the engine's recorded refusal (`policyDisabledFlows`, cleared the moment a flow gets past the gate), never from a live `resolveScheduledWorkPolicy()` read at call time. `_status` is served on demand, arbitrarily long after the bind — re-deriving would report a binding failure for a trigger that was never called, the defect the implementing round of #17396 already caught once. + - **Wire, not rendering.** `SCHEDULED_WORK_DISABLED_REASON`'s docblock is corrected: Studio's door now carries the reason, while displaying it distinctly remains objectui#9217's card. Declared is not delivered, and reaching the wire is not being shown. The published prose carrying the same claim moves with it — `content/docs/automation/flows.mdx`'s callout said the status door "has no field to say why", which this change makes false; both carriers are corrected in one landing, and neither now claims a console *renders* it. +- a484966: The TypeScript examples in these packages' **published** `README.md` now compile against the package they document — 43 of the 44 blocks the `measure-markdown-ts-blocks` census reported as syntactically valid and wrong, in documents that ship inside the npm tarball. + + `README.md` is listed in every one of these packages' `files[]`, so these bytes are the artefact a consumer — or a consumer's AI — reads and copies. What the census counted was not style: the examples named options the packages no longer accept, chained a method that returns a promise, and implemented interfaces they never imported. + + The corrections, by class: + + - **Legacy option vocabulary.** `@objectstack/client-react`'s hooks take `fields` / `orderBy` / `limit` / `where`, not `select` / `sort` / `top` / `filters`, and `PaginatedResult` carries `records`, not `value`. `@objectstack/service-job` takes `timeoutMs`, `@objectstack/service-queue` takes `maxAttempts`, and `IDataEngine.find` takes `where`. + - **Async registration used synchronously.** `ObjectKernel.use()` returns `Promise`, so `kernel.use(a).use(b)` does not chain; the examples now `await` each registration. `ObjectKernelConfig` has no `plugins` member. + - **Interfaces implemented but never imported.** Several plugin examples wrote `implements Plugin` with no import, which bound to the DOM's `Plugin`; they now import `Plugin` / `PluginContext` and declare the required `init`. `PluginContext.getService()` has no default type argument, so the examples that read a service now name its contract. + - **Removed or never-existing API.** `@objectstack/driver-memory`'s default export is a legacy `onEnable` object that `kernel.use()` refuses — the quick start now registers through `DriverPlugin`; its persistence adapters take an options bag under `persistence.adapter`. `defineStack` has no `driver` key. `@objectstack/rest`'s `RestServer` takes the host `IHttpServer` first and `registerRoutes()` takes no arguments; `RouteManager` is constructed on a server. `@objectstack/spec`'s `ObjectSchema.parse()` returns the value — the `{ success, data }` envelope is `safeParse`'s. `useMutation` has no `onMutate` / mutation context. + + No runtime code changed and no gate was added (#18715 ruling F). One block is deliberately left: `@objectstack/knowledge-ragflow`'s README writes `source.options.datasetId`, which is what the shipped adapter reads and what `KnowledgeSourceSchema` does not declare — correcting the document either way would contradict one of the two, so the conflict is reported rather than papered over. +- 288fe9c: `createHostImporter` stops prescribing an install repair for a `link:` / `file:` install that is already correct. The refusal is unchanged; only its wording is. + + A host app declaring `{"foo": "link:../bar"}` links `node_modules/foo` to a directory whose manifest may be named anything. `link:`, `file:` and git or tarball URLs name a LOCATION or a remote artefact, never a package, so the specifier carries no name for the ESM-only fallback finder to expect and the KEY stays the expectation — kept deliberately, because widening it would accept any directory sitting at the key and trade a wrong REMEDY for a wrong LOAD. When the linked manifest names something else the finder therefore refuses, and it was reporting that refusal with the `declared-unresolvable` INSTALL wording: run `pnpm install`, check a production prune did not drop it, check the dist was built. Driven on a real symlinked install, all three are measurably false — the finder had just read the manifest at `node_modules/foo`, so the package is on disk, was not pruned, and its `import` target exists. The operator reinstalls, nothing changes, and they go looking for a build that is not broken. + + That sub-case now states what was actually measured: the directory it consulted, the name the manifest there carries, the name it expected, and why a location specifier leaves it with only the key. It says outright that this is neither an install nor a declaration problem, and closes with the remedy that does work — make the two names agree, by declaring the linked package under its own name or by renaming the linked manifest to the key. Both ends are pinned as loading. + + Unchanged: the refusal itself, its `declared-unresolvable` kind, its `MODULE_NOT_FOUND` code and every consumer branch that reads them; the finder's accept set, which is byte-for-byte what it was — a `link:` install whose manifest matches the key still loads silently, and a plain range or an `npm:` alias whose directory holds a different package still gets the INSTALL wording, because there the install really is the fault. The second verification axis that would make these installs LOAD (comparing `realpath(node_modules/)` against the declared location) is deliberately not built here. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [7a25a3e] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [aaacf1d] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [6d2571f] +- Updated dependencies [2bf6ef1] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [abb01f1] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [b971924] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [5c5b67f] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [ecf90b2] +- Updated dependencies [90ff10a] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [6aa3188] +- Updated dependencies [ae7a35a] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [172b4cf] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [e7f69db] +- Updated dependencies [84156c7] +- Updated dependencies [e0f17a3] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [8a44ce7] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [a91d12a] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [5f9d7d7] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [2d91c9a] +- Updated dependencies [b285508] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [aeb0557] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [de8c973] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [fb38607] +- Updated dependencies [dc07593] +- Updated dependencies [e967cbd] +- Updated dependencies [8255a51] +- Updated dependencies [d1c01ff] +- Updated dependencies [9e1689f] +- Updated dependencies [b057434] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [96451ec] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index 66e360d19ea..f73c29333ba 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Shared interfaces describing the ObjectStack Runtime environment", "main": "dist/index.js", diff --git a/packages/verify/CHANGELOG.md b/packages/verify/CHANGELOG.md index 96655ce8693..a49c9a0b451 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,873 @@ # @objectstack/verify +## 17.5.0 + +### Minor Changes + +- a675ad4: The remaining raw `FieldSchema.reference` readers now **REFUSE** a carrier they cannot read, instead of answering "no target" (#18550). The previous release routed the arbiter (`referenceCarrierOf`) and the lint target readers; these were the measured residue of the same ruling — every reader, not just the arbiter. + + `FieldSchema.reference` is `z.string().optional()`, so `ObjectSchema.safeParse` refuses an object- or array-valued carrier at the contract door. These reads are the other door: the one a value reaches only when it never went through parse — a hand-built fixture, a raw `registerObject`, a stored row rehydrated past its schema. + + **`@objectstack/objectql`** — both of the delete cascade's carrier reads (`planCascadeAtomicity` and `cascadeDeleteRelations`). This is the one with a measurable runtime consequence, and it is why the level is not `patch`: + + ``` + before acct=1 task=1 + delete RESOLVED true <- success reported to the caller + after acct=0 task=1 <- an ORPHANED master_detail row + ``` + + An unreadable carrier made the relation invisible to the cascade, so the parent was deleted, the detail row stayed, and the caller was told the delete succeeded — no `restrict` refusal, no `set_null`, nothing logged. It now refuses before any row is touched. + + **`@objectstack/rest`** — the public-form lookup picker's field-def fallback. The field def is also hoisted out of the metadata fetch's `catch {}`, so an unreadable carrier is no longer reported as `LOOKUP_TARGET_MISSING`: "no target is declared" and "the declared target cannot be read" want different fixes from whoever owns the metadata. + + **`@objectstack/metadata-protocol`** — the seed dependency graph, which also retires an `as string` cast that asserted exactly what its truthiness guard had not checked. + + **`@objectstack/lint`** — the four remaining target readers: `masterDetailCount` (`validate-expressions`), the `displayField` consumer edge (`validate-field-consumers`), the field and action-param targets (`validate-object-references`), and `masterOf` (`validate-sharing-rule-enforceability`). + + **`@objectstack/verify`** — `relationTarget`, which no longer degrades an unreadable carrier to the generic "has no `reference` target" an object with no relationship metadata at all receives. + + `null`, `undefined` and `''` are ABSENCE, not a wrong shape, and still answer `undefined` at every one of these sites — a field is allowed to name no target, and `StrictField` declares `reference` nullable. Each site's absence answer is pinned alongside its refusal. + + Upgrading: nothing conformant changes. A non-string `reference` could not be authored, stored or parsed before this release either; what changes is that one now fails loudly at the read instead of being read as an absent target. If a test asserted the old silence, assert the refusal instead. +- 041d9fd: fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on a published + route — landing in the launch window as `minor` on all four packages (the + lockstep convention: during the window the bump level is not the carrier, this + banner and the disposition above are). Nothing that was already admitted + becomes refused **except** the requests `GET /data/` refuses today for + the same principal, which is the defect. Nothing that was refused becomes + admitted. + + `POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. + + The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. + + **This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. + + - **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. + - **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. + - **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. + - **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. + + The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. +- 611795e: verify: let `bootStack` be told which package `multiTenant: true` resolves, so the + `declared-unresolvable` control can name a subject the workspace can never supply + + `BootOptions` gains an optional `organizationsPackage`. It defaults to + `@objectstack/organizations` and production callers never pass it — the + operator-facing error still names that package literally, because in every + production boot it is the subject. Only the specifier moves. + + Why it exists: a fixture whose whole content is "this host root DECLARED the + package and does not have it" cannot state the second half with a name the + workspace owns. Since ADR-0132 the multi-org runtime is a tracked workspace + package, pnpm's hoisted store carries it, and a `pnpm exec`-launched runner + exports a `NODE_PATH` that reaches that store — so such a fixture resolved the + package out of the ambient workspace the moment it had been built, and its + verdict became a function of an unrelated package's build state rather than of + its own directory. The harness's own host-resolution control now hands in a + `@fixture/*` name and proves the absence instead of assuming it, the repair + already landed for `packages/qa/dogfood` and `packages/types`. +- 6058cb2: **Clause-②: yes** — new exported symbols on a published package (`bootStackOnce`, `isVerifyRefusal`, and ten new members on the `VerifyStack` every `bootStack` caller already holds), so the accept set a consumer writes against widens. Contract-review tier. + + Every `VerifyStack` now carries an **in-process handle** on the stack `bootStack` boots — a way to run a hook, a validation rule, a flow, an action, a seed or a read against the REAL engine and assert on what the engine did, instead of writing through HTTP and inferring from persisted rows, or rebuilding the engine's semantics in a test stand-in. + + New members on `VerifyStack` (the same object `bootStack` returns; `api` / `apiAs` / `signIn` / `signUp` / `stop` are unchanged): + + - `hooks.run(object, 'insert' | 'update' | 'delete', input, { as })` — one write through the engine's own door as the caller `as` (a bearer token from `signIn` / `signUp`). The bound hook chain, field defaults, declared validations and the SecurityPlugin middleware run inside it, in the engine's order, because this is the very call the REST data ingress makes. Returns what the engine returned; a refusal rejects with the engine's own error (`code`, `statusCode`). + - `validate(object, record, { as, mode? })` — the engine's dry-run validation pass (`ObjectQL.validate`), nothing written. + - `flows.run(name, params, { as })` / `flows.resume(run, input, { as })` — the runtime's `/automation` trigger and resume routes driven in-process (no Hono, no socket): the caller's resolved identity is forwarded exactly as the route forwards it, and the engine's `AutomationResult` comes back (plus `flowName`, so the value hands straight to `resume`). A never-dispatched refusal or a failed run rejects with the route's ADR-0112 envelope. + - `actions.run(object, action, { as, recordId?, params? })` — the `/actions/:object/:action` route driven in-process, the one door carrying the whole action contract (ADR-0066 D4 gate, ADR-0104 param contract, subject-record load, trusted body context). Returns the handler's value. + - `seed(object, rows)` / `rows(object, where?, { as? })` — real ObjectQL writes (the platform's own seed-replay context) and reads (system-scoped, or as a caller under that caller's grants and RLS). + - `metadata.object(name)` / `objects()` / `items(type)` / `types()` — the booted `SchemaRegistry`, by its own singular type vocabulary. + - `tenancy()` — the `tenancy` service AuthPlugin registered (`posture`, `requestedPosture`, `isolationActive`, `degraded`). + - `contextFor(token)` — the dispatcher's own request-identity resolution, exposed so a test can drive any kernel service as a real caller. + + Also new: `bootStackOnce(config, opts?)`, a per-process memo of `bootStack` keyed on the `config` and `opts` object identities — the worker-scoped shared boot `packages/qa/dogfood` kept privately, promoted for suites that run many files under `isolate: false`. + + Exported types: `VerifyHandle`, `VerifyRefusal` (with the `isVerifyRefusal` predicate), `AsUser`, `FlowRun`, `FlowRunRef`, `EngineRow`. + + **Zero re-implemented semantics.** Every method is a thin facade over a door the kernel wired at boot; the handle assembles no `ExecutionContext`, orders no hooks, evaluates no permission. The package's own tests pin each method against the real service behind it (the PR's ablation record breaks each service in turn and shows only that method's pin going red), pin `hooks.run` against the REST write on the same row **and** the same refusal, and port one hotcrm exemplar (`opportunity_lifecycle`) onto `hooks.run` as the proof of ergonomics. + + No boot option was added: the tenancy posture a stack runs under is still chosen by `multiTenant` (the `--multi-tenant` option `os verify` already has) and read back through `tenancy()`. `os verify`, `runCrudVerification` and `runRlsProofs` are unchanged. + +### Patch Changes + +- bdb247d: `@objectstack/spec/kernel` exports `SEED_WRITE_EXECUTION_CONTEXT`, the one spelling of the seed-write posture every seeder now reads + + The execution context a seed write must use — `isSystem`, `skipTriggers`, + `seedReplay` — had **no exported form**, so every seeder held a private copy of + it and nothing held the copies equal. There were three on `main`: + `SeedLoaderService.SEED_OPTIONS` (`@objectstack/metadata-protocol`), + `SEED_WRITE_OPTIONS` (`@objectstack/runtime`'s `AppPlugin`, whose own docblock + already recorded that it "mirrors" the first) and `SEED_CONTEXT` + (`@objectstack/verify`'s fixture writer, which spelled it a third time + specifically because the runtime kept its copy module-private). + + **Why a shared constant rather than three accurate copies.** `skipTriggers` is + what suppresses "on create" automation for seed rows, and `isSystem` alone does + **not** suppress dispatch. A seed path that lost that flag once seeded with + automation live while the main path had it suppressed — a self-trigger loop that + wedged first boot (#3760). A constant whose divergence re-opens a boot-wedging + defect is a kernel semantic, not a local detail. + + **What is exported, and what deliberately is not.** The **inner** + `ExecutionContext` value, and nothing wrapped around it: + + ```ts + import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; + + await ql.insert(object, rows, { context: SEED_WRITE_EXECUTION_CONTEXT }); + ``` + + The `{ context: … }` options bag stays at the call site. It is what all three + sites ultimately hand to `insert`, but it is an options envelope rather than the + posture: its type differs per engine method, so freezing one bag onto the + protocol surface would serve `insert` and no other operation, and it is + precisely the convenience bundle this export is not. + + ⛔ **No behaviour change.** The value is byte-identical to all three previous + copies, the three flags keep their existing meanings, and no seed path changes + what it writes or how. The three former copies now read this export, so the two + option bags are `{ context: SEED_WRITE_EXECUTION_CONTEXT }` and the `verify` + context is the export itself. + + **Additive, so `minor` on `@objectstack/spec`**: one new name on the existing + `./kernel` entry point, no existing export removed, renamed or narrowed. The + three consumers take `patch` — their published `dist` changes (an import edge, + and the constant now resolves through `@objectstack/spec/kernel`) while their + own public surfaces do not move. +- 5741ff1: Comment-only correction: the reason `bootStack`'s cross-tenant proofs stand in for `@objectstack/organizations` is now stated as the true one. + + Those doc comments said the enterprise multi-organization runtime was **cloud-private / not installable in this workspace**. ADR-0132 falsified that: the runtime is open core, Apache-2.0, and published on npm. The effect they describe has not changed, so the text now gives the reason that is actually load-bearing — **ADR-0132's entitlement boundary forbids any framework package DECLARING `@objectstack/organizations`** (`packages/plugins/organizations/src/no-framework-dependents.pin.test.ts`, its mechanical half: "Apps declare it; packages do not"), because the commercial repository ships a licence-gated subclass under the same package name. So `packages/verify` cannot depend on the runtime and cannot resolve it, the `'posture-only'` stand-in stays exactly what it was, and the proof that the real plugin walls tenants still lives in cloud's `security-enterprise` multi-organization integration test. + + ⛔ **No behaviour, no dependency and no public surface moves.** `BootOptions.multiTenant` accepts and does the same things it did; the only shipped bytes that change are the doc comments carried into `dist/index.d.ts`. Apps that mount the runtime keep declaring it in their own `package.json`, which is and remains the supported wiring. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [6057357] +- Updated dependencies [a60e04d] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [48f5200] +- Updated dependencies [245f360] +- Updated dependencies [d0f1845] +- Updated dependencies [9dcdb77] +- Updated dependencies [fdeeea0] +- Updated dependencies [6175da8] +- Updated dependencies [0283cb9] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [c8a006f] +- Updated dependencies [3c48234] +- Updated dependencies [7843663] +- Updated dependencies [eac58c3] +- Updated dependencies [08b213e] +- Updated dependencies [7851fa3] +- Updated dependencies [ce57857] +- Updated dependencies [2d81e39] +- Updated dependencies [744a0a3] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [f7a3495] +- Updated dependencies [97f4f8c] +- Updated dependencies [482d34d] +- Updated dependencies [e526556] +- Updated dependencies [7a25a3e] +- Updated dependencies [305e7fc] +- Updated dependencies [216b066] +- Updated dependencies [839d1b0] +- Updated dependencies [b722547] +- Updated dependencies [ee6fbd7] +- Updated dependencies [2fc092b] +- Updated dependencies [2dfe070] +- Updated dependencies [4f1a56b] +- Updated dependencies [f39ea95] +- Updated dependencies [6059b29] +- Updated dependencies [89a652b] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [3d8779d] +- Updated dependencies [0bd7dae] +- Updated dependencies [d34f9b6] +- Updated dependencies [57343f7] +- Updated dependencies [271d6bb] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [8b48903] +- Updated dependencies [2d235bc] +- Updated dependencies [4af758d] +- Updated dependencies [86c5052] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [9dacf61] +- Updated dependencies [146c291] +- Updated dependencies [4db1bf1] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- Updated dependencies [63b6818] +- Updated dependencies [c9246fa] +- Updated dependencies [0e51278] +- Updated dependencies [48203ff] +- Updated dependencies [cea85fd] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [7b1e4a4] +- Updated dependencies [d7c0241] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [8271c81] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [fbc12be] +- Updated dependencies [ec2ede0] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [2c1011b] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [f5a7250] +- Updated dependencies [1a2bb9e] +- Updated dependencies [c54d8d6] +- Updated dependencies [eea7ccc] +- Updated dependencies [eea7ccc] +- Updated dependencies [097d268] +- Updated dependencies [182bbde] +- Updated dependencies [5ce3705] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [bea41f6] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [8a017af] +- Updated dependencies [310760d] +- Updated dependencies [2b6a207] +- Updated dependencies [497655f] +- Updated dependencies [ada7012] +- Updated dependencies [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [6d2571f] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- Updated dependencies [092d460] +- Updated dependencies [09e16a5] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [b3b43b6] +- Updated dependencies [d93400f] +- Updated dependencies [b1d3945] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [c81e7ff] +- Updated dependencies [17005cc] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [b5cbfef] +- Updated dependencies [d438b3a] +- Updated dependencies [a83dbb6] +- Updated dependencies [d3a2331] +- Updated dependencies [51297e9] +- Updated dependencies [2d892dd] +- Updated dependencies [156792e] +- Updated dependencies [5ba2ec3] +- Updated dependencies [4d2008c] +- Updated dependencies [abb01f1] +- Updated dependencies [cf39b83] +- Updated dependencies [e64ae15] +- Updated dependencies [02bdeaa] +- Updated dependencies [5762eaf] +- Updated dependencies [66abef3] +- Updated dependencies [25c9a83] +- Updated dependencies [ee5812a] +- Updated dependencies [68fea8b] +- Updated dependencies [c049e74] +- Updated dependencies [bb9794a] +- Updated dependencies [d402e32] +- Updated dependencies [63a8eb4] +- Updated dependencies [9a910c4] +- Updated dependencies [adabccf] +- Updated dependencies [bce5270] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [55095cc] +- Updated dependencies [ad067ad] +- Updated dependencies [6e4024c] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [922c755] +- Updated dependencies [74832b6] +- Updated dependencies [c17ff70] +- Updated dependencies [df1b275] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [877dc03] +- Updated dependencies [2b80461] +- Updated dependencies [2bdb81f] +- Updated dependencies [cd5fdaa] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [21b7c12] +- Updated dependencies [74327d3] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [5941246] +- Updated dependencies [4efb988] +- Updated dependencies [8015dc8] +- Updated dependencies [4b58dcf] +- Updated dependencies [c23cfb3] +- Updated dependencies [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [731f020] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [170fd83] +- Updated dependencies [1f05ea4] +- Updated dependencies [922923b] +- Updated dependencies [2cac363] +- Updated dependencies [fc91239] +- Updated dependencies [97466dd] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [0318faf] +- Updated dependencies [ef256e6] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [4fef271] +- Updated dependencies [554e928] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [b49728f] +- Updated dependencies [2767af8] +- Updated dependencies [021755a] +- Updated dependencies [b929e0a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [215840f] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [502f179] +- Updated dependencies [f20fe29] +- Updated dependencies [362035c] +- Updated dependencies [7e0bfce] +- Updated dependencies [c120dbd] +- Updated dependencies [32b5831] +- Updated dependencies [d7f7e34] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [e56112c] +- Updated dependencies [aeaaa44] +- Updated dependencies [43460b9] +- Updated dependencies [13d5294] +- Updated dependencies [44a2332] +- Updated dependencies [f34dda6] +- Updated dependencies [488f4f5] +- Updated dependencies [15f9284] +- Updated dependencies [a4ca69a] +- Updated dependencies [1ff3a8f] +- Updated dependencies [61dd96f] +- Updated dependencies [74fb2f7] +- Updated dependencies [4be0868] +- Updated dependencies [b971924] +- Updated dependencies [2b321a4] +- Updated dependencies [6afa59d] +- Updated dependencies [e37ea4d] +- Updated dependencies [8f6d831] +- Updated dependencies [fa29803] +- Updated dependencies [b01bdbc] +- Updated dependencies [adbdbc5] +- Updated dependencies [6cc8dcd] +- Updated dependencies [ba77509] +- Updated dependencies [c02fa12] +- Updated dependencies [408ca2e] +- Updated dependencies [ec292cf] +- Updated dependencies [dc0ab6a] +- Updated dependencies [19e58e2] +- Updated dependencies [7e1b048] +- Updated dependencies [342808c] +- Updated dependencies [b3615f1] +- Updated dependencies [0b4022b] +- Updated dependencies [a60c913] +- Updated dependencies [0862063] +- Updated dependencies [c736eaa] +- Updated dependencies [4d0bd23] +- Updated dependencies [4045781] +- Updated dependencies [ecf56e7] +- Updated dependencies [0e658fb] +- Updated dependencies [9529989] +- Updated dependencies [236cec1] +- Updated dependencies [5c5b67f] +- Updated dependencies [f9977c1] +- Updated dependencies [eec56c3] +- Updated dependencies [3f9e2ea] +- Updated dependencies [77f54bf] +- Updated dependencies [ccccdcc] +- Updated dependencies [2aac821] +- Updated dependencies [48c91e9] +- Updated dependencies [2b52a5b] +- Updated dependencies [0f057b6] +- Updated dependencies [3875ae6] +- Updated dependencies [a754563] +- Updated dependencies [1c16889] +- Updated dependencies [1912237] +- Updated dependencies [fc29c74] +- Updated dependencies [95fb417] +- Updated dependencies [4ec3987] +- Updated dependencies [5b9402d] +- Updated dependencies [2cf9db7] +- Updated dependencies [3fd3a4f] +- Updated dependencies [dc1b986] +- Updated dependencies [655e8c0] +- Updated dependencies [041c8cf] +- Updated dependencies [5dba7f3] +- Updated dependencies [e3277c3] +- Updated dependencies [cc6dfd9] +- Updated dependencies [7536721] +- Updated dependencies [a5afe38] +- Updated dependencies [9df3934] +- Updated dependencies [0b83e01] +- Updated dependencies [ebc6afe] +- Updated dependencies [bc80e16] +- Updated dependencies [6696056] +- Updated dependencies [0e06f3b] +- Updated dependencies [c1dfa52] +- Updated dependencies [afc3b64] +- Updated dependencies [a6a4361] +- Updated dependencies [2548ba5] +- Updated dependencies [9282578] +- Updated dependencies [0e90a8d] +- Updated dependencies [ecf90b2] +- Updated dependencies [863a775] +- Updated dependencies [44ce049] +- Updated dependencies [2bbb462] +- Updated dependencies [90ff10a] +- Updated dependencies [3bd221d] +- Updated dependencies [2bbebf5] +- Updated dependencies [369bcbe] +- Updated dependencies [3bd28e2] +- Updated dependencies [b7b6cdd] +- Updated dependencies [9347c1f] +- Updated dependencies [c164186] +- Updated dependencies [e7344f0] +- Updated dependencies [4d7e740] +- Updated dependencies [de091b5] +- Updated dependencies [8490127] +- Updated dependencies [14add48] +- Updated dependencies [6aa3188] +- Updated dependencies [a34c27c] +- Updated dependencies [ae7a35a] +- Updated dependencies [ae0c90c] +- Updated dependencies [cf55914] +- Updated dependencies [17bd318] +- Updated dependencies [681868c] +- Updated dependencies [a9fb83e] +- Updated dependencies [0b866bf] +- Updated dependencies [c839986] +- Updated dependencies [2274894] +- Updated dependencies [e462186] +- Updated dependencies [009da14] +- Updated dependencies [b5853da] +- Updated dependencies [4ac9319] +- Updated dependencies [560b724] +- Updated dependencies [16c5473] +- Updated dependencies [55cd8d4] +- Updated dependencies [b276d44] +- Updated dependencies [3f86dc5] +- Updated dependencies [aa04ea2] +- Updated dependencies [e8f163f] +- Updated dependencies [172b4cf] +- Updated dependencies [b9e9609] +- Updated dependencies [b81da66] +- Updated dependencies [4463966] +- Updated dependencies [67c98f6] +- Updated dependencies [b98fbc2] +- Updated dependencies [60fdaa9] +- Updated dependencies [ab82001] +- Updated dependencies [7b76fff] +- Updated dependencies [26550c6] +- Updated dependencies [8e9a425] +- Updated dependencies [e7f69db] +- Updated dependencies [b373596] +- Updated dependencies [7465eeb] +- Updated dependencies [246314d] +- Updated dependencies [84156c7] +- Updated dependencies [ed3546f] +- Updated dependencies [7e6ca17] +- Updated dependencies [980bc05] +- Updated dependencies [bf37b99] +- Updated dependencies [e0f17a3] +- Updated dependencies [7766b62] +- Updated dependencies [0bf85ea] +- Updated dependencies [1df29df] +- Updated dependencies [7ddf396] +- Updated dependencies [226e00c] +- Updated dependencies [8a44ce7] +- Updated dependencies [d4c897e] +- Updated dependencies [ca753c0] +- Updated dependencies [8ecbe0f] +- Updated dependencies [6a4aec7] +- Updated dependencies [d624002] +- Updated dependencies [95ab93f] +- Updated dependencies [a8bcce6] +- Updated dependencies [fa00ebf] +- Updated dependencies [7c1039b] +- Updated dependencies [e4471e6] +- Updated dependencies [e8fcf55] +- Updated dependencies [a08e059] +- Updated dependencies [fe677ae] +- Updated dependencies [6780e34] +- Updated dependencies [536f2d5] +- Updated dependencies [fc646cf] +- Updated dependencies [8d1f7ab] +- Updated dependencies [cfc3bcf] +- Updated dependencies [dd1b803] +- Updated dependencies [949e99b] +- Updated dependencies [16c5a33] +- Updated dependencies [16c5a33] +- Updated dependencies [03d6cb0] +- Updated dependencies [9e7824a] +- Updated dependencies [16c5a33] +- Updated dependencies [9401b84] +- Updated dependencies [437bb0d] +- Updated dependencies [49144fc] +- Updated dependencies [e2c4e12] +- Updated dependencies [329ea2e] +- Updated dependencies [08c8484] +- Updated dependencies [93cfc3f] +- Updated dependencies [6ac33a5] +- Updated dependencies [cfe2387] +- Updated dependencies [443b2f4] +- Updated dependencies [7e7fab7] +- Updated dependencies [b09ce67] +- Updated dependencies [585c9af] +- Updated dependencies [2dccb7d] +- Updated dependencies [4df101c] +- Updated dependencies [6a6a17b] +- Updated dependencies [733822c] +- Updated dependencies [e5cf27d] +- Updated dependencies [a91d12a] +- Updated dependencies [2bcd5cf] +- Updated dependencies [bea6d2e] +- Updated dependencies [f415bcf] +- Updated dependencies [615c468] +- Updated dependencies [5f9d7d7] +- Updated dependencies [0d3ec47] +- Updated dependencies [31d281d] +- Updated dependencies [569d4d2] +- Updated dependencies [9e9bb46] +- Updated dependencies [0d7ed5a] +- Updated dependencies [2aa25ef] +- Updated dependencies [0e1afe8] +- Updated dependencies [288611e] +- Updated dependencies [dfd8e39] +- Updated dependencies [cc40033] +- Updated dependencies [89f87f2] +- Updated dependencies [28ad7e4] +- Updated dependencies [e6b7d8c] +- Updated dependencies [a78f731] +- Updated dependencies [3062e50] +- Updated dependencies [40b315b] +- Updated dependencies [f2c7eef] +- Updated dependencies [7e36a3c] +- Updated dependencies [5a6267f] +- Updated dependencies [0bbe400] +- Updated dependencies [a36a691] +- Updated dependencies [862b6ce] +- Updated dependencies [80153f5] +- Updated dependencies [26daf0b] +- Updated dependencies [826f327] +- Updated dependencies [7e5246d] +- Updated dependencies [c74de10] +- Updated dependencies [db74b16] +- Updated dependencies [2b24b8b] +- Updated dependencies [b810ddb] +- Updated dependencies [7dc45eb] +- Updated dependencies [95f729a] +- Updated dependencies [17e4f52] +- Updated dependencies [dcd3bce] +- Updated dependencies [c5d6b2b] +- Updated dependencies [2d91c9a] +- Updated dependencies [2f122b6] +- Updated dependencies [b285508] +- Updated dependencies [5049a3c] +- Updated dependencies [2c31070] +- Updated dependencies [7db1332] +- Updated dependencies [4a1df19] +- Updated dependencies [aeb0557] +- Updated dependencies [70ce802] +- Updated dependencies [1c1b8c8] +- Updated dependencies [05077d4] +- Updated dependencies [ba5927f] +- Updated dependencies [75b2169] +- Updated dependencies [7fa3e3e] +- Updated dependencies [de8c973] +- Updated dependencies [50e273f] +- Updated dependencies [c745e2b] +- Updated dependencies [9801da1] +- Updated dependencies [65352b7] +- Updated dependencies [e956924] +- Updated dependencies [5c7aa46] +- Updated dependencies [7d63088] +- Updated dependencies [87c37ae] +- Updated dependencies [24b7085] +- Updated dependencies [2304b16] +- Updated dependencies [c7ad16f] +- Updated dependencies [48efe91] +- Updated dependencies [8e02859] +- Updated dependencies [fb38607] +- Updated dependencies [2b53993] +- Updated dependencies [dc07593] +- Updated dependencies [397572e] +- Updated dependencies [e967cbd] +- Updated dependencies [9bf5e67] +- Updated dependencies [8255a51] +- Updated dependencies [45f428d] +- Updated dependencies [9449512] +- Updated dependencies [b2b6a06] +- Updated dependencies [8538edf] +- Updated dependencies [d1c01ff] +- Updated dependencies [6427e2c] +- Updated dependencies [9e1689f] +- Updated dependencies [fb194c7] +- Updated dependencies [b057434] +- Updated dependencies [b43a814] +- Updated dependencies [1378ec7] +- Updated dependencies [f6ceddc] +- Updated dependencies [92ea760] +- Updated dependencies [487a784] +- Updated dependencies [4c42fd1] +- Updated dependencies [76ddab7] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [bc2ec80] +- Updated dependencies [40098a4] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [113050e] +- Updated dependencies [5d12b16] +- Updated dependencies [54b3d1d] +- Updated dependencies [634f23d] +- Updated dependencies [f03f6c7] +- Updated dependencies [374d9d3] +- Updated dependencies [ea4d164] +- Updated dependencies [b8ec127] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [2c87a48] +- Updated dependencies [a36b526] +- Updated dependencies [dd2fd20] +- Updated dependencies [92865f6] +- Updated dependencies [e81c4e5] +- Updated dependencies [28f9277] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [72eeabd] +- Updated dependencies [3c557e2] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [b940f32] +- Updated dependencies [e66da5c] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [fb59fb5] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [e08c8b0] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [2266438] +- Updated dependencies [0a56d3b] +- Updated dependencies [f8e5790] +- Updated dependencies [cefe068] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [e77a23f] +- Updated dependencies [f9e16d8] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [a016f08] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c86d351] +- Updated dependencies [9cc5010] +- Updated dependencies [6e3462d] +- Updated dependencies [6e3e546] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [6af2901] +- Updated dependencies [96451ec] +- Updated dependencies [45c2cf9] +- Updated dependencies [0f38ab0] +- Updated dependencies [dfb42c5] +- Updated dependencies [9540590] +- Updated dependencies [ae6dcf6] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [3cf6449] +- Updated dependencies [3cf6449] +- Updated dependencies [2bd53f1] +- Updated dependencies [e6965dd] +- Updated dependencies [576d5df] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [ca31ff6] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [775e5ec] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [f3b28eb] +- Updated dependencies [fd5cff2] +- Updated dependencies [143c715] +- Updated dependencies [fb2bccf] +- Updated dependencies [0ced0aa] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [cca6991] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [470746a] +- Updated dependencies [b7c792b] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [777d0c2] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [4fba503] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [5cf58eb] +- Updated dependencies [66e266c] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [03b19d9] +- Updated dependencies [6154165] +- Updated dependencies [199002b] +- Updated dependencies [ab450f4] +- Updated dependencies [21ab410] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [029d8a4] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [43e17b8] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [f6189a4] +- Updated dependencies [7cd5874] +- Updated dependencies [a2509d7] +- Updated dependencies [3cb84d0] +- Updated dependencies [119a02b] +- Updated dependencies [eea8787] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/service-settings@17.5.0 + - @objectstack/service-automation@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/plugin-hono-server@17.5.0 + - @objectstack/plugin-sharing@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/service-analytics@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/service-datasource@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index b15e9885f60..719abcb5e43 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Boot any ObjectStack app in-process and verify it through the real HTTP stack — auto-derived CRUD round-trip fidelity plus the cross-owner RLS invariant. Catches runtime regressions that static checks miss.", "type": "module",